Files
SheetMe/src/SheetMe.Designer/Diagnostics/DialogShots.cs
T
MsystechandClaude Opus 5 f447ae742b 검증 실행 결과에 출력 행이 안 보이던 문제
결과를 ExpandoObject 목록으로 DataGrid 에 넘기고 있었다. DataGrid 의 AutoGenerateColumns 는
항목 <b>타입</b>의 속성을 반사해 열을 만드는데 ExpandoObject 에는 그런 속성이 없다 —
그래서 요약("성공 — 컬럼 2개, 표본 3행")은 맞게 뜨는데 표는 비어 있었다.
DataTable 로 바꿨다. DataGrid 가 직접 알아본다.

같은 컬럼명이 두 번 나오면(별칭 없는 조인에서 흔하다) DataTable 이 예외를 내므로 뒤에 번호를 붙인다.

■ 0행과 실패를 구분한다

구문은 맞는데 조건에 걸리는 데이터가 없을 수 있다. 검증 실행은 치환 변수를 빈 값으로 바꿔
돌리므로(런타임과 같은 방식) 조건이 빡빡하면 0행이 정상이다.
그 경우 표를 감추고 "구문은 정상입니다 — 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이
없을 수 있습니다" 를 덧붙인다. 빈 표를 보여 주면 실패한 것처럼 읽힌다.

결과 영역을 190 → 260px 로 키웠다.

■ 이 종류를 다시 놓치지 않도록

--query-popup 진단에 3건을 더 넣었다: 결과 창이 열리는가 · 표에 열이 생기는가 · 행이 채워지는가.
요약만 검사했다면 이번 결함을 그대로 통과시켰을 것이다. 현재 12/12.
--dialog-shots 에도 검증 실행을 마친 상태(03b-query-trial)를 추가해 눈으로도 남는다.

회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업·정렬·결과표 점검 12/12,
검증 실행 점검 10/10, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:10:27 +09:00

362 lines
19 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", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
("03b-query-trial", () => NewQueryEditorWithTrial(designer)),
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
("04-tag-picker", () => new Views.TagPickerDialogView(
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)",
Core.Catalog.LegacyTagCatalog.DataInterfaceTags, null,
Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataInterfaceTag", "TextBox"))),
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
// 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
("05b-datatable-field", () => new Views.DataTableFieldDialogView(
"MDataTable2.Rows(0).Item(\"ALGYON\")", new[] { "MDataTable1", "MDataTable2" })),
("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)),
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
("12b-inspector-picture-data", () => HostInspector(designer, "MPictureBox1", InspectorTab.Data)),
("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>
/// <summary>쿼리 편집기 — 이 서식의 컨트롤을 《컨트롤명》 후보로 넘겨 그룹이 실제로 차는지 본다</summary>
private static Window NewQueryEditor(DesignerViewModel designer, string sql)
=> new Views.QueryEditorWindow("데이터소스", sql, controlNames: designer.ControlNames());
/// <summary>
/// 검증 실행까지 마친 상태 — 결과 표가 실제로 채워지는지 눈으로 확인할 유일한 경로다.
/// DB 가 없으면 실패 메시지가 찍히고, 그것도 봐야 하는 화면이다.
/// </summary>
private static Window NewQueryEditorWithTrial(DesignerViewModel designer)
{
var window = new Views.QueryEditorWindow("데이터소스",
"SELECT ShtCod, ShtKorNam, ShtTyp FROM E_ShtMst WHERE ROWNUM <= 5",
controlNames: designer.ControlNames());
window.RunTrialForSmoke();
return window;
}
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));
// 이미지는 데이터 태그 후보가 운영 전체에서 7종뿐이다 — 타입별 제안이 실제로 좁아지는지 확인용
page.Controls.Add(NewControl("PictureBox", "MPictureBox1", string.Empty, 400, 40, 120, 90));
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
}