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;
///
/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해
/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다.
///
public static class EditSmoke
{
#region Methods
/// 스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환
public static int Run(string reportPath)
{
var lines = new List();
var failed = 0;
void Check(string name, bool condition, string? detail = null)
{
if (!condition)
{
failed++;
}
lines.Add($"{(condition ? "PASS" : "FAIL")} {name}{(condition || detail is null ? "" : " — " + detail)}");
}
try
{
var business = new FormDesignDataBusiness();
var serializer = new LegacyXmlSerializer();
var designer = new DesignerViewModel(business.CreateNew());
var none = new PointerContext(false, false, false, 1);
var alt = new PointerContext(false, false, true, 1);
var 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("")
&& xmlA.Contains(""));
// 15) 인스펙터 — 선택 시 행 구성 + Text 커밋
var inspectorTarget = Find("Label");
designer.Selection.SetSingle(inspectorTarget);
Check("인스펙터: 행 생성", designer.Inspector.Rows.Count > 5,
$"행 {designer.Inspector.Rows.Count}");
var textRow = designer.Inspector.Rows
.OfType()
.FirstOrDefault(r => r.Label == "텍스트");
Check("인스펙터: 텍스트 행 존재", textRow is not null);
if (textRow is not null)
{
textRow.ValueText = "수정된 라벨";
Check("인스펙터: Text 커밋 → 모델 반영",
inspectorTarget.Model.Props.GetText("Text") == "수정된 라벨"
&& inspectorTarget.Text == "수정된 라벨");
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
}
// 16) 인스펙터 — 이름 변경
var nameRow = designer.Inspector.Rows
.OfType()
.FirstOrDefault(r => r.Label == "이름");
if (nameRow is not null)
{
nameRow.ValueText = "lblTitle";
Check("인스펙터: 개명 반영", inspectorTarget.Id == "lblTitle");
}
// 17) 페이지 추가/삭제 + 용지 크기
designer.AddPage();
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
&& designer.Document.Pages.Count == 2);
designer.SelectedPage = designer.Pages[1];
designer.ApplyPaperSize(794, 1123);
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
designer.RemoveSelectedPageForSmoke();
Check("페이지 삭제: 1페이지", designer.Pages.Count == 1);
// 18) 컨테이너 드롭 — Panel 배치 후 내부에 TextBox 드롭
designer.AddControlAt("Panel", new Point(400, 400));
var panel = designer.Pages[0].Controls.OfType().First();
designer.AddControlAt("TextBox", new Point(panel.X + 30, panel.Y + 30));
Check("컨테이너 드롭: 자식 추가", panel.Model.Children.Count == 1
&& panel.Children.Count == 1
&& panel.Model.Children[0].Type == "TextBox");
// 19) 컨테이너 자식 직접 편집 — 히트테스트/이동/삭제
var panelVm = designer.Pages[0].Controls.OfType().First();
var child = panelVm.Children[0];
var childWorld = designer.WorldBoundsOf(child);
var childHit = designer.HitTestControl(new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2));
Check("자식 히트테스트", childHit == child, $"실제 {childHit?.Id}");
designer.Selection.SetSingle(child);
var childOrigX = child.X;
var center = new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2);
designer.Interaction.PointerDown(center, none);
designer.Interaction.PointerMove(new Point(center.X + 15, center.Y + 10), alt);
designer.Interaction.PointerUp(new Point(center.X + 15, center.Y + 10), alt);
Check("자식 이동(+15,+10 상대좌표)", child.X == childOrigX + 15,
$"실제 X={child.X} 기대 {childOrigX + 15}");
designer.Selection.SetSingle(child);
designer.DeleteSelection();
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
// 20) 그룹/해제 — 그룹 단위 클릭 선택
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().FirstOrDefault();
Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType().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().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().Count(c => c.LayerIndent == 32) == 2);
Check("레이어: 페이지 직속 컨트롤 들여쓰기 12",
lay.LayerRows.OfType()
.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().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().First();
groupRow.IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount);
// 그룹 일괄 숨김이 전 멤버에 퍼진다
groupRow = lay.LayerRows.OfType().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().First();
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
lay.RebuildLayerRows();
Check("레이어: 컨테이너 자식이 목록에 나옴",
lay.LayerRows.OfType().Any(c => ReferenceEquals(c.Parent, layerPanel)));
Check("레이어: 자식 들여쓰기 = 부모+16",
lay.LayerRows.OfType()
.Where(c => ReferenceEquals(c.Parent, layerPanel))
.All(c => c.LayerIndent == layerPanel.LayerIndent + 16));
var withChild = lay.LayerRows.Count;
layerPanel.IsLayerExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 컨테이너 접으면 자식 제거", lay.LayerRows.Count == withChild - 1);
layerPanel.IsLayerExpanded = true;
lay.RebuildLayerRows();
// 검색은 자손이 걸리면 조상도 남긴다 — 부모가 빠지면 자식이 통째로 사라진다
var childId = layerPanel.Children[0].Id;
lay.LayerFilter = childId;
Check("레이어: 검색 시 조상 보존",
lay.LayerRows.Contains(layerPanel)
&& lay.LayerRows.OfType().Any(c => c.Id == childId));
lay.LayerFilter = "존재하지않는이름____";
Check("레이어: 0건이면 빈 목록 안내", lay.IsLayerListEmpty && lay.LayerEmptyText.Contains("검색"));
lay.LayerFilter = string.Empty;
// 타입 필터
lay.SetLayerTypeFilter(new[] { "CheckBox" });
Check("레이어: 타입 필터 적용",
lay.HasLayerTypeFilter
&& lay.LayerRows.OfType().Any()
&& lay.LayerRows.OfType().All(c => c.Type == "CheckBox"));
lay.SetLayerTypeFilter(Array.Empty());
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
// 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑)
groupRow = lay.LayerRows.OfType().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().First();
lay.Selection.SetSingle(groupRow.Members[1]);
lay.DeleteSelection();
Check("레이어: 멤버 1개 남으면 그룹 자동 해제",
!lay.LayerRows.OfType().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().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().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().Count() == lay.Pages.Count);
// 페이지 접기 — 그 페이지 컨트롤 행만 빠진다
var pageHeader = lay.LayerRows.OfType().First();
var beforeCollapse = lay.LayerRows.Count;
pageHeader.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse);
Check("레이어: 페이지 접어도 헤더는 남음",
lay.LayerRows.OfType().Count() == lay.Pages.Count);
lay.LayerRows.OfType().First().IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse);
// 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다
var order = lay.LayerRows.OfType()
.Where(c => c.Parent is null && ReferenceEquals(lay.PageOf(c), lay.Pages[0])).ToList();
if (order.Count >= 2)
{
var mover = order[^1];
var anchor = order[0];
lay.MoveControlNextTo(mover, anchor, above: true);
Check("레이어: 드래그로 맨 위에 놓으면 z-order 최상위",
ReferenceEquals(lay.Pages[0].Model.Controls[0], mover.Model),
$"실제 {lay.Pages[0].Model.Controls[0].Id} 기대 {mover.Id}");
Check("레이어: 재정렬이 되돌리기 스텝", lay.Undo.CanUndo);
}
// 눈·자물쇠도 되돌릴 수 있어야 한다
var flagTarget = lay.LayerRows.OfType().First();
lay.Undo.Snapshot();
var depthBefore = lay.Undo.CanUndo;
flagTarget.IsLockedFlag = true;
lay.Undo.Undo();
Check("레이어: 잠금 토글이 Undo 로 원복",
lay.Pages.SelectMany(p => p.Controls).All(c => !c.Model.Locked) && depthBefore);
// 눈·자물쇠는 평소 숨기고 호버/상태 켜짐일 때만 띄운다 — 그 판정 플래그
var flagRow = lay.LayerRows.OfType().First();
Check("레이어: 상태 없으면 토글 숨김 대상", !flagRow.HasLayerFlag);
flagRow.IsHiddenFlag = true;
Check("레이어: 숨김이면 토글 계속 표시", flagRow.HasLayerFlag);
flagRow.IsHiddenFlag = false;
Check("레이어: 해제하면 다시 숨김 대상", !flagRow.HasLayerFlag);
// 데이터 배선 배지
var badgeTarget = lay.Pages[0].Controls[0];
Check("레이어: 배선 없는 컨트롤은 배지 없음", !badgeTarget.HasDataBinding);
badgeTarget.Model.Props.SetText("DataTableField", "DT.Item(\"ChtNum\")");
Check("레이어: DataTableField 있으면 배지", badgeTarget.HasDataBinding);
badgeTarget.Model.Props.SetText("DataTableField", string.Empty);
}
// 20-2-1-2) 페이지 속성 — 레거시 MDesignerHost 루트가 갖는 속성을 편집한다
{
var pg = new DesignerViewModel(business.CreateNew());
pg.AddControlAt("Label", new Point(100, 100));
var pageRow = pg.LayerRows.OfType().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().First();
((ViewModels.Inspector.NumberRowViewModel)sizePair.Left).ValueText = "794";
((ViewModels.Inspector.NumberRowViewModel)sizePair.Right).ValueText = "1123";
Check("페이지 속성: 용지 크기 편집(A4)",
pg.Pages[0].WidthDip == 794 && pg.Pages[0].HeightDip == 1123,
$"실제 {pg.Pages[0].WidthDip}×{pg.Pages[0].HeightDip}");
// 저장하면 writer 가 Bounds 로 Size 를 다시 쓴다 — 그 값이 편집 결과와 같아야 한다
var pageXml = serializer.Write(pg.Document);
Check("페이지 속성: 저장 XML 의 Size 가 편집값", pageXml.Contains("794, 1123"),
"Size 가 Bounds 와 어긋남");
// 배경색 — 값이 없으면 레거시 PropertyGrid 와 같은 기본값(Control)을 보여 주되
// 저장하지는 않는다. 지어낸 값이 XML 에 새로 생기면 왕복 diff 0 이 깨진다.
var colorRow = pg.Inspector.Rows.OfType()
.FirstOrDefault(r => r.Label == "배경색");
Check("페이지 속성: 배경색 행 존재", colorRow is not null);
if (colorRow is not null)
{
colorRow.ValueText = "Silver";
Check("페이지 속성: 배경색 편집 → 루트 반영",
pg.Pages[0].Model.Root.Props.GetText("BackColor") == "Silver");
}
// 값이 없는 페이지: 기본값 표시 + 미저장
{
var bare = new DesignerViewModel(business.CreateNew());
bare.Pages[0].Model.Root.Props.Remove("BackColor");
bare.ActivatePage(bare.Pages[0]);
var bareRow = bare.Inspector.Rows.OfType()
.FirstOrDefault(r => r.Label == "배경색");
Check("페이지 속성: 값 없으면 Control 로 표시",
bareRow?.ValueText == "Control", $"실제 {bareRow?.ValueText}");
Check("페이지 속성: 값 없으면 설명 표시", bareRow?.Hint is not null);
Check("페이지 속성: 표시만 하고 저장하지 않음",
bare.Pages[0].Model.Root.Props.GetText("BackColor") is null);
Check("페이지 속성: 값 없어도 용지는 흰색",
bare.Pages[0].PaperBrush is System.Windows.Media.SolidColorBrush { Color.R: 255, Color.G: 255, Color.B: 255 });
}
// 기본 글꼴 — 명시 Font 가 없는 컨트롤이 상속한다
var fontRow = pg.Inspector.Rows.OfType()
.FirstOrDefault(r => r.Label == "글꼴");
Check("페이지 속성: 기본 글꼴 행 존재", fontRow is not null);
if (fontRow is not null)
{
fontRow.ValueText = "돋움";
Check("페이지 속성: 기본 글꼴이 루트에 기록",
(pg.Pages[0].Model.Root.Props.GetText("Font") ?? string.Empty).StartsWith("돋움"),
$"실제 {pg.Pages[0].Model.Root.Props.GetText("Font")}");
}
Check("페이지 속성: 편집이 되돌리기 스텝", pg.Undo.CanUndo);
// 컨트롤을 고르면 페이지 모드에서 빠진다
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().FirstOrDefault();
Check("고급: 토글 행 존재", toggleRow is not null);
toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty);
var advRows = designer.Inspector.Rows
.OfType()
.Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence")
.ToList();
Check("고급: raw 속성 행 노출", designer.Inspector.Rows
.OfType()
.Any(r => r.Label.StartsWith("전체 속성")));
var addRow = designer.Inspector.Rows
.OfType().FirstOrDefault();
Check("고급: 속성 추가 행 존재", addRow is not null);
addRow!.KeyText = "EnterTabYon";
addRow.AddCommand!.Execute(null, EventArgs.Empty);
Check("고급: 속성 추가됨", advTarget.Model.Props.Contains("EnterTabYon"));
var enterTabRow = designer.Inspector.Rows
.OfType()
.FirstOrDefault(r => r.Label == "EnterTabYon");
Check("고급: 추가 속성 행 노출", enterTabRow is not null);
enterTabRow!.ValueText = "True";
Check("고급: raw 값 커밋", advTarget.Model.Props.GetText("EnterTabYon") == "True");
// 21) JSON 왕복(부 포맷) — 편집 결과 유지
var jsonSerializer = new Core.Serialization.FormJsonSerializer();
var json = jsonSerializer.Write(designer.Document);
var fromJson = jsonSerializer.Read(json);
Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document));
// 22) Spread 판정 — 복제/이름변경 가드의 근거 술어(가드 자체는 MessageBox 를 띄우므로 무인 검증 불가)
var spreadElement = new Core.Models.ControlElement { Type = "Spread", Id = "Spread1" };
var panelWithSpread = new Core.Models.ControlElement { Type = "Panel", Id = "Panel9" };
panelWithSpread.Children.Add(new Core.Models.ControlElement { Type = "Label", Id = "L9" });
panelWithSpread.Children.Add(spreadElement.Clone());
var plainPanel = new Core.Models.ControlElement { Type = "Panel", Id = "Panel8" };
plainPanel.Children.Add(new Core.Models.ControlElement { Type = "Label", Id = "L8" });
Check("Spread 판정: 자신", DesignerViewModel.ContainsSpread(spreadElement));
Check("Spread 판정: 컨테이너 자손", DesignerViewModel.ContainsSpread(panelWithSpread));
Check("Spread 판정: 무관 컨트롤 false", !DesignerViewModel.ContainsSpread(plainPanel));
Check("Spread 판정: Placeholder 오탐 없음",
!DesignerViewModel.ContainsSpread(new Core.Models.ControlElement { Type = "Placeholder", Id = "P1" }));
// 붙여넣기 필터 — 표만 걸러내고 나머지는 통과시켜야 한다(전체 거부는 정상 작업까지 막는다)
var mixed = new[] { plainPanel, spreadElement, panelWithSpread,
new Core.Models.ControlElement { Type = "Label", Id = "L7" } };
var (pastable, blockedSpreads) = DesignerViewModel.FilterPastable(mixed);
Check("붙여넣기 필터: 표 2개 차단", blockedSpreads == 2, $"차단 {blockedSpreads}");
Check("붙여넣기 필터: 나머지 2개 통과", pastable.Count == 2, $"통과 {pastable.Count}");
Check("붙여넣기 필터: 통과분에 표 없음", pastable.All(e => !DesignerViewModel.ContainsSpread(e)));
Check("붙여넣기 필터: 표 없으면 전량 통과",
DesignerViewModel.FilterPastable(new[] { plainPanel }) is { BlockedSpreads: 0, Pastable.Count: 1 });
// 23) 쿼리 편집기 — XAML 로드/바인딩과 두 모드(SQL/상용구)를 실제로 생성해 확인
var sqlEditor = new Views.QueryEditorWindow("스모크", "SELECT 1 FROM DUAL");
var variableItems = (sqlEditor.FindName("VariableList") as System.Windows.Controls.ListBox)?
.ItemsSource?.Cast