미리보기가 거짓말을 멈춘다, 그리고 9번째 컨트롤이 1번째를 덮지 않는다

3단계 시작 — 실사용 버그 두 건.

<b>① 미리보기·인쇄가 인쇄 필터를 무시하고 있었다.</b>
레거시 런타임은 인쇄에서 이렇게 걸러낸다 —
  If Me.Visible = False OrElse mbPrintOutPut = False Then Return False
그 문장이 ControlRegistry.cs:17 에 인용까지 되어 있는데, 렌더 경로가 두 키를 아무도 읽지 않았다.
대신 걸러낸 것은 ControlElement.Hidden 뿐이고 그건 [JsonIgnore] 디자이너 전용 플래그다
(주석에도 "레거시 Visible 과 별개"라고 적혀 있다).
그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
서식을 저장하기 전에 결과를 확인할 유일한 수단이 거짓말을 하고 있었다.

<b>키 이름이 타입마다 다르다.</b> 라벨만 소문자 visible 이다
(Label.vb 가 Shadows Property visible 로 Control.Visible 을 가리고 직렬화기가 그림자를 쓴다).
운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다.
그래서 관용으로 둘 다 읽지 않는다 — 타입이 선언한 키를 카탈로그에서 찾아 그것만 읽는다.
둘 다 읽으면 대문자 Visible=False 인 라벨이 우리 인쇄에서만 빠지고, 그건 호환이 아니라 새 차이다.
모르는 값과 없는 키는 인쇄한다 — 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다.

캔버스는 그대로 다 보여 준다. 편집 중인 것을 못 보면 고칠 수 없다.
--db-render 는 이 경로를 쓰지 않아 종이 렌더 md5 는 불변이다(확인했다).

<b>② 팔레트 즉시 배치가 9번째부터 겹쳤다.</b>
(page.Controls.Count % 8) * 16 이라 두 가지가 틀렸다 —
9번째가 1번째와 정확히 같은 자리에 떨어지고, 하나 지우고 다시 놓으면 지운 자리로 갔다.
단조 증가 카운터로 바꿨다. 계단은 종이를 벗어나기 전에 옆으로 비킨다.

<b>순수 함수 테스트만으로는 배선을 증명하지 못한다.</b>
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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 17:01:46 +09:00
co-authored by Claude Opus 5
parent d55ca39e6a
commit 1b49f07dd9
5 changed files with 251 additions and 4 deletions
+71
View File
@@ -0,0 +1,71 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 이 컨트롤이 <b>인쇄·미리보기에 나오는가</b>.
///
/// <b>왜 필요한가.</b> 레거시 런타임은 인쇄에서 이렇게 걸러낸다 —
/// <c>If Me.Visible = False OrElse mbPrintOutPut = False Then Return False</c>
/// (그 사실이 <see cref="ControlRegistry"/> 주석에 인용까지 되어 있다).
/// 그런데 우리 미리보기는 그 두 키를 <b>아무도 읽지 않았다</b>. 대신 디자이너 전용 플래그
/// (<c>ControlElement.Hidden</c>, <c>[JsonIgnore]</c>, 주석에 "레거시 Visible 과 별개")만 걸렀다.
/// 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
/// <b>저장 전 검증 수단이 거짓말을 하고 있었다.</b>
///
/// <b>키 이름이 타입마다 다르다.</b> 라벨만 소문자 <c>visible</c> 이다
/// (Label.vb 가 <c>Shadows Property visible</c> 로 <c>Control.Visible</c> 을 가리고 직렬화기가 그림자를 쓴다).
/// 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다.
/// 그래서 <b>타입이 선언한 키</b>를 카탈로그에서 찾아 그것만 읽는다 —
/// 둘 다 관용으로 읽으면, 대문자 Visible=False 인 라벨이 인쇄에서 빠진다.
/// 레거시는 그 라벨을 그대로 인쇄하므로 그건 우리가 만드는 새 차이다.
///
/// <b>모르는 값은 인쇄한다.</b> 키가 없거나 해석할 수 없으면 나온다 —
/// 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다(빠진 것은 아무도 못 본다).
/// </summary>
public static class PrintFilter
{
#region Methods
/// <summary>인쇄·미리보기에 나오는가</summary>
public static bool IsPrinted(ControlElement control)
=> IsOn(control, VisibleKeyOf(control.Type)) && IsOn(control, "PrintOutPut");
/// <summary>
/// 이 타입이 실제로 쓰는 '표시' 키. 카탈로그가 정답을 갖고 있으므로 하드코딩하지 않는다 —
/// 타입이 늘거나 키가 바뀌면 여기가 따라온다.
/// </summary>
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";
}
/// <summary>
/// 레거시가 쓰는 불리언 표기는 WinForms TypeConverter 의 <c>"True"</c>/<c>"False"</c> 다.
/// 운영 데이터에는 대소문자·공백이 섞여 있어 관용으로 읽는다.
/// </summary>
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
}
@@ -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})");
}
// 미리보기가 인쇄 필터를 <b>실제로 쓰는가</b>.
// 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);
@@ -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;
}
@@ -65,6 +65,12 @@ public sealed class DesignerViewModel : ViewModelBase
private const double PageGap = 28;
private const double WorldPad = 24;
private static readonly List<ControlElement> clipboard = new();
/// <summary>한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다</summary>
private const int PlacementLaneSteps = 12;
/// <summary>팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다</summary>
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));
// 겹침 방지 오프셋은 <b>단조 증가하는 자체 카운터</b>로 센다.
//
// 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 —
// ① % 8 이라 <b>9번째가 1번째와 정확히 같은 자리</b>에 떨어진다.
// ② 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));
}
/// <summary>팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위)</summary>
@@ -0,0 +1,94 @@
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 인쇄·미리보기 필터 고정.
///
/// 이 판정이 틀리면 <b>미리보기가 사용자에게 거짓말을 한다</b> — 인쇄에서 빠질 것을 보여 주거나,
/// 나올 것을 감춘다. 저장 전에 결과를 확인할 유일한 수단이라 틀린 값이 곧 잘못된 서식이 된다.
/// </summary>
[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_은_인쇄를_막지_못한다()
{
// 레거시 런타임이 읽지 않는 키다. 관용으로 둘 다 읽으면 레거시가 인쇄하는 라벨을
// 우리만 빼먹는 <b>새 차이</b>가 생긴다 — 그건 호환이 아니다.
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
}