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 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-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().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()) 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 }