From 1b49f07dd941341f64c7c4e297beccb057ffaded Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 14 Aug 2026 17:01:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EA=B0=80?= =?UTF-8?q?=20=EA=B1=B0=EC=A7=93=EB=A7=90=EC=9D=84=20=EB=A9=88=EC=B6=98?= =?UTF-8?q?=EB=8B=A4,=20=EA=B7=B8=EB=A6=AC=EA=B3=A0=209=EB=B2=88=EC=A7=B8?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EC=9D=B4=201=EB=B2=88=EC=A7=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=AE=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3단계 시작 — 실사용 버그 두 건. ① 미리보기·인쇄가 인쇄 필터를 무시하고 있었다. 레거시 런타임은 인쇄에서 이렇게 걸러낸다 — If Me.Visible = False OrElse mbPrintOutPut = False Then Return False 그 문장이 ControlRegistry.cs:17 에 인용까지 되어 있는데, 렌더 경로가 두 키를 아무도 읽지 않았다. 대신 걸러낸 것은 ControlElement.Hidden 뿐이고 그건 [JsonIgnore] 디자이너 전용 플래그다 (주석에도 "레거시 Visible 과 별개"라고 적혀 있다). 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 — 서식을 저장하기 전에 결과를 확인할 유일한 수단이 거짓말을 하고 있었다. 키 이름이 타입마다 다르다. 라벨만 소문자 visible 이다 (Label.vb 가 Shadows Property visible 로 Control.Visible 을 가리고 직렬화기가 그림자를 쓴다). 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다. 그래서 관용으로 둘 다 읽지 않는다 — 타입이 선언한 키를 카탈로그에서 찾아 그것만 읽는다. 둘 다 읽으면 대문자 Visible=False 인 라벨이 우리 인쇄에서만 빠지고, 그건 호환이 아니라 새 차이다. 모르는 값과 없는 키는 인쇄한다 — 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다. 캔버스는 그대로 다 보여 준다. 편집 중인 것을 못 보면 고칠 수 없다. --db-render 는 이 경로를 쓰지 않아 종이 렌더 md5 는 불변이다(확인했다). ② 팔레트 즉시 배치가 9번째부터 겹쳤다. (page.Controls.Count % 8) * 16 이라 두 가지가 틀렸다 — 9번째가 1번째와 정확히 같은 자리에 떨어지고, 하나 지우고 다시 놓으면 지운 자리로 갔다. 단조 증가 카운터로 바꿨다. 계단은 종이를 벗어나기 전에 옆으로 비킨다. 순수 함수 테스트만으로는 배선을 증명하지 못한다. PrintFilter 단위 테스트 8건은 판정이 옳다는 것만 말한다. 렌더 경로가 그 판정을 부르는지는 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 이번 세션에만 세 번 있었다. 그래서 --edit-smoke 에서 BuildPageVisual 의 자식 수를 실제로 세는 검사 3건을 더했다. 게이트: 테스트 285/285(신규 8), --edit-smoke 0실패(신규 5), --dialog-shots 넘침 0(대조군 4/4), --scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0, --db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. Co-Authored-By: Claude Opus 5 --- src/SheetMe.Core/Catalog/PrintFilter.cs | 71 ++++++++++++++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 59 ++++++++++++ src/SheetMe.Designer/Services/PrintService.cs | 11 ++- .../ViewModels/DesignerViewModel.cs | 20 +++- tests/SheetMe.Core.Tests/PrintFilterTests.cs | 94 +++++++++++++++++++ 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/SheetMe.Core/Catalog/PrintFilter.cs create mode 100644 tests/SheetMe.Core.Tests/PrintFilterTests.cs diff --git a/src/SheetMe.Core/Catalog/PrintFilter.cs b/src/SheetMe.Core/Catalog/PrintFilter.cs new file mode 100644 index 0000000..27e049c --- /dev/null +++ b/src/SheetMe.Core/Catalog/PrintFilter.cs @@ -0,0 +1,71 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Core.Catalog; + +/// +/// 이 컨트롤이 인쇄·미리보기에 나오는가. +/// +/// 왜 필요한가. 레거시 런타임은 인쇄에서 이렇게 걸러낸다 — +/// If Me.Visible = False OrElse mbPrintOutPut = False Then Return False +/// (그 사실이 주석에 인용까지 되어 있다). +/// 그런데 우리 미리보기는 그 두 키를 아무도 읽지 않았다. 대신 디자이너 전용 플래그 +/// (ControlElement.Hidden, [JsonIgnore], 주석에 "레거시 Visible 과 별개")만 걸렀다. +/// 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 — +/// 저장 전 검증 수단이 거짓말을 하고 있었다. +/// +/// 키 이름이 타입마다 다르다. 라벨만 소문자 visible 이다 +/// (Label.vb 가 Shadows Property visibleControl.Visible 을 가리고 직렬화기가 그림자를 쓴다). +/// 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다. +/// 그래서 타입이 선언한 키를 카탈로그에서 찾아 그것만 읽는다 — +/// 둘 다 관용으로 읽으면, 대문자 Visible=False 인 라벨이 인쇄에서 빠진다. +/// 레거시는 그 라벨을 그대로 인쇄하므로 그건 우리가 만드는 새 차이다. +/// +/// 모르는 값은 인쇄한다. 키가 없거나 해석할 수 없으면 나온다 — +/// 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다(빠진 것은 아무도 못 본다). +/// +public static class PrintFilter +{ + #region Methods + /// 인쇄·미리보기에 나오는가 + public static bool IsPrinted(ControlElement control) + => IsOn(control, VisibleKeyOf(control.Type)) && IsOn(control, "PrintOutPut"); + + /// + /// 이 타입이 실제로 쓰는 '표시' 키. 카탈로그가 정답을 갖고 있으므로 하드코딩하지 않는다 — + /// 타입이 늘거나 키가 바뀌면 여기가 따라온다. + /// + public static string VisibleKeyOf(string type) + { + var descriptor = ControlRegistry.Find(type); + if (descriptor is null) + { + return "Visible"; + } + foreach (var property in descriptor.Properties) + { + if (string.Equals(property.Key, "visible", StringComparison.Ordinal) + || string.Equals(property.Key, "Visible", StringComparison.Ordinal)) + { + return property.Key; + } + } + return "Visible"; + } + + /// + /// 레거시가 쓰는 불리언 표기는 WinForms TypeConverter 의 "True"/"False" 다. + /// 운영 데이터에는 대소문자·공백이 섞여 있어 관용으로 읽는다. + /// + private static bool IsOn(ControlElement control, string key) + { + var raw = control.Props.GetText(key)?.Trim(); + if (raw is null || raw.Length == 0) + { + return true; + } + return !(raw.Equals("False", StringComparison.OrdinalIgnoreCase) + || raw.Equals("0", StringComparison.Ordinal) + || raw.Equals("No", StringComparison.OrdinalIgnoreCase)); + } + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 082da2e..f69bf19 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -51,6 +51,65 @@ public static class EditSmoke && designer.Document.Pages[0].Controls.Count == 3); Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1); + // 팔레트 즉시 배치가 같은 자리에 겹치지 않는가. + // 전에는 (Controls.Count % 8) * 16 이라 9번째가 1번째와 정확히 같은 자리에 떨어졌고, + // 하나 지우고 다시 놓으면 지운 것과 같은 자리로 갔다. + // 별도 문서에서 잰다 — 위 3개가 든 문서를 쓰면 뒤 검사들의 인덱스가 밀린다. + { + var stack = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + var before = stack.Pages[0].Controls.Count; + for (var i = 0; i < 20; i++) + { + stack.AddPaletteItemAtCenter("Label"); + } + var placed = stack.Pages[0].Controls.Skip(0) + .Where(c => c.Type == "Label").Select(c => (c.X, c.Y)).ToList(); + Check("즉시 배치: 20개가 서로 다른 자리", placed.Distinct().Count() == placed.Count, + $"고유 {placed.Distinct().Count()} / 전체 {placed.Count} (기존 {before}개)"); + + // 지우고 다시 놓아도 지운 자리로 돌아가지 않는다 + var last = stack.Pages[0].Controls.First(c => c.Type == "Label"); + var lastSpot = (last.X, last.Y); + stack.Selection.SetSingle(last); + stack.DeleteSelection(); + stack.AddPaletteItemAtCenter("Label"); + var again = stack.Pages[0].Controls.First(c => c.Type == "Label"); + Check("즉시 배치: 지운 자리로 돌아가지 않는다", (again.X, again.Y) != lastSpot, + $"지운 자리 {lastSpot}, 새로 놓인 자리 ({again.X},{again.Y})"); + } + + // 미리보기가 인쇄 필터를 실제로 쓰는가. + // PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는 + // 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다. + { + var paper = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + paper.AddControlAt("Label", new Point(60, 60)); + paper.AddControlAt("Label", new Point(60, 120)); + paper.AddControlAt("TextBox", new Point(60, 180)); + var page = paper.Pages[0]; + var all = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + // 라벨은 소문자 visible, 텍스트박스는 대문자 Visible — 키를 바꿔 쓰면 안 걸려야 한다 + var lowerLabel = page.Controls.First(c => c.Type == "Label"); + lowerLabel.Model.Props.SetText("visible", "False"); + var afterLabel = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + var box = page.Controls.First(c => c.Type == "TextBox"); + box.Model.Props.SetText("PrintOutPut", "False"); + var afterBox = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + var wrongKey = page.Controls.Last(c => c.Type == "Label"); + wrongKey.Model.Props.SetText("Visible", "False"); + var afterWrongKey = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + Check("미리보기: 라벨 소문자 visible=False 를 뺀다", afterLabel == all - 1, + $"전 {all} → 후 {afterLabel}"); + Check("미리보기: PrintOutPut=False 를 뺀다", afterBox == all - 2, + $"전 {all} → 후 {afterBox}"); + Check("미리보기: 라벨의 대문자 Visible 은 무시한다", afterWrongKey == all - 2, + $"전 {all} → 후 {afterWrongKey} (레거시는 이 라벨을 인쇄한다)"); + } + // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) var textBox = Find("TextBox"); var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2); diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 9ca47c1..56d51fa 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -66,8 +66,15 @@ public static class PrintService foreach (var control in page.Controls) { - // 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외 - if (control.Model.Hidden || control is DataTableViewModel) + // 인쇄/미리보기에서 빠지는 것 셋: + // ① 디자이너 임시 숨김(Hidden — 저장되지 않는 편집 전용 상태) + // ② 데이터소스(MDataTable — 런타임 비가시) + // ③ 레거시 런타임이 인쇄에서 빼는 것: Visible=False 또는 PrintOutPut=False. + // ③ 을 오래 빠뜨려서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 — + // 저장 전 검증 수단이 거짓말을 하고 있었다. + // 캔버스는 계속 다 보여 준다(편집 중인 것을 못 보면 고칠 수 없다) — 여기만 거른다. + if (control.Model.Hidden || control is DataTableViewModel + || !SheetMe.Core.Catalog.PrintFilter.IsPrinted(control.Model)) { continue; } diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index d99cd5c..465d77c 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -65,6 +65,12 @@ public sealed class DesignerViewModel : ViewModelBase private const double PageGap = 28; private const double WorldPad = 24; private static readonly List clipboard = new(); + /// 한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다 + private const int PlacementLaneSteps = 12; + + /// 팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다 + private int placementStep; + private double zoom = 1.0; private FormDocument document; private PageViewModel? selectedPage; @@ -1153,8 +1159,18 @@ public sealed class DesignerViewModel : ViewModelBase { return; } - var offset = (page.Controls.Count % 8) * 16; - AddControlAt(type, new Point(page.WidthDip / 2 + offset, page.OffsetY + page.HeightDip / 3 + offset)); + // 겹침 방지 오프셋은 단조 증가하는 자체 카운터로 센다. + // + // 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 — + // ① % 8 이라 9번째가 1번째와 정확히 같은 자리에 떨어진다. + // ② Controls.Count 를 쓰므로 하나 지우고 다시 놓으면 지운 것과 같은 자리로 간다. + // 계단은 종이를 벗어나기 전에 접는다 — 접히더라도 방금 놓은 것 위로는 가지 않는다. + var step = placementStep++; + var lane = step / PlacementLaneSteps; + var offset = (step % PlacementLaneSteps) * 16; + AddControlAt(type, new Point( + page.WidthDip / 2 + offset + (lane * 12), + page.OffsetY + page.HeightDip / 3 + offset)); } /// 팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위) diff --git a/tests/SheetMe.Core.Tests/PrintFilterTests.cs b/tests/SheetMe.Core.Tests/PrintFilterTests.cs new file mode 100644 index 0000000..672a241 --- /dev/null +++ b/tests/SheetMe.Core.Tests/PrintFilterTests.cs @@ -0,0 +1,94 @@ +using SheetMe.Core.Catalog; +using SheetMe.Core.Models; + +namespace SheetMe.Core.Tests; + +/// +/// 인쇄·미리보기 필터 고정. +/// +/// 이 판정이 틀리면 미리보기가 사용자에게 거짓말을 한다 — 인쇄에서 빠질 것을 보여 주거나, +/// 나올 것을 감춘다. 저장 전에 결과를 확인할 유일한 수단이라 틀린 값이 곧 잘못된 서식이 된다. +/// +[TestClass] +public sealed class PrintFilterTests +{ + #region Methods + private static ControlElement Make(string type, params (string Key, string Value)[] props) + { + var control = new ControlElement { Type = type, Id = type + "1" }; + foreach (var (key, value) in props) + { + control.Props.SetText(key, value); + } + return control; + } + + [TestMethod] + public void 아무_설정도_없으면_인쇄된다() + { + // 키가 없을 때의 기본값은 True 다(레거시 컨트롤 필드 선언 기준). + // 여기서 false 를 내면 서식 대부분이 빈 종이로 나온다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("Label"))); + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox"))); + } + + [TestMethod] + public void 인쇄_출력을_끄면_빠진다() + { + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "False")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Label", ("PrintOutPut", "False")))); + } + + [TestMethod] + public void 라벨은_소문자_visible_을_본다() + { + // Label.vb 가 Shadows Property visible 로 Control.Visible 을 가리고 직렬화기가 그림자를 쓴다. + // 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다. + Assert.AreEqual("visible", PrintFilter.VisibleKeyOf("Label")); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Label", ("visible", "False")))); + } + + [TestMethod] + public void 라벨의_대문자_Visible_은_인쇄를_막지_못한다() + { + // 레거시 런타임이 읽지 않는 키다. 관용으로 둘 다 읽으면 레거시가 인쇄하는 라벨을 + // 우리만 빼먹는 새 차이가 생긴다 — 그건 호환이 아니다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("Label", ("Visible", "False")))); + } + + [TestMethod] + public void 라벨이_아닌_타입은_대문자_Visible_을_본다() + { + Assert.AreEqual("Visible", PrintFilter.VisibleKeyOf("TextBox")); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("Visible", "False")))); + // 그쪽에서는 소문자가 무시된다 + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("visible", "False")))); + } + + [TestMethod] + public void 대소문자와_공백을_관용으로_읽는다() + { + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", " false ")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "FALSE")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "0")))); + } + + [TestMethod] + public void 해석할_수_없는_값은_인쇄한다() + { + // 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다 — 빠진 것은 아무도 못 본다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "알수없는값")))); + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "")))); + } + + [TestMethod] + public void 모르는_타입도_판정을_거부하지_않는다() + { + // 카탈로그에 없는 타입(Spread·MSign 처럼 미지원으로 살아남은 242건)이 들어와도 + // 예외를 던지지 않고 대문자 Visible 규약으로 판정한다. + Assert.AreEqual("Visible", PrintFilter.VisibleKeyOf("Spread")); + Assert.IsTrue(PrintFilter.IsPrinted(Make("Spread"))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Spread", ("PrintOutPut", "False")))); + } + #endregion +}