Files
SheetMe/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
T
MsystechandClaude Opus 5 ac36f93a03 페이지 배경색 기본 표시를 White → Control 로 (레거시 PropertyGrid 와 일치)
페이지 루트에 BackColor 가 없을 때 내가 임의로 'White' 를 지어내 보여 주고 있었다.
페이지 루트는 WinForms UserControl 이고 그 BackColor 기본값은 SystemColors.Control 이라
레거시 PropertyGrid 는 'Control' 을 표시한다 — 사용자가 짚은 그대로다.

근거를 위해 --db-pageprops 진단을 신설해 전수 집계했다(디자인 1,271건 / 페이지 1,816장).

  BackColor  White 1,487장(81.9%) · (없음) 230장(12.7%) · Window 91장(5.0%)
             ButtonHighlight/ControlLightLight/MenuBar/Control/ControlLight 8장
  Font       (없음) 95.6% — 없을 때 굴림 9pt 로 보이는 현행 기본값이 맞다
  기타 키     Margin 9장, BorderStyle 6장, DoNotPrintThisPage 2장 …

12.7% 는 예외가 아니라 흔한 경우다. 표시만 고치고 저장은 하지 않는다 — 지어낸 값이 XML 에
새로 생기면 왕복 diff 0 이 조용히 깨진다. 이 규약을 두 겹으로 못 박았다.
- 편집 스모크: 값 없는 페이지에서 Control 표시 / 미저장 / 용지는 흰색
- --db-pageprops: BackColor 없는 실제 서식 40건을 열어 XML 이 그대로인지 대조

렌더는 흰색을 유지한다. 사용자 확인 결과 레거시 실행 시에도 흰색으로 나오고,
#F0F0F0 종이는 라이트 테마 캔버스 배경(#D8D8D8)과 1.13:1 이라 용지 경계가 사라진다.
대신 값이 없을 때는 라벨 툴팁으로 '저장된 값 없음 / 실행·인쇄는 흰색 / 바꾸면 그때 저장'을
알린다 — 안 그러면 "Control 인데 왜 희게 보이지?" 로 다시 읽힌다.

부수: PropertyRowViewModel 에 Hint 를 추가하고 색상 행 라벨 툴팁에 배선.

회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:44:52 +09:00

