다크 테마에서 흰 시스템 창이 튀어나오던 알림을 자체 창(MessageDialogView)으로 바꿨다. 앱 셸과 같은 커스텀 타이틀바(36px) + B.Surface 본문 + 성격 아이콘 + 우측 버튼 줄. 폭 440 고정, 내용에 따라 높이 자동, 긴 예외는 스크롤로 흡수한다. 진입점은 DialogService 정적 메서드 4개로 모았다 — Notify / Confirm / ConfirmWithCancel / ShowError. MessageBox 는 Win32 호출이라 아무 데서나 되지만 WPF 창은 아니다. 교체로 새로 생기는 제약 셋을 DialogService 한곳에서 막는다. - 진단 모드에서는 창을 만들지 않고 기각 기본값을 즉시 돌려준다. 스모크는 무인 실행이라 모달이 하나라도 뜨면 타임아웃 없이 영원히 멈춘다 — 실제로 편집 스모크가 지나는 경로에 붙여넣기·개명 가드가 있다. 규약을 스모크 검사 3건으로 고정했다. - UI 스레드가 아니면 Dispatcher 로 넘긴다. - 소유 창은 '살아 있는 것'만 건다. 아직 안 보였거나 이미 닫힌 창을 Owner 로 주면 예외이고, 진단 렌더러가 도는 동안 MainWindow 가 닫힌 창을 가리킬 수 있다. App.xaml.cs 4곳은 다르게 처리했다. - 기동 실패(:49)·예외 폭주(:82)·복구 안내(:89) 3곳은 순정 유지. 창이 없는 시점이라 자체 창을 띄우면 종료코드가 유실되고, 이미 예외가 터진 자리에서 WPF 창을 새로 만들면 같은 핸들러로 재진입한다. 이유를 각 자리에 주석으로 남겼다. 덤으로 :89 는 e.Handled 를 알림보다 먼저 세우도록 순서를 바로잡았다 — 알림이 던지면 '복구 가능한 예외'가 하드 크래시로 바뀐다. - 알 수 없는 진단 옵션(:179)은 알림을 없앴다. 옵션 오타 하나로 무인 실행이 멈추던 자리다. 함께 고친 것 — Primary 버튼 스타일. 공유 버튼 템플릿의 호버 트리거가 TargetName 으로 채움을 회색으로 덮는데(TargetName 트리거는 TemplateBinding 을 이긴다) 글자는 흰색 그대로라, 라이트에서 마우스를 올리면 #F3F3F3 위 흰 글자 1.08:1 로 사라진다. 지금까지 Primary 사용처가 0건이라 드러난 적이 없었고 이 창이 첫 사용이다. 전용 템플릿 + 채움 호버·누름 토큰 2종 신설. 신설 토큰: B.Warning / B.Danger(라이트는 다크값을 못 쓴다 — 앰버 #E0A33A 는 흰 면 위 2.22:1 로 아이콘 기준 3:1 도 미달), B.AccentFillHover / B.AccentFillPressed. 전부 양 테마에 동시 추가. Lucide 아이콘 4종(info·circle-alert·triangle-alert·circle-help) 추가. 바꾸지 않은 것: 예외 원문. 18곳을 ShowError 로 수렴시키면 화면에서 ex.Message 가 오류코드+로그로 대체되는 동작 변경이 된다 — 요청은 시각 변경이라 문구·정보량을 그대로 뒀다. 남는 시스템 대화상자: 인쇄(PrintDialog)와 파일 열기/저장 — OS 셸 대화상자라 대상이 아니다. 진단 렌더러에 알림 5종(오류·경고·확인·저장확인·정보)을 등록해 라이트/다크 10장이 자동으로 남는다. 회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
17 KiB
C#
333 lines
17 KiB
C#
using System.IO;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using SheetMe.Core.Models;
|
|
using SheetMe.Data.Stores;
|
|
using SheetMe.Designer.Services;
|
|
using SheetMe.Designer.ViewModels;
|
|
using SheetMe.Designer.ViewModels.Inspector;
|
|
|
|
namespace SheetMe.Designer.Diagnostics;
|
|
|
|
/// <summary>
|
|
/// 별도 창(대화상자) 오프스크린 스냅샷 — <c>--dialog-shots <출력폴더></c>.
|
|
///
|
|
/// 창 10종을 다크·라이트 양 테마로 열어 PNG 로 남긴다. 목적은 하나다:
|
|
/// <b>테마 대비 결함을 사람 손 없이 재현 가능하게 확인한다.</b>
|
|
/// 실입력(마우스·키보드) 주입은 화면 잠금·세션 격리 상태에서 OS 가 거부하지만
|
|
/// 이 경로는 창을 화면 밖에 띄워 RenderTargetBitmap 으로 찍으므로 그 제약을 받지 않는다.
|
|
///
|
|
/// 창은 화면 밖(-10000)에 <c>Show()</c> 로 띄운다 — 레이아웃이 실제로 돌아야 템플릿·트리거가
|
|
/// 적용된 상태로 찍힌다(Measure/Arrange 만으로는 Window 크롬이 구성되지 않는다).
|
|
/// 모든 창은 찍은 뒤 즉시 닫는다.
|
|
/// </summary>
|
|
public static class DialogShots
|
|
{
|
|
#region Methods
|
|
public static int Run(string outputDirectory)
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
// 기본 ShutdownMode 는 OnLastWindowClose 라, 찍고 닫는 순간 마지막 창이 사라져 앱이 종료된다
|
|
// (그 뒤 Application.Current 가 null 이 되어 테마 교체에서 NRE). 명시 종료로 바꾼다.
|
|
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
|
var lines = new List<string>();
|
|
|
|
foreach (var light in new[] { false, true })
|
|
{
|
|
ThemeManager.Apply(light);
|
|
var suffix = light ? "light" : "dark";
|
|
foreach (var (name, factory) in Factories())
|
|
{
|
|
try
|
|
{
|
|
var window = factory();
|
|
Capture(window, Path.Combine(outputDirectory, $"{name}-{suffix}.png"));
|
|
// 창 배경이 실제로 무엇으로 해석됐는지 함께 남긴다 — 픽셀만 보면 원인을 못 가린다.
|
|
// WPF 는 암시 스타일을 요소의 '정확한 타입'으로만 찾으므로, Window 를 상속한
|
|
// 대화상자에는 <Style TargetType="Window"> 가 적용되지 않는다(기본 흰 배경이 된다).
|
|
var background = window.Background is SolidColorBrush solid
|
|
? solid.Color.ToString()
|
|
: window.Background?.ToString() ?? "(null)";
|
|
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
|
|
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
lines.Add($"FAIL {name}-{suffix} — {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.Add(string.Empty);
|
|
lines.AddRange(StyleSelfCheck());
|
|
|
|
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
|
|
string.Join(Environment.NewLine, lines));
|
|
Console.WriteLine(string.Join(Environment.NewLine, lines));
|
|
return lines.Any(l => l.StartsWith("FAIL", StringComparison.Ordinal)) ? 1 : 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"대화상자 스냅샷 실패: {ex}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 암시 스타일 자가 점검 — 스크린샷에 안 잡히는 표면(툴팁·포커스링·툴바 자식·스크롤 코너)이
|
|
/// 실제로 테마 스타일을 받는지 값으로 확인한다.
|
|
/// 이들은 팝업이거나 프레임워크 템플릿 소속이라 창을 찍어도 보이지 않는데, 스타일이 없으면
|
|
/// WPF 기본(Aero2) 밝은 크롬으로 떨어져 다크에서 글자가 사라진다.
|
|
/// </summary>
|
|
private static List<string> StyleSelfCheck()
|
|
{
|
|
var lines = new List<string> { "[암시 스타일 자가 점검 — 라이트 테마 기준]" };
|
|
|
|
// 떼어 놓은 인스턴스는 트리에 붙기 전이라 Style 이 아직 null 이다 — 사전에 있는지로 판정한다
|
|
foreach (var (label, key) in new (string, object)[]
|
|
{
|
|
("ToolTip", typeof(System.Windows.Controls.ToolTip)),
|
|
("ListView", typeof(System.Windows.Controls.ListView)),
|
|
("ToolBar", typeof(System.Windows.Controls.ToolBar)),
|
|
("ToolBar.Button", System.Windows.Controls.ToolBar.ButtonStyleKey),
|
|
("ToolBar.Separator", System.Windows.Controls.ToolBar.SeparatorStyleKey),
|
|
("ToolBar.TextBox", System.Windows.Controls.ToolBar.TextBoxStyleKey),
|
|
("FocusVisual", SystemParameters.FocusVisualStyleKey),
|
|
("ScrollViewer코너", SystemColors.ControlBrushKey),
|
|
})
|
|
{
|
|
var found = Application.Current.TryFindResource(key);
|
|
lines.Add($" {label,-18} {(found is null ? "★ 미정의(Aero2 폴백)" : "정의됨")}");
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/// <summary>창 생성기 목록 — 실제 사용 시와 같은 인자로 만든다(빈 껍데기를 찍으면 의미가 없다)</summary>
|
|
private static List<(string Name, Func<Window> Factory)> Factories()
|
|
{
|
|
var designer = SampleDesigner();
|
|
return new List<(string, Func<Window>)>
|
|
{
|
|
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
|
|
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
|
|
("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
|
|
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
|
|
("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])),
|
|
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
|
|
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
|
|
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
|
|
("08-preview", () => new Views.PreviewWindow(designer)),
|
|
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
|
|
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
|
|
("09-inspector-label", () => HostInspector(designer, "Label1")),
|
|
("10-inspector-textbox", () => HostInspector(designer, "TextBox1")),
|
|
("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)),
|
|
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
|
|
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
|
|
designer.LayerTypeCandidates(), new[] { "Label" })),
|
|
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
|
|
// 강조는 IsSelected 가 아니라 Highlight 3상태가 그리므로 눈으로 확인할 경로가 이것뿐이다.
|
|
("14-layer-plain", () => HostLayerPanel(designer, LayerShotMode.None)),
|
|
("15-layer-selected", () => HostLayerPanel(designer, LayerShotMode.Single)),
|
|
("16-layer-group", () => HostLayerPanel(designer, LayerShotMode.Group)),
|
|
// 알림 대화상자 — 성격 4종과 버튼 3종의 대표 조합. 순정 MessageBox 를 걷어낸 자리라
|
|
// 라이트/다크 회귀를 여기서 잡는다.
|
|
("17-msg-error", () => new Views.MessageDialogView(
|
|
Services.DialogKind.Error, "오류", "DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)",
|
|
"ORA-00001: unique constraint (MSYSTECHHIS.PK_E_SDGMST) violated\n오류 코드: 20260813-0007",
|
|
Services.DialogButtons.Ok)),
|
|
("18-msg-warning", () => new Views.MessageDialogView(
|
|
Services.DialogKind.Warning, "DB 저장", "DB 저장이 비활성화되어 있습니다.",
|
|
"appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n" +
|
|
"(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)",
|
|
Services.DialogButtons.Ok)),
|
|
("19-msg-confirm", () => new Views.MessageDialogView(
|
|
Services.DialogKind.Question, "페이지 삭제", "페이지 2을(를) 삭제할까요?",
|
|
"컨트롤 116개가 함께 삭제됩니다.",
|
|
Services.DialogButtons.YesNo, yes: "삭제", no: "취소", destructive: true)),
|
|
("20-msg-save", () => new Views.MessageDialogView(
|
|
Services.DialogKind.Question, "종료", "'P093 간호초기평가기록지_V2' 문서에 저장하지 않은 변경이 있습니다.",
|
|
"저장할까요?", Services.DialogButtons.YesNoCancel, yes: "저장", no: "저장 안 함")),
|
|
("21-msg-info", () => new Views.MessageDialogView(
|
|
Services.DialogKind.Info, "DB 저장", "저장되었습니다. (SdgKey 52426)",
|
|
"레거시 뷰어/디자이너에서 열어 확인하세요.", Services.DialogButtons.Ok)),
|
|
};
|
|
}
|
|
|
|
/// <summary>레이어 패널 스냅숏의 선택 상태</summary>
|
|
private enum LayerShotMode
|
|
{
|
|
/// <summary>선택 없음</summary>
|
|
None,
|
|
|
|
/// <summary>컨트롤 하나 선택 — 파란 밴드 하나</summary>
|
|
Single,
|
|
|
|
/// <summary>그룹 전체 선택 — 폴더 파랑 + 멤버 서브트리, 이어진 블록</summary>
|
|
Group,
|
|
}
|
|
|
|
/// <summary>
|
|
/// 레이어 패널을 실제 패널 폭(255px)의 창에 담는다.
|
|
/// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 <c>CurrentDesigner</c> 하나만
|
|
/// 들고 있는 최소 DataContext 를 세워 준다.
|
|
/// </summary>
|
|
private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
|
|
{
|
|
designer.Selection.Clear();
|
|
designer.SetLayerTypeFilter(Array.Empty<string>());
|
|
designer.LayerFilter = string.Empty;
|
|
|
|
var page = designer.Pages[0];
|
|
if (mode != LayerShotMode.None && page.Controls.Count >= 3)
|
|
{
|
|
designer.Selection.Set(new[] { page.Controls[^1], page.Controls[^2] });
|
|
designer.GroupSelection();
|
|
if (mode == LayerShotMode.Single)
|
|
{
|
|
designer.Selection.SetSingle(page.Controls[0]);
|
|
}
|
|
}
|
|
designer.RebuildLayerRows();
|
|
|
|
return new Window
|
|
{
|
|
Title = $"레이어 — {mode}",
|
|
Width = 255,
|
|
Height = 620,
|
|
Content = new Views.LayerPanelView { DataContext = new LayerShotContext(designer) },
|
|
Background = Application.Current.TryFindResource("B.Panel") as Brush,
|
|
};
|
|
}
|
|
|
|
/// <summary>패널이 바인딩하는 <c>CurrentDesigner</c> 만 노출하는 스냅숏 전용 DataContext</summary>
|
|
private sealed class LayerShotContext
|
|
{
|
|
public LayerShotContext(DesignerViewModel designer) => CurrentDesigner = designer;
|
|
|
|
public DesignerViewModel CurrentDesigner { get; }
|
|
}
|
|
|
|
/// <summary>인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로</summary>
|
|
private static Window HostInspector(DesignerViewModel designer, string controlId,
|
|
InspectorTab tab = InspectorTab.Design)
|
|
{
|
|
var page = designer.Pages[0];
|
|
var target = page.Controls.FirstOrDefault(c => c.Id == controlId) ?? page.Controls[0];
|
|
designer.Selection.SetSingle(target);
|
|
designer.Inspector.SelectedTab = tab;
|
|
designer.Inspector.Rebuild();
|
|
|
|
return new Window
|
|
{
|
|
Title = $"속성 — {controlId}",
|
|
Width = 300,
|
|
Height = 720,
|
|
Content = new Views.InspectorView { DataContext = designer.Inspector },
|
|
Background = Application.Current.TryFindResource("B.Panel") as Brush,
|
|
};
|
|
}
|
|
|
|
/// <summary>화면 밖에 띄워 레이아웃을 돌린 뒤 PNG 로 찍고 닫는다</summary>
|
|
private static void Capture(Window window, string pngPath)
|
|
{
|
|
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
|
window.Left = -10000;
|
|
window.Top = -10000;
|
|
window.ShowInTaskbar = false;
|
|
window.Show();
|
|
window.UpdateLayout();
|
|
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
|
|
Pump();
|
|
|
|
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고,
|
|
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다).
|
|
var content = (FrameworkElement)window.Content;
|
|
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width);
|
|
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height);
|
|
var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
|
|
|
|
var drawing = new DrawingVisual();
|
|
using (var context = drawing.RenderOpen())
|
|
{
|
|
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height));
|
|
context.DrawRectangle(new VisualBrush(content) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
|
|
null, new Rect(0, 0, width, height));
|
|
}
|
|
bitmap.Render(drawing);
|
|
|
|
var encoder = new PngBitmapEncoder();
|
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
|
using (var stream = File.Create(pngPath))
|
|
{
|
|
encoder.Save(stream);
|
|
}
|
|
window.Close();
|
|
}
|
|
|
|
/// <summary>디스패처 큐를 비운다(Loaded/바인딩 갱신 반영)</summary>
|
|
private static void Pump()
|
|
{
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke(
|
|
() => { }, System.Windows.Threading.DispatcherPriority.ContextIdle);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Methods - 표본 데이터
|
|
private static List<SheetSummary> SampleSheets() => new()
|
|
{
|
|
new SheetSummary("C020", "협진기록지", true, true, "A", "의사기록"),
|
|
new SheetSummary("C030", "Doctor's Order List", false, true, "A", "의사기록"),
|
|
new SheetSummary("P001", "간호 기록지", true, true, "E", "간호기록"),
|
|
new SheetSummary("F004", "임상생리신경검사", true, false, "D", "검사결과"),
|
|
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
|
|
};
|
|
|
|
private static List<DesignVersionInfo> SampleVersions() => new()
|
|
{
|
|
new DesignVersionInfo(52443, "202608121030", "011825", false),
|
|
new DesignVersionInfo(52380, "202607161422", "011825", true),
|
|
new DesignVersionInfo(52241, "202607160901", "MSYS", true),
|
|
};
|
|
|
|
private static string[] SampleTags() => new[]
|
|
{
|
|
"SetPatientName", "SetChartNumber", "EnableControl", "SetVisitDate", "ClearValue",
|
|
};
|
|
|
|
/// <summary>폰트 관리자·미리보기용 표본 문서 — 컨트롤이 있어야 목록이 비지 않는다</summary>
|
|
private static DesignerViewModel SampleDesigner()
|
|
{
|
|
var document = new FormDocument { FormId = "S999", Title = "표본 서식" };
|
|
var page = Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1);
|
|
page.Controls.Add(NewControl("Label", "Label1", "환자명", 40, 40, 120, 24));
|
|
page.Controls.Add(NewControl("TextBox", "TextBox1", string.Empty, 170, 40, 200, 26));
|
|
page.Controls.Add(NewControl("Label", "Label2", "생년월일", 40, 80, 120, 24));
|
|
// 이름만 있고 문구가 없는 컨트롤 — 레이어 목록에서 이름 칸이 중복 표시되지 않는지 확인용
|
|
page.Controls.Add(NewControl("TextBox", "TextBox2", string.Empty, 170, 80, 200, 26));
|
|
document.Pages.Add(page);
|
|
return new DesignerViewModel(document);
|
|
}
|
|
|
|
private static ControlElement NewControl(string type, string id, string text, double x, double y, double w, double h)
|
|
{
|
|
var element = new ControlElement { Type = type, Id = id };
|
|
element.Bounds.X = x;
|
|
element.Bounds.Y = y;
|
|
element.Bounds.W = w;
|
|
element.Bounds.H = h;
|
|
if (text.Length > 0)
|
|
{
|
|
element.Props.SetText("Text", text);
|
|
}
|
|
return element;
|
|
}
|
|
#endregion
|
|
}
|