Files
SheetMe/src/SheetMe.Designer/Diagnostics/DialogShots.cs
T
MsystechandClaude Opus 5 604f8f7d1b 순정 MessageBox 를 앱 테마 대화상자로 교체 (53곳 중 49곳)
다크 테마에서 흰 시스템 창이 튀어나오던 알림을 자체 창(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>
2026-08-13 10:07:25 +09:00

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 &lt;출력폴더&gt;</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
}