using System.IO; using System.Windows; using SheetMe.Core.Models; using SheetMe.Core.Serialization; using SheetMe.Designer.DataBusiness; using SheetMe.Designer.Services; using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels.Controls; namespace SheetMe.Designer.Diagnostics; /// /// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해 /// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다. /// public static class EditSmoke { #region Methods /// 스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환 /// /// 시각 트리의 요소 수 — 컨테이너 까지 세야 자식 필터가 들었는지 보인다. /// Canvas 직계만 세면 컨테이너는 늘 1개라 안에서 뭐가 빠졌는지 알 수 없다. /// private static int CountVisuals(System.Windows.DependencyObject node) { var total = 1; var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(node); for (var i = 0; i < count; i++) { total += CountVisuals(System.Windows.Media.VisualTreeHelper.GetChild(node, i)); } return total; } /// /// 종이에 실제로 그려진 글자를 모은다 — 값이 찍혔는지는 시각 트리에서 확인해야 한다. /// VM 속성만 보면 템플릿이 그 값을 쓰는지 알 수 없다(배선과 결과는 다른 문제다). /// private static string CollectText(System.Windows.DependencyObject node) { var text = node is System.Windows.Controls.TextBlock block ? block.Text + "\n" : string.Empty; var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(node); for (var i = 0; i < count; i++) { text += CollectText(System.Windows.Media.VisualTreeHelper.GetChild(node, i)); } return text; } /// /// 검사용 해석기 — 실제 DB 없이 "값이 나오는 태그"와 "안 나오는 태그" 두 갈래를 만든다. /// 진단은 접속 없이도 같은 답을 내야 하므로 여기서 세션 해석기를 쓰지 않는다. /// private sealed class StubTagResolver : SheetMe.Core.Catalog.ITagValueResolver { public SheetMe.Core.Catalog.TagValue Resolve(string tag) => SheetMe.Core.Catalog.TagPreviewCatalog.CanResolve(tag) ? new SheetMe.Core.Catalog.TagValue(true, "2026-08-18") : new SheetMe.Core.Catalog.TagValue(false, SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag)); } /// /// 검사용 배선 해석기 — 표 한 장을 손으로 쥐고 네 갈래(정상·컬럼오타·없는소스·형식깨짐)를 만든다. /// 실행기()를 쓰면 DB 접속 여부에 판정이 묶인다. /// private sealed class StubFieldResolver : SheetMe.Core.Catalog.IDataFieldResolver { public SheetMe.Core.Catalog.TagValue Resolve(SheetMe.Core.Catalog.DataTableFieldSpec spec) { if (spec.TableName != "MDataTable1") { return new SheetMe.Core.Catalog.TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다"); } return spec.Field == "NAM" ? new SheetMe.Core.Catalog.TagValue(true, "ㄱ") : new SheetMe.Core.Catalog.TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"); } } /// 시각 트리에서 첫 TextBlock — 없으면 null private static System.Windows.Controls.TextBlock? FindFirstTextBlock(DependencyObject root) { if (root is System.Windows.Controls.TextBlock block) { return block; } var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root); for (var i = 0; i < count; i++) { if (FindFirstTextBlock(System.Windows.Media.VisualTreeHelper.GetChild(root, i)) is { } found) { return found; } } return null; } public static int Run(string reportPath) { var lines = new List(); var failed = 0; void Check(string name, bool condition, string? detail = null) { if (!condition) { failed++; } lines.Add($"{(condition ? "PASS" : "FAIL")} {name}{(condition || detail is null ? "" : " — " + detail)}"); } try { var business = new FormDesignDataBusiness(); var serializer = new LegacyXmlSerializer(); var designer = new DesignerViewModel(business.CreateNew()); var none = new PointerContext(false, false, false, 1); var alt = new PointerContext(false, false, true, 1); var doubleClick = new PointerContext(false, false, false, 2); var ctrl = new PointerContext(true, false, false, 1); ControlViewModel Find(string type) => designer.Pages[0].Controls.First(c => c.Type == type); // 1) 팔레트 배치 designer.AddControlAt("TextBox", new Point(100, 100)); designer.AddControlAt("Label", new Point(400, 60)); designer.AddControlAt("CheckBox", new Point(200, 260)); Check("배치: VM/모델 3개", designer.Pages[0].Controls.Count == 3 && designer.Document.Pages[0].Controls.Count == 3); Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1); // 팔레트 즉시 배치가 같은 자리에 겹치지 않는가. // 전에는 (Controls.Count % 8) * 16 이라 9번째가 1번째와 정확히 같은 자리에 떨어졌고, // 하나 지우고 다시 놓으면 지운 것과 같은 자리로 갔다. // 별도 문서에서 잰다 — 위 3개가 든 문서를 쓰면 뒤 검사들의 인덱스가 밀린다. { var stack = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); var before = stack.Pages[0].Controls.Count; for (var i = 0; i < 20; i++) { stack.AddPaletteItemAtCenter("Label"); } var placed = stack.Pages[0].Controls.Skip(0) .Where(c => c.Type == "Label").Select(c => (c.X, c.Y)).ToList(); Check("즉시 배치: 20개가 서로 다른 자리", placed.Distinct().Count() == placed.Count, $"고유 {placed.Distinct().Count()} / 전체 {placed.Count} (기존 {before}개)"); // 지우고 다시 놓아도 지운 자리로 돌아가지 않는다 var last = stack.Pages[0].Controls.First(c => c.Type == "Label"); var lastSpot = (last.X, last.Y); stack.Selection.SetSingle(last); stack.DeleteSelection(); stack.AddPaletteItemAtCenter("Label"); var again = stack.Pages[0].Controls.First(c => c.Type == "Label"); Check("즉시 배치: 지운 자리로 돌아가지 않는다", (again.X, again.Y) != lastSpot, $"지운 자리 {lastSpot}, 새로 놓인 자리 ({again.X},{again.Y})"); } // 키보드로 루프가 닫히는가 — Tab 으로 다음, Shift+Tab 으로 이전, Esc 로 밖, Enter 로 안. { var loop = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); var sheet = loop.Pages[0]; // 읽는 순서가 보이도록 아래→위 역순으로 놓는다. 순회가 z-order 를 따르면 이 순서가 그대로 나온다. loop.AddControlAt("Label", new Point(60, 300)); loop.AddControlAt("Label", new Point(60, 200)); loop.AddControlAt("Label", new Point(60, 100)); var byY = sheet.Controls.Where(c => c.Type == "Label").OrderBy(c => c.Y).ToList(); loop.Selection.Clear(); loop.SelectSibling(backward: false); var first = loop.Selection.Primary; loop.SelectSibling(backward: false); var second = loop.Selection.Primary; loop.SelectSibling(backward: false); loop.SelectSibling(backward: false); var wrapped = loop.Selection.Primary; Check("Tab: 읽는 순서로 돈다(위에서 아래)", ReferenceEquals(first, byY[0]) && ReferenceEquals(second, byY[1]), $"1번째 Y={first?.Y} 2번째 Y={second?.Y} (기대 {byY[0].Y}, {byY[1].Y})"); Check("Tab: 끝에서 처음으로 돌아온다", ReferenceEquals(wrapped, byY[0]), $"네 번째에서 Y={wrapped?.Y} (기대 {byY[0].Y})"); loop.SelectSibling(backward: true); Check("Shift+Tab: 거꾸로 간다", ReferenceEquals(loop.Selection.Primary, byY[^1]), $"Y={loop.Selection.Primary?.Y} (기대 {byY[^1].Y})"); // 컨테이너 안팎 — Enter 로 들어가고 Esc 로 나온다. // GroupSelection 을 쓰지 않는다: 그 경로에는 거절 조건(라디오·z 불연속)이 있어 // 여기서 막히면 순회를 재는 대신 그룹 규칙을 재게 된다. 문서에 직접 만든다. var nested = new FormDocument(); var nestedPage = new FormPage { Width = 720, Height = 856 }; nested.Pages.Add(nestedPage); var panelModel = new ControlElement { Type = "Panel", Id = "Panel9", Bounds = new LayoutRect { X = 40, Y = 40, W = 300, H = 200 }, }; foreach (var (name, y) in new[] { ("자식A", 10.0), ("자식B", 60.0) }) { var kid = new ControlElement { Type = "Label", Id = name, Bounds = new LayoutRect { X = 10, Y = y, W = 120, H = 18 }, }; kid.Props.SetText("Text", name); panelModel.Children.Add(kid); } nestedPage.Controls.Add(panelModel); var inner = new DesignerViewModel(nested); var wrap = inner.Pages[0].Controls.OfType().First(); loop = inner; loop.Selection.SetSingle(wrap); Check("Enter: 컨테이너 안으로 들어간다", loop.EnterSelection() && loop.Selection.Primary?.Parent == wrap); Check("Esc: 컨테이너 밖으로 나온다", loop.SelectParent() && ReferenceEquals(loop.Selection.Primary, wrap)); Check("Esc: 나갈 곳이 없으면 false", !loop.SelectParent()); // 글자를 담은 것에서 Enter 는 편집을 시작한다 var editable = wrap.Children.First(); loop.Selection.SetSingle(editable); var asked = 0; void Count(ControlViewModel _) => asked++; loop.InlineEditRequested += Count; loop.EnterSelection(); loop.InlineEditRequested -= Count; Check("Enter: 글자면 편집을 시작한다", asked == 1, $"요청 {asked}회"); } // 태그 행 인라인 완성 — 이 행에서 모달 없이 태그를 고르는 주 경로. { var tagRow = new ViewModels.Inspector.TagPickerRowViewModel( "데이터 태그", "태그 선택", SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataInterfaceTag", "TextBox")); var committed = new List(); tagRow.Commit = value => committed.Add(value); Check("완성: 값이 있어도 칩이 남는다", tagRow.ShowSuggestions, "잘못 고른 태그를 바꿀 때 도움이 끊기지 않아야 한다"); // 타이핑 전에는 열리지 않는다 tagRow.RefreshCompletions("환자"); Check("완성: 타이핑 전에는 안 뜬다", !tagRow.ShowCompletions); tagRow.BeginTyping(); Check("완성: 타이핑하면 칩이 접힌다", !tagRow.ShowSuggestions); // 별칭이 여기서도 들어야 한다 — 카탈로그 표기는 '이름' 이다 tagRow.RefreshCompletions("환자명"); Check("완성: 별칭이 인스펙터에서도 듣는다", tagRow.ShowCompletions && tagRow.Completions.Count > 0, $"목록 {tagRow.Completions.Count}개"); // 순위 — 이 타입에서 자주 쓰이는 것이 위로 tagRow.RefreshCompletions("PAT_"); var top = tagRow.Completions.FirstOrDefault(); Check("완성: 자주 쓰는 것이 위에", top == "PAT_이름", $"1번째 {top}"); Check("완성: 10줄을 넘기지 않는다", tagRow.Completions.Count <= 10, $"{tagRow.Completions.Count}줄"); // ↑↓ 순환 tagRow.MoveCompletion(-1); Check("완성: ↑ 로 끝으로 돈다", tagRow.CompletionIndex == tagRow.Completions.Count - 1, $"index {tagRow.CompletionIndex}"); tagRow.MoveCompletion(1); Check("완성: ↓ 로 처음으로 돈다", tagRow.CompletionIndex == 0); // Enter 확정 — 값이 되고 목록이 닫히고 칩이 돌아온다 var picked = tagRow.Completions[0]; Check("완성: Enter 로 확정된다", tagRow.CommitCompletion()); Check("완성: 확정한 값이 커밋된다", tagRow.ValueText == picked && committed.Contains(picked), $"값 {tagRow.ValueText}, 커밋 {committed.Count}회"); Check("완성: 확정하면 목록이 닫힌다", !tagRow.ShowCompletions); Check("완성: 확정하면 칩이 돌아온다", tagRow.ShowSuggestions); // 정확히 그것만 남으면 목록을 띄우지 않는다 — 다 골랐는데 가리는 셈이다 tagRow.BeginTyping(); tagRow.RefreshCompletions("PAT_이름"); var exact = tagRow.Completions.Count == 1 && tagRow.Completions[0] == "PAT_이름"; Check("완성: 정확히 하나 남으면 안 가린다", !exact || !tagRow.ShowCompletions, $"목록 {tagRow.Completions.Count}개, 열림 {tagRow.ShowCompletions}"); // Esc 는 값을 바꾸지 않는다 var before = tagRow.ValueText; tagRow.BeginTyping(); tagRow.RefreshCompletions("ETC_"); tagRow.EndTyping(); Check("완성: Esc 는 값을 바꾸지 않는다", tagRow.ValueText == before && !tagRow.ShowCompletions); } // 간격을 숫자로 놓는다 — 4px 씩이 아니라 정확한 값으로. { var gaps = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); gaps.AddControlAt("Label", new Point(60, 100)); gaps.AddControlAt("Label", new Point(60, 160)); gaps.AddControlAt("Label", new Point(60, 260)); var rows = gaps.Pages[0].Controls.Where(c => c.Type == "Label").OrderBy(c => c.Y).ToList(); gaps.Selection.Set(rows); Check("간격값: 갈린 값은 빈 칸", gaps.CurrentSpacing(horizontal: false) is null); gaps.SetExactSpacing(24, horizontal: false); Check("간격값: 24 로 놓으면 24 가 된다", gaps.CurrentSpacing(horizontal: false) == 24, $"실제 {gaps.CurrentSpacing(horizontal: false)}"); var head = rows.First(); var headY = head.Y; gaps.SetExactSpacing(8, horizontal: false); Check("간격값: 첫 항목은 움직이지 않는다", Math.Abs(head.Y - headY) < 0.5, $"{headY} → {head.Y}"); Check("간격값: 다시 넣어도 밀리지 않는다", gaps.CurrentSpacing(horizontal: false) == 8); // 음수·헛값은 무시한다 — 조용히 뒤엉킨 배치가 되는 것보다 아무 일도 안 하는 쪽이 낫다 gaps.SetExactSpacing(-4, horizontal: false); Check("간격값: 음수는 무시한다", gaps.CurrentSpacing(horizontal: false) == 8); gaps.SetExactSpacing(double.NaN, horizontal: false); Check("간격값: 숫자가 아니면 무시한다", gaps.CurrentSpacing(horizontal: false) == 8); } // 미리보기가 인쇄 필터를 실제로 쓰는가. // PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는 // 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다. { var paper = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); paper.AddControlAt("Label", new Point(60, 60)); paper.AddControlAt("Label", new Point(60, 120)); paper.AddControlAt("TextBox", new Point(60, 180)); var page = paper.Pages[0]; var all = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; // 라벨은 소문자 visible, 텍스트박스는 대문자 Visible — 키를 바꿔 쓰면 안 걸려야 한다 var lowerLabel = page.Controls.First(c => c.Type == "Label"); lowerLabel.Model.Props.SetText("visible", "False"); var afterLabel = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; var box = page.Controls.First(c => c.Type == "TextBox"); box.Model.Props.SetText("PrintOutPut", "False"); var afterBox = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; var wrongKey = page.Controls.Last(c => c.Type == "Label"); wrongKey.Model.Props.SetText("Visible", "False"); var afterWrongKey = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; Check("미리보기: 라벨 소문자 visible=False 를 뺀다", afterLabel == all - 1, $"전 {all} → 후 {afterLabel}"); Check("미리보기: PrintOutPut=False 를 뺀다", afterBox == all - 2, $"전 {all} → 후 {afterBox}"); Check("미리보기: 라벨의 대문자 Visible 은 무시한다", afterWrongKey == all - 2, $"전 {all} → 후 {afterWrongKey} (레거시는 이 라벨을 인쇄한다)"); // 컨테이너 도 필터를 타는가. // 위 검사는 전부 최상위였다. Panel·GroupBox 템플릿이 자식을 스스로 그리므로 // 최상위만 걸러서는 컨테이너 안이 그대로 나왔다 — // 인쇄 출력을 꺼도 인쇄됐고, 컨테이너 안 MDataTable 은 파란 DB 배지가 종이에 찍혔다. var boxed = new FormDocument(); var boxedPage = new FormPage { Width = 720, Height = 856 }; boxed.Pages.Add(boxedPage); var host = new ControlElement { Type = "Panel", Id = "PanelP", Bounds = new LayoutRect { X = 20, Y = 20, W = 400, H = 200 }, }; foreach (var (id, y) in new[] { ("보이는라벨", 10.0), ("숨긴라벨", 40.0) }) { var inner = new ControlElement { Type = "Label", Id = id, Bounds = new LayoutRect { X = 10, Y = y, W = 200, H = 18 }, }; inner.Props.SetText("Text", id); host.Children.Add(inner); } host.Children.Add(new ControlElement { Type = "MDataTable", Id = "안쪽데이터소스", Bounds = new LayoutRect { X = 10, Y = 90, W = 120, H = 40 }, }); boxedPage.Controls.Add(host); var boxedVm = new DesignerViewModel(boxed); var boxedPanel = (ContainerViewModel)boxedVm.Pages[0].Controls.First(c => c.Type == "Panel"); // 레이아웃을 돌려야 템플릿이 실체화된다 — 안 돌리면 Canvas 와 프레젠터만 세고 // 컨테이너 안은 존재하지도 않아 늘 같은 수가 나온다(실제로 2 → 2 로 걸렸다). int Rendered() { var visual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(boxedVm.Pages[0]); visual.Measure(new System.Windows.Size(720, 856)); visual.Arrange(new Rect(0, 0, 720, 856)); visual.UpdateLayout(); return CountVisuals(visual); } var baseline = Rendered(); boxedPanel.Children.First(c => c.Model.Id == "숨긴라벨") .Model.Props.SetText("PrintOutPut", "False"); var afterHide = Rendered(); Check("미리보기: 컨테이너 안의 PrintOutPut=False 도 빠진다", afterHide < baseline, $"요소 {baseline} → {afterHide} (안 줄면 최상위만 거르고 있다)"); // 보기 전환 — 레거시 미리보기는 PrintOutPut=False 를 그대로 보여 준다. // 필터를 끄면 방금 뺀 것이 돌아와야 한다. 안 돌아오면 전환이 배선만 된 것이다. int RenderedRaw() { var visual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(boxedVm.Pages[0], printFilter: false); visual.Measure(new System.Windows.Size(720, 856)); visual.Arrange(new Rect(0, 0, 720, 856)); visual.UpdateLayout(); return CountVisuals(visual); } Check("미리보기: '화면 그대로' 로 바꾸면 숨긴 것이 돌아온다", RenderedRaw() > afterHide, $"인쇄기준 {afterHide} → 화면기준 {RenderedRaw()}"); // 인쇄 필터가 캔버스로 새면 안 된다 — 편집 중인 것을 못 보면 고칠 수 없다 Check("미리보기: 캔버스는 자식을 그대로 다 보여 준다", boxedPanel.Children.Count == 3, $"{boxedPanel.Children.Count} (기대 3)"); // 창이 편집을 따라오는가. // 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제해도 옛 그림을 계속 보여 줬다 — // 저장 전에 결과를 확인하려고 여는 창이 구조적으로 거짓말을 하고 있었다. var live = new Views.PreviewWindow(boxedVm) { Left = -10000, Top = -10000, WindowStartupLocation = WindowStartupLocation.Manual, ShowActivated = false, ShowInTaskbar = false, }; live.Show(); // 레이아웃을 돌리고 센다 — 재빌드 직후에는 새 자식이 아직 실체화되지 않아 // 요소 수가 오히려 줄어 보인다(실제로 52 → 41 로 걸렸다). 세 번째 같은 함정이다. int Pages() { live.UpdateLayout(); return CountVisuals(live); } var livePages = Pages(); boxedVm.AddControlAt("Label", new Point(300, 400)); var liveAfterAdd = Pages(); Check("미리보기: 컨트롤을 추가하면 창이 따라온다", liveAfterAdd > livePages, $"요소 {livePages} → {liveAfterAdd} (안 늘면 생성자에서 한 번만 그린 것)"); live.Close(); // 종이 글자는 Display 로 바뀌지 않았는가 — 대조군이자 회귀 방어다. // 화면 UI 를 Display 로 돌리면서 종이까지 끌려가면 글리프 자릿수가 달라져 // 레거시와 다른 지면이 된다. --db-render md5 가 잠그고 있지만 // 그건 서식 하나뿐이므로 여기서 속성 값으로도 못 박는다. { var paperPage = new FormDocument(); var paperOnly = new FormPage { Width = 720, Height = 856 }; paperPage.Pages.Add(paperOnly); var paperLabel = new ControlElement { Type = "Label", Id = "종이글자", Bounds = new LayoutRect { X = 20, Y = 20, W = 200, H = 20 }, }; paperLabel.Props.SetText("Text", "종이"); paperOnly.Controls.Add(paperLabel); var paperVm = new DesignerViewModel(paperPage); var paperVisual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(paperVm.Pages[0]); paperVisual.Measure(new System.Windows.Size(720, 856)); paperVisual.Arrange(new Rect(0, 0, 720, 856)); paperVisual.UpdateLayout(); var paperBlock = FindFirstTextBlock(paperVisual); Check("종이 글자는 Ideal 을 유지한다(화면 Display 와 분리)", paperBlock is not null && System.Windows.Media.TextOptions.GetTextFormattingMode(paperBlock) == System.Windows.Media.TextFormattingMode.Ideal, paperBlock is null ? "종이에서 TextBlock 을 못 찾아 판정 불가" : System.Windows.Media.TextOptions.GetTextFormattingMode(paperBlock).ToString()); } // 태그가 종이에서 값이 되는가. // 지금까지 미리보기는 태그를 글자 그대로 찍었다 — PAT_이름 이 종이에 그렇게 인쇄됐다. // 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 사유로 바뀌어야 한다 // (빈칸이면 서식이 잘못된 줄 알고, 태그 이름 그대로면 값이 나온 줄 안다). var tagged = new FormDocument(); var tagPage = new FormPage { Width = 720, Height = 856 }; tagged.Pages.Add(tagPage); foreach (var (id, tag) in new[] { ("시각", "ETC_현재일자"), ("환자", "PAT_차트번호") }) { var field = new ControlElement { Type = "TextBox", Id = id, Bounds = new LayoutRect { X = 40, Y = id == "시각" ? 40 : 80, W = 200, H = 20 }, }; field.Props.SetText("Text", tag); field.Props.SetText("DataInterfaceTag", tag); tagPage.Controls.Add(field); } var taggedVm = new DesignerViewModel(tagged); string PaperText(SheetMe.Core.Catalog.ITagValueResolver? resolver) { var visual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(taggedVm.Pages[0], true, resolver); visual.Measure(new System.Windows.Size(720, 856)); visual.Arrange(new Rect(0, 0, 720, 856)); visual.UpdateLayout(); return CollectText(visual); } // 해석기가 없으면 지금까지와 같다 — 태그 이름 그대로 var noResolver = PaperText(null); Check("태그 값: 해석기가 없으면 태그 이름 그대로", noResolver.Contains("PAT_차트번호", StringComparison.Ordinal) && !noResolver.Contains("[", StringComparison.Ordinal), noResolver); // 값이 나오는 태그는 값으로, 안 나오는 태그는 빈칸으로 // (2026-08-20 사용자 결정 — 출력물에 [태그 — 사유] 가 찍히면 안 된다. // 태그 이름이 남아도 안 된다: 값이 나온 줄 알게 된다) var withResolver = PaperText(new StubTagResolver()); Check("태그 값: 값이 나오는 태그는 값이 찍힌다", withResolver.Contains("2026-08-18", StringComparison.Ordinal), withResolver); Check("태그 값: 못 만드는 태그는 빈칸이 된다", !withResolver.Contains("PAT_차트번호", StringComparison.Ordinal) && !withResolver.Contains("[", StringComparison.Ordinal), withResolver); // ── 환자 문맥을 붙였을 때 ── // 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고, // 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다"). // DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다. static SheetMe.Data.Stores.PatientContext Fake(string chart, string name) { var patInf = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["PATNAM"] = name, }; // 차트번호는 내원이 들고 있다 — 레거시 moPatInfoBiz.ChtNum 이 그것이다 // (bzDataInterface.vb:70). 여기를 PatInf 로 두면 실제와 다른 것을 검사한다. var comInf = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["COMCHTNUM"] = chart, }; var blank = new Dictionary(StringComparer.OrdinalIgnoreCase); return new SheetMe.Data.Stores.PatientContext( 90001m, "202608181500", patInf, comInf, blank, blank, blank); } var patientDoc = new FormDocument(); var patientPage = new FormPage { Width = 720, Height = 856 }; patientDoc.Pages.Add(patientPage); // 넷을 한 종이에 둔다: 매핑된 것 둘, 매핑 안 된 환자 태그 하나, 시각 태그 하나. // 하나만 두면 "값이 나온다"만 보고 나머지 갈래가 조용히 깨져도 모른다. var probes = new[] { ("차트", "PAT_차트번호"), ("이름", "PAT_이름"), ("간호", "ETC_수술실간호사"), ("시각", "ETC_현재일자"), }; for (var i = 0; i < probes.Length; i++) { var probe = new ControlElement { Type = "TextBox", Id = probes[i].Item1, Bounds = new LayoutRect { X = 40, Y = 40 + (i * 40), W = 260, H = 20 }, }; probe.Props.SetText("Text", probes[i].Item2); probe.Props.SetText("DataInterfaceTag", probes[i].Item2); patientPage.Controls.Add(probe); } var patientVm = new DesignerViewModel(patientDoc); string PatientPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver) { var visual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(patientVm.Pages[0], true, resolver); visual.Measure(new System.Windows.Size(720, 856)); visual.Arrange(new Rect(0, 0, 720, 856)); visual.UpdateLayout(); return CollectText(visual); } // ── 옮긴 태그 이름이 실존하는가 ── // 이 검사가 없어서 지어낸 이름 셋(PAT_휴대전화·PAT_전화번호·PAT_주소)이 // 표에 앉아 조용히 아무것도 안 하고 있었다. 이름이 틀리면 매칭이 안 되므로 // 값도 안 나오고 오류도 안 난다 — 화면에는 "아직 안 옮겼습니다"로 보인다. var bogus = Services.PatientTagResolver.MappedTags .Where(t => !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains(t)) .ToList(); Check("환자 태그: 옮긴 이름이 모두 실제 태그다", bogus.Count == 0, $"카탈로그에 없는 이름: {string.Join(", ", bogus)}"); // 대조군 — 없는 이름이 실제로 걸리는지. 통과만 보면 검사가 도는지 알 수 없다. Check("환자 태그(대조군): 없는 이름은 걸러진다", !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_주소")); // 대조군 — 환자 없이. 이 줄이 없으면 아래 판정은 "원래 그랬던 것"과 구분되지 않는다. // 2026-08-20 사용자 결정으로 못 만든 태그는 종이에서 빈칸이다 — // 값도, 태그 이름도, 사유도 찍히면 안 된다. var withoutPatient = PatientPaper(new StubTagResolver()); Check("환자 태그(대조군): 환자가 없으면 빈칸이다", !withoutPatient.Contains("C-9001", StringComparison.Ordinal) && !withoutPatient.Contains("PAT_차트번호", StringComparison.Ordinal) && !withoutPatient.Contains("PAT_이름", StringComparison.Ordinal), withoutPatient); var withPatient = PatientPaper( new Services.PatientTagResolver(Fake("C-9001", "가상환자"), new StubTagResolver())); Check("환자 태그: 문맥을 붙이면 값이 찍힌다", withPatient.Contains("C-9001", StringComparison.Ordinal) && withPatient.Contains("가상환자", StringComparison.Ordinal) && !withPatient.Contains("[PAT_차트번호", StringComparison.Ordinal) && !withPatient.Contains("[PAT_이름", StringComparison.Ordinal), withPatient); // 아직 안 옮긴 환자 태그는 종이에서 빈칸이지만 해석기는 그렇다고 말해야 한다 — // 사유는 진단·도구가 쓴다("DB 접속이 없습니다" 로 뭉개면 접속을 의심하게 된다). // 대조군 태그가 이식되면 이 판정이 깨진다 — 그때 다른 미이식 태그로 갈 것(PAT_나이에서 한 번 갈았다). var unmappedOnPaper = new Services.PatientTagResolver( Fake("C-9001", "가상환자"), new StubTagResolver()).Resolve("ETC_수술실간호사"); Check("환자 태그: 매핑 없는 환자 태그는 안 옮겼다고 말한다(해석기)", !unmappedOnPaper.Resolved && unmappedOnPaper.Text.Contains("아직 옮기지", StringComparison.Ordinal), unmappedOnPaper.Text); Check("환자 태그: 매핑 없는 환자 태그는 종이에서 빈칸이다", !withPatient.Contains("ETC_수술실간호사", StringComparison.Ordinal) && !withPatient.Contains("아직 옮기지", StringComparison.Ordinal), withPatient); // 사슬이 끊기지 않았는가 — 환자를 붙였다고 시각 태그가 죽으면 안 된다 Check("환자 태그: 앞 해석기(시각·사용자)가 계속 산다", withPatient.Contains("2026-08-18", StringComparison.Ordinal), withPatient); // 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면 // "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다. // ── DataTableField 배선이 종이에서 값이 되는가 ── // 서식의 값 상당수는 태그가 아니라 이 관으로 온다(운영 실측 Rows 형 1,624건). // 태그만 채워 보면 "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다. var fieldDoc = new FormDocument(); var fieldPage = new FormPage { Width = 720, Height = 856 }; fieldDoc.Pages.Add(fieldPage); var source = new ControlElement { Type = "MDataTable", Id = "MDataTable1", Bounds = new LayoutRect { X = 0, Y = 0, W = 20, H = 20 }, }; source.Props.SetText("Query", "SELECT 'ㄱ' NAM FROM DUAL"); fieldPage.Controls.Add(source); foreach (var (id, wiring, y) in new[] { ("정상", "MDataTable1.Rows(0).Item(\"NAM\")", 40), ("컬럼오타", "MDataTable1.Rows(0).Item(\"NAMX\")", 80), ("없는소스", "MDataTable9.Rows(0).Item(\"NAM\")", 120), ("형식깨짐", "MDataTable1.Rows(0)", 160), }) { var wired = new ControlElement { Type = "TextBox", Id = id, Bounds = new LayoutRect { X = 40, Y = y, W = 300, H = 20 }, }; wired.Props.SetText("Text", id); wired.Props.SetText("DataTableField", wiring); fieldPage.Controls.Add(wired); } var fieldVm = new DesignerViewModel(fieldDoc); string FieldPaper(SheetMe.Core.Catalog.IDataFieldResolver? resolver) { var visual = (System.Windows.FrameworkElement) Services.PrintService.BuildPageVisual(fieldVm.Pages[0], true, null, resolver); visual.Measure(new System.Windows.Size(720, 856)); visual.Arrange(new Rect(0, 0, 720, 856)); visual.UpdateLayout(); return CollectText(visual); } // 대조군 — 해석기가 없으면 지금까지와 같다(배선 문자열이 아니라 Text 가 그대로) var noFields = FieldPaper(null); Check("배선(대조군): 해석기가 없으면 종이가 그대로다", noFields.Contains("정상", StringComparison.Ordinal) && !noFields.Contains("[", StringComparison.Ordinal), noFields); // 표를 직접 쥐고 네 갈래를 한 번에 본다 — DB 없이 판정해야 진단이 접속에 안 묶인다. // 2026-08-20 부터 종이는 못 만든 값을 빈칸으로 두므로, 사유는 해석기 수준에서 // 판정하고 종이에서는 사유가 새지 않았는지를 본다. var stub = new StubFieldResolver(); var withFields = FieldPaper(stub); Check("배선: 맞는 배선은 값이 찍힌다", withFields.Contains("ㄱ", StringComparison.Ordinal), withFields); SheetMe.Core.Catalog.TagValue FieldValue(string wiring) { var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring); return spec is null ? new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다") : stub.Resolve(spec); } var wrongColumn = FieldValue("MDataTable1.Rows(0).Item(\"NAMX\")"); Check("배선: 컬럼명이 틀리면 그 컬럼이 없다고 말한다(해석기)", !wrongColumn.Resolved && wrongColumn.Text.Contains("NAMX", StringComparison.Ordinal) && wrongColumn.Text.Contains("없습니다", StringComparison.Ordinal), wrongColumn.Text); var missingSource = FieldValue("MDataTable9.Rows(0).Item(\"NAM\")"); Check("배선: 없는 데이터소스를 가리키면 이름을 말한다(해석기)", !missingSource.Resolved && missingSource.Text.Contains("MDataTable9", StringComparison.Ordinal), missingSource.Text); // 레거시 런타임은 형식 오류를 빈 catch 로 삼켜 아무 표시 없이 빈칸이 된다. // 종이는 이제 우리도 빈칸이지만, 해석기가 사유를 담아 진단·도구가 잡을 수 있다. var brokenSpec = FieldValue("MDataTable1.Rows(0)"); Check("배선: 형식이 깨지면 읽을 수 없다고 말한다(해석기)", !brokenSpec.Resolved && brokenSpec.Text.Contains("읽을 수 없습니다", StringComparison.Ordinal), brokenSpec.Text); Check("배선: 못 만든 배선은 종이에서 빈칸이다", !withFields.Contains("없습니다", StringComparison.Ordinal) && !withFields.Contains("MDataTable9", StringComparison.Ordinal) && !withFields.Contains("[", StringComparison.Ordinal), withFields); // 러너가 서식에서 데이터소스를 모으는가 — 이름을 못 모으면 배선이 전부 '없는 소스'가 된다. // 패널 안에 있는 것도 모아야 한다(운영 서식은 대개 패널 안에 넣는다). var runner = new Services.MDataTableRunner(fieldDoc, null); Check("배선: 서식에서 데이터소스 이름을 모은다", runner.Names.Count == 1 && runner.Names[0] == "MDataTable1", string.Join(", ", runner.Names)); Check("배선: 환자 없이 만든 러너는 문맥이 없다고 말한다", !runner.HasContext); // ── 미리보기 창을 통해서 값이 흐르는가 ── // 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를 // 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다. // 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다. var patientPreview = new Views.PreviewWindow(patientVm); patientPreview.Show(); string PreviewPaper() { patientPreview.UpdateLayout(); return CollectText(patientPreview); } var previewBefore = PreviewPaper(); patientPreview.UsePatient(Fake("C-9003", "창환자"), "표본"); var previewAfter = PreviewPaper(); Check("미리보기 창: 환자를 붙이면 종이가 값으로 바뀐다", !previewBefore.Contains("C-9003", StringComparison.Ordinal) && previewAfter.Contains("C-9003", StringComparison.Ordinal) && previewAfter.Contains("창환자", StringComparison.Ordinal), $"붙이기 전 {previewBefore.Contains("C-9003", StringComparison.Ordinal)}" + $" / 붙인 뒤 {previewAfter.Contains("C-9003", StringComparison.Ordinal)}"); // 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다. // 미리보기 창은 하나만 뜨므로 그 잔상은 다음 사람이 보게 된다. patientPreview.UsePatient(null, string.Empty); var previewCleared = PreviewPaper(); Check("미리보기 창: 환자를 떼면 값이 사라진다", !previewCleared.Contains("C-9003", StringComparison.Ordinal) && !previewCleared.Contains("PAT_차트번호", StringComparison.Ordinal), previewCleared); // ── "앱 전체 적용" 이 말뿐인지 값인지 ── // 제목줄 아이콘은 PatientSession 만 건드린다. 열려 있는 창들이 그것을 듣지 않으면 // 아이콘은 켜지는데 종이는 그대로다 — 사용자에게는 "설정했는데 안 먹는다"가 된다. // 세션에만 쓰고 종이가 따라오는지 본다(창의 메서드를 직접 부르지 않는다). Services.PatientSession.Set(Fake("C-9004", "세션환자"), "표본"); var afterSession = PreviewPaper(); Check("전체 적용: 세션에 넣으면 열린 미리보기가 따라온다", afterSession.Contains("C-9004", StringComparison.Ordinal) && afterSession.Contains("세션환자", StringComparison.Ordinal), afterSession); Services.PatientSession.Set(null, string.Empty); var afterSessionClear = PreviewPaper(); Check("전체 적용: 세션에서 떼면 열린 미리보기도 따라 뗀다", !afterSessionClear.Contains("C-9004", StringComparison.Ordinal) && !afterSessionClear.Contains("PAT_차트번호", StringComparison.Ordinal), afterSessionClear); // ── 미리보기와 인쇄가 같은 종이인가 ── // 인쇄 경로(BuildFixedDocument)가 해석기 없이 BuildPageVisual 을 불러서 // 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로였다 — // 실제 인쇄 PDF 를 화면과 나란히 놓고서야 발견했다. 이 판정이 없어서 못 잡은 것이다. string PrintPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver) { var fixedDoc = Services.PrintService.BuildFixedDocument( patientVm, resolver, null); var fixedPage = fixedDoc.Pages[0].Child; fixedPage.Measure(new System.Windows.Size(720, 856)); fixedPage.Arrange(new Rect(0, 0, 720, 856)); fixedPage.UpdateLayout(); return CollectText(fixedPage); } var printResolver = new Services.PatientTagResolver( Fake("C-9006", "인쇄환자"), new StubTagResolver()); // 대조군 — 해석기 없이 뽑으면 값이 없어야 한다. 이 줄이 없으면 아래 판정은 // "원래 값이 어디서든 나온다"와 구분되지 않는다. var printBlind = PrintPaper(null); Check("인쇄(대조군): 해석기가 없으면 값이 없다", !printBlind.Contains("C-9006", StringComparison.Ordinal), printBlind); var printPaper = PrintPaper(printResolver); var previewPaperSame = PatientPaper(printResolver); Check("인쇄: 해석기를 넘기면 환자 값이 인쇄 종이에 찍힌다", printPaper.Contains("C-9006", StringComparison.Ordinal) && printPaper.Contains("인쇄환자", StringComparison.Ordinal), printPaper); Check("인쇄: 미리보기 종이와 인쇄 종이의 글자가 같다", printPaper == previewPaperSame, $"미리보기 {previewPaperSame.Length}자 vs 인쇄 {printPaper.Length}자"); // 창을 닫은 뒤에도 이벤트가 남아 있으면 회수되지 않고, 죽은 창이 계속 그린다. // 닫고 다시 세션을 건드려 예외 없이 지나가는지 본다(구독이 남아 있으면 // 죽은 창의 PagesHost 를 만지다 터진다). patientPreview.Close(); Services.PatientSession.Set(Fake("C-9005", "닫힌뒤"), "표본"); Services.PatientSession.Set(null, string.Empty); Check("전체 적용: 창을 닫으면 구독이 떨어진다", true, "예외 없이 통과"); // 값이 비면 종이는 빈칸이지만 해석기는 사유를 담아야 한다 — // "이 환자는 이름이 없다"와 "태그가 고장났다"를 진단에서 구분하는 근거다 var emptyNameResolver = new Services.PatientTagResolver( Fake("C-9002", string.Empty), new StubTagResolver()); var emptyNameValue = emptyNameResolver.Resolve("PAT_이름"); Check("환자 태그: 값이 비면 사유를 밝힌다(해석기)", !emptyNameValue.Resolved && emptyNameValue.Text.Contains("비어", StringComparison.Ordinal), emptyNameValue.Text); var emptyName = PatientPaper(emptyNameResolver); Check("환자 태그: 값이 비면 종이에서는 빈칸이다", emptyName.Contains("C-9002", StringComparison.Ordinal) && !emptyName.Contains("비어", StringComparison.Ordinal) && !emptyName.Contains("PAT_이름", StringComparison.Ordinal), emptyName); } // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) var textBox = Find("TextBox"); var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2); designer.Interaction.PointerDown(clickPoint, none); designer.Interaction.PointerUp(clickPoint, none); Check("클릭 선택: Primary=TextBox", designer.Selection.Primary == textBox); // 3) 드래그 이동(Alt=자유 이동 — 결정적 검증) var origX = textBox.X; var origY = textBox.Y; designer.Interaction.PointerDown(clickPoint, none); designer.Interaction.PointerMove(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt); designer.Interaction.PointerUp(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt); Check("드래그 이동 +33,+47", textBox.X == origX + 33 && textBox.Y == origY + 47, $"실제 ({textBox.X},{textBox.Y}) 기대 ({origX + 33},{origY + 47})"); // 4) Undo → 위치 원복(VM 재생성되므로 재조회) designer.Undo.Undo(); var textBoxAfterUndo = Find("TextBox"); Check("Undo: 위치 원복", textBoxAfterUndo.X == origX && textBoxAfterUndo.Y == origY, $"실제 ({textBoxAfterUndo.X},{textBoxAfterUndo.Y})"); // 5) Redo → 이동 재적용 designer.Undo.Redo(); var textBoxAfterRedo = Find("TextBox"); Check("Redo: 이동 재적용", textBoxAfterRedo.X == origX + 33 && textBoxAfterRedo.Y == origY + 47); // 6) 리사이즈 — SE 핸들(4번) 드래그 var target = Find("TextBox"); designer.Selection.SetSingle(target); var bbox = designer.SelectionWorldBounds()!.Value; var seHandle = new Point(bbox.Right, bbox.Bottom); var origW = target.Width; var origH = target.Height; designer.Interaction.PointerDown(seHandle, none); designer.Interaction.PointerMove(new Point(seHandle.X + 40, seHandle.Y + 18), alt); designer.Interaction.PointerUp(new Point(seHandle.X + 40, seHandle.Y + 18), alt); Check("리사이즈 +40,+18", target.Width == origW + 40 && target.Height == origH + 18, $"실제 ({target.Width},{target.Height}) 기대 ({origW + 40},{origH + 18})"); // 7) 마퀴 다중선택 — 전체를 덮는 사각형 designer.Selection.Clear(); designer.Interaction.PointerDown(new Point(1, 1), none); designer.Interaction.PointerMove(new Point(700, 700), none); designer.Interaction.PointerUp(new Point(700, 700), none); Check("마퀴: 3개 선택", designer.Selection.Items.Count == 3, $"실제 {designer.Selection.Items.Count}"); // 8) Ctrl+클릭 토글 해제 var label = Find("Label"); var labelPoint = new Point(label.X + 3, label.Y + 3); designer.Interaction.PointerDown(labelPoint, ctrl); designer.Interaction.PointerUp(labelPoint, ctrl); Check("Ctrl+클릭: 토글 해제 → 2개", designer.Selection.Items.Count == 2); // 9) 복사/붙여넣기 designer.Selection.SetSingle(Find("CheckBox")); designer.CopySelection(); designer.Paste(); Check("붙여넣기: 4개 + 새 Id", designer.Pages[0].Controls.Count == 4 && designer.Pages[0].Controls.Count(c => c.Type == "CheckBox") == 2 && designer.Pages[0].Controls.Where(c => c.Type == "CheckBox").Select(c => c.Id).Distinct().Count() == 2); // 10) 삭제 designer.DeleteSelection(); Check("삭제: 3개", designer.Pages[0].Controls.Count == 3); // 11) z-order — TextBox 맨 뒤로 (모델 마지막 = VM 첫번째) designer.Selection.SetSingle(Find("TextBox")); designer.ReorderSelection(toFront: false); Check("맨 뒤로: 모델 끝/VM 처음", designer.Document.Pages[0].Controls[^1].Type == "TextBox" && designer.Pages[0].Controls[0].Type == "TextBox"); // 12) 넛지 var nudgeTarget = Find("Label"); designer.Selection.SetSingle(nudgeTarget); var beforeNudgeX = nudgeTarget.X; designer.NudgeSelection(1, 0); designer.NudgeSelection(1, 0); Check("넛지 +2", nudgeTarget.X == beforeNudgeX + 2); // 13) 저장 → 재로드 → 재직렬화 동일(왕복 안정) var tempPath = Path.Combine(Path.GetTempPath(), "sheetme_editsmoke.xml"); business.SaveXmlFile(designer.Document, tempPath); var reloaded = business.OpenXmlFile(tempPath); var xmlA = serializer.Write(designer.Document); var xmlB = serializer.Write(reloaded); Check("저장/재로드 왕복 동일", xmlA == xmlB); // 14) 신규 컨트롤 XML 에 레거시 필수 요소 포함 Check("XML: AQN/Location/Name 포함", xmlA.Contains("M.EMR.UserControl.TextBox,") && xmlA.Contains("") && xmlA.Contains("")); // 15) 인스펙터 — 선택 시 행 구성 + Text 커밋 var inspectorTarget = Find("Label"); designer.Selection.SetSingle(inspectorTarget); Check("인스펙터: 행 생성", designer.Inspector.Rows.Count > 5, $"행 {designer.Inspector.Rows.Count}"); var textRow = designer.Inspector.Rows .OfType() .FirstOrDefault(r => r.Label == "텍스트"); Check("인스펙터: 텍스트 행 존재", textRow is not null); if (textRow is not null) { textRow.ValueText = "수정된 라벨"; Check("인스펙터: Text 커밋 → 모델 반영", inspectorTarget.Model.Props.GetText("Text") == "수정된 라벨" && inspectorTarget.Text == "수정된 라벨"); Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo); } // 16) 인스펙터 — 이름 변경 var nameRow = designer.Inspector.Rows .OfType() .FirstOrDefault(r => r.Label == "이름"); if (nameRow is not null) { nameRow.ValueText = "lblTitle"; Check("인스펙터: 개명 반영", inspectorTarget.Id == "lblTitle"); } // 17) 페이지 추가/삭제 + 용지 크기 designer.AddPage(); Check("페이지 추가: 2페이지", designer.Pages.Count == 2 && designer.Document.Pages.Count == 2); // 루트 이름이 겹치면 E_SctMst 동기화가 둘째 루트를 첫째 행에 매핑해 항목 계층이 깨진다 Check("페이지 추가: 루트 이름 유일", designer.Document.Pages.Select(p => p.Root.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count() == designer.Document.Pages.Count, string.Join("/", designer.Document.Pages.Select(p => p.Root.Id))); designer.SelectedPage = designer.Pages[1]; designer.ApplyPaperSize(794, 1123); Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123); designer.RemoveSelectedPageForSmoke(); Check("페이지 삭제: 1페이지", designer.Pages.Count == 1); // 18) 컨테이너 드롭 — Panel 배치 후 내부에 TextBox 드롭 designer.AddControlAt("Panel", new Point(400, 400)); var panel = designer.Pages[0].Controls.OfType().First(); designer.AddControlAt("TextBox", new Point(panel.X + 30, panel.Y + 30)); Check("컨테이너 드롭: 자식 추가", panel.Model.Children.Count == 1 && panel.Children.Count == 1 && panel.Model.Children[0].Type == "TextBox"); // 19) 컨테이너 자식 직접 편집 — 히트테스트/이동/삭제 var panelVm = designer.Pages[0].Controls.OfType().First(); var child = panelVm.Children[0]; var childWorld = designer.WorldBoundsOf(child); var childHit = designer.HitTestControl(new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2)); Check("자식 히트테스트", childHit == child, $"실제 {childHit?.Id}"); designer.Selection.SetSingle(child); var childOrigX = child.X; var center = new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2); designer.Interaction.PointerDown(center, none); designer.Interaction.PointerMove(new Point(center.X + 15, center.Y + 10), alt); designer.Interaction.PointerUp(new Point(center.X + 15, center.Y + 10), alt); Check("자식 이동(+15,+10 상대좌표)", child.X == childOrigX + 15, $"실제 X={child.X} 기대 {childOrigX + 15}"); designer.Selection.SetSingle(child); designer.DeleteSelection(); Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0); // 20) 그룹 — 이제 진짜 Panel 로 감싼다. 별도 문서에서 본다(메인 designer 의 최상위 구성이 바뀌면 // 뒤따르는 Find("TextBox") 들이 전부 무너진다 — 실제로 그렇게 깨져서 분리했다). { var grp = new DesignerViewModel(business.CreateNew()); ControlViewModel Put(string type, double x, double y, double w, double h) { grp.AddControlAt(type, new Point(x, y)); var vm = grp.Pages[0].Controls[^1]; vm.X = x; vm.Y = y; vm.Width = w; vm.Height = h; return vm; } var a = Put("Label", 100, 100, 60, 20); var b = Put("TextBox", 200, 140, 80, 24); grp.Selection.Set(new List { a, b }, a); grp.GroupSelection(); var wrap = grp.Pages[0].Controls.OfType().FirstOrDefault(c => c.Type == "Panel"); Check("그룹: 패널이 생긴다", wrap is not null); Check("그룹: 멤버가 패널 자식이 된다", wrap is not null && wrap.Children.Count == 2 && !grp.Pages[0].Controls.Contains(a) && !grp.Pages[0].Controls.Contains(b), $"자식 {wrap?.Children.Count} 최상위 {grp.Pages[0].Controls.Count}"); Check("그룹: 패널이 멤버 bbox 를 덮는다", wrap is not null && wrap.X == 100 && wrap.Y == 100 && wrap.Width == 180 && wrap.Height == 64, $"실제 ({wrap?.X},{wrap?.Y},{wrap?.Width},{wrap?.Height})"); Check("그룹: 자식 좌표가 부모 상대로 바뀐다", wrap is not null && wrap.Children.Any(c => c.X == 0 && c.Y == 0) && wrap.Children.Any(c => c.X == 100 && c.Y == 40), string.Join(" ", wrap?.Children.Select(c => $"({c.X},{c.Y})") ?? Array.Empty())); // 자식을 집어도 패널이 잡히고, 끌면 묶음째 움직인다 — 이게 '그룹처럼'의 알맹이다 var kid = wrap!.Children[0]; var kidWorld = grp.WorldBoundsOf(kid); var grab = new Point(kidWorld.X + kidWorld.Width / 2, kidWorld.Y + kidWorld.Height / 2); grp.Selection.Clear(); grp.Interaction.PointerDown(grab, none); Check("그룹: 자식을 집으면 패널이 잡힌다", grp.Selection.Items.Count == 1 && ReferenceEquals(grp.Selection.Items[0], wrap), $"실제 {grp.Selection.Items.Count}개 / {grp.Selection.Items.FirstOrDefault()?.Type}"); var panelBefore = wrap.X; var kidLocalBefore = kid.X; grp.Interaction.PointerMove(new Point(grab.X + 40, grab.Y), alt); grp.Interaction.PointerUp(new Point(grab.X + 40, grab.Y), alt); Check("그룹: 자식을 끌면 패널째 움직인다", wrap.X == panelBefore + 40 && kid.X == kidLocalBefore, $"패널 {wrap.X}(시작 {panelBefore}) 자식상대 {kid.X}(시작 {kidLocalBefore})"); // 안으로 들어가려면 더블클릭 grp.Selection.Clear(); grp.Interaction.PointerDown(new Point(grab.X + 40, grab.Y), doubleClick); grp.Interaction.PointerUp(new Point(grab.X + 40, grab.Y), doubleClick); Check("그룹: 더블클릭이면 안으로 들어간다", grp.Selection.Items.Count == 1 && ReferenceEquals(grp.Selection.Items[0], kid), $"실제 {grp.Selection.Items.FirstOrDefault()?.Type}"); // 풀기 — 절대좌표 복원 grp.Selection.SetSingle(wrap!); grp.UngroupSelection(); Check("그룹 풀기: 최상위로 돌아온다", grp.Pages[0].Controls.Count == 2 && !grp.Pages[0].Controls.OfType().Any(), $"최상위 {grp.Pages[0].Controls.Count}"); Check("그룹 풀기: 좌표가 절대값으로 복원된다", grp.Pages[0].Controls.Any(c => c.X == 140 && c.Y == 100) && grp.Pages[0].Controls.Any(c => c.X == 240 && c.Y == 140), string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})"))); // 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다 var radioDoc = new DesignerViewModel(business.CreateNew()); radioDoc.AddControlAt("RadioButton", new Point(100, 100)); radioDoc.AddControlAt("RadioButton", new Point(100, 140)); radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]); var beforeRadio = radioDoc.Pages[0].Controls.Count; radioDoc.GroupSelection(); Check("그룹 가드: 라디오는 묶이지 않는다", radioDoc.Pages[0].Controls.Count == beforeRadio && !radioDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); // 가드 ② 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다 var zDoc = new DesignerViewModel(business.CreateNew()); zDoc.AddControlAt("Label", new Point(100, 100)); zDoc.AddControlAt("Label", new Point(120, 120)); zDoc.AddControlAt("Label", new Point(140, 140)); var zTop = zDoc.Pages[0].Controls[0]; var zBottom = zDoc.Pages[0].Controls[2]; zDoc.Selection.Set(new List { zTop, zBottom }, zTop); var beforeZ = zDoc.Pages[0].Controls.Count; zDoc.GroupSelection(); Check("그룹 가드: 사이에 남이 끼면 묶지 않는다", zDoc.Pages[0].Controls.Count == beforeZ && !zDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); } // 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋 var inlineTarget = Find("Label"); ControlViewModel? inlineRequested = null; designer.InlineEditRequested += vm => inlineRequested = vm; var inlineWorld = designer.WorldBoundsOf(inlineTarget); var inlineCenter = new Point(inlineWorld.X + inlineWorld.Width / 2, inlineWorld.Y + inlineWorld.Height / 2); designer.Interaction.PointerDown(inlineCenter, new PointerContext(false, false, false, 2)); designer.Interaction.PointerUp(inlineCenter, new PointerContext(false, false, false, 2)); Check("더블클릭 → 인라인 편집 요청", inlineRequested == inlineTarget, $"실제 {inlineRequested?.Id ?? "null"}"); designer.CommitInlineText(inlineTarget, "인라인 수정됨"); Check("인라인 커밋 → 모델/Undo 반영", inlineTarget.Text == "인라인 수정됨" && inlineTarget.Model.Props.GetText("Text") == "인라인 수정됨" && designer.Undo.CanUndo); // 20-2) 폰트 일괄 변경 — 집계 + 적용 var usage = designer.CollectFontUsage(); Check("폰트 집계: 조합 존재", usage.Count >= 2, $"실제 {usage.Count}"); var applied = designer.ApplyFontBulk(usage, new Core.Serialization.LegacyFont { Family = "돋움", SizePt = 12, Bold = true, }); Check("폰트 일괄 적용: 대상 수", applied >= 2, $"실제 {applied}"); var fontTarget = Find("Label"); Check("폰트 일괄 적용: 모델 반영", fontTarget.Model.Props.GetText("Font") == "돋움, 12pt, style=Bold" && fontTarget.EffectiveFont.Family == "돋움" && fontTarget.EffectiveFont.Bold); designer.Undo.Undo(); Check("폰트 일괄 적용: Undo 1스텝 원복", Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold"); // 20-2-1) 레이어 목록 — 그룹 폴더/계층 평탄화/검색·타입 필터/그룹 위생. // 뒤 단계들이 designer 의 컨트롤 개수를 세므로 별도 문서에서 검사한다. { var lay = new DesignerViewModel(business.CreateNew()); lay.AddControlAt("TextBox", new Point(100, 100)); lay.AddControlAt("Label", new Point(100, 200)); lay.AddControlAt("CheckBox", new Point(100, 300)); ControlViewModel Pick(string type) => lay.Pages[0].Controls.First(c => c.Type == type); lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") }); lay.GroupSelection(); // 그룹은 이제 진짜 패널이라 폴더 행(LayerGroupViewModel)이 아니라 컨테이너 행으로 나온다. // 폴더 행은 GroupId 시절의 표현이었고, 그 시절 그룹은 저장하면 사라졌다. var wrapRow = lay.LayerRows.OfType().FirstOrDefault(c => c.Type == "Panel"); Check("레이어: 그룹이 패널 행으로 나온다", wrapRow is not null); Check("레이어: 패널 행 아래 멤버 2", wrapRow is not null && wrapRow.Children.Count == 2, $"실제 {wrapRow?.Children.Count}"); // 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다 // 페이지는 별도 구획으로 나갔다 — 한 장짜리 문서의 트리에는 페이지 마디가 없다 Check("레이어: 페이지 구획이 페이지를 담는다", lay.PageRows.Count == lay.Pages.Count && lay.PageRows.Count == 1); Check("레이어: 한 장이면 트리에 페이지 마디가 없다", !lay.ShowPageNodes && !lay.LayerRows.OfType().Any()); Check("레이어: 첫 행이 곧 컨트롤·그룹이다", lay.LayerRows.FirstOrDefault() is not LayerPageViewModel); Check("레이어: 페이지 헤더는 선택 대상 아님", lay.LayerRows.OfType().Where(r => r is LayerPageViewModel) .All(r => !r.IsSelectableRow)); // 패널 바로 뒤에 자식이 들여쓰기된 채 이어져야 한다(평탄화 시퀀스) var folderIndex = wrapRow is null ? -1 : lay.LayerRows.IndexOf(wrapRow); Check("레이어: 패널 뒤에 자식이 들여쓰여 이어짐", folderIndex >= 0 && lay.LayerRows.Skip(folderIndex + 1).Take(2) .OfType().Count(c => c.LayerIndent > 0) == 2); Check("레이어: 한 장이면 최상위 들여쓰기가 0", lay.LayerRows.OfType() .Where(c => c.Model.GroupId is null && c.Parent is null) .All(c => c.LayerIndent == 0)); // 목록 맨 위 = z-order 최상위 (모델 리스트 index 0) var topControl = lay.LayerRows.OfType().First(); Check("레이어: 목록 맨 위가 z-order 최상위", ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model), $"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}"); // 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다 lay.AddControlAt("Panel", new Point(300, 600)); var layerPanel = lay.Pages[0].Controls.OfType().Last(); lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20)); lay.RebuildLayerRows(); Check("레이어: 컨테이너 자식이 목록에 나옴", lay.LayerRows.OfType().Any(c => ReferenceEquals(c.Parent, layerPanel))); Check("레이어: 자식 들여쓰기 = 부모+16", lay.LayerRows.OfType() .Where(c => ReferenceEquals(c.Parent, layerPanel)) .All(c => c.LayerIndent == layerPanel.LayerIndent + 16)); var withChild = lay.LayerRows.Count; layerPanel.IsLayerExpanded = false; lay.RebuildLayerRows(); Check("레이어: 컨테이너 접으면 자식 제거", lay.LayerRows.Count == withChild - 1); layerPanel.IsLayerExpanded = true; lay.RebuildLayerRows(); // 검색은 자손이 걸리면 조상도 남긴다 — 부모가 빠지면 자식이 통째로 사라진다 var childId = layerPanel.Children[0].Id; lay.LayerFilter = childId; Check("레이어: 검색 시 조상 보존", lay.LayerRows.Contains(layerPanel) && lay.LayerRows.OfType().Any(c => c.Id == childId)); lay.LayerFilter = "존재하지않는이름____"; Check("레이어: 0건이면 빈 목록 안내", lay.IsLayerListEmpty && lay.LayerEmptyText.Contains("검색")); lay.LayerFilter = string.Empty; // 타입 필터 lay.SetLayerTypeFilter(new[] { "CheckBox" }); Check("레이어: 타입 필터 적용", lay.HasLayerTypeFilter && lay.LayerRows.OfType().Any() && lay.LayerRows.OfType().All(c => c.Type == "CheckBox")); lay.SetLayerTypeFilter(Array.Empty()); Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter); // 패널 자식을 복사해 붙이면 최상위로 나온다 — 원본 패널에 끼면 안 된다 var paneled = lay.LayerRows.OfType().FirstOrDefault(c => c.Type == "Panel"); if (paneled is { Children.Count: > 0 }) { lay.Selection.SetSingle(paneled.Children[0]); lay.CopySelection(); lay.Paste(); Check("레이어: 패널 자식 붙여넣기는 원본 패널에 끼지 않음", lay.Selection.Items.Count > 0 && !ReferenceEquals(lay.Selection.Items[0].Parent, paneled)); } // 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다) Check("레이어: 페이지 헤더는 항상 무강조", lay.LayerRows.OfType().All(p => p.Highlight == LayerHighlight.None)); // 페이지 복제 — 컨트롤 이름 유일성과 그룹 분리가 지켜져야 한다 var beforePages = lay.Pages.Count; var beforeIds = lay.Pages[0].Controls.Select(c => c.Id).ToHashSet(StringComparer.Ordinal); var copied = lay.DuplicatePage(lay.Pages[0]); Check("레이어: 페이지 복제 — 장수 +1", lay.Pages.Count == beforePages + 1 && copied is not null); if (copied is not null) { Check("레이어: 페이지 복제 — 컨트롤 수 동일", copied.Controls.Count == lay.Pages[0].Controls.Count); Check("레이어: 페이지 복제 — 이름이 전부 새로 발급됨", copied.Controls.All(c => !beforeIds.Contains(c.Id))); Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨", copied.Controls.Where(c => c.Model.GroupId is not null) .All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId))); // Clone 은 Root.Id 까지 복사한다 — 자식만 재발급하면 같은 이름의 루트가 둘 생긴다 Check("레이어: 페이지 복제 — 루트 이름도 새로 발급", lay.Document.Pages.Select(p => p.Root.Id) .Distinct(StringComparer.OrdinalIgnoreCase).Count() == lay.Document.Pages.Count, string.Join("/", lay.Document.Pages.Select(p => p.Root.Id))); } Check("레이어: 전 페이지가 아웃라인에 나옴", lay.LayerRows.OfType().Count() == lay.Pages.Count); // 페이지 접기 — 그 페이지 컨트롤 행만 빠진다 var pageHeader = lay.LayerRows.OfType().First(); var beforeCollapse = lay.LayerRows.Count; pageHeader.IsExpanded = false; lay.RebuildLayerRows(); Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse); Check("레이어: 페이지 접어도 헤더는 남음", lay.LayerRows.OfType().Count() == lay.Pages.Count); lay.LayerRows.OfType().First().IsExpanded = true; lay.RebuildLayerRows(); Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse); // 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다 var order = lay.LayerRows.OfType() .Where(c => c.Parent is null && ReferenceEquals(lay.PageOf(c), lay.Pages[0])).ToList(); if (order.Count >= 2) { var mover = order[^1]; var anchor = order[0]; lay.MoveControlNextTo(mover, anchor, above: true); Check("레이어: 드래그로 맨 위에 놓으면 z-order 최상위", ReferenceEquals(lay.Pages[0].Model.Controls[0], mover.Model), $"실제 {lay.Pages[0].Model.Controls[0].Id} 기대 {mover.Id}"); Check("레이어: 재정렬이 되돌리기 스텝", lay.Undo.CanUndo); } // 눈·자물쇠도 되돌릴 수 있어야 한다 var flagTarget = lay.LayerRows.OfType().First(); lay.Undo.Snapshot(); var depthBefore = lay.Undo.CanUndo; flagTarget.IsLockedFlag = true; lay.Undo.Undo(); Check("레이어: 잠금 토글이 Undo 로 원복", lay.Pages.SelectMany(p => p.Controls).All(c => !c.Model.Locked) && depthBefore); // 눈·자물쇠는 평소 숨기고 호버/상태 켜짐일 때만 띄운다 — 그 판정 플래그 var flagRow = lay.LayerRows.OfType().First(); Check("레이어: 상태 없으면 토글 숨김 대상", !flagRow.HasLayerFlag); flagRow.IsHiddenFlag = true; Check("레이어: 숨김이면 토글 계속 표시", flagRow.HasLayerFlag); flagRow.IsHiddenFlag = false; Check("레이어: 해제하면 다시 숨김 대상", !flagRow.HasLayerFlag); // 데이터 배선 배지 var badgeTarget = lay.Pages[0].Controls[0]; Check("레이어: 배선 없는 컨트롤은 배지 없음", !badgeTarget.HasDataBinding); badgeTarget.Model.Props.SetText("DataTableField", "DT.Item(\"ChtNum\")"); Check("레이어: DataTableField 있으면 배지", badgeTarget.HasDataBinding); badgeTarget.Model.Props.SetText("DataTableField", string.Empty); } // 20-2-1-2) 페이지 속성 — 레거시 MDesignerHost 루트가 갖는 속성을 편집한다 { var pg = new DesignerViewModel(business.CreateNew()); pg.AddControlAt("Label", new Point(100, 100)); var pageRow = pg.PageRows[0]; pg.ActivatePage(pageRow.Page); Check("페이지 속성: 페이지 모드 진입", pg.InspectedPage is not null && pg.Selection.Items.Count == 0); Check("페이지 속성: 요약이 페이지를 가리킴", pg.Inspector.Summary.StartsWith("페이지 1"), $"실제 {pg.Inspector.Summary}"); Check("페이지 속성: 행 생성", pg.Inspector.Rows.Count > 0, $"행 {pg.Inspector.Rows.Count}"); // 용지 크기 — raw Size 가 아니라 Bounds 를 고쳐야 저장 시 되돌아가지 않는다 var sizePair = pg.Inspector.Rows.OfType().First(); ((ViewModels.Inspector.NumberRowViewModel)sizePair.Left).ValueText = "794"; ((ViewModels.Inspector.NumberRowViewModel)sizePair.Right).ValueText = "1123"; Check("페이지 속성: 용지 크기 편집(A4)", pg.Pages[0].WidthDip == 794 && pg.Pages[0].HeightDip == 1123, $"실제 {pg.Pages[0].WidthDip}×{pg.Pages[0].HeightDip}"); // 저장하면 writer 가 Bounds 로 Size 를 다시 쓴다 — 그 값이 편집 결과와 같아야 한다 var pageXml = serializer.Write(pg.Document); Check("페이지 속성: 저장 XML 의 Size 가 편집값", pageXml.Contains("794, 1123"), "Size 가 Bounds 와 어긋남"); // 배경색 — 값이 없으면 레거시 PropertyGrid 와 같은 기본값(Control)을 보여 주되 // 저장하지는 않는다. 지어낸 값이 XML 에 새로 생기면 왕복 diff 0 이 깨진다. var colorRow = pg.Inspector.Rows.OfType() .FirstOrDefault(r => r.Label == "배경색"); Check("페이지 속성: 배경색 행 존재", colorRow is not null); if (colorRow is not null) { colorRow.ValueText = "Silver"; Check("페이지 속성: 배경색 편집 → 루트 반영", pg.Pages[0].Model.Root.Props.GetText("BackColor") == "Silver"); } // 값이 없는 페이지: 기본값 표시 + 미저장 { var bare = new DesignerViewModel(business.CreateNew()); bare.Pages[0].Model.Root.Props.Remove("BackColor"); bare.ActivatePage(bare.Pages[0]); var bareRow = bare.Inspector.Rows.OfType() .FirstOrDefault(r => r.Label == "배경색"); Check("페이지 속성: 값 없으면 Control 로 표시", bareRow?.ValueText == "Control", $"실제 {bareRow?.ValueText}"); Check("페이지 속성: 값 없으면 설명 표시", bareRow?.Hint is not null); Check("페이지 속성: 표시만 하고 저장하지 않음", bare.Pages[0].Model.Root.Props.GetText("BackColor") is null); Check("페이지 속성: 값 없어도 용지는 흰색", bare.Pages[0].PaperBrush is System.Windows.Media.SolidColorBrush { Color.R: 255, Color.G: 255, Color.B: 255 }); } // 기본 글꼴 — 명시 Font 가 없는 컨트롤이 상속한다 var fontRow = pg.Inspector.Rows.OfType() .FirstOrDefault(r => r.Label == "글꼴"); Check("페이지 속성: 기본 글꼴 행 존재", fontRow is not null); if (fontRow is not null) { fontRow.ValueText = "돋움"; Check("페이지 속성: 기본 글꼴이 루트에 기록", (pg.Pages[0].Model.Root.Props.GetText("Font") ?? string.Empty).StartsWith("돋움"), $"실제 {pg.Pages[0].Model.Root.Props.GetText("Font")}"); } Check("페이지 속성: 편집이 되돌리기 스텝", pg.Undo.CanUndo); Check("페이지 속성: '선택 없음' 안내를 띄우지 않음", pg.Inspector.HasSelection); Check("페이지 속성: 탭 스트립 숨김", !pg.Inspector.ShowTabStrip); // 인라인 팔레트 — 견본은 레거시 원문 값을 그대로 넣는다. // 명명색을 RGB 로 풀면 원문과 달라져 왕복 diff 가 생기고 시스템색의 의미도 잃는다. if (colorRow is not null) { var swatch = colorRow.BackgroundSwatches.First(s => s.Value == "224, 224, 224"); colorRow.ApplySwatchCommand!.Execute(swatch); Check("색 팔레트: 견본이 원문 값 그대로 적용", pg.Pages[0].Model.Root.Props.GetText("BackColor") == "224, 224, 224"); Check("색 팔레트: 적용 후 닫힘", !colorRow.IsPaletteOpen); var named = colorRow.BackgroundSwatches.First(s => s.Value == "Window"); colorRow.ApplySwatchCommand!.Execute(named); Check("색 팔레트: 명명색은 이름 그대로 저장", pg.Pages[0].Model.Root.Props.GetText("BackColor") == "Window"); // 비우기 = 키 제거(기본값 저장이 아니다) — 원래 없던 페이지를 원상태로 되돌릴 수 있어야 한다 colorRow.ClearCommand!.Execute(null); Check("색 팔레트: 비우기는 키를 지운다", pg.Pages[0].Model.Root.Props.GetText("BackColor") is null); } // 컨트롤을 고르면 페이지 모드에서 빠진다 pg.Selection.SetSingle(pg.Pages[0].Controls[0]); Check("페이지 속성: 컨트롤 선택 시 해제", pg.InspectedPage is null && !pg.Inspector.Summary.StartsWith("페이지")); } // 20-2-2) 알림이 무인 실행을 멈추지 않는다 — 진단 모드에서는 창을 만들지 않고 즉시 // 기각 기본값을 돌려줘야 한다. 이 규약이 깨지면 여기서 영원히 대기한다. Check("알림: 진단 모드에서 창 없이 반환", Services.UserSession.IsDiagnostic); Services.DialogService.Notify(Services.DialogKind.Warning, "스모크", "무인 실행 확인"); Check("알림: 확인 기본값 = 취소", !Services.DialogService.Confirm("스모크", "묻지 않고 지나가야 한다")); Check("알림: 3버튼 기본값 = 취소", Services.DialogService.ConfirmWithCancel("스모크", "묻지 않고 지나가야 한다") is null); // 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유) var designerB = new DesignerViewModel(business.CreateNew()); designer.Selection.SetSingle(Find("TextBox")); designer.CopySelection(); var beforePasteB = designerB.Pages[0].Controls.Count; designerB.Paste(); Check("문서 간 붙여넣기: B 문서에 추가", designerB.Pages[0].Controls.Count == beforePasteB + 1 && designerB.Pages[0].Controls.Any(c => c.Type == "TextBox"), $"실제 {designerB.Pages[0].Controls.Count}"); Check("문서 간 붙여넣기: A 문서 불변", designer.Pages[0].Controls.Count(c => c.Type == "TextBox") == 1); // 20-4) 배치 명령 — 같은 크기/간격 균등/페이지 가운데/잘라내기 designer.AddControlAt("Label", new Point(100, 500)); designer.AddControlAt("Label", new Point(300, 520)); designer.AddControlAt("Label", new Point(560, 540)); var arrangeTargets = designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList(); designer.Selection.Set(arrangeTargets, arrangeTargets[0]); designer.SizeToControl("Both"); Check("같은 크기로: 기준에 통일", arrangeTargets.All(c => c.Width == arrangeTargets[0].Width && c.Height == arrangeTargets[0].Height)); designer.AdjustSpacing("HorizEqual"); var sorted = arrangeTargets.OrderBy(c => c.X).ToList(); var gap1 = sorted[1].X - (sorted[0].X + sorted[0].Width); var gap2 = sorted[2].X - (sorted[1].X + sorted[1].Width); Check("가로 간격 균등", Math.Abs(gap1 - gap2) <= 1, $"gap1={gap1} gap2={gap2}"); designer.AdjustSpacing("HorizConcat"); sorted = arrangeTargets.OrderBy(c => c.X).ToList(); Check("가로 붙이기: 간격 0", sorted[1].X == sorted[0].X + sorted[0].Width && sorted[2].X == sorted[1].X + sorted[1].Width); designer.CenterInPage("H"); var bboxAfterCenter = designer.SelectionWorldBounds()!.Value; var pageW = designer.Pages[0].WidthDip; Check("페이지 가로 가운데", Math.Abs((bboxAfterCenter.X + bboxAfterCenter.Width / 2) - pageW / 2) <= 1, $"중심 {bboxAfterCenter.X + bboxAfterCenter.Width / 2} 기대 {pageW / 2}"); var beforeCut = designer.Pages[0].Controls.Count; designer.Selection.Set(new[] { arrangeTargets[0] }, arrangeTargets[0]); designer.CutSelection(); Check("잘라내기: 삭제됨", designer.Pages[0].Controls.Count == beforeCut - 1); designer.Paste(); Check("잘라내기 → 붙여넣기 복원", designer.Pages[0].Controls.Count == beforeCut); // 남은 배치 테스트 컨트롤 정리 designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList()); designer.DeleteSelection(); // 20-5) 탭순서 편집 — 모드 진입 → 역순 클릭 → 완료 적용(레거시 규약 0,100,200…) designer.AddControlAt("TextBox", new Point(100, 620)); designer.AddControlAt("TextBox", new Point(300, 620)); designer.AddControlAt("TextBox", new Point(500, 620)); var tabTargets = designer.Pages[0].Controls .Where(c => c.Type == "TextBox" && c.Y >= 600) .OrderBy(c => c.X) .ToList(); // 2페이지에 이미 순서가 잡힌 컨트롤을 둔다 — 1페이지만 편집했는데 여기가 밀리면 안 된다 designer.AddPage(); designer.AddControlAt("TextBox", new Point(120, designer.Pages[1].OffsetY + 120)); var otherPageBox = designer.Pages[1].Controls.First(c => c.Type == "TextBox"); otherPageBox.Model.Props.SetText("TabIndex", "300"); designer.ToggleTabOrderMode(); // 시작 Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode && designer.Overlay.TabBadges.Count >= 3, $"배지 {designer.Overlay.TabBadges.Count}"); // 오른쪽→왼쪽 역순으로 클릭(입력 순서 지정) for (var i = tabTargets.Count - 1; i >= 0; i--) { var world = designer.WorldBoundsOf(tabTargets[i]); designer.Interaction.PointerDown(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none); designer.Interaction.PointerUp(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none); } Check("탭순서: 배지 순번 표시", designer.Overlay.TabBadges.Count(b => b.IsAssigned) == 3); designer.ToggleTabOrderMode(); // 완료(적용) Check("탭순서: 적용 — 클릭 순서 0/100/200", !designer.IsTabOrderMode && tabTargets[2].Model.Props.GetText("TabIndex") == "0" && tabTargets[1].Model.Props.GetText("TabIndex") == "100" && tabTargets[0].Model.Props.GetText("TabIndex") == "200", $"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}"); Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)", Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true); // 레거시는 페이지마다 별도 디자인 서피스 — 손대지 않은 페이지는 그대로 남아야 한다 Check("탭순서: 손대지 않은 페이지는 불변", otherPageBox.Model.Props.GetText("TabIndex") == "300", $"실제 {otherPageBox.Model.Props.GetText("TabIndex")}"); designer.Undo.Undo(); Check("탭순서: Undo 원복", designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0"); // 정리 designer.SelectedPage = designer.Pages[1]; designer.RemoveSelectedPageForSmoke(); designer.SelectedPage = designer.Pages[0]; designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList()); designer.DeleteSelection(); // 20-5b) 이름 채번 규약 — 지운 번호를 다시 쓰지 않는다(레거시 clsNameCreationService). // 이름은 저장된 답변(E_SctDta.SctObjNam)이 붙는 열쇠다. 번호를 재사용하면 // 지운 칸의 과거 입력이 새 칸에 그대로 나타나고, 액션 대상·CalcBox 수식도 오배선된다. { var pool = new HashSet(StringComparer.OrdinalIgnoreCase) { "TextBox1", "TextBox2", "TextBox3" }; pool.Remove("TextBox2"); Check("이름 채번: 빈 번호를 메우지 않고 최대+1", Services.IdGenerator.NextId(pool, "TextBox") == "TextBox4"); var odd = new HashSet(StringComparer.OrdinalIgnoreCase) { "TextBoxTotal", "TextBox2a", "MyTextBox9" }; Check("이름 채번: 숫자 아닌 접미사는 번호로 보지 않음", Services.IdGenerator.NextId(odd, "TextBox") == "TextBox1"); // 문서 경유 — 가운데 번호를 지워도 그 이름이 되살아나면 안 된다. // (가장 큰 번호를 지운 경우는 최대값 자체가 내려가므로 같은 이름이 다시 나오는 것이 정상이다 — // 레거시 clsNameCreationService 도 현재 사용 중인 이름만 보고 최대+1 을 계산한다.) var beforeAdd = designer.Pages[0].Controls.ToList(); designer.AddControlAt("TextBox", new Point(120, 700)); designer.AddControlAt("TextBox", new Point(320, 700)); var pair = designer.Pages[0].Controls.Except(beforeAdd).ToList(); Check("이름 채번: 시험용 컨트롤 2개 배치", pair.Count == 2, $"실제 {pair.Count}"); var middle = pair.OrderBy(c => c.Id.Length).ThenBy(c => c.Id, StringComparer.Ordinal).First(); var removedId = middle.Id; designer.Selection.SetSingle(middle); designer.DeleteSelection(); var beforeReborn = designer.Pages[0].Controls.ToList(); designer.AddControlAt("TextBox", new Point(520, 700)); var reborn = designer.Pages[0].Controls.Except(beforeReborn).Single(); Check("이름 채번: 삭제한 가운데 이름을 재사용하지 않음", reborn.Id != removedId, $"삭제 {removedId} → 신규 {reborn.Id}"); designer.Selection.Set(pair.Where(c => c != middle).Append(reborn).ToList()); designer.DeleteSelection(); } // 20-5c) 선택이 빈 상태의 Ctrl+드래그 — 주선택이 없어도 죽지 않아야 한다. // Ctrl/Shift 클릭은 선택을 바꾸지 않고 업에서 토글하므로, 빈 선택으로 드래그가 시작될 수 있다. { var dragTarget = Find("Label"); var box = designer.WorldBoundsOf(dragTarget); var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); designer.Selection.Clear(); var survived = true; try { designer.Interaction.PointerDown(grab, ctrl); designer.Interaction.PointerMove(new Point(grab.X + 40, grab.Y + 40), ctrl); designer.Interaction.PointerUp(new Point(grab.X + 40, grab.Y + 40), ctrl); } catch (Exception ex) { survived = false; Check("빈 선택 Ctrl+드래그: 예외 없음", false, ex.GetType().Name + ": " + ex.Message); } if (survived) { Check("빈 선택 Ctrl+드래그: 예외 없음", true); } designer.Selection.Clear(); } // 20-5e) 선 기하 — 레거시 MLine 은 두께 축을 BorderWidth 로 강제하고, // 방향 전환은 길이를 보존한 채 축을 바꾼다. 이걸 안 지키면 디자이너와 EMR 이 다른 그림을 그린다. { var beforeLine = designer.Pages[0].Controls.ToList(); designer.AddControlAt("Line", new Point(60, 300)); var line = designer.Pages[0].Controls.Except(beforeLine).Single(); Check("선: 기본은 가로 200×1", Math.Abs(line.Width - 200) < 0.5 && Math.Abs(line.Height - 1) < 0.5, $"{line.Width}×{line.Height}"); Check("선: 기본에는 Orientation 키가 없다(레거시 DefaultValue 와 동일)", line.Model.Props.GetText("Orientation") is null); // 세로로 끌어 늘려도 두께는 되돌아온다 — '세로처럼 보이지만 EMR 에선 점' 상태를 만들 수 없다 line.Height = 210; SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(line.Model); Check("선: 두께는 끌어서 못 바꾼다", Math.Abs(line.Height - 1) < 0.5, $"높이 {line.Height}"); // 방향 전환 — 길이 보존 SheetMe.Core.Catalog.LineGeometry.ApplyOrientation(line.Model, vertical: true); Check("선: 세로 전환은 길이를 보존", Math.Abs(line.Model.Bounds.W - 1) < 0.5 && Math.Abs(line.Model.Bounds.H - 200) < 0.5, $"{line.Model.Bounds.W}×{line.Model.Bounds.H}"); Check("선: 세로면 Orientation=Vertical 이 기록된다", line.Model.Props.GetText("Orientation") == "Vertical"); // 굵기를 키우면 두께 축만 따라간다 line.Model.Props.SetText("BorderWidth", "3"); SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(line.Model); Check("선: 굵기는 두께 축만 바꾼다", Math.Abs(line.Model.Bounds.W - 3) < 0.5 && Math.Abs(line.Model.Bounds.H - 200) < 0.5, $"{line.Model.Bounds.W}×{line.Model.Bounds.H}"); // 가로로 되돌리면 Orientation 키가 지워진다(레거시 직렬화기가 기본값을 생략하는 것과 같다) SheetMe.Core.Catalog.LineGeometry.ApplyOrientation(line.Model, vertical: false); Check("선: 가로로 되돌리면 Orientation 키가 지워진다", line.Model.Props.GetText("Orientation") is null && Math.Abs(line.Model.Bounds.W - 200) < 0.5 && Math.Abs(line.Model.Bounds.H - 3) < 0.5, $"{line.Model.Bounds.W}×{line.Model.Bounds.H}"); designer.Selection.SetSingle(line); designer.DeleteSelection(); } // 20-5f) 선택 밖 컨트롤을 Ctrl+드래그하면 그것이 끌려야 한다(기존 선택은 유지) { var anchor = Find("CheckBox"); var grabbed = Find("Label"); designer.Selection.SetSingle(anchor); var anchorAt = new Point(anchor.X, anchor.Y); var box = designer.WorldBoundsOf(grabbed); var from = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); var grabbedX = grabbed.X; // Alt 로 정렬 스냅을 끈다 — 스냅이 걸리면 이동량이 가이드에 붙어 델타 단언이 흔들린다 var ctrlAlt = new PointerContext(true, false, true, 1); designer.Interaction.PointerDown(from, ctrlAlt); designer.Interaction.PointerMove(new Point(from.X + 30, from.Y), ctrlAlt); designer.Interaction.PointerUp(new Point(from.X + 30, from.Y), ctrlAlt); Check("Ctrl+드래그: 잡은 컨트롤이 움직인다", Math.Abs(grabbed.X - (grabbedX + 30)) < 2, $"{grabbedX} → {grabbed.X}"); Check("Ctrl+드래그: 기존 선택도 함께 움직인다(합집합)", Math.Abs(anchor.X - (anchorAt.X + 30)) < 2, $"{anchorAt.X} → {anchor.X}"); designer.Undo.Undo(); designer.Selection.Clear(); } // 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다 { designer.AddControlAt("DataTable", new Point(600, 320)); var source = designer.Pages[0].Controls.First(c => c.Type == "DataTable"); var bound = Find("TextBox"); designer.Selection.SetSingle(bound); // 배선은 '무슨 값이 채워지는가'라 데이터 탭에 있다 var previousTab = designer.Inspector.SelectedTab; designer.Inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Data; var dtfRow = designer.Inspector.Rows .OfType().FirstOrDefault(); Check("배선: 전용 편집 행이 붙는다", dtfRow is not null); if (dtfRow is null) { designer.Inspector.SelectedTab = previousTab; goto skipWiring; } Check("배선: 값이 없으면 안내 문구", dtfRow.Summary == "(배선 없음)", dtfRow.Summary); // 운영 다수파인 Rows 형을 읽어야 한다(1,624건, 76%) dtfRow.ValueText = $"{source.Id}.Rows(0).Item(\"ALGYON\")"; Check("배선: Rows 형을 읽는다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"), dtfRow.Summary); // 해석 못 하는 값은 조용히 고치지 않고 경고로 드러낸다 dtfRow.ValueText = "MDataTable1.Rows"; Check("배선: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary); dtfRow.ValueText = string.Empty; designer.Inspector.SelectedTab = previousTab; skipWiring: designer.Selection.SetSingle(source); designer.DeleteSelection(); designer.Selection.Clear(); } // 20-5h) 같은 서식 중복 열기 판정 — 출처(파일/DB)를 보지 않는다. // 출처를 따지면 XML 로 열어 둔 서식을 DB 로 또 열 수 있고, 두 탭에서 교대로 저장하면 // 낙관적 충돌 검출이 어디에도 없어 마지막 저장이 앞선 편집을 통째로 덮는다. { ViewModels.DesignerViewModel Tab(string formId, decimal? history = null, bool fromDb = false) { var doc = new Core.Models.FormDocument { FormId = formId }; doc.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1)); return new ViewModels.DesignerViewModel(doc) { IsFromDb = fromDb, HistorySdgKey = history }; } var fileTab = Tab("S001"); var otherTab = Tab("S002", fromDb: true); var historyTab = Tab("S003", history: 42m); var open = new[] { fileTab, otherTab, historyTab }; Check("중복 열기: 파일 탭을 DB 열기가 찾아낸다", ReferenceEquals(ViewModels.MainViewModel.FindOpenSheet(open, "S001"), fileTab)); Check("중복 열기: 대소문자를 가리지 않는다", ReferenceEquals(ViewModels.MainViewModel.FindOpenSheet(open, "s001"), fileTab)); Check("중복 열기: 이력 탭은 현재본으로 치지 않는다", ViewModels.MainViewModel.FindOpenSheet(open, "S003") is null); Check("중복 열기: 다른 서식은 걸리지 않는다", ViewModels.MainViewModel.FindOpenSheet(open, "S999") is null); // 빈 코드끼리 묶으면 '새 서식'을 두 번 만들 수 없다 Check("중복 열기: 빈 서식코드는 서로 다른 문서로 본다", ViewModels.MainViewModel.FindOpenSheet(new[] { Tab(string.Empty) }, string.Empty) is null); } // 20-5i) 태그 제안 — 후보는 컨트롤 타입마다 거의 완전히 갈린다. // 이미지의 데이터 태그는 운영 전체가 6종(서명·직인·로고)이라 500여 개 목록을 뒤질 이유가 없다. { var pictureSuggest = SheetMe.Core.Catalog.LegacyTagUsageCatalog .SuggestFor("DataInterfaceTag", "PictureBox"); Check("태그 제안: 이미지는 서명·직인으로 좁혀진다", pictureSuggest.Count is > 0 and <= 8 && pictureSuggest[0] == "OCM_진단서의사싸인" && pictureSuggest.All(t => t.Contains("싸인") || t.Contains("직인") || t.Contains("로고")), string.Join(",", pictureSuggest)); var textSuggest = SheetMe.Core.Catalog.LegacyTagUsageCatalog .SuggestFor("DataInterfaceTag", "TextBox"); Check("태그 제안: 텍스트박스는 환자 기본정보가 앞에", textSuggest[0] == "PAT_이름", textSuggest[0]); Check("태그 제안: 타입마다 후보가 다르다", !textSuggest.SequenceEqual(pictureSuggest)); // 액션 태그는 타입 자료가 없으면 추측하지 않는다 — 잘못 권하면 런타임이 조용히 어긋난다 Check("태그 제안: 자료 없는 타입의 액션 태그는 빈 목록", SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataActionTag", "Panel").Count == 0); // 제안은 전부 실제 카탈로그에 있어야 한다 — 없는 태그를 권하면 그 칸은 배포 후 빈칸이 된다 foreach (var (key, catalog) in new[] { ("DataInterfaceTag", (IReadOnlyList)SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags), ("DataActionTag", SheetMe.Core.Catalog.LegacyTagCatalog.DataActionTags), }) { foreach (var type in new[] { "TextBox", "PictureBox", "MaskedTextBox", "CheckBox", "RadioButton", "Button" }) { var bad = SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor(key, type) .Where(t => !catalog.Contains(t)).ToList(); Check($"태그 제안: {key}|{type} 은 전부 카탈로그에 있다", bad.Count == 0, string.Join(",", bad)); } } // 칩을 감추는 기준이 바뀌었다. // // 전에는 "값이 정해지면 감춘다"였고 이 검사가 그것을 고정하고 있었다. // 그 규칙 때문에 잘못 고른 태그를 바꿀 때 도움이 끊겼다 — // 태그를 고치는 일은 처음 고르는 일만큼 잦은데(운영 8,032건) 고치는 쪽만 맨손이었다. // 이제 기준은 '값이 있는지'가 아니라 '타이핑 중인지'다. // 타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다. var tagRow = new ViewModels.Inspector.TagPickerRowViewModel("데이터 태그", "제목", SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, pictureSuggest); Check("태그 제안: 값이 비면 칩을 보여 준다", tagRow.ShowSuggestions); tagRow.ValueText = "PAT_이름"; Check("태그 제안: 값이 있어도 칩이 남는다", tagRow.ShowSuggestions); tagRow.BeginTyping(); Check("태그 제안: 타이핑 중에는 칩을 접는다", !tagRow.ShowSuggestions); } // 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다 Check("태그 카탈로그: 미구현 3종 제외", !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI") && !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_IBW") && !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_IBW_퍼센트")); // 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가 var advTarget = Find("TextBox"); designer.Selection.SetSingle(advTarget); var toggleRow = designer.Inspector.Rows .OfType().FirstOrDefault(); Check("고급: 토글 행 존재", toggleRow is not null); toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty); var advRows = designer.Inspector.Rows .OfType() .Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence") .ToList(); Check("고급: raw 속성 행 노출", designer.Inspector.Rows .OfType() .Any(r => r.Label.StartsWith("전체 속성"))); var addRow = designer.Inspector.Rows .OfType().FirstOrDefault(); Check("고급: 속성 추가 행 존재", addRow is not null); // 자동완성 — 타입별 유효 키를 제안한다(철자를 외워 맞히지 않도록) Check("고급: 제안 목록 존재", addRow!.Suggestions.Count > 0, $"{addRow.Suggestions.Count}개"); Check("고급: 이미 있는 키와 큐레이션된 키는 제안에서 빠진다", !addRow.Suggestions.Contains("AutoHeight") && !addRow.Suggestions.Contains("Text"), "AutoHeight/Text 는 TextBox 인스펙터가 이미 다룬다"); addRow.KeyText = "Print"; Check("고급: 부분 일치로 좁혀진다", addRow.Matches.Count > 0 && addRow.Matches.All(m => m.Contains("Print", StringComparison.OrdinalIgnoreCase)), string.Join(",", addRow.Matches)); Check("고급: 접두 일치가 앞에 온다", addRow.Matches[0].StartsWith("Print", StringComparison.OrdinalIgnoreCase), addRow.Matches[0]); addRow.KeyText = string.Empty; // 라벨의 소문자 visible — 대문자로 쓰면 라벨이 그대로 보인다. 제안이 이 함정을 덮어야 한다. var labelSuggest = SheetMe.Core.Catalog.LegacyPropertyCatalog.SuggestFor("Label", Array.Empty()); Check("고급: 라벨 제안에는 대문자 Visible 이 없다", !labelSuggest.Contains("Visible"), string.Join(",", labelSuggest.Where(s => s.Contains("isible")))); addRow!.KeyText = "EnterTabYon"; addRow.AddCommand!.Execute(null, EventArgs.Empty); Check("고급: 속성 추가됨", advTarget.Model.Props.Contains("EnterTabYon")); var enterTabRow = designer.Inspector.Rows .OfType() .FirstOrDefault(r => r.Label == "EnterTabYon"); Check("고급: 추가 속성 행 노출", enterTabRow is not null); enterTabRow!.ValueText = "True"; Check("고급: raw 값 커밋", advTarget.Model.Props.GetText("EnterTabYon") == "True"); // 21) JSON 왕복(부 포맷) — 편집 결과 유지 var jsonSerializer = new Core.Serialization.FormJsonSerializer(); var json = jsonSerializer.Write(designer.Document); var fromJson = jsonSerializer.Read(json); Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document)); // 22) Spread 판정 — 복제/이름변경 가드의 근거 술어(가드 자체는 MessageBox 를 띄우므로 무인 검증 불가) var spreadElement = new Core.Models.ControlElement { Type = "Spread", Id = "Spread1" }; var panelWithSpread = new Core.Models.ControlElement { Type = "Panel", Id = "Panel9" }; panelWithSpread.Children.Add(new Core.Models.ControlElement { Type = "Label", Id = "L9" }); panelWithSpread.Children.Add(spreadElement.Clone()); var plainPanel = new Core.Models.ControlElement { Type = "Panel", Id = "Panel8" }; plainPanel.Children.Add(new Core.Models.ControlElement { Type = "Label", Id = "L8" }); Check("Spread 판정: 자신", DesignerViewModel.ContainsSpread(spreadElement)); Check("Spread 판정: 컨테이너 자손", DesignerViewModel.ContainsSpread(panelWithSpread)); Check("Spread 판정: 무관 컨트롤 false", !DesignerViewModel.ContainsSpread(plainPanel)); Check("Spread 판정: Placeholder 오탐 없음", !DesignerViewModel.ContainsSpread(new Core.Models.ControlElement { Type = "Placeholder", Id = "P1" })); // 붙여넣기 필터 — 표만 걸러내고 나머지는 통과시켜야 한다(전체 거부는 정상 작업까지 막는다) var mixed = new[] { plainPanel, spreadElement, panelWithSpread, new Core.Models.ControlElement { Type = "Label", Id = "L7" } }; var (pastable, blockedSpreads) = DesignerViewModel.FilterPastable(mixed); Check("붙여넣기 필터: 표 2개 차단", blockedSpreads == 2, $"차단 {blockedSpreads}"); Check("붙여넣기 필터: 나머지 2개 통과", pastable.Count == 2, $"통과 {pastable.Count}"); Check("붙여넣기 필터: 통과분에 표 없음", pastable.All(e => !DesignerViewModel.ContainsSpread(e))); Check("붙여넣기 필터: 표 없으면 전량 통과", DesignerViewModel.FilterPastable(new[] { plainPanel }) is { BlockedSpreads: 0, Pastable.Count: 1 }); // 23) 쿼리 편집기 — XAML 로드/바인딩과 두 모드(SQL/상용구)를 실제로 생성해 확인 var sqlEditor = new Views.QueryEditorWindow("스모크", "SELECT 1 FROM DUAL"); var variableItems = (sqlEditor.FindName("VariableList") as System.Windows.Controls.ListBox)? .ItemsSource?.Cast().Count() ?? 0; Check("쿼리 편집기: 치환 변수 목록 바인딩", variableItems == Core.Catalog.LegacyQueryVariableCatalog.All.Count, $"바인딩 {variableItems}건 / 카탈로그 {Core.Catalog.LegacyQueryVariableCatalog.All.Count}건"); Check("쿼리 편집기: 변수 패널 표시", (sqlEditor.FindName("VariablePane") as UIElement)?.Visibility == Visibility.Visible); sqlEditor.Close(); var wordEditor = new Views.QueryEditorWindow("상용구 수정", "문구", showVariables: false); Check("상용구 모드: 변수 패널 숨김", (wordEditor.FindName("VariablePane") as UIElement)?.Visibility == Visibility.Collapsed); Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title); wordEditor.Close(); // 24) 로그 마스킹 — 접속 문자열이 예외 메시지에 섞여 로그로 나가는 것을 막는 마지막 방어선 // 픽스처에는 절대 실제 자격증명을 쓰지 않는다 — 소스에 박히면 비밀 스캔이 영구히 걸리고, // 저장소 자체가 유출 경로가 된다. const string fakeUser = "SAMPLE_ACCOUNT"; const string fakePassword = "SAMPLE_SECRET_VALUE"; var secretish = $"ORA-12545: Data Source=(HOST=1.2.3.4);User Id={fakeUser};Password={fakePassword};"; var redacted = AppLog.Redact(secretish); Check("마스킹: 비밀번호 제거", !redacted.Contains(fakePassword, StringComparison.Ordinal), redacted); Check("마스킹: 계정 제거", !redacted.Contains(fakeUser, StringComparison.Ordinal), redacted); Check("마스킹: 진단 문맥은 보존", redacted.Contains("ORA-12545", StringComparison.Ordinal), redacted); Check("마스킹: 키 이름은 남김(형태 파악용)", redacted.Contains("Password=", StringComparison.Ordinal), redacted); Check("마스킹: 빈 문자열 안전", AppLog.Redact(string.Empty) == string.Empty); // LogManager 는 ConcurrentQueue 배치 기록이고 flush API 가 없다(6.0.0 에 HandleShutdown 미존재). // 기록 직후에는 파일이 아직 없을 수 있으므로 잠시 폴링한다. AppLog.Info("edit-smoke 로그 배선 확인"); var logWritten = false; for (var wait = 0; wait < 30 && !logWritten; wait++) { logWritten = Directory.Exists(AppLog.LogPath) && Directory.GetFiles(AppLog.LogPath, "*", SearchOption.AllDirectories).Length > 0; if (!logWritten) { System.Threading.Thread.Sleep(100); } } Check("로그: 파일 생성됨", logWritten, AppLog.LogPath); // 25) 기동 인자 파싱 — 레거시 관용 파싱(PadRight 후 split) 동형 Check("인자: 표준 3필드", StartupArguments.ParseRaw("MSYS,,S999") is { UidCod: "MSYS", ShtCod: "S999", ComNum: null }); Check("인자: ComNum 포함", StartupArguments.ParseRaw("MSYS,12345,S999") is { ComNum: 12345, ShtCod: "S999" }); Check("인자: UidCod 단독", StartupArguments.ParseRaw("MSYS") is { UidCod: "MSYS", ShtCod: "" }); Check("인자: 빈 문자열", StartupArguments.ParseRaw("") is { UidCod: "", ShtCod: "" }); Check("인자: 필드 초과해도 index 안전", StartupArguments.ParseRaw("MSYS,,S999,X,Y") is { ShtCod: "S999" }); Check("인자: 비숫자 ComNum 관용 처리", StartupArguments.ParseRaw("MSYS,abc,S999") is { ComNum: null, ShtCod: "S999" }); Check("인자: 진단 플래그는 HIS 인자가 아님", StartupArguments.Parse(new[] { "--db-smoke", "r.txt" }) is null); Check("인자: 인자 없음", StartupArguments.Parse(Array.Empty()) is null); // 26) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게) var dirtyDoc = new DesignerViewModel(business.CreateNew()); Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty); dirtyDoc.AddControlAt("Label", new Point(50, 50)); Check("더티: 편집 후 dirty", dirtyDoc.Undo.IsDirty); dirtyDoc.Undo.MarkSaved(); Check("더티: 저장 표시 후 clean", !dirtyDoc.Undo.IsDirty); dirtyDoc.AddControlAt("Label", new Point(90, 90)); Check("더티: 저장 후 편집하면 dirty", dirtyDoc.Undo.IsDirty); dirtyDoc.Undo.Undo(); Check("더티: 저장 지점으로 Undo 하면 clean", !dirtyDoc.Undo.IsDirty); dirtyDoc.Undo.Undo(); Check("더티: 저장 지점보다 더 되돌리면 dirty", dirtyDoc.Undo.IsDirty); dirtyDoc.Undo.Redo(); Check("더티: Redo 로 저장 지점 복귀하면 clean", !dirtyDoc.Undo.IsDirty); // 저장 지점보다 얕은 상태에서 새 편집 → 그 지점은 되돌아갈 수 없으므로 영구 dirty dirtyDoc.Undo.Undo(); dirtyDoc.AddControlAt("CheckBox", new Point(120, 120)); dirtyDoc.Undo.Undo(); Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty); // 24-1) 정렬 — 기준이 무엇이 되는가. 산술 자체는 단위 테스트(AlignSolverTests)가 보고, // 여기서는 배선을 본다: 어느 사각형을 기준으로 잡았는가. // 별도 문서에 붙인다 — 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를 갉아먹는다. { var alignDoc = new DesignerViewModel(business.CreateNew()); ControlViewModel Put(double x, double y, double w, double h) { alignDoc.AddControlAt("Label", new Point(x, y)); var vm = alignDoc.Pages[0].Controls[^1]; vm.X = x; vm.Y = y; vm.Width = w; vm.Height = h; return vm; } var page0 = alignDoc.Pages[0]; var solo = Put(137, 211, 75, 20); alignDoc.Selection.SetSingle(solo); alignDoc.Align("Left"); Check("정렬: 1개는 페이지 기준 — 왼쪽", solo.X == 0, $"실제 {solo.X}"); alignDoc.Align("Right"); Check("정렬: 1개는 페이지 기준 — 오른쪽", solo.X == page0.WidthDip - solo.Width, $"실제 {solo.X} 기대 {page0.WidthDip - solo.Width}"); alignDoc.Align("CenterV"); Check("정렬: 1개는 페이지 기준 — 세로 가운데", solo.Y > 0 && solo.Y == Math.Round((page0.HeightDip - solo.Height) / 2, MidpointRounding.AwayFromZero), $"실제 {solo.Y}"); var beforeIdle = alignDoc.Undo.CanUndo; var idleX = solo.X; alignDoc.Align("Right"); Check("정렬: 이미 맞은 상태를 다시 눌러도 실행취소가 쌓이지 않는다", solo.X == idleX && alignDoc.Undo.CanUndo == beforeIdle); // 실행취소 1회로 완전 복원 — 정렬 1회 = 1스텝 var beforeUndoX = solo.X; alignDoc.Align("Left"); alignDoc.Undo.Undo(); var restored = alignDoc.Pages[0].Controls.FirstOrDefault(c => c.Width == 75); Check("정렬: 실행취소 1회로 되돌아온다", restored is not null && restored.X == beforeUndoX, $"실제 {restored?.X}"); // 기준 전환 — 여럿을 골라 놓고 '서로 맞추기'와 '종이에 맞추기'가 갈린다 var a = Put(300, 500, 60, 20); var b = Put(340, 540, 60, 20); alignDoc.Selection.Set(new List { a, b }, a); alignDoc.AlignToPage = false; alignDoc.Align("Left"); Check("정렬: 여럿을 서로 맞추면 선택 영역 왼쪽", a.X == 300 && b.X == 300, $"실제 {a.X} / {b.X}"); alignDoc.AlignToPage = true; alignDoc.Align("Left"); Check("정렬: 기준을 페이지로 바꾸면 종이 왼쪽", a.X == 0 && b.X == 0, $"실제 {a.X} / {b.X}"); alignDoc.Align("Right"); Check("정렬: 페이지 기준 오른쪽", a.X == page0.WidthDip - a.Width && b.X == page0.WidthDip - b.Width, $"실제 {a.X} / {b.X}"); alignDoc.AlignToPage = false; // 컨테이너 자식은 페이지가 아니라 부모가 기준 — 페이지 기준이면 X 가 음수가 된다 var nestDoc = new DesignerViewModel(business.CreateNew()); nestDoc.AddControlAt("Panel", new Point(120, 90)); var holder = nestDoc.Pages[0].Controls[^1] as ContainerViewModel; if (holder is not null) { holder.X = 120; holder.Y = 90; holder.Width = 300; holder.Height = 200; nestDoc.Selection.SetSingle(holder); nestDoc.AddControlAt("Label", new Point(180, 140)); var inner = holder.Children.Count > 0 ? holder.Children[^1] : null; if (inner is not null) { inner.X = 60; inner.Y = 50; inner.Width = 80; inner.Height = 20; nestDoc.Selection.SetSingle(inner); nestDoc.Align("Left"); Check("정렬: 컨테이너 자식은 부모 기준(음수로 튀지 않는다)", inner.X == 0, $"실제 {inner.X}"); nestDoc.Align("Right"); Check("정렬: 컨테이너 자식 오른쪽도 부모 기준", inner.X == holder.Width - inner.Width, $"실제 {inner.X}"); // 부모와 자식을 함께 선택하면 자식은 대상에서 빠진다(두 번 옮겨지지 않게) var innerBefore = inner.X; nestDoc.Selection.Set(new List { holder, inner }, holder); nestDoc.Align("Left"); Check("정렬: 조상이 선택돼 있으면 자식은 건드리지 않는다", inner.X == innerBefore, $"실제 {inner.X} (시작 {innerBefore})"); } } // 2페이지 컨트롤 1개 — 그 페이지 사각형을 기준으로 잡아야 한다 var twoPage = new DesignerViewModel(business.CreateNew()); twoPage.AddControlAt("Label", new Point(80, 120)); twoPage.AddPage(); if (twoPage.Pages.Count >= 2) { var second = twoPage.Pages[1]; twoPage.AddControlAt("Label", new Point(200, second.OffsetY + 300)); var onSecond = second.Controls.Count > 0 ? second.Controls[^1] : null; if (onSecond is not null) { onSecond.X = 200; onSecond.Y = 300; onSecond.Width = 60; onSecond.Height = 20; twoPage.Selection.SetSingle(onSecond); twoPage.Align("Top"); Check("정렬: 2페이지 컨트롤은 그 페이지 기준", onSecond.Y == 0, $"실제 {onSecond.Y}"); // 페이지를 넘는 선택 — 세로는 거부, 가로는 허용 var onFirst = twoPage.Pages[0].Controls.Count > 0 ? twoPage.Pages[0].Controls[0] : null; if (onFirst is not null) { var mixedY = onSecond.Y; var canUndoBefore = twoPage.Undo.CanUndo; twoPage.Selection.Set(new List { onFirst, onSecond }, onFirst); twoPage.Align("Top"); Check("정렬: 페이지를 넘는 선택은 세로 정렬을 거부한다", onSecond.Y == mixedY && twoPage.Undo.CanUndo == canUndoBefore, $"실제 {onSecond.Y} (시작 {mixedY})"); twoPage.Align("Left"); Check("정렬: 페이지를 넘어도 가로는 맞는다", onFirst.X == onSecond.X, $"실제 {onFirst.X} / {onSecond.X}"); } } } } // 24-2) 스냅 — 정렬 가이드와 간격 균등. // 별도 문서에 붙인다. 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를 // 드래그마다 잡아먹어 그 검사가 조용히 깨진다(UndoService 용량 100). { var snapDoc = new DesignerViewModel(business.CreateNew()); ControlViewModel Place(DesignerViewModel doc, string type, double x, double y, double w, double h) { doc.AddControlAt(type, new Point(x, y)); var vm = doc.Pages[0].Controls[^1]; vm.X = x; vm.Y = y; vm.Width = w; vm.Height = h; return vm; } static Point Middle(ControlViewModel vm) => new(vm.X + vm.Width / 2, vm.Y + vm.Height / 2); // 형제 둘을 같은 좌변에 세로로 벌려 둔다 — 가이드 병합과 선분 범위를 함께 본다 var anchorTop = Place(snapDoc, "Label", 200, 100, 60, 20); var anchorBottom = Place(snapDoc, "Label", 200, 400, 60, 20); // 폭을 형제와 다르게 둔다 — 같으면 좌·중·우가 동시에 맞아 가이드가 3개 나온다 // (그것도 옳은 동작이지만 여기서 보려는 것은 '같은 좌표의 형제 둘이 하나로 합쳐지는가'다) var mover = Place(snapDoc, "Label", 197, 250, 40, 20); var grab = Middle(mover); snapDoc.Interaction.PointerDown(grab, none); snapDoc.Interaction.PointerMove(new Point(grab.X + 3, grab.Y), none); Check("스냅: 형제 좌변에 붙는다", mover.X == anchorTop.X, $"실제 {mover.X}"); Check("스냅: 같은 좌표 형제 둘이 가이드 하나로 병합된다", snapDoc.Overlay.Guides.Count == 1, $"실제 {snapDoc.Overlay.Guides.Count}"); var guide = snapDoc.Overlay.Guides.Count > 0 ? snapDoc.Overlay.Guides[0] : null; Check("스냅: 가이드가 무한선이 아니라 선분이다", guide is not null && guide.IsVertical && guide.Y2 - guide.Y1 < 1000, guide is null ? "가이드 없음" : $"길이 {guide.Y2 - guide.Y1}"); Check("스냅: 선분이 관련된 형제 전부를 덮는다", guide is not null && guide.Y1 <= anchorTop.Y && guide.Y2 >= anchorBottom.Y + anchorBottom.Height, guide is null ? "가이드 없음" : $"{guide.Y1}~{guide.Y2}"); snapDoc.Interaction.PointerUp(new Point(grab.X + 3, grab.Y), none); Check("스냅: 놓으면 표시가 사라진다", snapDoc.Overlay.Guides.Count == 0 && snapDoc.Overlay.Spacings.Count == 0); // Alt 는 격자까지 전부 끈다 var freeStart = Middle(mover); var beforeX = mover.X; snapDoc.Interaction.PointerDown(freeStart, none); snapDoc.Interaction.PointerMove(new Point(freeStart.X + 3, freeStart.Y), alt); Check("스냅: Alt 는 가이드를 내지 않는다", snapDoc.Overlay.Guides.Count == 0); Check("스냅: Alt 는 정확히 그만큼 옮긴다", mover.X == beforeX + 3, $"실제 {mover.X}"); snapDoc.Interaction.PointerUp(new Point(freeStart.X + 3, freeStart.Y), alt); // 간격 균등 — 30 간격 둘 뒤에 이어 붙는다 var spacingDoc = new DesignerViewModel(business.CreateNew()); Place(spacingDoc, "Label", 100, 600, 50, 20); Place(spacingDoc, "Label", 180, 600, 50, 20); // 간격 30 var third = Place(spacingDoc, "Label", 257, 600, 40, 20); // 260 이면 균등 var spacingGrab = Middle(third); spacingDoc.Interaction.PointerDown(spacingGrab, none); spacingDoc.Interaction.PointerMove(new Point(spacingGrab.X + 3, spacingGrab.Y), none); Check("스냅: 같은 간격으로 이어 붙는다", third.X == 260, $"실제 {third.X}"); Check("스냅: 간격 막대가 보인다", spacingDoc.Overlay.Spacings.Count >= 1, $"실제 {spacingDoc.Overlay.Spacings.Count}"); spacingDoc.Interaction.PointerUp(new Point(spacingGrab.X + 3, spacingGrab.Y), none); // 컨테이너 자식도 후보가 된다 / 컨테이너는 제 자식에게 붙지 않는다 var nestDoc = new DesignerViewModel(business.CreateNew()); var nestPanel = Place(nestDoc, "Panel", 100, 100, 300, 200) as ContainerViewModel; if (nestPanel is not null) { nestDoc.Selection.SetSingle(nestPanel); nestDoc.AddControlAt("Label", new Point(150, 150)); var nestChild = nestPanel.Children.Count > 0 ? nestPanel.Children[^1] : null; if (nestChild is not null) { nestChild.X = 50; // 월드 150 nestChild.Y = 50; nestChild.Width = 60; nestChild.Height = 20; var outsider = Place(nestDoc, "Label", 147, 500, 60, 20); nestDoc.Selection.SetSingle(outsider); var outGrab = Middle(outsider); nestDoc.Interaction.PointerDown(outGrab, none); nestDoc.Interaction.PointerMove(new Point(outGrab.X + 3, outGrab.Y), none); Check("스냅: 컨테이너 자식도 후보가 된다", outsider.X == 150, $"실제 {outsider.X}"); nestDoc.Interaction.PointerUp(new Point(outGrab.X + 3, outGrab.Y), none); var panelBefore = nestPanel.X; var panelGrab = new Point(nestPanel.X + 5, nestPanel.Y + 5); nestDoc.Selection.SetSingle(nestPanel); nestDoc.Interaction.PointerDown(panelGrab, none); nestDoc.Interaction.PointerMove(new Point(panelGrab.X + 40, panelGrab.Y), none); Check("스냅: 컨테이너는 제 자식에게 붙지 않는다", nestPanel.X != panelBefore, $"실제 {nestPanel.X} (시작 {panelBefore})"); nestDoc.Interaction.PointerUp(new Point(panelGrab.X + 40, panelGrab.Y), none); } } // 리사이즈 — 이동 드래그를 한 번도 안 한 상태에서 바로 핸들을 잡아도 붙어야 한다 var resizeDoc = new DesignerViewModel(business.CreateNew()); var wall = Place(resizeDoc, "Label", 300, 100, 60, 200); var box = Place(resizeDoc, "Label", 100, 150, 100, 40); resizeDoc.Selection.SetSingle(box); resizeDoc.RefreshOverlay(); var eastHandle = new Point(box.X + box.Width, box.Y + box.Height / 2); resizeDoc.Interaction.PointerDown(eastHandle, none); resizeDoc.Interaction.PointerMove(new Point(298, eastHandle.Y), none); Check("스냅: 리사이즈가 첫 드래그부터 형제 모서리에 붙는다", box.X + box.Width == wall.X, $"실제 우변 {box.X + box.Width}"); Check("스냅: 리사이즈에도 가이드가 보인다", resizeDoc.Overlay.Guides.Count >= 1, $"실제 {resizeDoc.Overlay.Guides.Count}"); resizeDoc.Interaction.PointerUp(new Point(298, eastHandle.Y), none); Check("스냅: 리사이즈 후에도 표시가 사라진다", resizeDoc.Overlay.Guides.Count == 0); } // 25) 연속 Undo 로 빈 문서까지 var guard = 0; while (designer.Undo.CanUndo && guard++ < 80) { designer.Undo.Undo(); } Check("전체 Undo → 빈 문서", designer.Document.Pages[0].Controls.Count == 0 && designer.Document.Pages.Count == 1, $"실제 컨트롤 {designer.Document.Pages[0].Controls.Count} 페이지 {designer.Document.Pages.Count}"); } catch (Exception ex) { failed++; lines.Add("EXCEPTION " + ex); } lines.Add($"결과: 실패 {failed}건"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return failed == 0 ? 0 : 1; } #endregion }