89 lines
4.4 KiB
C#
89 lines
4.4 KiB
C#
using System.Text.RegularExpressions;
|
|
|
|
namespace VideoDownloader.Tests.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// 아이콘 글리프(PUA, Segoe Fluent Icons)를 본문 폰트 요소에 넣으면 tofu(□)로 렌더된다
|
|
/// (2026-08-18 실행 캡처로 확인 — "□ 모두 일시정지"). XAML/코드 정적 검사로 재발을 막는다.
|
|
/// 규칙:
|
|
/// ① PUA 를 쓰는 Content/Text 는 IconFont 보장 스타일(IconFont 를 설정하는 스타일 또는 그 BasedOn 파생)에
|
|
/// 있거나 요소에 FontFamily=IconFont 를 명시한다.
|
|
/// ② 한 문자열에 PUA 와 일반 텍스트를 섞지 않는다 — 폰트가 하나뿐이라 어느 한쪽은 반드시 깨진다.
|
|
/// </summary>
|
|
public class IconGlyphXamlTests
|
|
{
|
|
private static readonly Regex PuaEntity = new(@"&#x(E[0-9A-Fa-f]{3});", RegexOptions.Compiled);
|
|
|
|
/// <summary>Shared.xaml 에서 IconFont 를 보장하는 스타일 키 집합(BasedOn 파생 포함).</summary>
|
|
private static HashSet<string> IconFontStyles()
|
|
{
|
|
var shared = SourceTree.SharedXaml;
|
|
var direct = new HashSet<string>();
|
|
var basedOn = new Dictionary<string, string>();
|
|
foreach (Match style in Regex.Matches(shared, "<Style x:Key=\"(\\w+)\"([^>]*)>(.*?)</Style>", 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<string> 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));
|
|
}
|
|
}
|