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(@"(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}");
}
}
}
}