816 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.Windows;
using SheetMe.Core.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>
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 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);
// 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);
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) 그룹/해제 — 그룹 단위 클릭 선택
designer.Selection.Set(new[] { Find("TextBox"), Find("Label") });
designer.GroupSelection();
designer.Selection.Clear();
var member = Find("TextBox");
var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2);
designer.Interaction.PointerDown(memberPoint, none);
designer.Interaction.PointerUp(memberPoint, none);
Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2,
$"실제 {designer.Selection.Items.Count}");
designer.UngroupSelection();
designer.Selection.Clear();
designer.Interaction.PointerDown(memberPoint, none);
designer.Interaction.PointerUp(memberPoint, none);
Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1);
// 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();
var groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().FirstOrDefault();
Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType<LayerGroupViewModel>().Count() == 1);
Check("레이어: 폴더 멤버 2 + 기본 이름",
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
// 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다
Check("레이어: 첫 행은 페이지 헤더", lay.LayerRows.FirstOrDefault() is LayerPageViewModel);
Check("레이어: 마지막 행은 페이지 추가", lay.LayerRows.LastOrDefault() is LayerAddPageViewModel);
Check("레이어: 페이지 헤더는 선택 대상 아님",
lay.LayerRows.OfType<ILayerRow>().Where(r => r is LayerPageViewModel or LayerAddPageViewModel)
.All(r => !r.IsSelectableRow));
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
folderIndex >= 0
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 32) == 2);
Check("레이어: 페이지 직속 컨트롤 들여쓰기 12",
lay.LayerRows.OfType<ControlViewModel>()
.Where(c => c.Model.GroupId is null && c.Parent is null)
.All(c => c.LayerIndent == 12));
// 목록 맨 위 = 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}");
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
var expandedCount = lay.LayerRows.Count;
groupRow!.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 접으면 멤버 행 제거", lay.LayerRows.Count == expandedCount - 2,
$"실제 {lay.LayerRows.Count} 기대 {expandedCount - 2}");
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount);
// 그룹 일괄 숨김이 전 멤버에 퍼진다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsHiddenFlag = true;
Check("레이어: 그룹 숨김 → 전 멤버", groupRow.Members.All(m => m.IsHiddenFlag));
groupRow.IsHiddenFlag = false;
Check("레이어: 그룹 표시 복귀", groupRow.Members.All(m => !m.IsHiddenFlag));
// 그룹 이름은 문서가 소유 — 비우면 기본값으로 되돌아간다
groupRow.Name = "머리글 묶음";
Check("레이어: 그룹 개명", lay.GroupNameOf(groupRow.GroupId) == "머리글 묶음");
groupRow.Name = string.Empty;
Check("레이어: 이름 비우면 기본값", lay.GroupNameOf(groupRow.GroupId) == "그룹");
// 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다
lay.AddControlAt("Panel", new Point(300, 600));
var layerPanel = lay.Pages[0].Controls.OfType<PanelViewModel>().First();
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);
// 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑)
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
var sourceGroupId = groupRow.Members[0].Model.GroupId;
lay.Selection.SetSingle(groupRow.Members[0]);
lay.CopySelection();
lay.Paste();
Check("레이어: 붙여넣기가 원본 그룹에 끼지 않음",
lay.Selection.Items[0].Model.GroupId != sourceGroupId,
$"원본 {sourceGroupId} 복제 {lay.Selection.Items[0].Model.GroupId}");
// 멤버가 1개로 줄면 그룹은 자동 해제되고 이름도 정리된다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
lay.Selection.SetSingle(groupRow.Members[1]);
lay.DeleteSelection();
Check("레이어: 멤버 1개 남으면 그룹 자동 해제",
!lay.LayerRows.OfType<LayerGroupViewModel>().Any()
&& lay.Pages[0].Controls.All(c => c.Model.GroupId is null)
&& lay.Document.GroupNames.Count == 0);
// 그룹은 편집 전용 상태다 — 저장 XML 에 흔적이 남으면 왕복 불변식이 깨진다
lay.Selection.Set(lay.Pages[0].Controls.Take(2).ToList());
lay.GroupSelection();
Check("레이어: 그룹은 XML 에 나가지 않음",
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
// 강조 3상태 — 그룹 전체 선택이면 폴더=Blue, 멤버=Navy 이고 연속 블록으로 이어진다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
lay.Selection.Set(groupRow.Members.ToList(), groupRow.Members[0]);
lay.UpdateLayerHighlight();
Check("레이어: 그룹 전원 선택 → 폴더 Blue", groupRow.Highlight == LayerHighlight.Blue,
$"실제 {groupRow.Highlight}");
Check("레이어: 그룹 전원 선택 → 멤버 Navy",
groupRow.Members.All(m => m.Highlight == LayerHighlight.Navy));
Check("레이어: 폴더와 멤버가 이어진 블록",
groupRow.MergeBottom && groupRow.Members[0].MergeTop);
lay.Selection.SetSingle(groupRow.Members[0]);
lay.UpdateLayerHighlight();
Check("레이어: 멤버 하나만 선택 → 그 행만 Blue, 폴더 무강조",
groupRow.Members[0].Highlight == LayerHighlight.Blue
&& groupRow.Highlight == LayerHighlight.None);
// 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다)
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)));
}
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.LayerRows.OfType<LayerPageViewModel>().First();
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);
// 컨트롤을 고르면 페이지 모드에서 빠진다
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();
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);
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.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
designer.DeleteSelection();
// 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);
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);
// 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
}