글자가 잘리면 CI 가 실패하게 만든다 — 사람이 그림 57장을 보는 대신

재설계 1단계. 화면 픽셀은 하나도 안 옮긴다.

지금까지 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없었다.
테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
--dialog-shots 는 PNG 56장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
그래서 이번 세션에만 세 건이 눈으로만 잡혔다 — "232 로는 이름이 잘린다",
"220 이면 마지막 토글이 잘린다", "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다".

글자를 담은 요소만 본다. WPF 에서 DesiredSize > RenderSize 는 정상인 경우가 많아
전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.

측정이 검사 방식을 두 번 정정했다.

① "Width 를 좁게 주고 말줄임표를 끄면 잘린다"고 놓았는데 아니었다.
   TextTrimming=None 이면 TextBlock 은 준 Width 를 지키지 않고 자연폭을 그대로
   ActualWidth 로 보고한다(Width=40 인데 ActualWidth=213.3).
   즉 WPF 에서 말줄임표 없는 NoWrap 글자는 잘리는 것이 아니라 <b>옆을 침범한다</b>.
   그래서 자기 ActualWidth 와 비교하는 대신 <b>자르는 조상의 사각형</b>과 비교한다.
   자르는 조상이 없으면 위반으로 세지 않는다 — 겹침은 다른 종류의 결함이고
   이 검사기가 판정할 수 있는 것이 아니다.

② 0 폭 칸에 갇힌 글자는 ActualWidth 가 0 이 아니라 9.6 이었다(말줄임표 글리프 몫).
   대조군 기대값을 그에 맞게 고쳤다.

자가 점검을 함께 넣었다. 위반 0건은 "화면이 깨끗하다"와 "검사기가 고장났다"를
구분해 주지 않고, 보고서에서 그 둘은 똑같이 생긴다. 이번 세션에 그 함정에 두 번 빠졌다 —
--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
--modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다.
그래서 일부러 잘린 대조군 4종을 넣고, 검사한 글자 수를 함께 적는다.

실측: 대조군 4/4 · 창 57개에서 글자 1,936개 검사 · 넘침 0건.

기동 화면 밖 문제는 최우선에서 내렸다 — 현장 단말이 1920x1080 이상뿐이라는 답을 받았다.
1440x920 을 1920x1032 에 CenterScreen 하면 Left 240 / Top 56 으로 양수다.
MaximizeToWorkArea.cs:251 의 1366x768@125% 주석은 현장 기술이 아니라 방어적 가정이었다.
같은 이유로 A4 맞춤은 50% 가 아니라 77% 이고, 화면 예산 회수는 급한 일이 아니다.

