401 lines
17 KiB
C#
401 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 아이콘 및 글리프 디자인 시스템 통합 검증 (XAML 정적 PUA 분석 + 픽셀 렌더 무결점 + 실창 폰트 바인딩).
|
|
/// </summary>
|
|
[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<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;
|
|
}
|
|
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<string> 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}");
|
|
}
|
|
}
|
|
}
|
|
}
|