초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)

레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너.
기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49.

이 커밋에 함께 포함된 자격증명 분리:
- appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환
- 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외,
  csproj Debug 조건부 복사라 Release 산출물에 실리지 않음)
- ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경,
  미치환 플레이스홀더는 '미설정'으로 간주

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
using System.IO;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 실DB 검증 진단.
/// --db-smoke &lt;리포트&gt; [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
/// --db-save-smoke &lt;서식코드&gt; &lt;리포트&gt; : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
/// </summary>
public static class DbSmoke
{
#region Methods
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
public static int RunReadSmoke(string reportPath, int maxSheets)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000);
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
var perfect = 0;
var withWarnings = 0;
var diffSheets = new List<string>();
var failed = new List<string>();
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var sheet in targets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var rewritten = serializer.Write(document);
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
{
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
}
if (diffs.Count == 0)
{
perfect++;
if (document.Meta.ReadWarnings.Count > 0)
{
withWarnings++;
}
}
else
{
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
}
}
catch (Exception ex)
{
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
}
}
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
if (placeholderTypes.Count > 0)
{
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
}
if (diffSheets.Count > 0)
{
lines.Add("[diff 발생 서식]");
lines.AddRange(diffSheets.Take(20));
}
if (failed.Count > 0)
{
lines.Add("[예외 서식]");
lines.AddRange(failed.Take(20));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
public static int RunRow(string shtCod, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
"FROM E_ShtMst WHERE ShtCod = :s";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
File.WriteAllText(reportPath, "행 없음");
return 1;
}
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
public static int RunSample(string tableName, string reportPath, int max = 12)
{
try
{
if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$"))
{
throw new ArgumentException("유효하지 않은 테이블명");
}
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var parts = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? "";
if (value.Length > 30)
{
value = value[..30] + "…";
}
parts.Add($"{reader.GetName(i)}={value}");
}
lines.Add(string.Join(" | ", parts));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용)</summary>
public static int RunSpreadDump(string shtCodOrAll, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
if (shtCodOrAll == "*")
{
command.CommandText =
"SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
}
else
{
command.CommandText =
"SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll));
File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)");
}
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용</summary>
public static int RunDesignXml(string shtCod, string reportPath)
{
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var raw = store.LoadActiveDesignRaw(shtCod);
File.WriteAllText(reportPath,
raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}");
return raw is null ? 1 : 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 검색(--db-find) — 키워드 목록 출력</summary>
public static int RunFind(string keyword, string reportPath)
{
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var sheets = store.ListSheets(keyword, max: 50);
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "O" : "-")}\t{s.Name}")));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음)</summary>
public static int RunWordSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new RecordWordStore(config.ConnectionString);
// 이전 스모크 잔여 정리
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
{
store.Remove(stale.Key);
}
var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건");
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "" : "")}");
store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "" : "")}");
var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse();
store.Reorder(reordered, Environment.UserName);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "" : "")}");
store.Remove(key1);
store.Remove(key2);
var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "" : "")}");
var ok = okAdd && okUpdate && okReorder && okRemove;
lines.Add(ok ? "결과: 통과" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증</summary>
public static int RunSaveSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var before = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
var cneYon = store.GetShtCneYon(shtCod);
var versionsBefore = store.ListVersions(shtCod);
lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자");
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
var savedKey = store.SaveDesign(document, Environment.UserName);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
var versionsAfter = store.ListVersions(shtCod);
lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? " " : " ")}), 버전 {versionsAfter.Count}건");
var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10);
lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건");
lines.AddRange(diffs.Select(d => " " + d));
var sctCount = store.CountSctRows(savedKey);
var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count;
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctCount == walkerCount ? "" : "")}");
if (cneYon != "Y")
{
var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey);
lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "" : "!")}");
}
var ok = diffs.Count == 0 && sctCount == walkerCount;
lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
#endregion
}
@@ -0,0 +1,408 @@
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-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) 연속 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
}