2125 lines
108 KiB
C#
2125 lines
108 KiB
C#
using System.IO;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using Paca.App;
|
|
using Paca.App.Browser.Features;
|
|
using Paca.Server;
|
|
using Paca.Tests.Infrastructure;
|
|
using Xunit;
|
|
|
|
namespace Paca.Tests.E2E;
|
|
|
|
/// <summary>
|
|
/// 초정밀 E2E 시각 & 디자인 게이트 검증 스위트 (Designpaca & Visual Quality Gate).
|
|
/// 1. 데스크톱 XAML 테마 토큰 무결성 및 100% 키 일치도 검증
|
|
/// 2. WCAG 2.1 AA/AAA 기준 수학적 상대 휘도 명도 대비 검증 (TextPrimary, TextSecondary, TextMuted, Accent, Semantic)
|
|
/// 3. 웹 플랫폼(site/) 디자인 시스템 토큰, 뷰포트 메타, 네비게이션 무결성 검증
|
|
/// 4. 내장 HTML(새 탭 NTP, 리더 모드, 웹 컴패니언) 렌더링 무결성 및 접근성 속성 검증
|
|
/// 5. 데스크톱 UI 창 및 공용 컨트롤 템플릿(버튼, 텍스트박스, 진행바, 콤보박스 등) 무결성 검증
|
|
/// </summary>
|
|
public class VisualDesignGateTests
|
|
{
|
|
private static string SiteDir => Path.Combine(SourceTree.Root, "site");
|
|
|
|
// =========================================================================
|
|
// 1. 데스크톱 XAML 테마 토큰 무결성 & 100% 키 일치도 게이트
|
|
// =========================================================================
|
|
|
|
private static readonly string[] RequiredBrushKeys =
|
|
[
|
|
"WindowBg", "WindowBgGradient", "PanelBg", "Surface2", "GlassBg", "GlassBorder",
|
|
"CardBg", "CardBgHover", "DimOverlayBrush", "BorderBrush", "BorderSubtle",
|
|
"TextPrimary", "TextSecondary", "TextMuted", "TextDisabled", "TrackBrush",
|
|
"SuccessBrush", "ErrorBrush", "PendingBrush", "WarningBrush", "InfoBrush",
|
|
"SelectionBrush", "FocusAdornerBrush", "LinkBrush", "VisitedLinkBrush",
|
|
"AccentTextBrush", "AccentBrush", "AccentGradient", "AccentFadeBrush",
|
|
"ScrollBarTrackBrush", "ScrollBarThumbBrush", "ScrollBarThumbHoverBrush", "ScrollBarThumbDragBrush",
|
|
"Accent.Blue", "Accent.Green", "Accent.Pink", "Accent.Orange"
|
|
];
|
|
|
|
[Fact]
|
|
public void Gate01_DarkTheme_AllRequiredTokensDefined()
|
|
{
|
|
var darkXaml = SourceTree.ThemeDarkXaml;
|
|
foreach (var key in RequiredBrushKeys)
|
|
{
|
|
Assert.True(SourceTree.HasKey(darkXaml, key),
|
|
$"[Dark Theme Gate] '{key}' 토큰이 Themes/Dark.xaml 에 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate02_LightTheme_AllRequiredTokensDefined()
|
|
{
|
|
var lightXaml = SourceTree.ThemeLightXaml;
|
|
foreach (var key in RequiredBrushKeys)
|
|
{
|
|
Assert.True(SourceTree.HasKey(lightXaml, key),
|
|
$"[Light Theme Gate] '{key}' 토큰이 Themes/Light.xaml 에 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate03_HighContrastTheme_AllRequiredTokensDefined()
|
|
{
|
|
var hcPath = SourceTree.AppPath("Themes/HighContrast.xaml");
|
|
Assert.True(File.Exists(hcPath), "Themes/HighContrast.xaml 파일이 존재해야 합니다.");
|
|
var hcXaml = File.ReadAllText(hcPath);
|
|
foreach (var key in RequiredBrushKeys)
|
|
{
|
|
Assert.True(SourceTree.HasKey(hcXaml, key),
|
|
$"[HighContrast Theme Gate] '{key}' 토큰이 Themes/HighContrast.xaml 에 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. WCAG 2.1 AA/AAA 수학적 상대 휘도 명도 대비 검증 게이트
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void Gate04_DarkTheme_WcagContrast_MeetsAaAndAaa()
|
|
{
|
|
var dark = SourceTree.ThemeDarkXaml;
|
|
var textPrimary = SourceTree.BrushHex(dark, "TextPrimary")!;
|
|
var textSecondary = SourceTree.BrushHex(dark, "TextSecondary")!;
|
|
var textMuted = SourceTree.BrushHex(dark, "TextMuted")!;
|
|
var accentText = SourceTree.BrushHex(dark, "AccentTextBrush")!;
|
|
var cardBg = SourceTree.BrushHex(dark, "CardBg")!;
|
|
var windowBg = SourceTree.BrushHex(dark, "WindowBg")!;
|
|
var panelBg = SourceTree.BrushHex(dark, "PanelBg")!;
|
|
var cardHover = SourceTree.BrushHex(dark, "CardBgHover")!;
|
|
|
|
// TextPrimary: AAA >= 7.0:1
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, cardBg) >= 7.0,
|
|
$"Dark TextPrimary/CardBg ratio {SourceTree.ContrastRatio(textPrimary, cardBg):F2} < 7.0 (AAA)");
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, windowBg) >= 7.0,
|
|
$"Dark TextPrimary/WindowBg ratio {SourceTree.ContrastRatio(textPrimary, windowBg):F2} < 7.0 (AAA)");
|
|
|
|
// TextSecondary: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(textSecondary, cardBg) >= 4.5,
|
|
$"Dark TextSecondary/CardBg ratio {SourceTree.ContrastRatio(textSecondary, cardBg):F2} < 4.5 (AA)");
|
|
Assert.True(SourceTree.ContrastRatio(textSecondary, panelBg) >= 4.5,
|
|
$"Dark TextSecondary/PanelBg ratio {SourceTree.ContrastRatio(textSecondary, panelBg):F2} < 4.5 (AA)");
|
|
|
|
// TextMuted: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(textMuted, cardBg) >= 4.5,
|
|
$"Dark TextMuted/CardBg ratio {SourceTree.ContrastRatio(textMuted, cardBg):F2} < 4.5 (AA)");
|
|
Assert.True(SourceTree.ContrastRatio(textMuted, cardHover) >= 4.5,
|
|
$"Dark TextMuted/CardBgHover ratio {SourceTree.ContrastRatio(textMuted, cardHover):F2} < 4.5 (AA)");
|
|
|
|
// AccentText: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(accentText, cardBg) >= 4.5,
|
|
$"Dark AccentText/CardBg ratio {SourceTree.ContrastRatio(accentText, cardBg):F2} < 4.5 (AA)");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate05_LightTheme_WcagContrast_MeetsAaAndAaa()
|
|
{
|
|
var light = SourceTree.ThemeLightXaml;
|
|
var textPrimary = SourceTree.BrushHex(light, "TextPrimary")!;
|
|
var textSecondary = SourceTree.BrushHex(light, "TextSecondary")!;
|
|
var textMuted = SourceTree.BrushHex(light, "TextMuted")!;
|
|
var accentText = SourceTree.BrushHex(light, "AccentTextBrush")!;
|
|
var cardBg = SourceTree.BrushHex(light, "CardBg")!;
|
|
var windowBg = SourceTree.BrushHex(light, "WindowBg")!;
|
|
var panelBg = SourceTree.BrushHex(light, "PanelBg")!;
|
|
var glassBg = SourceTree.BrushHex(light, "GlassBg")!;
|
|
|
|
// TextPrimary: AAA >= 7.0:1
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, cardBg) >= 7.0,
|
|
$"Light TextPrimary/CardBg ratio {SourceTree.ContrastRatio(textPrimary, cardBg):F2} < 7.0 (AAA)");
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, windowBg) >= 7.0,
|
|
$"Light TextPrimary/WindowBg ratio {SourceTree.ContrastRatio(textPrimary, windowBg):F2} < 7.0 (AAA)");
|
|
|
|
// TextSecondary: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(textSecondary, cardBg) >= 4.5,
|
|
$"Light TextSecondary/CardBg ratio {SourceTree.ContrastRatio(textSecondary, cardBg):F2} < 4.5 (AA)");
|
|
Assert.True(SourceTree.ContrastRatio(textSecondary, panelBg) >= 4.5,
|
|
$"Light TextSecondary/PanelBg ratio {SourceTree.ContrastRatio(textSecondary, panelBg):F2} < 4.5 (AA)");
|
|
|
|
// TextMuted: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(textMuted, cardBg) >= 4.5,
|
|
$"Light TextMuted/CardBg ratio {SourceTree.ContrastRatio(textMuted, cardBg):F2} < 4.5 (AA)");
|
|
Assert.True(SourceTree.ContrastRatio(textMuted, glassBg) >= 4.5,
|
|
$"Light TextMuted/GlassBg ratio {SourceTree.ContrastRatio(textMuted, glassBg):F2} < 4.5 (AA)");
|
|
|
|
// AccentText: AA >= 4.5:1
|
|
Assert.True(SourceTree.ContrastRatio(accentText, cardBg) >= 4.5,
|
|
$"Light AccentText/CardBg ratio {SourceTree.ContrastRatio(accentText, cardBg):F2} < 4.5 (AA)");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate06_HighContrastTheme_WcagContrast_ExceedsAaa()
|
|
{
|
|
var hcXaml = File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml"));
|
|
var textPrimary = SourceTree.BrushHex(hcXaml, "TextPrimary")!;
|
|
var textSecondary = SourceTree.BrushHex(hcXaml, "TextSecondary")!;
|
|
var textMuted = SourceTree.BrushHex(hcXaml, "TextMuted")!;
|
|
var cardBg = SourceTree.BrushHex(hcXaml, "CardBg")!;
|
|
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, cardBg) >= 15.0,
|
|
$"HighContrast TextPrimary/CardBg {SourceTree.ContrastRatio(textPrimary, cardBg):F2} < 15.0");
|
|
Assert.True(SourceTree.ContrastRatio(textSecondary, cardBg) >= 10.0,
|
|
$"HighContrast TextSecondary/CardBg {SourceTree.ContrastRatio(textSecondary, cardBg):F2} < 10.0");
|
|
Assert.True(SourceTree.ContrastRatio(textMuted, cardBg) >= 7.0,
|
|
$"HighContrast TextMuted/CardBg {SourceTree.ContrastRatio(textMuted, cardBg):F2} < 7.0");
|
|
}
|
|
|
|
// =========================================================================
|
|
// 3. 웹 플랫폼(site/) 디자인 시스템 및 HTML 구조 무결성 게이트
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void Gate07_WebSite_StylesCss_ContainsFullDesignTokens()
|
|
{
|
|
var cssPath = Path.Combine(SiteDir, "styles.css");
|
|
Assert.True(File.Exists(cssPath), "site/styles.css 파일이 존재해야 합니다.");
|
|
var css = File.ReadAllText(cssPath);
|
|
|
|
string[] requiredTokens =
|
|
[
|
|
"--bg-canvas", "--surface-1", "--surface-2", "--surface-3", "--surface-4",
|
|
"--border-subtle", "--border-medium", "--border-highlight",
|
|
"--text-primary", "--text-secondary", "--text-muted",
|
|
"--accent", "--accent-hover", "--accent-soft", "--accent-glow",
|
|
"--success", "--warning", "--danger",
|
|
"--step-0", "--step-1", "--step-2", "--step-3", "--step-4", "--step-5",
|
|
"--space-1", "--space-2", "--space-3", "--space-4", "--space-5",
|
|
"--radius-pill", "--radius-xl", "--radius-lg", "--radius-md", "--radius-sm",
|
|
"--nav-h", "--container-max"
|
|
];
|
|
|
|
foreach (var tok in requiredTokens)
|
|
{
|
|
Assert.True(css.Contains(tok), $"[Web Token Gate] '{tok}' 변수가 site/styles.css 에 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("index.html")]
|
|
[InlineData("guide.html")]
|
|
[InlineData("releases.html")]
|
|
[InlineData("404.html")]
|
|
public void Gate08_WebSite_HtmlFiles_HaveProperMetaAndStructure(string fileName)
|
|
{
|
|
var filePath = Path.Combine(SiteDir, fileName);
|
|
Assert.True(File.Exists(filePath), $"site/{fileName} 파일이 존재해야 합니다.");
|
|
var html = File.ReadAllText(filePath);
|
|
|
|
Assert.True(html.Contains("<!doctype html>") || html.Contains("<!DOCTYPE html>"),
|
|
$"{fileName}에 DOCTYPE 선언이 누락되었습니다.");
|
|
Assert.True(html.Contains("<meta charset=\"utf-8\">") || html.Contains("<meta charset=\"UTF-8\">"),
|
|
$"{fileName}에 UTF-8 메타 태그가 누락되었습니다.");
|
|
Assert.True(html.Contains("name=\"viewport\""),
|
|
$"{fileName}에 viewport 메타 태그가 누락되었습니다.");
|
|
Assert.True(html.Contains("<title>") && html.Contains("</title>"),
|
|
$"{fileName}에 title 태그가 누락되었습니다.");
|
|
Assert.True(html.Contains("styles.css"),
|
|
$"{fileName}에 styles.css 링크가 누락되었습니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate09_WebSite_ReleasesPage_HasMatchingContainerId()
|
|
{
|
|
var relHtml = File.ReadAllText(Path.Combine(SiteDir, "releases.html"));
|
|
var siteJs = File.ReadAllText(Path.Combine(SiteDir, "site.js"));
|
|
|
|
Assert.True(relHtml.Contains("id=\"release-root\""),
|
|
"releases.html 에 dynamic renderer 용 id=\"release-root\" 가 존재해야 합니다.");
|
|
Assert.True(siteJs.Contains("release-root"),
|
|
"site.js 에 release-root 셀렉터 지원이 존재해야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate10_WebSite_GuidePage_AllTocAnchorsMatch()
|
|
{
|
|
var guideHtml = File.ReadAllText(Path.Combine(SiteDir, "guide.html"));
|
|
string[] requiredAnchors = ["install", "download", "batch", "phone", "shortcuts", "faq"];
|
|
|
|
foreach (var anchor in requiredAnchors)
|
|
{
|
|
Assert.True(guideHtml.Contains($"href=\"#{anchor}\""),
|
|
$"guide.html TOC 에 href=\"#{anchor}\" 링크가 누락되었습니다.");
|
|
Assert.True(guideHtml.Contains($"id=\"{anchor}\""),
|
|
$"guide.html 본문에 id=\"{anchor}\" 대상 섹션이 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 4. 내장 HTML(새 탭 NTP, 리더 모드, 웹 컴패니언) 렌더링 무결성 게이트
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void Gate11_NewTabPageBuilder_RendersAccessibleResponsiveHtml()
|
|
{
|
|
var html = NewTabPageBuilder.BuildNtp(isDark: true);
|
|
Assert.StartsWith("<!DOCTYPE html>", html.TrimStart());
|
|
Assert.Contains("<meta charset=\"utf-8\">", html);
|
|
Assert.Contains("name=\"viewport\"", html);
|
|
Assert.Contains("class=\"dark\"", html);
|
|
Assert.Contains("shortcuts-grid", html);
|
|
Assert.Contains("search-box", html);
|
|
Assert.Contains("customize-drawer", html);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate12_ReaderModeBuilder_RendersValidKoreanDocument()
|
|
{
|
|
var html = ReaderMode.BuildReaderHtml("테스트 기사", "작성자", "요약문", "<p>본문 내용</p>", lightTheme: true);
|
|
Assert.StartsWith("<!DOCTYPE html>", html.TrimStart());
|
|
Assert.Contains("lang=\"ko\"", html);
|
|
Assert.Contains("<meta charset=\"utf-8\">", html);
|
|
Assert.Contains("name=\"viewport\"", html);
|
|
Assert.Contains("테스트 기사", html);
|
|
Assert.Contains("본문 내용", html);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate13_WebCompanion_HasSafeViewportsAndGlassTokens()
|
|
{
|
|
var html = WebCompanion.Html;
|
|
Assert.StartsWith("<!DOCTYPE html>", html.TrimStart());
|
|
Assert.Contains("viewport-fit=cover", html);
|
|
Assert.Contains("<meta charset=\"utf-8\">", html);
|
|
Assert.Contains("--bg-gradient", html);
|
|
Assert.Contains("--glass-border", html);
|
|
Assert.Contains("PACA", html);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 5. 데스크톱 UI 창 및 공용 컨트롤 템플릿 무결성 게이트
|
|
// =========================================================================
|
|
|
|
[Theory]
|
|
[InlineData("MainWindow.xaml")]
|
|
[InlineData("SettingsWindow.xaml")]
|
|
[InlineData("BookmarkManagerWindow.xaml")]
|
|
[InlineData("ExtensionManagerWindow.xaml")]
|
|
[InlineData("LogViewerWindow.xaml")]
|
|
[InlineData("CrashReportDialog.xaml")]
|
|
public void Gate14_DesktopWindows_ExistAndUseUiFontAndWindowBg(string windowXamlFile)
|
|
{
|
|
var path = SourceTree.AppPath(windowXamlFile);
|
|
Assert.True(File.Exists(path), $"{windowXamlFile} 파일이 존재해야 합니다.");
|
|
var xaml = File.ReadAllText(path);
|
|
|
|
Assert.True(xaml.Contains("FontFamily=\"{DynamicResource UiFont}\""),
|
|
$"{windowXamlFile} 에 FontFamily=\"{{DynamicResource UiFont}}\" 선언이 필요합니다.");
|
|
Assert.True(xaml.Contains("Background=\"{DynamicResource WindowBgGradient}\"") ||
|
|
xaml.Contains("Background=\"{DynamicResource WindowBg}\""),
|
|
$"{windowXamlFile} 에 WindowBg/WindowBgGradient 배경 브러시 선언이 필요합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate15_SharedStyles_AllCoreControlTemplatesDefined()
|
|
{
|
|
var shared = SourceTree.SharedXaml;
|
|
string[] requiredStyles =
|
|
[
|
|
"GhostButton", "PrimaryButton", "DarkTextBox", "AddressTextBox",
|
|
"DarkCheckBox", "DarkRadioButton", "DarkComboBox",
|
|
"AccentProgressBar", "FocusVisual", "SearchInputContainer"
|
|
];
|
|
|
|
foreach (var styleKey in requiredStyles)
|
|
{
|
|
Assert.True(SourceTree.HasKey(shared, styleKey),
|
|
$"[Shared XAML Gate] '{styleKey}' 스타일이 Themes/Shared.xaml 에 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate16_AddressPopup_AnchoredToAddressBarContainer_WithDynamicWidth()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
Assert.True(mwXaml.Contains("PlacementTarget=\"{Binding ElementName=AddressBarContainer}\""),
|
|
"AddressPopup 이 AddressBarContainer 에 앵커되어야 합니다.");
|
|
Assert.True(mwXaml.Contains("Width=\"{Binding ActualWidth, ElementName=AddressBarContainer}\""),
|
|
"AddressPopup 의 Width 가 AddressBarContainer 의 ActualWidth 에 동적으로 바인딩되어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate17_FlyoutsAndSubWindows_ProperlyStyled()
|
|
{
|
|
var bmPath = SourceTree.AppPath("BookmarkManagerWindow.xaml");
|
|
var extPath = SourceTree.AppPath("ExtensionManagerWindow.xaml");
|
|
var logPath = SourceTree.AppPath("LogViewerWindow.xaml");
|
|
var crashPath = SourceTree.AppPath("CrashReportDialog.xaml");
|
|
|
|
var bmXaml = File.ReadAllText(bmPath);
|
|
var extXaml = File.ReadAllText(extPath);
|
|
var logXaml = File.ReadAllText(logPath);
|
|
var crashXaml = File.ReadAllText(crashPath);
|
|
|
|
Assert.Contains("DynamicResource CardBgHover", bmXaml);
|
|
Assert.Contains("DynamicResource AccentFadeBrush", bmXaml);
|
|
Assert.Contains("DynamicResource AccentFadeBrush", extXaml);
|
|
Assert.Contains("DynamicResource GlassBg", logXaml);
|
|
Assert.Contains("DynamicResource ErrorBrush", crashXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate18_SettingsWindow_BentoLayoutAndCategories()
|
|
{
|
|
var settingsXaml = File.ReadAllText(SourceTree.AppPath("SettingsWindow.xaml"));
|
|
string[] requiredSections =
|
|
[
|
|
"SectionAppearance", "SectionToolbar", "SectionDownload",
|
|
"SectionGeneral", "SectionBrowser", "SectionNotify",
|
|
"SectionAccessibility", "SectionShortcuts", "SectionAbout"
|
|
];
|
|
|
|
foreach (var sec in requiredSections)
|
|
{
|
|
Assert.Contains($"x:Name=\"{sec}\"", settingsXaml);
|
|
}
|
|
|
|
Assert.Contains("x:Name=\"SettingsSearchBox\"", settingsXaml);
|
|
Assert.Contains("x:Name=\"ResetDefaultsButton\"", settingsXaml);
|
|
Assert.Contains("x:Name=\"ExportSettingsButton\"", settingsXaml);
|
|
Assert.Contains("x:Name=\"ImportSettingsButton\"", settingsXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate19_DownloadCard_SegmentMapAndTypography()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
Assert.Contains("DataType=\"{x:Type vm:DownloadItemViewModel}\"", mwXaml);
|
|
Assert.Contains("x:Name=\"SegmentMapHost\"", mwXaml);
|
|
Assert.Contains("Style=\"{StaticResource Type.Mono}\"", mwXaml);
|
|
Assert.Contains("CornerRadius=\"{StaticResource CornerRadius.Card}\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate20_TitleBarAndAddressBar_VerticalAlignmentAndSpacing()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
Assert.Contains("x:Name=\"AddressBarContainer\"", mwXaml);
|
|
Assert.Contains("x:Name=\"PageControlsBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"PageControlsDomainText\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BookmarkStarBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"NavProgress\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate21_GlobalMediaHub_And_ProfileControls_Structure()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
Assert.Contains("x:Name=\"GlobalMediaBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ProfileBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ProfileAvatarBadge\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ExtensionsBtn\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate22_ToolbarPopups_RightAlignmentAndPureWhiteCanvas()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
|
|
// Verify popups are dynamically aligned using CustomPopupPlacementCallback
|
|
Assert.Contains("x:Name=\"ProfilePopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"GlobalMediaPopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ExtensionsPopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"MainMenuPopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"PageControlsPopup\"", mwXaml);
|
|
|
|
// Verify MainWindow.xaml.cs configures right alignment
|
|
Assert.Contains("targetSize.Width - popupSize.Width", mwCs);
|
|
Assert.Contains("ConfigureRight(ProfilePopup, ProfileBtn);", mwCs);
|
|
Assert.Contains("ConfigureRight(MainMenuPopup, MainMenuBtn);", mwCs);
|
|
Assert.Contains("ConfigureRight(GlobalMediaPopup, GlobalMediaBtn);", mwCs);
|
|
Assert.Contains("ConfigureRight(ExtensionsPopup, ExtensionsBtn);", mwCs);
|
|
|
|
// Verify default toolbar buttons do not include standalone settings (settings is in 3-dots main menu)
|
|
Assert.DoesNotContain("\"settings\"", Paca.Core.Config.AppConfig.DefaultToolbarButtons);
|
|
|
|
// Verify TabHost uses theme background (DynamicResource WindowBg) to prevent dark mode blinding glare
|
|
Assert.Contains("Background=\"{DynamicResource WindowBg}\"", mwXaml);
|
|
|
|
// Verify TabViewService initializes WebView2 with Color.White
|
|
var tabViewServiceCode = File.ReadAllText(SourceTree.AppPath(Path.Combine("Services", "TabViewService.cs")));
|
|
Assert.Contains("DefaultBackgroundColor = System.Drawing.Color.White", tabViewServiceCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate23_LuxuryDownloadExperience_UnifiedPopup_And_ThumbnailCard()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
|
|
// 1. Download Card 16:9 Thumbnail, Glass Format Tag and Play Button
|
|
Assert.Contains("Binding ThumbnailUrl", mwXaml);
|
|
Assert.Contains("CardPlay_Click", mwXaml);
|
|
Assert.Contains("CardPlay_Click", mwCs);
|
|
|
|
// 2. Unified DownloadsPopup with Media Streams and Page Files
|
|
Assert.Contains("PopupMediaDownloadsList", mwXaml);
|
|
Assert.Contains("DownloadsList", mwXaml);
|
|
Assert.Contains("OpenDownloadPanel_Click", mwXaml);
|
|
Assert.Contains("OpenDownloadFolder_Click", mwXaml);
|
|
Assert.Contains("OpenDownloadPanel_Click", mwCs);
|
|
Assert.Contains("OpenDownloadFolder_Click", mwCs);
|
|
|
|
// 3. Contextual Floating Batch Action Bar
|
|
Assert.Contains("x:Name=\"BatchActionBar\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BatchSelectedCountText\"", mwXaml);
|
|
Assert.Contains("UpdateBatchActionUi", mwCs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate24_UnifiedDockedSidePanel_Downloads_History_Bookmarks()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
|
|
// 1. Right Docked Sidebar Segmented Tabs
|
|
Assert.Contains("x:Name=\"SideTabsContainer\"", mwXaml);
|
|
Assert.Contains("x:Name=\"SideTabDownloads\"", mwXaml);
|
|
Assert.Contains("x:Name=\"SideTabHistory\"", mwXaml);
|
|
Assert.Contains("x:Name=\"SideTabBookmarks\"", mwXaml);
|
|
Assert.Contains("SideTab_Checked", mwXaml);
|
|
|
|
// 2. 3 Multi-Views in Docked Sidebar
|
|
Assert.Contains("x:Name=\"DownloadsPanelView\"", mwXaml);
|
|
Assert.Contains("x:Name=\"HistoryPanelView\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BookmarksPanelView\"", mwXaml);
|
|
|
|
// 3. Docked History & Bookmark Lists with Search and Grouping
|
|
Assert.Contains("x:Name=\"HistorySearchBox\"", mwXaml);
|
|
Assert.Contains("Name=\"HistoryList\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BookmarkSearchBox\"", mwXaml);
|
|
Assert.Contains("Name=\"BookmarksList\"", mwXaml);
|
|
|
|
// 4. Code-behind SidePanelTab and ShowSidePanel Routing
|
|
Assert.Contains("enum SidePanelTab", mwCs);
|
|
Assert.Contains("ShowSidePanel", mwCs);
|
|
Assert.Contains("UpdateSidePanelBadge", mwCs);
|
|
Assert.Contains("SideTab_Checked", mwCs);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 6. 초고강도 디자인 & UX 무결성 감사 게이트 (Strict Design & Anti-Slop Gates)
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void Gate25_IconAndText_VerticalAlignment_StrictAudit()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var sharedXaml = SourceTree.SharedXaml;
|
|
|
|
// 아이콘과 텍스트가 함께 들어가는 핵심 UI 블록(주소창 컨테이너, 탭 제목 등)에서 수직 정렬 Center 보장
|
|
Assert.Contains("x:Name=\"AddressBarContainer\"", mwXaml);
|
|
Assert.Contains("Name=\"AddressBox\"", mwXaml);
|
|
Assert.Contains("VerticalAlignment=\"Center\"", sharedXaml);
|
|
|
|
// AddressTextBox 스타일에 VerticalContentAlignment="Center"가 설정되어 텍스트 수직 치우침이 없는지 확인
|
|
Assert.Contains("Property=\"VerticalContentAlignment\" Value=\"Center\"", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate26_InputControls_PaddingAndErgonomics_StrictAudit()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var sharedXaml = SourceTree.SharedXaml;
|
|
|
|
// 인풋창 내부 텍스트가 경계선에 달라붙지 않도록 TextBox 기본 스타일 및 개별 인풋창에 Padding 설정 보장
|
|
Assert.Contains("TargetType=\"{x:Type TextBox}\"", sharedXaml);
|
|
Assert.Contains("Property=\"Padding\"", sharedXaml);
|
|
|
|
// 핵심 인풋창 존재 확인
|
|
Assert.Contains("Name=\"AddressBox\"", mwXaml);
|
|
Assert.Contains("x:Name=\"HistorySearchBox\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BookmarkSearchBox\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate27_ContainerOverflowAndTextTrimming_StrictAudit()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
var sharedXaml = SourceTree.SharedXaml;
|
|
|
|
// 가로 스크롤 및 텍스트 넘침 방지를 위한 CharacterEllipsis 적용 전수 검증 (스타일 Setter 및 인라인 어트리뷰트)
|
|
Assert.Contains("Value=\"CharacterEllipsis\"", sharedXaml);
|
|
Assert.Contains("TextTrimming=\"CharacterEllipsis\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate28_InteractiveTargets_MinimumSizing_StrictAudit()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
|
|
// 클릭 불가능하거나 너무 작은 터치 타깃 방지 (핵심 액션 버튼들이 적절한 크기를 갖추고 있는지 검증)
|
|
Assert.Contains("x:Name=\"PageControlsBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"BookmarkStarBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ExtensionsBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"MainMenuBtn\"", mwXaml);
|
|
Assert.Contains("x:Name=\"GlobalMediaBtn\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate29_Typography_ScaleAndHierarchy_StrictAudit()
|
|
{
|
|
var sharedXaml = SourceTree.SharedXaml;
|
|
|
|
// 타이포그래피 토큰 계층: XLarge(22) > Large(16) > Base(13) > Small(11.5)
|
|
var xlarge = SourceTree.GetDoubleResource(sharedXaml, "FontSize.XLarge");
|
|
var large = SourceTree.GetDoubleResource(sharedXaml, "FontSize.Large");
|
|
var baseSize = SourceTree.GetDoubleResource(sharedXaml, "FontSize.Base");
|
|
var small = SourceTree.GetDoubleResource(sharedXaml, "FontSize.Small");
|
|
|
|
Assert.NotNull(xlarge);
|
|
Assert.NotNull(large);
|
|
Assert.NotNull(baseSize);
|
|
Assert.NotNull(small);
|
|
|
|
Assert.True(xlarge > large, $"FontSize.XLarge ({xlarge}) > FontSize.Large ({large}) 계층구조 위반");
|
|
Assert.True(large > baseSize, $"FontSize.Large ({large}) > FontSize.Base ({baseSize}) 계층구조 위반");
|
|
Assert.True(baseSize > small, $"FontSize.Base ({baseSize}) > FontSize.Small ({small}) 계층구조 위반");
|
|
Assert.True(small >= 10.0, $"FontSize.Small ({small}) < 10.0 (안 보이는 폰트 버그)");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate30_AlignmentAndOffAxisBias_StrictAudit()
|
|
{
|
|
var mwXaml = SourceTree.MainWindowXaml;
|
|
|
|
// 상단 바 및 주요 컨테이너의 구조적 균형 검증 (ToolbarRow, 네비게이션 진행바, 우측 도킹 패널 탭)
|
|
Assert.Contains("x:Name=\"ToolbarRow\"", mwXaml);
|
|
Assert.Contains("TitleBar_PreviewMouseLeftButtonDown", mwXaml);
|
|
Assert.Contains("x:Name=\"SideTabsContainer\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate31_AuxiliaryWindows_DesignSystemIntegrity_Audit()
|
|
{
|
|
string[] auxWindows =
|
|
[
|
|
"SettingsWindow.xaml",
|
|
"BookmarkManagerWindow.xaml",
|
|
"CredentialVaultWindow.xaml",
|
|
"ExtensionManagerWindow.xaml",
|
|
"LogViewerWindow.xaml",
|
|
"CrashReportDialog.xaml"
|
|
];
|
|
|
|
foreach (var winFile in auxWindows)
|
|
{
|
|
var winPath = SourceTree.AppPath(winFile);
|
|
Assert.True(File.Exists(winPath), $"{winFile} 파일이 존재해야 합니다.");
|
|
var xaml = File.ReadAllText(winPath);
|
|
|
|
// 1. 공통 디자인 시스템 토큰 UiFont 사용 검증 (폰트 파편화 방지)
|
|
Assert.True(xaml.Contains("FontFamily=\"{DynamicResource UiFont}\""),
|
|
$"{winFile} 창은 FontFamily=\"{{DynamicResource UiFont}}\" 를 선언해야 합니다.");
|
|
|
|
// 2. 다크/글래스 테마 배경 토큰 사용 검증
|
|
Assert.True(xaml.Contains("DynamicResource WindowBgGradient") || xaml.Contains("DynamicResource PanelBg") || xaml.Contains("DynamicResource GlassBg") || xaml.Contains("DynamicResource CardBg"),
|
|
$"{winFile} 창은 표준 테마 배경 토큰을 사용해야 합니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate32_SettingsSearchBox_Ergonomics_Audit()
|
|
{
|
|
var settingsXaml = SourceTree.SettingsWindowXaml;
|
|
|
|
// SettingsSearchBox의 내부 패딩이 0이 아니라 최소 4px 이상의 인체공학적 여백을 가지는지 검증
|
|
var match = Regex.Match(settingsXaml, @"<TextBox[^>]*x:Name=""SettingsSearchBox""[^>]*Padding=""([^""]+)""");
|
|
Assert.True(match.Success, "SettingsSearchBox에 Padding 어트리뷰트가 정의되어야 합니다.");
|
|
var padVal = match.Groups[1].Value;
|
|
Assert.NotEqual("0", padVal.Trim());
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate33_VerticalTabs_NewTabButtons_Visibility_StrictAudit()
|
|
{
|
|
using var sta = StaDispatcher.Start();
|
|
WpfTestApp.Ensure(sta.Dispatcher);
|
|
|
|
sta.Invoke(() =>
|
|
{
|
|
var window = new MainWindow();
|
|
window.Show();
|
|
|
|
// 1. 기본 가로 탭 모드일 때 NewTabBtn, NewTabMoreBtn은 Visible이어야 함
|
|
Assert.Equal(Visibility.Visible, window.NewTabBtn.Visibility);
|
|
|
|
// 2. 세로 탭 모드로 전환
|
|
var toggleMethod = typeof(MainWindow).GetMethod("ToggleVerticalTabs", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
Assert.NotNull(toggleMethod);
|
|
toggleMethod.Invoke(window, null);
|
|
|
|
// 3. 세로 탭 모드에서는 TabContainerPanel 내의 가로 전용 새 탭 버튼들이 숨겨져야(Collapsed) 시각적 붕괴 방지!
|
|
Assert.Equal(Visibility.Collapsed, window.NewTabBtn.Visibility);
|
|
Assert.Equal(Visibility.Collapsed, window.NewTabMoreBtn.Visibility);
|
|
|
|
// 4. 다시 가로 탭 모드로 복귀 시 정상 복원 검증
|
|
toggleMethod.Invoke(window, null);
|
|
Assert.Equal(Visibility.Visible, window.NewTabBtn.Visibility);
|
|
Assert.Equal(Visibility.Visible, window.NewTabMoreBtn.Visibility);
|
|
|
|
window.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate34_StatusText_VerticalAlignment_StrictAudit()
|
|
{
|
|
var sharedXaml = SourceTree.SharedXaml;
|
|
|
|
// StatusTextStyle에 VerticalAlignment = Center 세터가 정의되어 있어 상태바 텍스트의 상단 치우침/잘림을 방지하는지 검증
|
|
var match = Regex.Match(sharedXaml, @"<Style[^>]*x:Key=""StatusTextStyle""[^>]*>(.*?)</Style>", RegexOptions.Singleline);
|
|
Assert.True(match.Success, "StatusTextStyle이 정의되어 있어야 합니다.");
|
|
var styleBody = match.Groups[1].Value;
|
|
Assert.True(styleBody.Contains("Property=\"VerticalAlignment\" Value=\"Center\""),
|
|
"StatusTextStyle은 상태바 내 텍스트의 수직 정렬을 위해 VerticalAlignment=\"Center\"를 선언해야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate35_VerticalTabsHeader_InteractiveTargets_Audit()
|
|
{
|
|
using var sta = StaDispatcher.Start();
|
|
WpfTestApp.Ensure(sta.Dispatcher);
|
|
|
|
sta.Invoke(() =>
|
|
{
|
|
var window = new MainWindow();
|
|
var method = typeof(MainWindow).GetMethod("CreateVerticalTabsHeader", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
Assert.NotNull(method);
|
|
|
|
var header = (Border)method.Invoke(window, null)!;
|
|
Assert.NotNull(header);
|
|
|
|
// 헤더 내부의 모든 Button을 탐색하여 최소 클릭 타깃 높이(>= 30px) 검증
|
|
var buttons = FindLogicalChildren<Button>(header).ToList();
|
|
Assert.NotEmpty(buttons);
|
|
foreach (var btn in buttons)
|
|
{
|
|
Assert.True(!double.IsNaN(btn.Height) && btn.Height >= 30.0,
|
|
$"세로 탭 헤더 내 버튼 {btn.ToolTip ?? btn.Name}의 높이({btn.Height})가 최소 인체공학적 규격 30px 미만입니다.");
|
|
}
|
|
|
|
window.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate36_NewTabPage_VisualDesign_StrictAudit()
|
|
{
|
|
// NewTabPageBuilder로 생성된 HTML의 디자인 토큰 및 클릭 타깃 규격 기계적 검증
|
|
var dummyShortcuts = new List<Paca.Browser.NewTab.NtpShortcut>
|
|
{
|
|
new("sc1", "유튜브", "https://youtube.com")
|
|
};
|
|
var settings = new Paca.Browser.NewTab.NtpSettings();
|
|
var html = Paca.App.Browser.Features.NewTabPageBuilder.BuildNtp(dummyShortcuts, settings);
|
|
|
|
// 1. 타일 메뉴 버튼 클릭 타깃 최소 면적 (DoD §5.1 #8 규격: 최소 28px 이상)
|
|
// 기존 22px x 22px는 마우스 오작동/미클릭 유발
|
|
var menuBtnMatch = System.Text.RegularExpressions.Regex.Match(html, @"\.tile-menu-btn\s*\{[^}]*width:\s*(\d+)px;[^}]*height:\s*(\d+)px;");
|
|
Assert.True(menuBtnMatch.Success, "CSS에 .tile-menu-btn 크기 선언이 누락되었습니다.");
|
|
int width = int.Parse(menuBtnMatch.Groups[1].Value);
|
|
int height = int.Parse(menuBtnMatch.Groups[2].Value);
|
|
Assert.True(width >= 28 && height >= 28,
|
|
$".tile-menu-btn의 크기({width}x{height}px)가 최소 인체공학적 타깃 규격(28x28px 이상)에 미달합니다.");
|
|
|
|
// 2. 검색창 입력 규격
|
|
Assert.Contains("height: 50px;", html);
|
|
Assert.Contains("text-overflow: ellipsis;", html);
|
|
Assert.Contains("white-space: nowrap;", html);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate37_SidebarPanels_ActionButtons_ErgonomicTargets_Audit()
|
|
{
|
|
// 방문 기록 및 즐겨찾기 목록 내의 액션 버튼(삭제, 편집, 폴더이동) 클릭 타깃 규격 기계적 검증 (DoD §5.1 #8)
|
|
var xamlPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App", "MainWindow.xaml"));
|
|
Assert.True(File.Exists(xamlPath), $"MainWindow.xaml 파일이 존재하지 않습니다: {xamlPath}");
|
|
var content = File.ReadAllText(xamlPath);
|
|
|
|
// 1. 방문 기록 삭제 버튼 (DeleteHistory_Click)
|
|
var historyBtnMatch = System.Text.RegularExpressions.Regex.Match(content, @"Click=""DeleteHistory_Click""[^>]*Width=""(\d+)""[^>]*Height=""(\d+)""");
|
|
Assert.True(historyBtnMatch.Success, "방문 기록 삭제 버튼의 Width/Height 속성을 찾을 수 없습니다.");
|
|
int hWidth = int.Parse(historyBtnMatch.Groups[1].Value);
|
|
int hHeight = int.Parse(historyBtnMatch.Groups[2].Value);
|
|
Assert.True(hWidth >= 26 && hHeight >= 26,
|
|
$"방문 기록 삭제 버튼 크기({hWidth}x{hHeight}px)가 최소 인체공학적 타깃 규격(26x26px 이상)에 미달합니다.");
|
|
|
|
// 2. 즐겨찾기 편집/이동/삭제 버튼
|
|
var bookmarkEditMatch = System.Text.RegularExpressions.Regex.Match(content, @"Click=""EditBookmark_Click""[^>]*Width=""(\d+)""[^>]*Height=""(\d+)""");
|
|
Assert.True(bookmarkEditMatch.Success, "즐겨찾기 편집 버튼의 Width/Height 속성을 찾을 수 없습니다.");
|
|
int bWidth = int.Parse(bookmarkEditMatch.Groups[1].Value);
|
|
int bHeight = int.Parse(bookmarkEditMatch.Groups[2].Value);
|
|
Assert.True(bWidth >= 26 && bHeight >= 26,
|
|
$"즐겨찾기 편집 버튼 크기({bWidth}x{bHeight}px)가 최소 인체공학적 타깃 규격(26x26px 이상)에 미달합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate38_DownloadCard_ActionButtons_ErgonomicTargets_Audit()
|
|
{
|
|
// 다운로드 카드 우측 상단 빠른 재생/폴더/제거 버튼 클릭 타깃 규격 기계적 검증 (DoD §5.1 #8)
|
|
var xamlPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App", "MainWindow.xaml"));
|
|
Assert.True(File.Exists(xamlPath), $"MainWindow.xaml 파일이 존재하지 않습니다: {xamlPath}");
|
|
var content = File.ReadAllText(xamlPath);
|
|
|
|
// 1. 카드 닫기/제거 버튼 (CardDismiss_Click)
|
|
var dismissMatch = System.Text.RegularExpressions.Regex.Match(content, @"Click=""CardDismiss_Click""[^>]*Width=""(\d+)""[^>]*Height=""(\d+)""");
|
|
if (!dismissMatch.Success)
|
|
{
|
|
dismissMatch = System.Text.RegularExpressions.Regex.Match(content, @"Width=""(\d+)""[^>]*Height=""(\d+)""[^>]*Click=""CardDismiss_Click""");
|
|
}
|
|
Assert.True(dismissMatch.Success, "다운로드 카드 제거 버튼의 Width/Height 속성을 찾을 수 없습니다.");
|
|
int dWidth = int.Parse(dismissMatch.Groups[1].Value);
|
|
int dHeight = int.Parse(dismissMatch.Groups[2].Value);
|
|
Assert.True(dWidth >= 26 && dHeight >= 26,
|
|
$"다운로드 카드 제거 버튼 크기({dWidth}x{dHeight}px)가 최소 인체공학적 타깃 규격(26x26px 이상)에 미달합니다.");
|
|
|
|
// 2. 카드 빠른 재생 버튼 (CardPlay_Click)
|
|
var playMatch = System.Text.RegularExpressions.Regex.Match(content, @"Width=""(\d+)""[^>]*Height=""(\d+)""[^>]*Click=""CardPlay_Click""");
|
|
if (playMatch.Success)
|
|
{
|
|
int pWidth = int.Parse(playMatch.Groups[1].Value);
|
|
int pHeight = int.Parse(playMatch.Groups[2].Value);
|
|
Assert.True(pWidth >= 26 && pHeight >= 26,
|
|
$"다운로드 카드 재생 버튼 크기({pWidth}x{pHeight}px)가 최소 인체공학적 타깃 규격(26x26px 이상)에 미달합니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate39_StatusBar_ServerStatusIcon_VerticalAlignment_Audit()
|
|
{
|
|
// 상태바 우측 ServerStatusIcon의 수직 가운데 정렬 기계적 검증 (DoD §5.1 #5)
|
|
var xamlPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App", "MainWindow.xaml"));
|
|
Assert.True(File.Exists(xamlPath), $"MainWindow.xaml 파일이 존재하지 않습니다: {xamlPath}");
|
|
var content = File.ReadAllText(xamlPath);
|
|
|
|
var match = System.Text.RegularExpressions.Regex.Match(content, @"<TextBlock\s+x:Name=""ServerStatusIcon""([^>]+)/>");
|
|
Assert.True(match.Success, "ServerStatusIcon 요소를 찾을 수 없습니다.");
|
|
var attrs = match.Groups[1].Value;
|
|
Assert.True(attrs.Contains(@"VerticalAlignment=""Center"""),
|
|
"ServerStatusIcon에 VerticalAlignment=\"Center\"가 누락되어 기준선 정합성이 어긋납니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate40_TabCloseButton_ErgonomicHitbox_Audit()
|
|
{
|
|
// 탭 닫기 버튼의 최소 클릭 히트박스(최소 20x20px 이상) 규격 기계적 검증 (DoD §5.1 #8)
|
|
var sharedXamlPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App", "Themes", "Shared.xaml"));
|
|
Assert.True(File.Exists(sharedXamlPath), $"Shared.xaml 파일이 존재하지 않습니다: {sharedXamlPath}");
|
|
var content = File.ReadAllText(sharedXamlPath);
|
|
|
|
var styleMatch = System.Text.RegularExpressions.Regex.Match(content, @"<Style\s+x:Key=""TabCloseButton""[^>]*>(.*?)</Style>", System.Text.RegularExpressions.RegexOptions.Singleline);
|
|
Assert.True(styleMatch.Success, "TabCloseButton 스타일을 찾을 수 없습니다.");
|
|
var styleBody = styleMatch.Groups[1].Value;
|
|
|
|
var widthMatch = System.Text.RegularExpressions.Regex.Match(styleBody, @"<Setter\s+Property=""Width""\s+Value=""(\d+)""");
|
|
var heightMatch = System.Text.RegularExpressions.Regex.Match(styleBody, @"<Setter\s+Property=""Height""\s+Value=""(\d+)""");
|
|
Assert.True(widthMatch.Success && heightMatch.Success, "Width 또는 Height Setter를 찾을 수 없습니다.");
|
|
|
|
int w = int.Parse(widthMatch.Groups[1].Value);
|
|
int h = int.Parse(heightMatch.Groups[1].Value);
|
|
|
|
Assert.True(w >= 20 && h >= 20,
|
|
$"탭 닫기 버튼 히트박스 크기({w}x{h}px)가 최소 인체공학적 타깃 규격(20x20px 이상)에 미달합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate41_MainMenu_ZoomControls_ErgonomicHeight_Audit()
|
|
{
|
|
// 메인 메뉴 줌 컨트롤 버튼의 인체공학적 최소 높이 규격(최소 26px 이상) 기계적 검증 (DoD §5.1 #8)
|
|
var xamlPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App", "MainWindow.xaml"));
|
|
Assert.True(File.Exists(xamlPath), $"MainWindow.xaml 파일이 존재하지 않습니다: {xamlPath}");
|
|
var content = File.ReadAllText(xamlPath);
|
|
|
|
var match = System.Text.RegularExpressions.Regex.Match(content, @"x:Name=""MenuZoomText""([^>]+)/>");
|
|
Assert.True(match.Success, "MenuZoomText 버튼을 찾을 수 없습니다.");
|
|
var attrs = match.Groups[1].Value;
|
|
|
|
var heightMatch = System.Text.RegularExpressions.Regex.Match(attrs, @"(?:MinHeight|Height)=""(\d+)""");
|
|
Assert.True(heightMatch.Success, "MenuZoomText에 Height 또는 MinHeight가 지정되지 않아 터치/마우스 타깃이 너무 낮습니다.");
|
|
|
|
int h = int.Parse(heightMatch.Groups[1].Value);
|
|
Assert.True(h >= 26, $"MenuZoomText 높이({h}px)가 최소 인체공학적 규격(26px 이상)에 미달합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate42_VerticalTabs_TabContainer_VerticalAlignment_MustBeTop_Audit()
|
|
{
|
|
// 세로 탭 모드일 때 탭 컨테이너(TabContainerPanel)가 ScrollViewer 상단(Top)부터 배치되어야 함 (DoD §5.1 #1 & #7)
|
|
// 현재는 XAML의 VerticalAlignment="Bottom"이 그대로 남아 화면 저 아래부터 탭이 나타나는 치명적 시각 결함 발생
|
|
using var sta = Paca.Tests.Infrastructure.StaDispatcher.Start();
|
|
sta.Invoke(() =>
|
|
{
|
|
var win = new MainWindow();
|
|
var applyMethod = typeof(MainWindow).GetMethod("ApplyVerticalTabsLayout", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
var vTabsField = typeof(MainWindow).GetField("_verticalTabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(applyMethod);
|
|
Assert.NotNull(vTabsField);
|
|
|
|
// 세로 탭 모드 활성화 및 레이아웃 적용
|
|
vTabsField.SetValue(win, true);
|
|
applyMethod.Invoke(win, null);
|
|
|
|
var panel = win.FindName("TabContainerPanel") as StackPanel;
|
|
Assert.NotNull(panel);
|
|
Assert.Equal(VerticalAlignment.Top, panel.VerticalAlignment);
|
|
Assert.Equal(HorizontalAlignment.Stretch, panel.HorizontalAlignment);
|
|
|
|
// 가로 탭 모드로 복귀 시에는 탭 하단이 맞닿아야 하므로 Bottom, Left 검증
|
|
vTabsField.SetValue(win, false);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(VerticalAlignment.Bottom, panel.VerticalAlignment);
|
|
Assert.Equal(HorizontalAlignment.Left, panel.HorizontalAlignment);
|
|
|
|
win.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate43_VerticalTabs_TabItemMaxWidth_ExpandsOnSidebarResize_Audit()
|
|
{
|
|
// 세로 탭 모드에서 사이드바 폭(최대 360px)에 맞추어 탭 아이템이 우측 빈 공간 없이 꽉 차도록 확장되어야 함 (DoD §5.1 #7)
|
|
// 현재는 XAML에 MaxWidth="220"이 하드코딩되어 세로 탭 사이드바 폭이 커져도 220px에서 멈추는 결함 발생
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.Contains("MaxWidth=\"{Binding Path=TabItemMaxWidth, RelativeSource={RelativeSource AncestorType=Window}}\"", xaml);
|
|
|
|
using var sta = Paca.Tests.Infrastructure.StaDispatcher.Start();
|
|
sta.Invoke(() =>
|
|
{
|
|
var win = new MainWindow();
|
|
var applyMethod = typeof(MainWindow).GetMethod("ApplyVerticalTabsLayout", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
var vTabsField = typeof(MainWindow).GetField("_verticalTabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(applyMethod);
|
|
Assert.NotNull(vTabsField);
|
|
|
|
// 가로 탭 모드 기본값: 220.0
|
|
Assert.Equal(220.0, win.TabItemMaxWidth);
|
|
|
|
// 세로 탭 모드 활성화: 사이드바 폭을 채우기 위해 PositiveInfinity로 확장
|
|
vTabsField.SetValue(win, true);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(double.PositiveInfinity, win.TabItemMaxWidth);
|
|
|
|
// 가로 탭 모드 복귀: 다시 220.0으로 복원
|
|
vTabsField.SetValue(win, false);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(220.0, win.TabItemMaxWidth);
|
|
|
|
win.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate44_VerticalTabs_TabCornerRadius_And_BorderThickness_CardStyle_Audit()
|
|
{
|
|
// 세로 탭 모드에서 탭 아이템은 독립적인 카드 형태이므로 4면 모서리(CornerRadius=6)와 4면 테두리(BorderThickness=1)가 완비되어야 함 (DoD §5.1 #9)
|
|
// 가로 탭 모드에서는 브라우저 본문 상단과 맞닿으므로 CornerRadius="8,8,0,0", BorderThickness="1,1,1,0"이어야 함
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.Contains("CornerRadius=\"{Binding Path=TabCornerRadius, RelativeSource={RelativeSource AncestorType=Window}}\"", xaml);
|
|
Assert.Contains("BorderThickness=\"{Binding Path=TabBorderThickness, RelativeSource={RelativeSource AncestorType=Window}}\"", xaml);
|
|
|
|
using var sta = Paca.Tests.Infrastructure.StaDispatcher.Start();
|
|
sta.Invoke(() =>
|
|
{
|
|
var win = new MainWindow();
|
|
var applyMethod = typeof(MainWindow).GetMethod("ApplyVerticalTabsLayout", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
var vTabsField = typeof(MainWindow).GetField("_verticalTabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(applyMethod);
|
|
Assert.NotNull(vTabsField);
|
|
|
|
// 1. 가로 탭 모드 기본값 검증
|
|
Assert.Equal(new CornerRadius(8, 8, 0, 0), win.TabCornerRadius);
|
|
Assert.Equal(new Thickness(1, 1, 1, 0), win.TabBorderThickness);
|
|
|
|
// 2. 세로 탭 모드 활성화: 완전한 카드 형태 (모서리 6, 4면 테두리 1)
|
|
vTabsField.SetValue(win, true);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(new CornerRadius(6), win.TabCornerRadius);
|
|
Assert.Equal(new Thickness(1), win.TabBorderThickness);
|
|
|
|
// 3. 가로 탭 모드 복귀: 가로 탭 형태로 원상 복원
|
|
vTabsField.SetValue(win, false);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(new CornerRadius(8, 8, 0, 0), win.TabCornerRadius);
|
|
Assert.Equal(new Thickness(1, 1, 1, 0), win.TabBorderThickness);
|
|
|
|
win.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate45_VerticalTabs_TabItemMargin_Separation_Audit()
|
|
{
|
|
// 세로 탭 모드에서는 탭 아이템 간의 상하 간격 분리를 위해 하단 마진(0, 0, 0, 4)이 적용되어야 함 (DoD §5.1 #1 & #7)
|
|
// 가로 탭 모드에서는 좌우 나열이므로 우측 마진(0, 0, 4, 0)이 적용되어야 함
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.Contains("Margin=\"{Binding Path=TabItemMargin, RelativeSource={RelativeSource AncestorType=Window}}\"", xaml);
|
|
|
|
using var sta = Paca.Tests.Infrastructure.StaDispatcher.Start();
|
|
sta.Invoke(() =>
|
|
{
|
|
var win = new MainWindow();
|
|
var applyMethod = typeof(MainWindow).GetMethod("ApplyVerticalTabsLayout", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
var vTabsField = typeof(MainWindow).GetField("_verticalTabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
Assert.NotNull(applyMethod);
|
|
Assert.NotNull(vTabsField);
|
|
|
|
// 1. 가로 탭 모드 기본값: 우측 마진 4px
|
|
Assert.Equal(new Thickness(0, 0, 4, 0), win.TabItemMargin);
|
|
|
|
// 2. 세로 탭 모드 활성화: 하단 마진 4px
|
|
vTabsField.SetValue(win, true);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(new Thickness(0, 0, 0, 4), win.TabItemMargin);
|
|
|
|
// 3. 가로 탭 모드 복귀: 우측 마진 4px 복원
|
|
vTabsField.SetValue(win, false);
|
|
applyMethod.Invoke(win, null);
|
|
Assert.Equal(new Thickness(0, 0, 4, 0), win.TabItemMargin);
|
|
|
|
win.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate46_AddressBarContainer_Height_And_Padding_Audit()
|
|
{
|
|
// DoD §5.1 #4: 인풋 필드 규격 및 내부 패딩 - 텍스트 박스 최소 높이 36px~44px 유지
|
|
// 현재 AddressBarContainer의 높이가 32px로 협소하여 현대 브라우저 기준 36px로 상향 필요
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
var match = System.Text.RegularExpressions.Regex.Match(xaml, @"<Border\s+x:Name=""AddressBarContainer""[^>]*Height=""(\d+)""");
|
|
Assert.True(match.Success, "AddressBarContainer 요소를 찾을 수 없습니다.");
|
|
|
|
int h = int.Parse(match.Groups[1].Value);
|
|
Assert.True(h >= 36, $"AddressBarContainer 높이({h}px)가 DoD §5.1 #4 규격(36px 이상)에 미달합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate47_SearchBoxes_Padding_And_MinHeight_DoD_Audit()
|
|
{
|
|
// DoD §5.1 #4: 인풋 필드 규격 및 내부 패딩 - 텍스트 박스 패딩 Padding="8,6" 이상 유지
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
var searchBoxes = new[]
|
|
{
|
|
"FlyoutBookmarkSearchBox",
|
|
"FlyoutHistorySearchBox",
|
|
"NotesSearchBox",
|
|
"PlaylistSearchBox",
|
|
"LibrarySearchBox",
|
|
"ArchiveSearchBox"
|
|
};
|
|
|
|
foreach (var boxName in searchBoxes)
|
|
{
|
|
var match = System.Text.RegularExpressions.Regex.Match(xaml, $@"<TextBox\s+x:Name=""{boxName}""[^>]*Padding=""([^""]+)""");
|
|
Assert.True(match.Success, $"{boxName} 요소를 찾을 수 없습니다.");
|
|
|
|
var pad = match.Groups[1].Value;
|
|
var parts = pad.Split(',').Select(p => double.Parse(p.Trim())).ToArray();
|
|
Assert.True(parts.Length >= 2, $"{boxName} 패딩 형식이 올바르지 않습니다: {pad}");
|
|
Assert.True(parts[0] >= 8 && parts[1] >= 6, $"{boxName} 패딩({pad})이 DoD §5.1 #4 규격(8,6 이상)에 미달합니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate48_InteractiveHitbox_MinimumArea_DoD_Audit()
|
|
{
|
|
// DoD §5.1 #8: 클릭 타깃 최소 면적 - 인터랙션 요소 최소 26px 이상 확보
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
// 1. MediaDetectedCloseBtn
|
|
var m1 = System.Text.RegularExpressions.Regex.Match(xaml, @"<Button\s+x:Name=""MediaDetectedCloseBtn""[^>]*Width=""(\d+)""\s+Height=""(\d+)""");
|
|
Assert.True(m1.Success && int.Parse(m1.Groups[1].Value) >= 26 && int.Parse(m1.Groups[2].Value) >= 26, "MediaDetectedCloseBtn 크기가 26px 이상이어야 합니다.");
|
|
|
|
// 2. PasswordSaveCloseBtn
|
|
var m2 = System.Text.RegularExpressions.Regex.Match(xaml, @"<Button\s+x:Name=""PasswordSaveCloseBtn""[^>]*Width=""(\d+)""\s+Height=""(\d+)""");
|
|
Assert.True(m2.Success && int.Parse(m2.Groups[1].Value) >= 26 && int.Parse(m2.Groups[2].Value) >= 26, "PasswordSaveCloseBtn 크기가 26px 이상이어야 합니다.");
|
|
|
|
// 3. PcMediaPip
|
|
var m3 = System.Text.RegularExpressions.Regex.Match(xaml, @"Click=""PcMediaPip_Click""[^>]*Width=""(\d+)""\s+Height=""(\d+)""");
|
|
Assert.True(m3.Success && int.Parse(m3.Groups[1].Value) >= 26 && int.Parse(m3.Groups[2].Value) >= 26, "PcMediaPip 버튼 크기가 26px 이상이어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate49_Slider_ModernTheme_Style_DoD_Audit()
|
|
{
|
|
// DoD §5.1 #9: 디자인 철학 일치 - 기본 회색 WPF 슬라이더 제거 및 ModernSlider 스타일 정의 보장
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
var match = System.Text.RegularExpressions.Regex.Match(sharedXaml, @"<Style\s+(x:Key=""ModernSlider""|TargetType=""\{x:Type Slider\}"")");
|
|
Assert.True(match.Success, "Themes/Shared.xaml에 ModernSlider 스타일이 정의되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate50_ToastNotifier_TextWrapping_Bounds_Audit()
|
|
{
|
|
// DoD §5.1 #2: 컨테이너 오버플로우 방지 - 수평 StackPanel 내 TextWrapping TextBlock의 폭 제약 보장
|
|
var code = File.ReadAllText(SourceTree.AppPath("Services/ToastNotifier.cs"));
|
|
var match = System.Text.RegularExpressions.Regex.Match(code, @"new TextBlock\s*\{[^}]*TextWrapping\s*=\s*TextWrapping\.Wrap[^}]*MaxWidth\s*=\s*(\d+)");
|
|
Assert.True(match.Success, "ToastNotifier.cs의 래핑 TextBlock에 MaxWidth 폭 제약이 지정되어 있어야 합니다.");
|
|
|
|
int maxWidth = int.Parse(match.Groups[1].Value);
|
|
Assert.True(maxWidth <= 290 && maxWidth >= 200, $"ToastNotifier TextBlock MaxWidth({maxWidth}px)가 컨테이너 가용 폭(200~290px) 범위 내여야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate51_DownloadCard_ProgressBar_And_Meta_Layout_Audit()
|
|
{
|
|
// DoD §5.1 #1 & #7: 프로그레스 바 영역에서 대기 상태일 때 빈 텍스트(Speed, Remaining, TotalSize)가
|
|
// 고정 MinWidth(168px)를 차지하여 프로그레스바가 반토막 나는 레이아웃 왜곡 방지
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
var matchOldBrokenDock = System.Text.RegularExpressions.Regex.Match(
|
|
xaml,
|
|
@"<TextBlock\s+Text=""\{Binding SpeedText\}""[^>]*MinWidth=""\d+""");
|
|
Assert.False(matchOldBrokenDock.Success, "대기 상태에서 빈 텍스트에 고정 MinWidth가 적용되어 프로그레스바가 반토막 나는 결함이 남아있습니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate52_DownloadCard_Buttons_StateVisibility_And_Hitbox_Audit()
|
|
{
|
|
// DoD §5.1 #8 & #9: 다운로드 카드의 버튼들은 대기 상태에서 불필요한 '취소'나 '폴더'가 노출되지 않아야 하고,
|
|
// 받기 버튼의 히트박스가 최소 26px 이상이어야 함.
|
|
var xaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
// 1. 대기 상태에서 취소 버튼은 기본 Visibility가 Collapsed 여야 함
|
|
var cancelBtnMatch = System.Text.RegularExpressions.Regex.Match(
|
|
xaml,
|
|
@"<Button\s+Content=""취소""[^>]*Click=""CardCancel_Click""[^>]*>[\s\S]*?<Setter\s+Property=""Visibility""\s+Value=""Collapsed""");
|
|
Assert.True(cancelBtnMatch.Success, "카드 취소 버튼은 대기 상태일 때 Collapsed 처리되어야 합니다.");
|
|
|
|
// 2. 받기 버튼의 세로 패딩 및 높이가 최소 26px 이상이어야 함
|
|
var downloadBtnMatch = System.Text.RegularExpressions.Regex.Match(
|
|
xaml,
|
|
@"<Button\s+Content=""(?:⬇\s*)?받기""[^>]*Padding=""(\d+),([0-9\.]+)""");
|
|
Assert.True(downloadBtnMatch.Success, "받기 버튼을 찾을 수 없습니다.");
|
|
double vPad = double.Parse(downloadBtnMatch.Groups[2].Value);
|
|
Assert.True(vPad >= 4.0, $"받기 버튼 세로 패딩({vPad})이 최소 규격(4.0 이상)에 미달합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate53_FileNameUtil_PlayerJunkNames_Filter_Audit()
|
|
{
|
|
// 유튜브 등의 플레이어 텔레메트리/액션 단어(open, audio, success, no_input 등)가 제목으로 오탐되는 결함 차단
|
|
var junkUrls = new[]
|
|
{
|
|
"https://www.youtube.com/api/stats/playback/open",
|
|
"https://www.youtube.com/videoplayback/audio",
|
|
"https://www.youtube.com/v1/player/event/success",
|
|
"https://www.youtube.com/ptracking/no_input"
|
|
};
|
|
|
|
foreach (var url in junkUrls)
|
|
{
|
|
var name = Paca.Core.Util.FileNameUtil.DeriveName(new Uri(url));
|
|
Assert.NotEqual("open", name);
|
|
Assert.NotEqual("audio", name);
|
|
Assert.NotEqual("success", name);
|
|
Assert.NotEqual("no_input", name);
|
|
Assert.Equal("www.youtube.com", name);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate54_Expander_ModernStyle_DoD_Audit()
|
|
{
|
|
// DoD §5.1 #9: 디자인 철학 일치 - 기본 회색 WPF Expander 제거 및 ModernExpander 스타일 정의 보장
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
var match = System.Text.RegularExpressions.Regex.Match(sharedXaml, @"<Style\s+(x:Key=""ModernExpander""|TargetType=""\{x:Type Expander\}"")");
|
|
Assert.True(match.Success, "Themes/Shared.xaml에 ModernExpander 스타일이 정의되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate55_Theme_TextContrast_And_Token_Audit()
|
|
{
|
|
// DoD §5.1 #3: WCAG 명도 대비 — 다크 테마에서 TextMuted와 TextSecondary가 어두운 배경에 묻히지 않도록 명도 보강 검증
|
|
var darkXaml = File.ReadAllText(SourceTree.AppPath("Themes/Dark.xaml"));
|
|
|
|
var matchMuted = System.Text.RegularExpressions.Regex.Match(darkXaml, @"<SolidColorBrush\s+x:Key=""TextMuted""\s+Color=""#([0-9A-Fa-f]{6})""");
|
|
Assert.True(matchMuted.Success, "Themes/Dark.xaml에 TextMuted 브러시가 선언되어 있어야 합니다.");
|
|
var mutedHex = matchMuted.Groups[1].Value;
|
|
int r = Convert.ToInt32(mutedHex.Substring(0, 2), 16);
|
|
int g = Convert.ToInt32(mutedHex.Substring(2, 2), 16);
|
|
int b = Convert.ToInt32(mutedHex.Substring(4, 2), 16);
|
|
int avg = (r + g + b) / 3;
|
|
Assert.True(avg >= 155, $"TextMuted 평균 명도({avg})가 155 이상으로 상향되어 텍스트가 묻히지 않아야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate56_DownloadCard_RowHeight_And_Padding_Compact_Audit()
|
|
{
|
|
// DoD §5.1 #1 & #7: 다운로드 카드의 과도한 row height(높이) 및 상하 패딩을 축소하여 컴팩트 밀도 확보
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
// 1. 카드 패딩이 8,6 이하로 슬림화되어 있는지 검증 (기존 Padding="11"은 너무 큼)
|
|
var cardBorderMatch = System.Text.RegularExpressions.Regex.Match(
|
|
mwXaml,
|
|
@"<DataTemplate DataType=""\{x:Type vm:DownloadItemViewModel\}""[\s\S]*?<Border[^>]*Padding=""(\d+),(\d+)""");
|
|
Assert.True(cardBorderMatch.Success, "다운로드 카드 Border에 가로,세로 분리 패딩이 지정되어야 합니다.");
|
|
int vPad = int.Parse(cardBorderMatch.Groups[2].Value);
|
|
Assert.True(vPad <= 7, $"다운로드 카드 세로 패딩({vPad})이 7px 이하로 컴팩트화되어야 합니다.");
|
|
|
|
// 2. 썸네일 높이가 48px 이하로 슬림화되어 세로 낭비를 막아야 함 (기존 Height="56")
|
|
var thumbMatch = System.Text.RegularExpressions.Regex.Match(
|
|
mwXaml,
|
|
@"<Grid Grid\.Column=""0""[^>]*Height=""(\d+)""");
|
|
Assert.True(thumbMatch.Success, "썸네일 Grid 높이 설정을 찾을 수 없습니다.");
|
|
int thumbHeight = int.Parse(thumbMatch.Groups[1].Value);
|
|
Assert.True(thumbHeight <= 48, $"썸네일 높이({thumbHeight}px)가 48px 이하로 슬림화되어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate57_CardDetail_NoIsolatedRow_Inline_Audit()
|
|
{
|
|
// DoD §5.1 #1 & #7: 상세 정보 폴드아웃이 별도 행으로 개행되어 카드의 row height를 뚱뚱하게 늘리지 않고,
|
|
// 상태 바(Status DockPanel) 내부에 인라인으로 위치하여 평소 불필요한 세로 행 높이를 낭비하지 않는지 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
var statusPanelMatch = System.Text.RegularExpressions.Regex.Match(
|
|
mwXaml,
|
|
@"<DockPanel\s+Margin=""0,2,0,0""\s+LastChildFill=""False"">([\s\S]*?)</DockPanel>");
|
|
Assert.True(statusPanelMatch.Success, "상태 바 DockPanel을 찾을 수 없습니다.");
|
|
var insideDockPanel = statusPanelMatch.Groups[1].Value;
|
|
Assert.True(insideDockPanel.Contains("CardDetailToggle") || insideDockPanel.Contains("CardDetailExpander"),
|
|
"상세 정보 토글이 상태 바 DockPanel 내부에 인라인으로 배치되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate58_CardDetail_TextColor_Theme_Contrast_Audit()
|
|
{
|
|
// DoD §5.1 #3: 상세 정보 텍스트가 기본 검은 글씨로 떨어져 테마에 묻히는 결함 방지
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
// 상세 정보 헤더/토글의 TextBlock에 TextSecondary 또는 TextPrimary 브러시가 명시적으로 지정되어야 함
|
|
var headerColorMatch = System.Text.RegularExpressions.Regex.Match(
|
|
mwXaml,
|
|
@"<TextBlock[^>]*Text=""상세 정보""[^>]*Foreground=""\{DynamicResource\s+(TextSecondary|TextPrimary|TextMuted)\}""");
|
|
Assert.True(headerColorMatch.Success, "상세 정보 텍스트에 테마 브러시가 명시적으로 지정되어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate59_DownloadsToolbar_FilterControls_Audit()
|
|
{
|
|
// DoD §5.1 #1 & #8: 다운로드 도구 모음에 스마트 필터링(오디오/비디오, 포맷, 1MB미만 숨김) 및 목록 클리어, 재수집 컨트롤 완비 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.True(mwXaml.Contains("FilterMediaVideo") || mwXaml.Contains("FilterMediaAudio"),
|
|
"DownloadsPanelView에 오디오/비디오 미디어 종류 필터가 선언되어 있어야 합니다.");
|
|
Assert.True(mwXaml.Contains("FormatFilterComboBox"),
|
|
"DownloadsPanelView에 포맷별 필터 콤보박스(FormatFilterComboBox)가 선언되어 있어야 합니다.");
|
|
Assert.True(mwXaml.Contains("FilterHideSmallMedia"),
|
|
"DownloadsPanelView에 소용량 미디어(1MB 미만) 숨김 필터(FilterHideSmallMedia)가 선언되어 있어야 합니다.");
|
|
Assert.True(mwXaml.Contains("ClearFinishedDownloads_Click") || mwXaml.Contains("ClearFinishedDownloadsBtn"),
|
|
"DownloadsPanelView에 완료된 다운로드 정리 기능이 선언되어 있어야 합니다.");
|
|
Assert.True(mwXaml.Contains("ClearAllDownloads_Click") || mwXaml.Contains("ClearAllDownloadsBtn"),
|
|
"DownloadsPanelView에 전체 목록 비우기 기능이 선언되어 있어야 합니다.");
|
|
Assert.True(mwXaml.Contains("RescanMedia_Click") || mwXaml.Contains("RescanMediaBtn"),
|
|
"DownloadsPanelView에 미디어 재수집(Rescan) 기능이 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate60_DownloadItemViewModel_MediaKind_And_Filter_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel이 오디오와 비디오를 정확히 분류하는지 단위 검증
|
|
var videoItem = new Paca.App.ViewModels.DownloadItemViewModel("Test Video")
|
|
{
|
|
FormatTag = "MP4",
|
|
ContentType = "video/mp4",
|
|
MediaUrl = new Uri("https://example.com/video.mp4")
|
|
};
|
|
var audioItem = new Paca.App.ViewModels.DownloadItemViewModel("Test Audio")
|
|
{
|
|
FormatTag = "MP3",
|
|
ContentType = "audio/mpeg",
|
|
MediaUrl = new Uri("https://example.com/song.mp3")
|
|
};
|
|
|
|
Assert.True(videoItem.IsVideo);
|
|
Assert.False(videoItem.IsAudio);
|
|
Assert.True(audioItem.IsAudio);
|
|
Assert.False(audioItem.IsVideo);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate61_DownloadFilter_MultiCriteria_Filtering_Audit()
|
|
{
|
|
// DoD: 소용량(1MB 미만) 및 포맷별 필터링 판정 로직 무결성 검증
|
|
var smallAd = new Paca.App.ViewModels.DownloadItemViewModel("Beacon Ad")
|
|
{
|
|
FormatTag = "MP4",
|
|
TotalBytes = 150 * 1024, // 150KB
|
|
MediaUrl = new Uri("https://example.com/ad.mp4")
|
|
};
|
|
var largeVideo = new Paca.App.ViewModels.DownloadItemViewModel("Main Feature")
|
|
{
|
|
FormatTag = "MP4",
|
|
TotalBytes = 150 * 1024 * 1024, // 150MB
|
|
MediaUrl = new Uri("https://example.com/feature.mp4")
|
|
};
|
|
var musicAudio = new Paca.App.ViewModels.DownloadItemViewModel("Soundtrack")
|
|
{
|
|
FormatTag = "MP3",
|
|
TotalBytes = 8 * 1024 * 1024, // 8MB
|
|
MediaUrl = new Uri("https://example.com/soundtrack.mp3")
|
|
};
|
|
|
|
// 1MB 미만 숨김 조건 검증 (1MB = 1048576)
|
|
bool hideSmall = true;
|
|
Predicate<Paca.App.ViewModels.DownloadItemViewModel> filterSmall = it =>
|
|
!(hideSmall && it.TotalBytes > 0 && it.TotalBytes < 1024 * 1024);
|
|
|
|
Assert.False(filterSmall(smallAd)); // 150KB는 제외
|
|
Assert.True(filterSmall(largeVideo)); // 150MB는 통과
|
|
Assert.True(filterSmall(musicAudio)); // 8MB는 통과
|
|
|
|
// 포맷 필터 "MP3" 검증
|
|
string fmt = "MP3";
|
|
Predicate<Paca.App.ViewModels.DownloadItemViewModel> filterFmt = it =>
|
|
it.FormatTag.Contains(fmt, StringComparison.OrdinalIgnoreCase);
|
|
|
|
Assert.False(filterFmt(largeVideo));
|
|
Assert.True(filterFmt(musicAudio));
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate62_DownloadSort_UI_Controls_Audit()
|
|
{
|
|
// DoD: 대기열 정렬(Sort) UI 콤보박스 컨트롤(DownloadSortComboBox) 존재 및 이벤트 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("DownloadSortComboBox"), "MainWindow.xaml에 DownloadSortComboBox가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate63_AudioOnly_Extraction_Action_Audit()
|
|
{
|
|
// DoD: 비디오 항목에서 오디오만 즉시 추출하는 기능 및 UI 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("CardExtractAudio_Click") || mwXaml.Contains("ExtractAudio"),
|
|
"카드 액션 또는 컨텍스트 메뉴에 오디오 전용 추출(ExtractAudio) 액션이 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate64_PinToTop_Queue_Feature_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 IsPinned 프로퍼티가 구현되어 상단 고정을 지원하는지 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Pinned Item");
|
|
Assert.False(item.IsPinned);
|
|
item.IsPinned = true;
|
|
Assert.True(item.IsPinned);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate65_CompactDensity_Toggle_Audit()
|
|
{
|
|
// DoD: 초소형 콤팩트 뷰 밀도 전환 버튼 및 UI 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("ToggleDensityBtn") || mwXaml.Contains("ToggleDensity_Click"),
|
|
"MainWindow.xaml에 목록 뷰 밀도(컴팩트/표준) 전환 버튼이 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate66_PostDownload_Action_Selection_Audit()
|
|
{
|
|
// DoD: 다운로드 완료 후 자동 행동(Post-Download Actions) UI 콤보박스 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("PostDownloadActionComboBox") || mwXaml.Contains("PostActionComboBox"),
|
|
"MainWindow.xaml에 완료 후 자동 행동 선택 콤보박스가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate67_DownloadsStatsCard_Audit()
|
|
{
|
|
// DoD: 대기열 히스토리 통계 미니 벤토 카드 및 텍스트 블록 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("DownloadsStatsCard") || mwXaml.Contains("StatsCompletedCountText"),
|
|
"MainWindow.xaml에 다운로드 통계 요약 카드(DownloadsStatsCard)가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate68_MediaStash_WatchLater_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 IsStashed 프로퍼티가 구현되어 보관함 기능을 지원하는지 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Stashed Item");
|
|
Assert.False(item.IsStashed);
|
|
item.IsStashed = true;
|
|
Assert.True(item.IsStashed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate69_StatusGlow_Theme_Brushes_Audit()
|
|
{
|
|
// DoD: Dark.xaml에 다운로드 상태별 마이크로 글로우 브러시(StatusGlowActiveBrush, StatusGlowSuccessBrush) 선언 검증
|
|
var darkXaml = File.ReadAllText(SourceTree.AppPath("Themes/Dark.xaml"));
|
|
Assert.True(darkXaml.Contains("StatusGlowActiveBrush"), "Dark.xaml에 StatusGlowActiveBrush가 선언되어 있어야 합니다.");
|
|
Assert.True(darkXaml.Contains("StatusGlowSuccessBrush"), "Dark.xaml에 StatusGlowSuccessBrush가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate70_CustomTag_Labeling_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 TagLabel 프로퍼티가 지원되는지 단위 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Tagged Item");
|
|
Assert.Equal("", item.TagLabel);
|
|
item.TagLabel = "강의";
|
|
Assert.Equal("강의", item.TagLabel);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate71_SmartClipboard_Copy_Actions_Audit()
|
|
{
|
|
// DoD: MainWindow.xaml에 스트림 URL 또는 yt-dlp 명령어로 복사하는 스마트 클립보드 액션이 선언되어 있는지 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("CopyStreamUrl_Click") || mwXaml.Contains("CopyYtdlpCmd_Click"),
|
|
"MainWindow.xaml에 스마트 클립보드 복사(CopyStreamUrl_Click 또는 CopyYtdlpCmd_Click)가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate72_SystemThemeWatcher_Config_Audit()
|
|
{
|
|
// DoD: AppConfig에 FollowSystemTheme 설정이 지원되는지 검증
|
|
var cfg = new Paca.Core.Config.AppConfig();
|
|
Assert.False(cfg.FollowSystemTheme);
|
|
cfg.FollowSystemTheme = true;
|
|
Assert.True(cfg.FollowSystemTheme);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate73_SpeedLimit_Bandwidth_Control_Audit()
|
|
{
|
|
// DoD: MainWindow.xaml에 속도 제한 콤보박스(SpeedLimitComboBox) 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("SpeedLimitComboBox"), "MainWindow.xaml에 SpeedLimitComboBox가 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate74_Thumbnail_Zoom_Preview_Audit()
|
|
{
|
|
// DoD: MainWindow.xaml에 썸네일 돋보기 확대 프리뷰 팝업(ThumbnailZoomPopup) 선언 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.True(mwXaml.Contains("ThumbnailZoomPopup") || mwXaml.Contains("ThumbnailZoomImage"),
|
|
"MainWindow.xaml에 썸네일 확대 프리뷰 팝업이 선언되어 있어야 합니다.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate75_Subtitles_Download_Support_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 HasSubtitles 및 DownloadSubtitles 프로퍼티 지원 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Subtitled Video");
|
|
Assert.False(item.HasSubtitles);
|
|
Assert.False(item.DownloadSubtitles);
|
|
item.HasSubtitles = true;
|
|
item.DownloadSubtitles = true;
|
|
Assert.True(item.HasSubtitles);
|
|
Assert.True(item.DownloadSubtitles);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate76_ClipboardMonitor_Feature_Audit()
|
|
{
|
|
// DoD: AppConfig에 ClipboardMonitorEnabled 설정이 지원되는지 검증
|
|
var cfg = new Paca.Core.Config.AppConfig();
|
|
Assert.False(cfg.ClipboardMonitorEnabled);
|
|
cfg.ClipboardMonitorEnabled = true;
|
|
Assert.True(cfg.ClipboardMonitorEnabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate77_ScheduledDownload_Feature_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 IsScheduled 및 ScheduledStartTime 프로퍼티 지원 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Scheduled Download");
|
|
Assert.False(item.IsScheduled);
|
|
item.IsScheduled = true;
|
|
item.ScheduledStartTime = DateTime.Today.AddHours(2);
|
|
Assert.True(item.IsScheduled);
|
|
Assert.NotNull(item.ScheduledStartTime);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate78_Deduplication_Guard_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 IsDuplicate 플래그 지원 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Duplicate Item");
|
|
Assert.False(item.IsDuplicate);
|
|
item.IsDuplicate = true;
|
|
Assert.True(item.IsDuplicate);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate79_Intelligent_Reconnect_Backoff_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 ReconnectAttemptCount 및 지능형 재연결 메트릭 지원 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Reconnect Item");
|
|
Assert.Equal(0, item.ReconnectAttemptCount);
|
|
item.ReconnectAttemptCount = 3;
|
|
Assert.Equal(3, item.ReconnectAttemptCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate80_DetailedLogs_Drawer_Audit()
|
|
{
|
|
// DoD: DownloadItemViewModel에 DetailedLogs 컬렉션 지원 검증
|
|
var item = new Paca.App.ViewModels.DownloadItemViewModel("Test Log Item");
|
|
Assert.NotNull(item.DetailedLogs);
|
|
item.DetailedLogs.Add("HLS Manifest fetched successfully");
|
|
Assert.Single(item.DetailedLogs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate81_TurboMode_ConcurrentSegments_Audit()
|
|
{
|
|
// DoD: AppConfig에 TurboModeEnabled 및 MaxConcurrentSegments 설정 지원 검증
|
|
var cfg = new Paca.Core.Config.AppConfig();
|
|
Assert.False(cfg.TurboModeEnabled);
|
|
Assert.True(cfg.MaxConcurrentSegments >= 4);
|
|
cfg.TurboModeEnabled = true;
|
|
cfg.MaxConcurrentSegments = 16;
|
|
Assert.True(cfg.TurboModeEnabled);
|
|
Assert.Equal(16, cfg.MaxConcurrentSegments);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate82_ButtonDesignSystem_NoHairlineBorder_Audit()
|
|
{
|
|
// DoD: GhostButton, CardActionButton에 투박한 1px 헤어라인 테두리가 없고 BorderThickness가 0이어야 함
|
|
var sharedXamlPath = Path.Combine(SourceTree.Root, "src", "Paca.App", "Themes", "Shared.xaml");
|
|
var sharedXaml = File.ReadAllText(sharedXamlPath);
|
|
|
|
// GhostButton 템플릿의 BorderThickness가 0인지 검증
|
|
var ghostMatch = Regex.Match(sharedXaml, @"x:Key=""GhostButton""[\s\S]*?<Border\s+x:Name=""bd""[^>]*BorderThickness=""(\d+)""");
|
|
Assert.True(ghostMatch.Success, "GhostButton BorderThickness definition must exist");
|
|
Assert.Equal("0", ghostMatch.Groups[1].Value);
|
|
|
|
// CardActionButton은 GhostButton 기반이거나 BorderThickness=0이어야 함 (1px 헤어라인 박멸)
|
|
var cardActionMatch = Regex.Match(sharedXaml, @"x:Key=""CardActionButton""[\s\S]*?<Setter\s+Property=""BorderThickness""\s+Value=""(\d+)""");
|
|
if (cardActionMatch.Success)
|
|
{
|
|
Assert.Equal("0", cardActionMatch.Groups[1].Value);
|
|
}
|
|
|
|
// MainWindow.xaml의 Window.Resources에 Themes/Dark.xaml 하드코딩 머지가 없어야 함 (라이트 테마 프레임 차단 방지)
|
|
var mwXaml = File.ReadAllText(Path.Combine(SourceTree.Root, "src", "Paca.App", "MainWindow.xaml"));
|
|
Assert.DoesNotContain("<ResourceDictionary Source=\"Themes/Dark.xaml\"/>", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate83_Desktop_LightTheme_FrameResources_Audit()
|
|
{
|
|
using var sta = StaDispatcher.Start();
|
|
WpfTestApp.Ensure(sta.Dispatcher);
|
|
|
|
sta.Invoke(() =>
|
|
{
|
|
// DoD: 라이트 테마 적용 시 App.IsLightTheme 이 true 이고 MainWindow.Resources에 Dark.xaml 강제 고정이 없어야 함
|
|
Paca.App.App.ApplyTheme("light");
|
|
Assert.True(Paca.App.App.IsLightTheme, "App.IsLightTheme must be true after applying light theme");
|
|
Assert.Equal("light", Paca.App.App.CurrentTheme);
|
|
|
|
var mw = new MainWindow();
|
|
Assert.NotNull(mw);
|
|
// MainWindow 인스턴스의 프레임 배경이 Dark 고정이 아님을 확인
|
|
var toolbar = mw.FindName("ToolbarRow") as FrameworkElement;
|
|
Assert.NotNull(toolbar);
|
|
mw.Close();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate84_Mobile_ThemeSystem_LightPalette_And_Frame_Audit()
|
|
{
|
|
// DoD: 모바일 라이트 테마 팔레트 검증 및 BrowserPage 동적 테마 지원 구조 검증
|
|
var lightPalette = Paca.Mobile.Core.MobileThemeSystem.GetPalette(Paca.Mobile.Core.MobileThemeMode.Light);
|
|
Assert.Equal("#F8FAFC", lightPalette.Background);
|
|
Assert.Equal("#FFFFFF", lightPalette.Surface1);
|
|
Assert.Equal("#0F172A", lightPalette.TextPrimary);
|
|
|
|
var browserPageCs = File.ReadAllText(Path.Combine(SourceTree.Root, "src", "Paca.Mobile", "BrowserPage.cs"));
|
|
Assert.Contains("ApplyTheme", browserPageCs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate85_DownloadPanel_StreamlinedLayout_And_NoOvercrowding_Audit()
|
|
{
|
|
// DoD: 다운로드 패널의 과밀(Overcrowding) 해소 및 모던 미니멀 레이아웃 검증
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
// 1. FilterTabStyle의 IsChecked 투박한 사각형 테두리 박멸 검증
|
|
Assert.DoesNotContain("<Setter TargetName=\"bd\" Property=\"BorderBrush\" Value=\"{DynamicResource BorderBrush}\"/>", mwXaml);
|
|
|
|
// 2. 속도 제한 콤보박스 너비 잘림 방지 (80px 이상 확보)
|
|
Assert.Contains("x:Name=\"SpeedLimitComboBox\"", mwXaml);
|
|
Assert.DoesNotContain("Width=\"68\"", mwXaml); // 68px 너비는 "무제하"로 잘림
|
|
|
|
// 3. 세부 필터 서랍 (Collapsible Filter Details Drawer) 구조 지원 검증
|
|
Assert.Contains("x:Name=\"FilterDetailsDrawer\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ToggleFilterDrawerBtn\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate86_WCAG22_TargetSize_SC258_Minimum_24px_Audit()
|
|
{
|
|
// DoD (WCAG 2.2 SC 2.5.8): 모든 대화형 컨트롤/버튼은 최소 24x24px 이상의 터치/클릭 타깃 면적을 가져야 함.
|
|
// 탭 닫기 전용 미니 버튼(TabCloseButton)이 20px 미달 결함 없이 최소 24x24px를 만족해야 함.
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"TabCloseButton\"", sharedXaml);
|
|
// 20px 미달 크기 방지
|
|
Assert.DoesNotContain("<Setter Property=\"Width\" Value=\"20\"/>", sharedXaml);
|
|
Assert.DoesNotContain("<Setter Property=\"Height\" Value=\"20\"/>", sharedXaml);
|
|
|
|
// 최소 24x24px 보장
|
|
Assert.Contains("<Setter Property=\"Width\" Value=\"24\"/>", sharedXaml);
|
|
Assert.Contains("<Setter Property=\"Height\" Value=\"24\"/>", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate87_WCAG22_FocusAppearance_SC2413_Minimum_2px_Audit()
|
|
{
|
|
// DoD (WCAG 2.2 SC 2.4.13 Focus Appearance): 키보드 포커스 링은 최소 2px 이상의 둘레 두께(Perimeter Thickness)를 가져야 함.
|
|
// 기존 1.5px 두께는 WCAG 2.2 기준에 미달하므로 최소 2px 이상이어야 함.
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"FocusVisual\"", sharedXaml);
|
|
Assert.DoesNotContain("StrokeThickness=\"1.5\"", sharedXaml);
|
|
Assert.Contains("StrokeThickness=\"2\"", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate88_DarkMode_Halation_Astigmatism_OffWhite_Audit()
|
|
{
|
|
// DoD: 다크모드 광륜/난시 생리학적 보호 (Dark Mode Halation & Astigmatism Shield)
|
|
// 완전 검정(#000000) 배경 위에 순백(#FFFFFF) 텍스트의 직접 배치를 차단하고,
|
|
// 표면 표고(#0B0B10 ~ #1C1C26)와 오프화이트(#F2F2F6 등) 본문을 적용하여 빛 번짐을 방지함.
|
|
var darkXaml = SourceTree.ThemeDarkXaml;
|
|
|
|
var textPrimary = SourceTree.BrushHex(darkXaml, "TextPrimary");
|
|
Assert.NotNull(textPrimary);
|
|
// 순백 #FFFFFF 직격 금지 (난시 블룸 방지)
|
|
Assert.NotEqual("#FFFFFF", textPrimary.ToUpperInvariant());
|
|
Assert.NotEqual("#FFF", textPrimary.ToUpperInvariant());
|
|
|
|
// 배경색 또한 완전한 무저갱 #000000 대신 서피스 엘리베이션(#0B0B10 등) 적용 확인
|
|
var windowBg = SourceTree.BrushHex(darkXaml, "WindowBg");
|
|
Assert.NotNull(windowBg);
|
|
Assert.NotEqual("#000000", windowBg.ToUpperInvariant());
|
|
|
|
// 명도 대비는 여전히 WCAG AAA (7.0:1 이상)을 충족해야 함
|
|
var cardBg = SourceTree.BrushHex(darkXaml, "CardBg")!;
|
|
Assert.True(SourceTree.ContrastRatio(textPrimary, cardBg) >= 7.0,
|
|
$"Dark TextPrimary({textPrimary}) on CardBg({cardBg}) contrast must be >= 7.0 (AAA)");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate89_SwissGrid_SpacingTokens_Multiples_Of_4_Audit()
|
|
{
|
|
// DoD: 스위스 타이포그래피 8pt/4pt 베이스라인 그리드 불변식 (Swiss Baseline Invariant)
|
|
// Spacing.S, Spacing.M, Spacing.L 등 모든 공간 토큰은 4의 배수여야 함.
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
var spacingRegex = new Regex(@"<Thickness\s+x:Key=""Spacing\.(\w+)""\s*>([^<]+)</Thickness>");
|
|
var matches = spacingRegex.Matches(sharedXaml);
|
|
Assert.NotEmpty(matches);
|
|
|
|
foreach (Match match in matches)
|
|
{
|
|
var key = match.Groups[1].Value;
|
|
var values = match.Groups[2].Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var valStr in values)
|
|
{
|
|
Assert.True(double.TryParse(valStr, out var d), $"Spacing.{key} value '{valStr}' is not a valid number.");
|
|
Assert.True(d % 4 == 0, $"[Swiss Grid Invariant] Spacing.{key} value '{d}' is not a multiple of 4px.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate90_MicroTypography_TabularNumerals_tnum_AntiJitter_Audit()
|
|
{
|
|
// DoD: 마이크로 타이포그래피 및 고정폭 숫자 안티지터 (Tabular Numerals Anti-Jitter - tnum)
|
|
// 다운로드 전송 속도, 남은 시간, 총 크기 표시에 쓰이는 Type.Mono 스타일에
|
|
// Typography.NumeralAlignment="Tabular" 속성이 반드시 명시되어 실시간 렌더링 시 수평 떨림(Jitter)을 방지해야 함.
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"Type.Mono\"", sharedXaml);
|
|
Assert.Contains("Property=\"Typography.NumeralAlignment\" Value=\"Tabular\"", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate91_FittsLaw_CaptionButtons_EdgePlacement_Audit()
|
|
{
|
|
// DoD: 피츠의 법칙 (Fitts's Law) 창 모서리 캡션 단추 무한 타깃 배치 검증
|
|
// 윈도우 캡션 버튼(최소화/최대화/닫기)은 마우스 포인터가 창 경계에서 벗어나지 않으므로 W -> 무한대 효과를 발휘함.
|
|
// CaptionButton 및 CloseButton은 너비 44px 이상, 높이 32px 이상이어야 하며 테마에서 올바르게 정의되어야 함.
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"CaptionButton\"", sharedXaml);
|
|
Assert.Contains("x:Key=\"CloseButton\"", sharedXaml);
|
|
Assert.Contains("Style=\"{StaticResource CloseButton}\"", mwXaml);
|
|
Assert.Contains("Name=\"MaxRestoreBtn\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate92_HickHyman_Miller_Chunking_DownloadCard_Audit()
|
|
{
|
|
// DoD: 힉-하이먼 및 밀러의 법칙 (7±2 정보 청킹 및 인지 부하 최적화)
|
|
// 다운로드 카드는 단일 덩어리가 아니라 [헤더/포맷태그], [진행바/수치], [액션버튼] 3개 청크로 분할되어야 함.
|
|
// 또한 툴바 과밀을 방지하는 FilterDetailsDrawer 가 MainWindow.xaml 에 유지되어야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("x:Name=\"FilterDetailsDrawer\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ToggleFilterDrawerBtn\"", mwXaml);
|
|
Assert.Contains("AutomationProperties.Name=\"상세 필터 토글\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate93_WCAG22_SC257_DraggingMovements_SinglePointerAlternative_Audit()
|
|
{
|
|
// DoD (WCAG 2.2 SC 2.5.7): 드래그 조작에 대한 단일 포인터/키보드 대안 보증
|
|
// 탭 조작(닫기, 전환) 및 북마크 항목은 드래그 외에도 단일 클릭 닫기 버튼 및 우클릭 컨텍스트 메뉴로 조작 가능해야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"BookmarkItemContextMenu\"", mwXaml);
|
|
Assert.Contains("TabClose_Click", mwXaml);
|
|
Assert.Contains("ToolTip=\"탭 닫기 (Ctrl+W)\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate94_WCAG22_SC337_RedundantEntry_Prevention_Audit()
|
|
{
|
|
// DoD (WCAG 2.2 SC 3.3.7): 중복 입력 방지 및 재선택/자동채움 지원
|
|
// 다운로드 검색창 및 북마크/히스토리 컨텍스트 메뉴를 통해 기존 입력 정보의 재활용을 보장해야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("x:Name=\"DownloadSearchBox\"", mwXaml);
|
|
Assert.Contains("x:Name=\"DownloadSearchClearBtn\"", mwXaml);
|
|
Assert.Contains("AutomationProperties.Name=\"검색어 지우기\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate95_MicrosoftUIA_AccessibilityInsights_ButtonNames_Audit()
|
|
{
|
|
// DoD: Microsoft Accessibility Insights for Windows UIA 계약
|
|
// MainWindow.xaml 내의 주요 조작 버튼(내장 플레이어 재생, 추가 화질 조회, 미디어 플레이어 재생/일시정지 등)은
|
|
// 스크린 리더(Narrator/NVDA)가 읽을 수 있도록 AutomationProperties.Name 이 필수 지정되어야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("AutomationProperties.Name=\"내장 플레이어로 재생\"", mwXaml);
|
|
Assert.Contains("AutomationProperties.Name=\"서버에서 추가 화질 조회\"", mwXaml);
|
|
Assert.Contains("AutomationProperties.Name=\"재생 또는 일시정지\"", mwXaml);
|
|
Assert.Contains("AutomationProperties.Name=\"자막 설정\"", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate96_WCAG141_UseOfColor_MultiChannelSignaling_Audit()
|
|
{
|
|
// DoD (WCAG 1.4.1 Use of Color & CVD 방어):
|
|
// 색상만이 상태를 전달하는 유일한 수단이 되어서는 안 되며,
|
|
// 다운로드 카드와 미디어 항목은 색상과 함께 텍스트(Status) 및 아이콘 글리프가 다채널(Triple-Channel)로 병행 표기되어야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("Text=\"{Binding Status}\"", mwXaml);
|
|
Assert.Contains("MediaKindBadge", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate97_OLED_BlackSmearing_5PercentTintedBlack_Audit()
|
|
{
|
|
// DoD: OLED 디스플레이 물리 공학 및 블랙 스미어링(Purple Smearing) 방어
|
|
// 기본 배경(WindowBg)은 완전 검정(#000000)을 지양하고, 서브픽셀 프리바이어스를 유지하는
|
|
// 5% 틴티드 서피스(#050505 이상, 다크 테마 #0B0B10)를 충족해야 함.
|
|
var darkXaml = SourceTree.ThemeDarkXaml;
|
|
var windowBg = SourceTree.BrushHex(darkXaml, "WindowBg");
|
|
Assert.NotNull(windowBg);
|
|
|
|
// #000000 금지
|
|
Assert.NotEqual("#000000", windowBg.ToUpperInvariant());
|
|
|
|
// RGB 각 채널이 최소 5 이상이어야 함
|
|
var cleanHex = windowBg.TrimStart('#');
|
|
if (cleanHex.Length == 8) cleanHex = cleanHex.Substring(2); // ARGB -> RGB
|
|
var r = Convert.ToInt32(cleanHex.Substring(0, 2), 16);
|
|
var g = Convert.ToInt32(cleanHex.Substring(2, 2), 16);
|
|
var b = Convert.ToInt32(cleanHex.Substring(4, 2), 16);
|
|
|
|
Assert.True(r >= 5 && g >= 5 && b >= 5,
|
|
$"WindowBg ({windowBg}) RGB channels ({r},{g},{b}) must be >= 5 to prevent OLED pixel turn-on delay");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate98_WPF_HiDPI_SubpixelSnapping_LayoutRounding_Audit()
|
|
{
|
|
// DoD: HiDPI 서브픽셀 스냅 및 레이아웃 라운딩 (WPF Display Engine)
|
|
// 125%, 150% 등 분수 DPI 배율 환경에서 안티앨리어싱 번짐을 방지하기 위해
|
|
// MainWindow는 UseLayoutRounding="True"를 활성화하고,
|
|
// 공용 컨트롤(TextBlock, CheckBox, RadioButton, ComboBox)은 SnapsToDevicePixels="True"를 보장해야 함.
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
Assert.Contains("UseLayoutRounding=\"True\"", mwXaml);
|
|
Assert.Contains("<Style TargetType=\"TextBlock\">\r\n <Setter Property=\"SnapsToDevicePixels\" Value=\"True\"/>", sharedXaml.Replace("\r\n", "\n").Replace("\n", "\r\n"));
|
|
Assert.Contains("x:Key=\"DarkCheckBox\"", sharedXaml);
|
|
Assert.Contains("<Setter Property=\"SnapsToDevicePixels\" Value=\"True\"/>", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate99_WCAG22_SC1411_NonTextContrast_Borders_And_Focus_Audit()
|
|
{
|
|
// DoD (WCAG 2.2 SC 1.4.11 Non-Text Contrast Level AA):
|
|
// 활성 UI 컴포넌트의 시각적 경계(BorderBrush) 및 포커스 상태(FocusAdornerBrush)는
|
|
// 인접한 배경색(WindowBg, CardBg)에 대해 최소 3.0:1 이상의 명도 대비를 유지해야 함.
|
|
var dark = SourceTree.ThemeDarkXaml;
|
|
var light = SourceTree.ThemeLightXaml;
|
|
|
|
// 1. 다크 테마 비텍스트 명도 대비 검증
|
|
var darkBorder = SourceTree.BrushHex(dark, "BorderBrush")!;
|
|
var darkWindowBg = SourceTree.BrushHex(dark, "WindowBg")!;
|
|
var darkCardBg = SourceTree.BrushHex(dark, "CardBg")!;
|
|
var darkFocus = SourceTree.BrushHex(dark, "FocusAdornerBrush")!;
|
|
|
|
Assert.True(SourceTree.ContrastRatio(darkBorder, darkWindowBg) >= 3.0,
|
|
$"Dark Border/WindowBg contrast {SourceTree.ContrastRatio(darkBorder, darkWindowBg):F2} < 3.0");
|
|
Assert.True(SourceTree.ContrastRatio(darkBorder, darkCardBg) >= 3.0,
|
|
$"Dark Border/CardBg contrast {SourceTree.ContrastRatio(darkBorder, darkCardBg):F2} < 3.0");
|
|
Assert.True(SourceTree.ContrastRatio(darkFocus, darkWindowBg) >= 3.0,
|
|
$"Dark Focus/WindowBg contrast {SourceTree.ContrastRatio(darkFocus, darkWindowBg):F2} < 3.0");
|
|
|
|
// 2. 라이트 테마 비텍스트 명도 대비 검증 (기존 #AEB4C4는 2.07:1로 탈락하므로 >= 3.0 필수)
|
|
var lightBorder = SourceTree.BrushHex(light, "BorderBrush")!;
|
|
var lightWindowBg = SourceTree.BrushHex(light, "WindowBg")!;
|
|
var lightCardBg = SourceTree.BrushHex(light, "CardBg")!;
|
|
var lightFocus = SourceTree.BrushHex(light, "FocusAdornerBrush")!;
|
|
|
|
Assert.True(SourceTree.ContrastRatio(lightBorder, lightWindowBg) >= 3.0,
|
|
$"Light Border/WindowBg contrast {SourceTree.ContrastRatio(lightBorder, lightWindowBg):F2} < 3.0 (SC 1.4.11)");
|
|
Assert.True(SourceTree.ContrastRatio(lightBorder, lightCardBg) >= 3.0,
|
|
$"Light Border/CardBg contrast {SourceTree.ContrastRatio(lightBorder, lightCardBg):F2} < 3.0 (SC 1.4.11)");
|
|
Assert.True(SourceTree.ContrastRatio(lightFocus, lightCardBg) >= 3.0,
|
|
$"Light Focus/CardBg contrast {SourceTree.ContrastRatio(lightFocus, lightCardBg):F2} < 3.0 (SC 1.4.11)");
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate100_Century_DesignAudit_TextSpacing_And_Reflow_Audit()
|
|
{
|
|
// DoD: 제100차 센추리 디자인 감사 게이트 (WCAG 2.2 SC 1.4.12 Text Spacing & SC 1.4.10 Reflow)
|
|
// 1. Text Spacing: Type.LineHeight 가 1.5 이상으로 정의되어야 함
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
Assert.Contains("<sys:Double x:Key=\"Type.LineHeight\">1.5</sys:Double>", sharedXaml);
|
|
|
|
// 2. Reflow & Layout Constraints: 메인 창의 기본 크기가 안정적 뷰포트를 확보해야 함
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.Contains("Width=\"1300\"", mwXaml);
|
|
Assert.Contains("Height=\"820\"", mwXaml);
|
|
Assert.Contains("UseLayoutRounding=\"True\"", mwXaml);
|
|
|
|
// 3. 다크/라이트/고대비 3대 테마 100% 토큰 패리티 달성 확인
|
|
var dark = SourceTree.ThemeDarkXaml;
|
|
var light = SourceTree.ThemeLightXaml;
|
|
var hc = File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml"));
|
|
|
|
foreach (var key in RequiredBrushKeys)
|
|
{
|
|
Assert.True(SourceTree.HasKey(dark, key), $"Dark missing {key}");
|
|
Assert.True(SourceTree.HasKey(light, key), $"Light missing {key}");
|
|
Assert.True(SourceTree.HasKey(hc, key), $"HighContrast missing {key}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate101_ExtensionPopup_And_SettingsHeader_LayoutIntegrity_Audit()
|
|
{
|
|
// DoD: 확장 프로그램 팝업 및 설정 창 헤더 레이아웃 무결성 감사
|
|
// 1. MainWindow.xaml.cs의 popupWin은 DWM 검은색 바/비클라이언트 왜곡 방지를 위해 WindowChrome을 설정해야 함
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
|
|
Assert.Contains("WindowChrome.SetWindowChrome(popupWin", mwCs);
|
|
Assert.Contains("CaptionHeight = 40", mwCs);
|
|
Assert.Contains("WindowChrome.SetIsHitTestVisibleInChrome", mwCs);
|
|
Assert.Contains(@"\uE8BB", mwCs); // 스위스 미니멀 표준 닫기 아이콘
|
|
|
|
// 2. SettingsWindow.xaml 헤더의 스위스 미니멀 패딩 및 MinHeight 검증
|
|
var settingsXaml = File.ReadAllText(SourceTree.AppPath("SettingsWindow.xaml"));
|
|
Assert.Contains("MinHeight=\"44\"", settingsXaml);
|
|
Assert.Contains("TitleBar_MouseDown", settingsXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate102_HighContrast_Palette_WCAG_AAA_7to1_Audit()
|
|
{
|
|
// DoD: 윈도우 고대비 모드(WHCM / Forced Colors) WCAG AAA 7.0:1 초고대비 감사
|
|
// Themes/HighContrast.xaml 의 모든 핵심 기능 브러시는 WindowBg(#000000) 대비 7.0:1 이상이어야 함
|
|
var hc = File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml"));
|
|
var windowBg = SourceTree.BrushHex(hc, "WindowBg")!;
|
|
Assert.Equal("#000000", windowBg.ToUpperInvariant());
|
|
|
|
var requiredAaaTokens = new[]
|
|
{
|
|
"TextPrimary",
|
|
"TextSecondary",
|
|
"AccentBrush",
|
|
"SuccessBrush",
|
|
"ErrorBrush",
|
|
"SelectionBrush",
|
|
"FocusAdornerBrush"
|
|
};
|
|
|
|
foreach (var token in requiredAaaTokens)
|
|
{
|
|
var hex = SourceTree.BrushHex(hc, token)!;
|
|
var ratio = SourceTree.ContrastRatio(hex, windowBg);
|
|
Assert.True(ratio >= 7.0,
|
|
$"HighContrast {token} ({hex}) on {windowBg} contrast ratio {ratio:F2} is under WCAG AAA 7.0:1 requirement");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate103_ReducedMotion_Tokens_And_Duration_Bound_Audit()
|
|
{
|
|
// DoD: 전정기관 질환 보호 움직임 감소(Reduced Motion SC 2.3.3)
|
|
// Themes/Shared.xaml 에 Duration.Fast 와 Duration.Normal 토큰이 정의되어 있어야 하며,
|
|
// 애니메이션 지속시간은 현기증/오심을 방지하기 위해 최대 0.3s (300ms) 이하로 상한 제약되어야 함
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
Assert.Contains("x:Key=\"Duration.Fast\"", sharedXaml);
|
|
Assert.Contains("x:Key=\"Duration.Normal\"", sharedXaml);
|
|
Assert.Contains("0:0:0.15", sharedXaml);
|
|
Assert.Contains("0:0:0.3", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate104_ModalWindows_FocusTrapping_TabNavigationCycle_Audit()
|
|
{
|
|
// DoD: 모달 다이얼로그 포커스 트랩(Focus Trap WCAG 2.2 SC 2.4.11/2.4.12)
|
|
// 키보드 탭 탐색이 모달 외부로 탈출하는 것을 방지하기 위해
|
|
// 주요 대화상자 XAML 은 KeyboardNavigation.TabNavigation="Cycle" 을 반드시 선언해야 함
|
|
var modalFiles = new[]
|
|
{
|
|
"SettingsWindow.xaml",
|
|
"CredentialVaultWindow.xaml",
|
|
"BookmarkManagerWindow.xaml",
|
|
"ExtensionManagerWindow.xaml",
|
|
"LogViewerWindow.xaml",
|
|
"CrashReportDialog.xaml"
|
|
};
|
|
|
|
foreach (var modalFile in modalFiles)
|
|
{
|
|
var path = SourceTree.AppPath(modalFile);
|
|
Assert.True(File.Exists(path), $"{modalFile} 파일이 존재해야 합니다.");
|
|
var content = File.ReadAllText(path);
|
|
Assert.Contains("KeyboardNavigation.TabNavigation=\"Cycle\"", content);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate105_ModalWindows_HiDPI_And_UIA_FastPass_Audit()
|
|
{
|
|
// DoD: 모달 창 HiDPI 서브픽셀 스냅 및 Microsoft UIA FastPass 스크린 리더 레이블 계약
|
|
// 모든 대화상자는 UseLayoutRounding="True" 와 AutomationProperties.Name 속성을 필수로 선언해야 함
|
|
var modalFiles = new[]
|
|
{
|
|
"SettingsWindow.xaml",
|
|
"CredentialVaultWindow.xaml",
|
|
"BookmarkManagerWindow.xaml",
|
|
"ExtensionManagerWindow.xaml",
|
|
"LogViewerWindow.xaml",
|
|
"CrashReportDialog.xaml"
|
|
};
|
|
|
|
foreach (var modalFile in modalFiles)
|
|
{
|
|
var path = SourceTree.AppPath(modalFile);
|
|
var content = File.ReadAllText(path);
|
|
Assert.Contains("UseLayoutRounding=\"True\"", content);
|
|
Assert.Contains("AutomationProperties.Name=", content);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate106_ModalWindows_EscapeLightDismiss_Audit()
|
|
{
|
|
// DoD: 키보드 라이트 디스미스(Escape Light Dismiss Pattern)
|
|
// 사용자가 Escape 키를 눌러 모달을 안전하게 닫을 수 있도록 IsCancel="True" 버튼 또는 Esc 키 바인딩을 제공해야 함
|
|
var modalFiles = new[]
|
|
{
|
|
"SettingsWindow.xaml",
|
|
"CredentialVaultWindow.xaml",
|
|
"BookmarkManagerWindow.xaml",
|
|
"ExtensionManagerWindow.xaml",
|
|
"LogViewerWindow.xaml",
|
|
"CrashReportDialog.xaml"
|
|
};
|
|
|
|
foreach (var modalFile in modalFiles)
|
|
{
|
|
var path = SourceTree.AppPath(modalFile);
|
|
var content = File.ReadAllText(path);
|
|
var hasCancelButton = content.Contains("IsCancel=\"True\"");
|
|
var hasEscBinding = content.Contains("Key=\"Escape\"") || content.Contains("Close_Click") || content.Contains("KeyDown=\"Window_KeyDown\"");
|
|
Assert.True(hasCancelButton || hasEscBinding, $"{modalFile} 에 Esc 닫기 또는 IsCancel=True 버튼이 누락되었습니다.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate107_KLM_KeyShortcut_Coverage_Audit()
|
|
{
|
|
// DoD: 키스트로크 레벨 모델(KLM) 기반 마우스-키보드 호밍 시간(H=0.4s) 및 포인팅 시간(P=1.1s) 제거 감사
|
|
// MainWindow.xaml.cs 의 OnPreviewKeyDown 에 8대 핵심 단축키가 100% 매핑되어 있어야 함
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
|
|
Assert.Contains("Key == Key.T", mwCs); // 새 탭
|
|
Assert.Contains("Key == Key.W", mwCs); // 탭 닫기
|
|
Assert.Contains("Key == Key.L", mwCs); // 주소창 포커스
|
|
Assert.Contains("Key == Key.J", mwCs); // 다운로드 창
|
|
Assert.Contains("Key == Key.D", mwCs); // 북마크 다이얼로그
|
|
Assert.Contains("Key == Key.F", mwCs); // 페이지 내 검색
|
|
Assert.Contains("Key == Key.F5", mwCs); // 새로고침
|
|
Assert.Contains("Key == Key.Escape", mwCs); // 라이트 디스미스 및 정지
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate108_DanSaffer_MicroInteraction_4StageLoop_Audit()
|
|
{
|
|
// DoD: 댄 섀퍼(Dan Saffer)의 마이크로인터랙션 4단계 피드백 루프 감사
|
|
// 1. Trigger: 카드 인터랙티브 조작 버튼 선언 (Play, Dismiss, Folder)
|
|
// 2. Rules: DownloadItemViewModel 상태 머신 바인딩
|
|
// 3. Feedback: Tabular 고정폭 숫자 속도 표시 및 진행률 바
|
|
// 4. Loops: 재시도 및 이어받기 지원
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
|
|
Assert.Contains("CardPlay_Click", mwXaml);
|
|
Assert.Contains("CardDismiss_Click", mwXaml);
|
|
Assert.Contains("ProgressBar", mwXaml);
|
|
Assert.Contains("Type.Mono", mwXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate109_WCAG2ICT_Reflow_SingleVerticalScroll_Audit()
|
|
{
|
|
// DoD: WCAG2ICT SC 1.4.10 Reflow (400% 줌 등가 1차원 세로 스크롤 보증)
|
|
// 저시력 사용자의 가로 왕복 스크롤 인지 피로를 차단하기 위해,
|
|
// 리스트뷰 및 사이드바 패널의 ScrollViewer.HorizontalScrollBarVisibility는 Disabled를 강제해야 함
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
var bmXaml = File.ReadAllText(SourceTree.AppPath("BookmarkManagerWindow.xaml"));
|
|
|
|
Assert.Contains("HorizontalScrollBarVisibility=\"Disabled\"", mwXaml);
|
|
Assert.Contains("ScrollViewer.HorizontalScrollBarVisibility=\"Disabled\"", bmXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate110_Grand_VisualDesignGate_CentennialSuperPass_Audit()
|
|
{
|
|
// DoD: 제110대 비주얼 디자인 게이트 그랜드 슈퍼 패스
|
|
// 110개 전수 비주얼 디자인 게이트의 존재와 무결성을 기계적으로 확정
|
|
var path = Path.Combine(SourceTree.Root, "tests", "Paca.Tests", "E2E", "VisualDesignGateTests.cs");
|
|
Assert.True(File.Exists(path), "VisualDesignGateTests.cs 파일 존재 필수");
|
|
var selfCode = File.ReadAllText(path);
|
|
for (int i = 1; i <= 113; i++)
|
|
{
|
|
var prefix = i < 10 ? $"Gate0{i}_" : $"Gate{i}_";
|
|
Assert.Contains(prefix, selfCode);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate111_PopupCornerClipping_And_DividerLineElimination_Audit()
|
|
{
|
|
// DoD: 모달 팝업 모퉁이 직각 클리핑/사각 잔여물 방어 및 조잡한 1px 헤어라인 구분선 제거 감사
|
|
// 1. Themes/Shared.xaml의 FlyoutBorder 스타일에 Margin="10" 이상 선언 필수 (WPF Popup HWND 하드 클리핑 차단)
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
Assert.Contains("<Setter Property=\"Margin\" Value=\"10\"/>", sharedXaml);
|
|
|
|
// 2. PopupShadow 리소스의 흐림(Blur) 및 불투명도(Opacity)가 정밀 튜닝되어 여백 내에서 감쇄되어야 함
|
|
Assert.Contains("BlurRadius=\"12\"", sharedXaml);
|
|
Assert.Contains("Opacity=\"0.22\"", sharedXaml);
|
|
|
|
// 3. PopupItemRow 스타일이 정의되어 구분선(BorderThickness=0) 없이 마우스 호버(CardBgHover)로만 상호작용해야 함
|
|
Assert.Contains("x:Key=\"PopupItemRow\"", sharedXaml);
|
|
|
|
// 4. MainWindow.xaml의 PageControlsPopup, ProfilePopup, GlobalMediaPopup 루트는 FlyoutBorder 스타일을 준용해야 함
|
|
var mwXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
|
|
Assert.Contains("x:Name=\"PageControlsPopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"ProfilePopup\"", mwXaml);
|
|
Assert.Contains("x:Name=\"GlobalMediaPopup\"", mwXaml);
|
|
|
|
var pageControlsIdx = mwXaml.IndexOf("x:Name=\"PageControlsPopup\"");
|
|
var pageControlsEnd = mwXaml.IndexOf("</Popup>", pageControlsIdx);
|
|
var pageControlsSection = mwXaml.Substring(pageControlsIdx, pageControlsEnd - pageControlsIdx);
|
|
|
|
// PageControlsPopup 루트 Border는 FlyoutBorder 스타일이어야 함
|
|
Assert.Contains("Style=\"{StaticResource FlyoutBorder}\"", pageControlsSection);
|
|
|
|
// PageControlsPopup 내부에 조잡한 구분선(BorderThickness="0,0,0,1")이 단 하나도 없어야 함
|
|
Assert.DoesNotContain("BorderThickness=\"0,0,0,1\"", pageControlsSection);
|
|
|
|
// PageControlsPopup의 권한 아이템들은 PopupItemRow 스타일을 적용해야 함
|
|
Assert.Contains("Style=\"{StaticResource PopupItemRow}\"", pageControlsSection);
|
|
|
|
// ProfilePopup, GlobalMediaPopup 또한 FlyoutBorder 스타일이어야 함
|
|
var profileIdx = mwXaml.IndexOf("x:Name=\"ProfilePopup\"");
|
|
var profileEnd = mwXaml.IndexOf("</Popup>", profileIdx);
|
|
var profileSection = mwXaml.Substring(profileIdx, profileEnd - profileIdx);
|
|
Assert.Contains("Style=\"{StaticResource FlyoutBorder}\"", profileSection);
|
|
|
|
var gmcIdx = mwXaml.IndexOf("x:Name=\"GlobalMediaPopup\"");
|
|
var gmcEnd = mwXaml.IndexOf("</Popup>", gmcIdx);
|
|
var gmcSection = mwXaml.Substring(gmcIdx, gmcEnd - gmcIdx);
|
|
Assert.Contains("Style=\"{StaticResource FlyoutBorder}\"", gmcSection);
|
|
|
|
// 5. MainWindow.xaml.cs의 SetupPopupPlacements에서 10px 마진 보정을 통해 4px 간격 및 버튼 정렬을 정확히 유지해야 함
|
|
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
|
|
Assert.Contains("popupSize.Width + 10", mwCs);
|
|
Assert.Contains("targetSize.Height + 4 - 10", mwCs);
|
|
Assert.Contains("0 - 10", mwCs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate112_Fido2PasskeyAuthentication_SwissMinimalDesign_Audit()
|
|
{
|
|
// DoD: Passkey 2차 인증 프롬프트 모달의 스위스 미니멀리즘 디자인 규격 감사
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
// 1. PasskeyPromptCard 스타일 선언 확인
|
|
Assert.Contains("x:Key=\"PasskeyPromptCard\"", sharedXaml);
|
|
|
|
// 2. 16px 라운딩 및 24px 패딩 규격 확인
|
|
Assert.Contains("<Setter Property=\"CornerRadius\" Value=\"16\"/>", sharedXaml);
|
|
Assert.Contains("<Setter Property=\"Padding\" Value=\"24\"/>", sharedXaml);
|
|
|
|
// 3. 최소 가로폭 380px 및 팝업 그림자 적용 확인
|
|
Assert.Contains("<Setter Property=\"MinWidth\" Value=\"380\"/>", sharedXaml);
|
|
Assert.Contains("StaticResource PopupShadow", sharedXaml);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate113_QrPairingModal_SwissGridAndSquareAspect_Audit()
|
|
{
|
|
// DoD: 모바일 P2P QR 페어링 대화상자 스위스 8pt 그리드 및 레이아웃 감사
|
|
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
|
|
|
|
// 1. QrPairingModalBorder 스타일 선언 확인
|
|
Assert.Contains("x:Key=\"QrPairingModalBorder\"", sharedXaml);
|
|
|
|
// 2. 고정 치수 규격 확인
|
|
Assert.Contains("<Setter Property=\"Width\" Value=\"320\"/>", sharedXaml);
|
|
Assert.Contains("<Setter Property=\"Height\" Value=\"360\"/>", sharedXaml);
|
|
}
|
|
|
|
private static IEnumerable<T> FindLogicalChildren<T>(DependencyObject depObj) where T : DependencyObject
|
|
{
|
|
if (depObj == null) yield break;
|
|
foreach (var rawChild in LogicalTreeHelper.GetChildren(depObj))
|
|
{
|
|
if (rawChild is DependencyObject child)
|
|
{
|
|
if (child is T t) yield return t;
|
|
foreach (var grandChild in FindLogicalChildren<T>(child))
|
|
yield return grandChild;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|