Files
SheetMe/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
T
MsystechandClaude Fable 5 31a05c205b 값을 못 만든 태그는 종이·미리보기에서 빈칸 — 사유 표기 제거(사용자 결정)
지금까지는 값이 안 되는 태그를 [태그명 — 사유] 로 그렸다("못 만든
것을 감추지 않는다"). 실사용 출력물에 그 문구가 찍히면 안 되므로
빈칸으로 바꾼다 — 태그 이름도 지운다(남기면 값이 나온 줄 안다).
렌더 공용 지점(WithTagValue) 한 곳 수정이라 미리보기·인쇄 동시 적용.
체크류(컨트롤 유지)·해석기 없음(디자인 화면 — 태그 이름 유지)은 그대로.

사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
진단이 그것으로 판정한다. 종이 기반이던 사유 판정 8건을 해석기 수준
판정 + "종이에 사유가 새지 않았다" 판정으로 전환:
- 못 만드는 태그/환자 없음/매핑 없음/값 빈 이름 → 해석기 사유 확인
  + 종이 빈칸 확인(각각 쌍)
- 배선 3건(컬럼 오타·없는 소스·형식 깨짐) → 해석기 사유 + 종이 빈칸
- 환자 뗌/세션 뗌 → "[PAT_차트번호] 사유 복귀" 대신 "이름·값 모두 없음"

- dotnet test 342/342 · --edit-smoke 실패 0 · --dialog-shots 통과
- --db-patient ①~㊱ 전건 통과 (⑬ 등은 해석기 직접 판정이라 무영향)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
  (렌더 대조는 해석기 없는 경로 — 이 변경의 영향 밖)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 09:18:46 +09:00

2198 lines
136 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해
/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다.
/// </summary>
public static class EditSmoke
{
#region Methods
/// <summary>스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환</summary>
/// <summary>
/// 시각 트리의 요소 수 — 컨테이너 <b>안</b>까지 세야 자식 필터가 들었는지 보인다.
/// Canvas 직계만 세면 컨테이너는 늘 1개라 안에서 뭐가 빠졌는지 알 수 없다.
/// </summary>
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;
}
/// <summary>
/// 종이에 실제로 그려진 글자를 모은다 — 값이 <b>찍혔는지</b>는 시각 트리에서 확인해야 한다.
/// VM 속성만 보면 템플릿이 그 값을 쓰는지 알 수 없다(배선과 결과는 다른 문제다).
/// </summary>
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;
}
/// <summary>
/// 검사용 해석기 — 실제 DB 없이 "값이 나오는 태그"와 "안 나오는 태그" 두 갈래를 만든다.
/// 진단은 접속 없이도 같은 답을 내야 하므로 여기서 세션 해석기를 쓰지 않는다.
/// </summary>
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));
}
/// <summary>
/// 검사용 배선 해석기 — 표 한 장을 손으로 쥐고 네 갈래(정상·컬럼오타·없는소스·형식깨짐)를 만든다.
/// 실행기(<see cref="Services.MDataTableRunner"/>)를 쓰면 DB 접속 여부에 판정이 묶인다.
/// </summary>
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}' 컬럼이 없습니다");
}
}
/// <summary>시각 트리에서 첫 TextBlock — 없으면 null</summary>
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<string>();
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<ContainerViewModel>().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<string>();
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);
}
// 미리보기가 인쇄 필터를 <b>실제로 쓰는가</b>.
// 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} (레거시는 이 라벨을 인쇄한다)");
// 컨테이너 <b>안</b>도 필터를 타는가.
// 위 검사는 전부 최상위였다. 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 를 그대로 보여 준다.
// 필터를 끄면 방금 뺀 것이 <b>돌아와야</b> 한다. 안 돌아오면 전환이 배선만 된 것이다.
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_이름 이 종이에 그렇게 인쇄됐다.
// 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 <b>사유</b>로 바뀌어야 한다
// (빈칸이면 서식이 잘못된 줄 알고, 태그 이름 그대로면 값이 나온 줄 안다).
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);
// 값이 나오는 태그는 값으로, 안 나오는 태그는 <b>빈칸</b>으로
// (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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATNAM"] = name,
};
// 차트번호는 <b>내원</b>이 들고 있다 — 레거시 moPatInfoBiz.ChtNum 이 그것이다
// (bzDataInterface.vb:70). 여기를 PatInf 로 두면 실제와 다른 것을 검사한다.
var comInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["COMCHTNUM"] = chart,
};
var blank = new Dictionary<string, string>(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);
}
// ── 옮긴 태그 이름이 <b>실존하는가</b> ──
// 이 검사가 없어서 지어낸 이름 셋(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 사용자 결정으로 못 만든 태그는 종이에서 <b>빈칸</b>이다 —
// 값도, 태그 이름도, 사유도 찍히면 안 된다.
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);
// 아직 안 옮긴 환자 태그는 종이에서 빈칸이지만 <b>해석기는 그렇다고 말해야</b> 한다 —
// 사유는 진단·도구가 쓴다("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);
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
// 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다.
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)}");
// 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다.
// 미리보기 창은 하나만 뜨므로 그 잔상은 <b>다음 사람이</b> 보게 된다.
patientPreview.UsePatient(null, string.Empty);
var previewCleared = PreviewPaper();
Check("미리보기 창: 환자를 떼면 값이 사라진다",
!previewCleared.Contains("C-9003", StringComparison.Ordinal)
&& !previewCleared.Contains("PAT_차트번호", StringComparison.Ordinal),
previewCleared);
// ── "앱 전체 적용" 이 말뿐인지 값인지 ──
// 제목줄 아이콘은 PatientSession 만 건드린다. 열려 있는 창들이 그것을 <b>듣지 않으면</b>
// 아이콘은 켜지는데 종이는 그대로다 — 사용자에게는 "설정했는데 안 먹는다"가 된다.
// 세션에만 쓰고 종이가 따라오는지 본다(창의 메서드를 직접 부르지 않는다).
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);
// ── 미리보기와 인쇄가 <b>같은 종이</b>인가 ──
// 인쇄 경로(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}자");
// 창을 닫은 뒤에도 이벤트가 남아 있으면 회수되지 않고, 죽은 창이 계속 그린다.
// 닫고 다시 세션을 건드려 <b>예외 없이</b> 지나가는지 본다(구독이 남아 있으면
// 죽은 창의 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("<Property name=\"Location\">")
&& xmlA.Contains("<Property name=\"Name\">"));
// 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<ViewModels.Inspector.MultilineTextRowViewModel>()
.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<ViewModels.Inspector.TextRowViewModel>()
.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<PanelViewModel>().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<PanelViewModel>().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<ControlViewModel> { a, b }, a);
grp.GroupSelection();
var wrap = grp.Pages[0].Controls.OfType<ContainerViewModel>().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<string>()));
// 자식을 집어도 패널이 잡히고, 끌면 묶음째 움직인다 — 이게 '그룹처럼'의 알맹이다
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<ContainerViewModel>().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<ControlViewModel> { 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<ContainerViewModel>().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<LayerPageViewModel>().Any());
Check("레이어: 첫 행이 곧 컨트롤·그룹이다", lay.LayerRows.FirstOrDefault() is not LayerPageViewModel);
Check("레이어: 페이지 헤더는 선택 대상 아님",
lay.LayerRows.OfType<ILayerRow>().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<ControlViewModel>().Count(c => c.LayerIndent > 0) == 2);
Check("레이어: 한 장이면 최상위 들여쓰기가 0",
lay.LayerRows.OfType<ControlViewModel>()
.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<ControlViewModel>().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<PanelViewModel>().Last();
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
lay.RebuildLayerRows();
Check("레이어: 컨테이너 자식이 목록에 나옴",
lay.LayerRows.OfType<ControlViewModel>().Any(c => ReferenceEquals(c.Parent, layerPanel)));
Check("레이어: 자식 들여쓰기 = 부모+16",
lay.LayerRows.OfType<ControlViewModel>()
.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<ControlViewModel>().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<ControlViewModel>().Any()
&& lay.LayerRows.OfType<ControlViewModel>().All(c => c.Type == "CheckBox"));
lay.SetLayerTypeFilter(Array.Empty<string>());
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
// 패널 자식을 복사해 붙이면 최상위로 나온다 — 원본 패널에 끼면 안 된다
var paneled = lay.LayerRows.OfType<ContainerViewModel>().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<LayerPageViewModel>().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<LayerPageViewModel>().Count() == lay.Pages.Count);
// 페이지 접기 — 그 페이지 컨트롤 행만 빠진다
var pageHeader = lay.LayerRows.OfType<LayerPageViewModel>().First();
var beforeCollapse = lay.LayerRows.Count;
pageHeader.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse);
Check("레이어: 페이지 접어도 헤더는 남음",
lay.LayerRows.OfType<LayerPageViewModel>().Count() == lay.Pages.Count);
lay.LayerRows.OfType<LayerPageViewModel>().First().IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse);
// 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다
var order = lay.LayerRows.OfType<ControlViewModel>()
.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<ControlViewModel>().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<ControlViewModel>().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<ViewModels.Inspector.PairRowViewModel>().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<ViewModels.Inspector.ColorRowViewModel>()
.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<ViewModels.Inspector.ColorRowViewModel>()
.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<ViewModels.Inspector.TextRowViewModel>()
.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<string>(StringComparer.OrdinalIgnoreCase) { "TextBox1", "TextBox2", "TextBox3" };
pool.Remove("TextBox2");
Check("이름 채번: 빈 번호를 메우지 않고 최대+1",
Services.IdGenerator.NextId(pool, "TextBox") == "TextBox4");
var odd = new HashSet<string>(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<ViewModels.Inspector.DataTableFieldRowViewModel>().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<string>)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));
}
}
// 칩을 감추는 기준이 바뀌었다.
//
// 전에는 "값이 정해지면 감춘다"였고 이 검사가 그것을 고정하고 있었다.
// 그 규칙 때문에 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b> —
// 태그를 고치는 일은 처음 고르는 일만큼 잦은데(운영 8,032건) 고치는 쪽만 맨손이었다.
// 이제 기준은 '값이 있는지'가 아니라 <b>'타이핑 중인지'</b>다.
// 타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다.
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<ViewModels.Inspector.ToggleAdvancedRowViewModel>().FirstOrDefault();
Check("고급: 토글 행 존재", toggleRow is not null);
toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty);
var advRows = designer.Inspector.Rows
.OfType<ViewModels.Inspector.TextRowViewModel>()
.Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence")
.ToList();
Check("고급: raw 속성 행 노출", designer.Inspector.Rows
.OfType<ViewModels.Inspector.SectionRowViewModel>()
.Any(r => r.Label.StartsWith("전체 속성")));
var addRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.AddPropertyRowViewModel>().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<string>());
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<ViewModels.Inspector.TextRowViewModel>()
.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<object>().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<string>()) 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<ControlViewModel> { 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<ControlViewModel> { 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<ControlViewModel> { 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
}