using System.Text.RegularExpressions; namespace VideoDownloader.Tests.Infrastructure; /// /// 아이콘 글리프(PUA, Segoe Fluent Icons)를 본문 폰트 요소에 넣으면 tofu(□)로 렌더된다 /// (2026-08-18 실행 캡처로 확인 — "□ 모두 일시정지"). XAML/코드 정적 검사로 재발을 막는다. /// 규칙: /// ① PUA 를 쓰는 Content/Text 는 IconFont 보장 스타일(IconFont 를 설정하는 스타일 또는 그 BasedOn 파생)에 /// 있거나 요소에 FontFamily=IconFont 를 명시한다. /// ② 한 문자열에 PUA 와 일반 텍스트를 섞지 않는다 — 폰트가 하나뿐이라 어느 한쪽은 반드시 깨진다. /// public class IconGlyphXamlTests { private static readonly Regex PuaEntity = new(@"&#x(E[0-9A-Fa-f]{3});", RegexOptions.Compiled); /// Shared.xaml 에서 IconFont 를 보장하는 스타일 키 집합(BasedOn 파생 포함). private static HashSet IconFontStyles() { var shared = SourceTree.SharedXaml; var direct = new HashSet(); var basedOn = new Dictionary(); foreach (Match style in Regex.Matches(shared, "", RegexOptions.Singleline)) { var key = style.Groups[1].Value; if (style.Groups[3].Value.Contains("IconFont")) direct.Add(key); var b = Regex.Match(style.Groups[2].Value, "BasedOn=\"\\{StaticResource (\\w+)\\}\""); if (b.Success) basedOn[key] = b.Groups[1].Value; } // BasedOn 체인 전개 (파생이 부모의 FontFamily 세터를 상속) bool grew; do { grew = false; foreach (var (child, parent) in basedOn) if (!direct.Contains(child) && direct.Contains(parent)) { direct.Add(child); grew = true; } } while (grew); return direct; } private static IEnumerable Violations(string xaml, string fileName) { var iconStyles = IconFontStyles(); // 여는 태그 단위로 검사 — Button/TextBlock/Run 등 텍스트 표시 요소 foreach (Match tag in Regex.Matches(xaml, @"<(Button|TextBlock|Run|Label)\b[^>]*?>", RegexOptions.Singleline)) { var open = tag.Value; foreach (Match attr in Regex.Matches(open, "(?:Content|Text)=\"([^\"]*)\"")) { var value = attr.Groups[1].Value; if (!PuaEntity.IsMatch(value)) continue; var line = xaml[..tag.Index].Count(c => c == '\n') + 1; var stripped = PuaEntity.Replace(value, "").Trim(); if (stripped.Length > 0) { yield return $"{fileName} L{line}: PUA+일반 텍스트 혼합 \"{value}\" — 한 폰트로는 둘 다 못 그린다"; continue; } var styleMatch = Regex.Match(open, "Style=\"\\{(?:StaticResource|DynamicResource) (\\w+)\\}\""); var styled = styleMatch.Success && iconStyles.Contains(styleMatch.Groups[1].Value); var explicitFont = open.Contains("FontFamily=\"{DynamicResource IconFont}\"") || open.Contains("FontFamily=\"{StaticResource IconFont}\""); if (!styled && !explicitFont) yield return $"{fileName} L{line}: PUA 글리프 \"{value}\" 가 IconFont 보장 없이 사용됨(본문 폰트 → tofu)"; } } } [Fact] public void XAML_의_PUA_글리프는_IconFont_보장_하에서만_쓴다() { var violations = Violations(SourceTree.MainWindowXaml, "MainWindow.xaml") .Concat(Violations(SourceTree.SettingsWindowXaml, "SettingsWindow.xaml")) .ToList(); Assert.True(violations.Count == 0, "tofu 유발 지점:\n" + string.Join("\n", violations)); } [Fact] public void 코드비하인드도_PUA_와_일반_텍스트를_섞지_않는다() { // 예: LibraryViewToggle.Content = "; 그리드" — 본문 폰트 버튼이라 tofu + 잉여 세미콜론 노출 var offenders = Regex.Matches(SourceTree.MainWindowCs + File.ReadAllText(SourceTree.AppPath("MainWindow.Usecases.cs")), @"""\\uE[0-9A-Fa-f]{3}[^""]*[가-힣A-Za-z][^""]*""") .Select(m => m.Value).ToList(); Assert.True(offenders.Count == 0, "코드 내 혼합 문자열:\n" + string.Join("\n", offenders)); } }