게이트: 테스트 277/277, --dialog-shots 대조군 4/4 넘침 0, --cleartype 11/11,
--edit-smoke 0실패, --modal-check 0실패, --maxrect 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 16:33:57 +09:00
co-authored by Claude Opus 5
parent 280594362a
commit 862657cf2f
2 changed files with 330 additions and 0 deletions
@@ -53,6 +53,13 @@ public static class DialogShots
: window.Background?.ToString() ?? "(null)";
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
// 그림은 증거만 만든다 — 잘렸는지는 값으로 단정해야 CI 가 잡는다
var violations = OverflowCheck.Inspect(window, out var inspected);
lines.Add($" 글자 {inspected}개 검사, 넘침 {violations.Count}건");
foreach (var violation in violations)
{
lines.Add($"FAIL 넘침 {name}-{suffix} {violation}");
}
}
catch (Exception ex)
{
@@ -63,6 +70,8 @@ public static class DialogShots
lines.Add(string.Empty);
lines.AddRange(StyleSelfCheck());
lines.Add(string.Empty);
lines.AddRange(OverflowCheck.SelfCheck());
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
string.Join(Environment.NewLine, lines));
@@ -0,0 +1,321 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 레이아웃이 글자를 잘라먹었는지 <b>단정</b>한다.
///
/// <b>왜 필요한가.</b> 지금 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없다.
/// 테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
/// --dialog-shots 는 PNG 57장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
/// 즉 회귀를 잡는 유일한 수단이 <b>사람이 그림 57장을 보는 것</b>이었다.
/// 실제로 이번 세션에만 "232 로는 이름이 잘린다", "220 이면 마지막 토글이 잘린다",
/// "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다" 세 건이 눈으로만 잡혔다.
///
/// <b>왜 글자만 보는가.</b> WPF 에서 <c>DesiredSize &gt; RenderSize</c> 는 정상인 경우가 많다
/// (Auto 칸, 클리핑을 의도한 컨테이너, 측정 전 요소). 전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.
/// 잘려서 <b>실제로 손해가 나는 것</b>은 읽어야 할 글자뿐이라 거기만 본다.
/// </summary>
internal static class OverflowCheck
{
#region Member Fields
/// <summary>서브픽셀 반올림 오차 — 이보다 작은 초과는 잘린 것이 아니다</summary>
private const double Slack = 0.75;
#endregion
#region Types
internal readonly record struct Violation(string Kind, string Text, string Detail)
{
public override string ToString() => $"{Kind}: \"{Text}\" — {Detail}";
}
#endregion
#region Methods
/// <summary>
/// 이 창의 시각 트리를 훑어 잘린 글자를 찾는다. <b>Measure/Arrange 가 끝난 뒤</b> 불러야 한다.
///
/// <paramref name="inspected"/> 를 함께 돌려주는 이유: 위반 0건은
/// "깨끗하다"와 "훑기가 깨졌다"를 구분해 주지 않는다. 몇 개를 봤는지 같이 적어야
/// 0 이 뜻을 갖는다(--cleartype 의 대조군과 같은 장치다).
/// </summary>
public static List<Violation> Inspect(Visual root, out int inspected)
{
var found = new List<Violation>();
inspected = 0;
Walk(root, found, ref inspected);
return found;
}
/// <summary>
/// <b>대조군.</b> 일부러 잘린 글자 넷을 만들어 검사기가 실제로 잡는지 확인한다.
///
/// 이것이 없으면 위반 0건이 "화면이 깨끗하다"가 아니라 "검사기가 고장났다"일 수 있고,
/// 그 둘은 보고서에서 똑같이 생겼다. 이번 세션에만 그 함정에 두 번 빠졌다
/// (--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
/// --modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다).
/// </summary>
public static List<string> SelfCheck()
{
var lines = new List<string> { "[넘침 검사기 자가 점검 — 일부러 잘린 것을 잡는가]" };
// 대조군은 <b>실제로 손해가 나는 모양</b>이어야 한다. 처음에는 "Width 를 좁게 주고 말줄임표를 끈"
// 것을 잘림으로 놓았는데, 실측해 보니 그건 잘리는 게 아니라 자연폭 그대로 그려지는 것이었다
// (ActualWidth 가 213.3 으로 나왔다). 대조군이 틀렸던 것이라 모양을 바꿨다.
var zeroHost = new Grid { Width = 0, ClipToBounds = true };
var zeroText = Text("0 크기 칸에 갇힌 글자", TextTrimming.CharacterEllipsis, tip: false);
zeroHost.Children.Add(zeroText);
var spillHost = new Border { Width = 40, Height = 20, ClipToBounds = true };
var spillText = Text("자르는 칸 밖으로 삐져나간 긴 문구", TextTrimming.None, tip: false);
spillHost.Child = spillText;
var trimmedNoTip = Text("말줄임표는 있고 툴팁은 없는 긴 문구", TextTrimming.CharacterEllipsis, tip: false);
trimmedNoTip.Width = 40;
var trimmedWithTip = Text("툴팁이 있으니 전체를 읽을 수 있다", TextTrimming.CharacterEllipsis, tip: true);
trimmedWithTip.Width = 40;
// 0 폭 칸에 갇힌 글자는 ActualWidth 가 정확히 0 이 되지 않는다 — 말줄임표 글리프 몫으로 9.6 이 남는다.
// 그래서 이 대조군의 정답은 '크기0' 이 아니라 두 규칙이 함께 걸리는 것이다.
// 이것도 처음에 기대값을 잘못 적었고 실측이 정정했다.
// '크기0' 규칙 자체는 남긴다 — 0 크기 Grid 가 자식을 폭 0 으로 재던 실제 버그를 겨눈 것이고,
// 합성으로 그 모양을 정확히 만들지 못한다는 사실은 규칙이 틀렸다는 뜻이 아니다.
var cases = new (string Expect, TextBlock Element)[]
{
("컨테이너넘침,툴팁없이잘림", zeroText),
("컨테이너넘침", spillText),
("툴팁없이잘림", trimmedNoTip),
("통과", trimmedWithTip),
};
var host = new StackPanel();
host.Children.Add(zeroHost);
host.Children.Add(spillHost);
host.Children.Add(trimmedNoTip);
host.Children.Add(trimmedWithTip);
var window = new Window
{
Width = 400, Height = 300, Left = -6000, Top = -6000,
WindowStartupLocation = WindowStartupLocation.Manual,
ShowActivated = false, ShowInTaskbar = false, Content = host,
};
window.Show();
window.UpdateLayout();
foreach (var (expect, element) in cases)
{
var hits = Inspect(element, out var seen);
var kinds = hits.Count == 0 ? "통과" : string.Join(",", hits.Select(h => h.Kind));
var ok = kinds == expect && seen == 1;
var block = element;
lines.Add($"{(ok ? "PASS" : "FAIL")} 대조군 {expect,-14} → {kinds} (검사 {seen}개)"
+ $" [보임={block.IsVisible} 폭={block.ActualWidth:0.#} 높이={block.ActualHeight:0.#}"
+ $" 자연폭={(block.IsVisible ? NaturalWidth(block) : 0):0.#} 줄바꿈={block.TextWrapping}]");
}
window.Close();
return lines;
}
private static TextBlock Text(string text, TextTrimming trimming, bool tip)
{
var block = new TextBlock
{
Text = text,
FontSize = 13,
TextWrapping = TextWrapping.NoWrap,
TextTrimming = trimming,
HorizontalAlignment = HorizontalAlignment.Left,
};
if (tip)
{
block.ToolTip = text;
}
return block;
}
private static void Walk(DependencyObject node, List<Violation> found, ref int inspected)
{
if (node is TextBlock text)
{
inspected++;
Judge(text, found);
}
var count = VisualTreeHelper.GetChildrenCount(node);
for (var i = 0; i < count; i++)
{
Walk(VisualTreeHelper.GetChild(node, i), found, ref inspected);
}
// 팝업 내용은 시각 트리에 안 걸리므로 논리 자식으로 따라 들어간다 —
// 메뉴·플라이아웃·자동완성이 전부 이 경로에 있다
if (node is FrameworkElement { } element)
{
foreach (var child in LogicalTreeHelper.GetChildren(element))
{
if (child is System.Windows.Controls.Primitives.Popup { Child: { } popupChild })
{
Walk(popupChild, found, ref inspected);
}
}
}
}
private static void Judge(TextBlock text, List<Violation> found)
{
var content = text.Text;
if (!text.IsVisible || content.Length == 0 || IsWhiteSpace(content))
{
return;
}
var label = content.Length > 28 ? content[..28] + "…" : content;
var owner = OwnerOf(text);
// ① 보이는데 크기가 0 — 이번 세션에 실제로 낸 버그다(0 크기 Grid 가 자식을 폭 0 으로 잰다).
// 이 경우 사용자는 글자가 <b>아예 없는</b> 것으로 본다.
if (text.ActualWidth < 0.5 || text.ActualHeight < 0.5)
{
found.Add(new Violation("크기0", label,
$"{owner} ActualWidth={text.ActualWidth:0.#} ActualHeight={text.ActualHeight:0.#}"));
return;
}
// ② 세로로 잘림 — 줄바꿈이 일어났는데 높이를 안 준 경우다.
if (text.DesiredSize.Height - text.RenderSize.Height > Slack)
{
found.Add(new Violation("세로잘림", label,
$"{owner} 필요 {text.DesiredSize.Height:0.#} > 실제 {text.RenderSize.Height:0.#}"));
}
// ③ 자기를 자르는 조상 밖으로 삐져나갔는가.
//
// <b>왜 ActualWidth 로는 못 잡는가.</b> TextTrimming 이 None 이면 TextBlock 은 주어진 Width 를
// 지키지 않고 자연폭을 그대로 ActualWidth 로 보고한다(실측: Width=40 인데 ActualWidth=213.3).
// 즉 WPF 에서 말줄임표 없는 NoWrap 글자는 <b>잘리는 것이 아니라 옆을 침범한다</b>.
// 그래서 "글자 자연폭 > 자기 ActualWidth" 는 이 경우 항상 거짓이고, 대신
// <b>자르는 조상의 사각형</b>과 비교해야 한다.
Clipped(text, label, owner, found);
// ④ 말줄임표가 실제로 걸렸는가 — 이때는 ActualWidth 가 좁혀지므로 자연폭과 비교할 수 있다.
if (text.TextWrapping != TextWrapping.NoWrap || text.TextTrimming == TextTrimming.None)
{
return;
}
var natural = NaturalWidth(text);
var available = text.ActualWidth - text.Padding.Left - text.Padding.Right;
if (natural - available <= Slack)
{
return;
}
// 말줄임표는 있지만 툴팁이 없으면 전체 문구를 볼 방법이 없다.
if (text.ToolTip is null && ToolTipService.GetToolTip(text) is null && !AncestorHasToolTip(text))
{
found.Add(new Violation("툴팁없이잘림", label,
$"{owner} 필요 {natural:0.#} > 가용 {available:0.#}"));
}
}
/// <summary>
/// 글자의 사각형을 <b>자기를 자르는 가장 가까운 조상</b>의 좌표로 옮겨 비교한다.
/// 자르는 조상이 없으면 삐져나가도 안 잘리므로(옆과 겹칠 뿐) 위반으로 세지 않는다 —
/// 겹침은 다른 종류의 결함이고 이 검사기가 판정할 수 있는 것이 아니다.
/// </summary>
private static void Clipped(TextBlock text, string label, string owner, List<Violation> found)
{
var node = (DependencyObject)text;
for (var depth = 0; depth < 24; depth++)
{
node = VisualTreeHelper.GetParent(node);
if (node is not FrameworkElement { IsVisible: true } ancestor)
{
if (node is null)
{
return;
}
continue;
}
// ScrollViewer 안은 스크롤로 볼 수 있으므로 잘린 것이 아니다
if (ancestor is ScrollViewer or ScrollContentPresenter)
{
return;
}
if (!ancestor.ClipToBounds && ancestor.Clip is null)
{
continue;
}
var box = text.TransformToAncestor(ancestor)
.TransformBounds(new Rect(0, 0, text.ActualWidth, text.ActualHeight));
var overRight = box.Right - ancestor.ActualWidth;
var overBottom = box.Bottom - ancestor.ActualHeight;
if (overRight > Slack || overBottom > Slack || box.Left < -Slack || box.Top < -Slack)
{
found.Add(new Violation("컨테이너넘침", label,
$"{owner} → {ancestor.GetType().Name}"
+ $"({ancestor.ActualWidth:0.#}×{ancestor.ActualHeight:0.#}) 밖으로 "
+ $"우 {Math.Max(0, overRight):0.#} 하 {Math.Max(0, overBottom):0.#}"));
}
return;
}
}
/// <summary>이 글자가 한 줄로 다 그려지려면 몇 픽셀이 필요한가</summary>
private static double NaturalWidth(TextBlock text)
{
var typeface = new Typeface(text.FontFamily, text.FontStyle, text.FontWeight, text.FontStretch);
// 요소와 같은 서식 모드로 재야 한다 — Ideal 과 Display 는 글립 진행이 달라
// 같은 글자도 폭이 다르게 나온다. 이 값은 생성자 인자로만 줄 수 있다.
var formatted = new FormattedText(
text.Text,
CultureInfo.CurrentUICulture,
text.FlowDirection,
typeface,
text.FontSize,
Brushes.Black,
numberSubstitution: null,
TextOptions.GetTextFormattingMode(text),
VisualTreeHelper.GetDpi(text).PixelsPerDip);
return formatted.WidthIncludingTrailingWhitespace;
}
/// <summary>행 전체에 툴팁이 걸린 경우가 흔하다 — 그러면 글자에 없어도 읽을 수 있다</summary>
private static bool AncestorHasToolTip(DependencyObject node)
{
for (var i = 0; i < 6 && node is not null; i++)
{
node = VisualTreeHelper.GetParent(node);
if (node is FrameworkElement { } element
&& (element.ToolTip is not null || ToolTipService.GetToolTip(element) is not null))
{
return true;
}
}
return false;
}
/// <summary>어디서 잘렸는지 — 이름 있는 가장 가까운 조상을 짚어 준다</summary>
private static string OwnerOf(DependencyObject node)
{
for (var i = 0; i < 10 && node is not null; i++)
{
if (node is FrameworkElement { Name.Length: > 0 } named)
{
return named.Name;
}
node = VisualTreeHelper.GetParent(node);
}
return "(이름없음)";
}
private static bool IsWhiteSpace(string value)
{
foreach (var character in value)
{
if (!char.IsWhiteSpace(character))
{
return false;
}
}
return true;
}
#endregion
}