diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs index eeb6799..0df7f3a 100644 --- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs +++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs @@ -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)); diff --git a/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs b/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs new file mode 100644 index 0000000..2c6c748 --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs @@ -0,0 +1,321 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace SheetMe.Designer.Diagnostics; + +/// +/// 레이아웃이 글자를 잘라먹었는지 단정한다. +/// +/// 왜 필요한가. 지금 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없다. +/// 테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고, +/// --dialog-shots 는 PNG 57장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다. +/// 즉 회귀를 잡는 유일한 수단이 사람이 그림 57장을 보는 것이었다. +/// 실제로 이번 세션에만 "232 로는 이름이 잘린다", "220 이면 마지막 토글이 잘린다", +/// "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다" 세 건이 눈으로만 잡혔다. +/// +/// 왜 글자만 보는가. WPF 에서 DesiredSize > RenderSize 는 정상인 경우가 많다 +/// (Auto 칸, 클리핑을 의도한 컨테이너, 측정 전 요소). 전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다. +/// 잘려서 실제로 손해가 나는 것은 읽어야 할 글자뿐이라 거기만 본다. +/// +internal static class OverflowCheck +{ + #region Member Fields + /// 서브픽셀 반올림 오차 — 이보다 작은 초과는 잘린 것이 아니다 + 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 + /// + /// 이 창의 시각 트리를 훑어 잘린 글자를 찾는다. Measure/Arrange 가 끝난 뒤 불러야 한다. + /// + /// 를 함께 돌려주는 이유: 위반 0건은 + /// "깨끗하다"와 "훑기가 깨졌다"를 구분해 주지 않는다. 몇 개를 봤는지 같이 적어야 + /// 0 이 뜻을 갖는다(--cleartype 의 대조군과 같은 장치다). + /// + public static List Inspect(Visual root, out int inspected) + { + var found = new List(); + inspected = 0; + Walk(root, found, ref inspected); + return found; + } + + /// + /// 대조군. 일부러 잘린 글자 넷을 만들어 검사기가 실제로 잡는지 확인한다. + /// + /// 이것이 없으면 위반 0건이 "화면이 깨끗하다"가 아니라 "검사기가 고장났다"일 수 있고, + /// 그 둘은 보고서에서 똑같이 생겼다. 이번 세션에만 그 함정에 두 번 빠졌다 + /// (--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고, + /// --modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다). + /// + public static List SelfCheck() + { + var lines = new List { "[넘침 검사기 자가 점검 — 일부러 잘린 것을 잡는가]" }; + + // 대조군은 실제로 손해가 나는 모양이어야 한다. 처음에는 "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 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 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 으로 잰다). + // 이 경우 사용자는 글자가 아예 없는 것으로 본다. + 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.#}")); + } + + // ③ 자기를 자르는 조상 밖으로 삐져나갔는가. + // + // 왜 ActualWidth 로는 못 잡는가. TextTrimming 이 None 이면 TextBlock 은 주어진 Width 를 + // 지키지 않고 자연폭을 그대로 ActualWidth 로 보고한다(실측: Width=40 인데 ActualWidth=213.3). + // 즉 WPF 에서 말줄임표 없는 NoWrap 글자는 잘리는 것이 아니라 옆을 침범한다. + // 그래서 "글자 자연폭 > 자기 ActualWidth" 는 이 경우 항상 거짓이고, 대신 + // 자르는 조상의 사각형과 비교해야 한다. + 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.#}")); + } + } + + /// + /// 글자의 사각형을 자기를 자르는 가장 가까운 조상의 좌표로 옮겨 비교한다. + /// 자르는 조상이 없으면 삐져나가도 안 잘리므로(옆과 겹칠 뿐) 위반으로 세지 않는다 — + /// 겹침은 다른 종류의 결함이고 이 검사기가 판정할 수 있는 것이 아니다. + /// + private static void Clipped(TextBlock text, string label, string owner, List 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; + } + } + + /// 이 글자가 한 줄로 다 그려지려면 몇 픽셀이 필요한가 + 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; + } + + /// 행 전체에 툴팁이 걸린 경우가 흔하다 — 그러면 글자에 없어도 읽을 수 있다 + 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; + } + + /// 어디서 잘렸는지 — 이름 있는 가장 가까운 조상을 짚어 준다 + 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 +}