[200] 의 패널은 코드로 Border 를 쌓는 비가상화 렌더고 SheetMe 는 가상화 ListBox 다. 골격은 SheetMe 것을 지키고 겉모습과 조작 표면만 옮겼다 — 통째로 베끼면 1,067 컨트롤 서식에서 검색어 한 글자마다 수천 비주얼을 다시 만들게 되고, [200] 에 대응물이 없는 컨테이너 자식 재귀 표시가 사라진다. 구조 - 페이지를 별도 섹션이 아니라 아웃라인의 최상위 노드로. 접기·복제·삭제·컨트롤 수·현재 강조 - 맨 아래 '+ 페이지 추가' 행 - 목록 순서를 뒤집었다 — 이제 맨 위가 z-order 최상위(캔버스에서 위에 겹쳐 보이는 것이 목록 위) - 들여쓰기 12 / 32 / +16 - 레이어 패널을 LayerPanelView 로 분리(MainView.xaml 이 1,000줄을 넘겼고, 진단 렌더러가 이 패널만 따로 찍어 라이트·다크·선택 상태를 검사할 수 있어야 한다) 선택 표시 - IsSelected 가 아니라 Highlight 3상태(None/Blue/Navy)가 배경을 그린다. 그룹 전원 선택 시 폴더는 파랑, 멤버는 서브트리 배경이 되고 맞닿는 모서리를 각지게 해 하나의 블록으로 이어진다 - 밴드 위에서는 아이콘·보조 텍스트 색도 함께 뒤집는다. 안 그러면 눈·자물쇠가 1.6:1 로 묻혀 선택한 행에서만 잠금 상태가 안 보이는 상태가 된다 - 신설 토큰 3종(B.LayerSel / B.LayerSubtree / B.OnSelMuted) — [200] 원본값은 각각 흰 글자 3.00:1, 다크 밴드 1.03:1, 라이트 밴드 1.14:1 로 이 저장소가 이미 기각한 값이다 기능 - 드래그로 z-order 재정렬(삽입선 어도너). 임의 index 이동 연산 MoveWithinParent 신설 - 페이지 복제 — 컨트롤 이름을 전부 새로 발급하고 그룹도 복제본끼리 다시 묶는다 ([200] 은 GroupId 를 그대로 둬서 복제 그룹이 원본과 gid 를 공유한다) - 페이지 헤더 클릭 → 캔버스가 그 페이지로 이동(ScrollToPage) - 데이터 배선 배지 — 값이 조회·데이터소스에서 오는 컨트롤 표시 - 검색칩에 테두리(라이트에서 B.Input=B.Panel 이라 경계 대비가 1.00:1 이었다) 이식하지 않은 것: 컨트롤 행 인라인 개명. [200] 이 고치는 값은 표시 전용 별칭이지만 SheetMe 의 컨트롤 이름은 외부 참조 키라 목록에서 무심코 고치면 배선이 끊긴다. 더블클릭은 요청대로 캔버스 이동을 유지하고, 그룹 행에서만 이름 편집으로 간다. 동반 수정: 눈·자물쇠 토글에 되돌리기 지점 추가(400ms 코얼레스), 그룹 이름 비우기 경로가 스냅숏 없이 지워지던 것, 숨김 흐리기를 행 전체가 아니라 이름 줄에만. 편집 스모크의 레이어 검사 19 → 42건. 회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
15 KiB
C#
312 lines
15 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)),
|
|
};
|
|
}
|
|
|
|
/// <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
|
|
}
|