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