using System.IO; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using Paca.App; using Paca.Tests.Infrastructure; using Xunit; namespace Paca.Tests.E2E; /// /// 아이콘 및 글리프 디자인 시스템 통합 검증 (XAML 정적 PUA 분석 + 픽셀 렌더 무결점 + 실창 폰트 바인딩). /// [Collection("E2E")] public class IconGlyphDesignSystemTests { private static readonly Regex PuaEntity = new(@"&#x(E[0-9A-Fa-f]{3});", RegexOptions.Compiled); // ========================================================================= // 1. XAML 정적 글리프 및 PUA 규칙 검증 // ========================================================================= 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; } 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; } internal static IEnumerable Violations(string xaml, string fileName) { var iconStyles = IconFontStyles(); 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 (\\w+)\\}\""); var hasIconStyle = styleMatch.Success && iconStyles.Contains(styleMatch.Groups[1].Value); var hasFontFamily = open.Contains("FontFamily=\"{DynamicResource IconFont}\"") || open.Contains("FontFamily=\"{StaticResource IconFont}\"") || open.Contains("FontFamily=\"Segoe Fluent Icons\""); if (!hasIconStyle && !hasFontFamily) yield return $"{fileName} L{line}: PUA \"{value}\" 에 IconFont 보장 없음"; } } } [Fact] public void MainWindow_XAML_의_모든_PUA_는_IconFont_를_보장한다() { var v = Violations(SourceTree.MainWindowXaml, "MainWindow.xaml").ToList(); Assert.True(v.Count == 0, "MainWindow.xaml 에 아이콘 깨짐 위험:\n" + string.Join("\n", v)); } [Fact] public void SettingsWindow_XAML_의_모든_PUA_는_IconFont_를_보장한다() { var v = Violations(SourceTree.SettingsWindowXaml, "SettingsWindow.xaml").ToList(); Assert.True(v.Count == 0, "SettingsWindow.xaml 에 아이콘 깨짐 위험:\n" + string.Join("\n", v)); } // ========================================================================= // 2. 실창(Live Window) 폰트 상속 및 콘텐츠 TextBlock 검증 // ========================================================================= private static TextBlock? FindTextBlock(DependencyObject root) { if (root is TextBlock tb) return tb; for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) { if (FindTextBlock(VisualTreeHelper.GetChild(root, i)) is { } found) return found; } return null; } [Fact] public async Task 아이콘_버튼의_콘텐츠_TextBlock_은_아이콘폰트로_렌더된다() { var dataDir = Path.Combine(Path.GetTempPath(), $"vd_iconfont_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); try { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); var (navFamily, captionFamily, bodyFamily) = await sta.Dispatcher.InvokeAsync(() => { Paca.App.App.ApplyTheme("dark"); var win = new MainWindow { Left = 20, Top = 700, WindowStartupLocation = WindowStartupLocation.Manual }; win.Show(); win.UpdateLayout(); var nav = (Button)win.FindName("NavBack")!; var navText = FindTextBlock(nav)!; var caption = win.FindName("MaxRestoreBtn") as Button; var captionText = caption != null ? FindTextBlock(caption) : null; var status = (TextBlock)win.FindName("StatusText")!; var result = (navText.FontFamily.Source, captionText?.FontFamily.Source ?? "(캡션 버튼 없음)", status.FontFamily.Source); win.Close(); return result; }); Assert.Contains("Segoe Fluent Icons", navFamily); Assert.Contains("Segoe Fluent Icons", captionFamily); Assert.Contains("Pretendard", bodyFamily); } finally { try { if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true); } catch { } } } // ========================================================================= // 3. 비트맵 픽셀 렌더링 무결점 검증 // ========================================================================= private static byte[] Render(FrameworkElement el) { el.Measure(new Size(48, 48)); el.Arrange(new Rect(0, 0, 48, 48)); el.UpdateLayout(); var rtb = new RenderTargetBitmap(48, 48, 96, 96, PixelFormats.Pbgra32); rtb.Render(el); var px = new byte[48 * 48 * 4]; rtb.CopyPixels(px, 48 * 4, 0); return px; } private static int Lit(byte[] px) { var lit = 0; for (var i = 3; i < px.Length; i += 4) if (px[i] > 60) lit++; return lit; } [Fact] public async Task FluentIcon_스타일_글리프는_명시적_아이콘폰트_렌더와_동일하다() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); var (family, litStyled, litExplicit, litBody, sameAsExplicit, sameAsBody) = await sta.Dispatcher.InvokeAsync(() => { TextBlock Make() => new() { Text = "", FontSize = 14, Foreground = Brushes.White, Background = Brushes.Black, Width = 48, Height = 48, }; var styled = Make(); styled.Style = (Style)Application.Current.FindResource("FluentIcon"); var explicitIcon = Make(); explicitIcon.FontFamily = new FontFamily("Segoe Fluent Icons"); var body = Make(); body.FontFamily = (FontFamily)Application.Current.FindResource("UiFont"); var pStyled = Render(styled); var pExplicit = Render(explicitIcon); var pBody = Render(body); return ( styled.FontFamily.Source, Lit(pStyled), Lit(pExplicit), Lit(pBody), pStyled.SequenceEqual(pExplicit), pStyled.SequenceEqual(pBody) ); }); Assert.Contains("Segoe Fluent Icons", family); Assert.True(litStyled > 10); Assert.True(sameAsExplicit); Assert.False(sameAsBody); } [Fact] public void ProfileIconHelper_DetectsPrivateUseGlyph_And_CalculatesMargin() { // 1. PUA 글리프(LucideFont \uE000~\uF8FF)는 오프셋 불필요 (Margin=0) Assert.True(ProfileIconHelper.IsPrivateUseGlyph("\ue19f")); Assert.True(ProfileIconHelper.IsPrivateUseGlyph("\ue0d7")); Assert.Equal(new Thickness(0), ProfileIconHelper.GetMargin("\ue19f", 12)); Assert.Equal(new Thickness(0), ProfileIconHelper.GetMargin("\ue19f", 16)); // 2. 이모지(🎬 \uD83C\uDFAC, 💼, ⭐ 등)는 베이스라인 쏠림 보정(-shift, shift 대칭 마진) Assert.False(ProfileIconHelper.IsPrivateUseGlyph("\uD83C\uDFAC")); Assert.False(ProfileIconHelper.IsPrivateUseGlyph("⭐")); Assert.Equal(new Thickness(0, -2, 0, 2), ProfileIconHelper.GetMargin("\uD83C\uDFAC", 12)); Assert.Equal(new Thickness(0, -2, 0, 2), ProfileIconHelper.GetMargin("\uD83C\uDFAC", 16)); // 3. Converter 바인딩 정상 변환 확인 var marginResult = (Thickness)ProfileIconMarginConverter.Instance.Convert("\uD83C\uDFAC", typeof(Thickness), "12", System.Globalization.CultureInfo.InvariantCulture); Assert.Equal(new Thickness(0, -2, 0, 2), marginResult); } [Fact] public async Task ProfileAvatarBadge_IconGlyph_IsCenteredInsideBadge() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); var (unadjustedDiffY, adjustedResults) = await sta.Dispatcher.InvokeAsync(() => { var bgCol = Color.FromRgb(79, 70, 229); // 1. 보정 전(nudge=0) 이모지 🎬는 아래로 치우쳐 diffY >= 4 결함 확인 (RED 검증 증명) int unadjDiff; { var badge = new Border { Width = 24, Height = 24, Background = new SolidColorBrush(bgCol) }; var tb = new TextBlock { Text = "\uD83C\uDFAC", FontFamily = (FontFamily)Application.Current.FindResource("LucideFont"), FontSize = 12, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0) }; badge.Child = tb; badge.Measure(new Size(24, 24)); badge.Arrange(new Rect(0, 0, 24, 24)); badge.UpdateLayout(); var rtb = new RenderTargetBitmap(24, 24, 96, 96, PixelFormats.Pbgra32); rtb.Render(badge); var px = new byte[24 * 24 * 4]; rtb.CopyPixels(px, 24 * 4, 0); int minY = 24, maxY = -1; for (int y = 0; y < 24; y++) { for (int x = 0; x < 24; x++) { int i = (y * 24 + x) * 4; int diff = Math.Abs(px[i + 2] - bgCol.R) + Math.Abs(px[i + 1] - bgCol.G) + Math.Abs(px[i] - bgCol.B); if (diff > 30) { if (y < minY) minY = y; if (y > maxY) maxY = y; } } } int top = minY; int bottom = 23 - maxY; unadjDiff = top - bottom; } // 2. ProfileIconHelper.GetMargin 적용 시 24x24 및 36x36 뱃지에서 완벽한 정렬 검증 (DoD: 오차 <= 1px) string[] testIcons = ["\uD83C\uDFAC", "💼", "🎮", "💻", "🎨", "🚀", "⭐", "\ue19f"]; var list = new List<(string icon, int size, int err)>(); foreach (var icon in testIcons) { // 24x24 badge (Toolbar ProfileAvatarBadge, FontSize 12) { var badge = new Border { Width = 24, Height = 24, Background = new SolidColorBrush(bgCol) }; var tb = new TextBlock { Text = icon, FontFamily = (FontFamily)Application.Current.FindResource("LucideFont"), FontSize = 12, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = ProfileIconHelper.GetMargin(icon, 12) }; badge.Child = tb; badge.Measure(new Size(24, 24)); badge.Arrange(new Rect(0, 0, 24, 24)); badge.UpdateLayout(); var rtb = new RenderTargetBitmap(24, 24, 96, 96, PixelFormats.Pbgra32); rtb.Render(badge); var px = new byte[24 * 24 * 4]; rtb.CopyPixels(px, 24 * 4, 0); int minY = 24, maxY = -1; for (int y = 0; y < 24; y++) { for (int x = 0; x < 24; x++) { int i = (y * 24 + x) * 4; int diff = Math.Abs(px[i + 2] - bgCol.R) + Math.Abs(px[i + 1] - bgCol.G) + Math.Abs(px[i] - bgCol.B); if (diff > 30) { if (y < minY) minY = y; if (y > maxY) maxY = y; } } } int top = minY; int bottom = 23 - maxY; list.Add((icon, 24, Math.Abs(top - bottom))); } // 36x36 badge (Popup CurrentProfileAvatar, FontSize 16) { var badge = new Border { Width = 36, Height = 36, Background = new SolidColorBrush(bgCol) }; var tb = new TextBlock { Text = icon, FontFamily = (FontFamily)Application.Current.FindResource("LucideFont"), FontSize = 16, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = ProfileIconHelper.GetMargin(icon, 16) }; badge.Child = tb; badge.Measure(new Size(36, 36)); badge.Arrange(new Rect(0, 0, 36, 36)); badge.UpdateLayout(); var rtb = new RenderTargetBitmap(36, 36, 96, 96, PixelFormats.Pbgra32); rtb.Render(badge); var px = new byte[36 * 36 * 4]; rtb.CopyPixels(px, 36 * 4, 0); int minY = 36, maxY = -1; for (int y = 0; y < 36; y++) { for (int x = 0; x < 36; x++) { int i = (y * 36 + x) * 4; int diff = Math.Abs(px[i + 2] - bgCol.R) + Math.Abs(px[i + 1] - bgCol.G) + Math.Abs(px[i] - bgCol.B); if (diff > 30) { if (y < minY) minY = y; if (y > maxY) maxY = y; } } } int top = minY; int bottom = 35 - maxY; list.Add((icon, 36, Math.Abs(top - bottom))); } } return (unadjDiff, list); }); // 1. 미보정 시 4px 하단 쏠림 증명 (RED 증명) Assert.True(unadjustedDiffY >= 4); // 2. 문제 제기된 템플릿 프로필 아이콘(🎬) 및 기본 글리프() 24x24 뱃지 0px 오차 완전 일치 보장 var clapper24 = adjustedResults.First(r => r.icon == "\uD83C\uDFAC" && r.size == 24); Assert.Equal(0, clapper24.err); var lucide24 = adjustedResults.First(r => r.icon == "\ue19f" && r.size == 24); Assert.Equal(0, lucide24.err); // 3. 36x36 뱃지에서도 0~1px 정수 격자 최적 일치 보장 var clapper36 = adjustedResults.First(r => r.icon == "\uD83C\uDFAC" && r.size == 36); Assert.True(clapper36.err <= 1); var lucide36 = adjustedResults.First(r => r.icon == "\ue19f" && r.size == 36); Assert.Equal(0, lucide36.err); // 4. 전체 다양한 이모지 군에 대해서도 시각적 중심 편차 2px 이내 억제 보장 foreach (var (icon, size, err) in adjustedResults) { if (size == 24) { Assert.True(err <= 2, $"Icon '{icon}' in 24x24 badge exceeded 2px tolerance: err={err}"); } } } }