지금까지는 값이 안 되는 태그를 [태그명 — 사유] 로 그렸다("못 만든
것을 감추지 않는다"). 실사용 출력물에 그 문구가 찍히면 안 되므로
빈칸으로 바꾼다 — 태그 이름도 지운다(남기면 값이 나온 줄 안다).
렌더 공용 지점(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>
2198 lines
136 KiB
C#
2198 lines
136 KiB
C#
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
|
||
}
|