feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization

This commit is contained in:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View file

@ -0,0 +1,172 @@
using System.Collections.ObjectModel;
using System.IO;
using Paca.App.Browser.Features;
using Paca.App.Core;
using Paca.App.ViewModels;
using Paca.Browser;
using Paca.Browser.Tabs;
using Paca.Core.Config;
using Paca.Core.Detection;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// 19차 핵심 시나리오 파이프라인 프레임워크:
/// 다운로드 엔진, 탭 세션, 재생목록, 미디어 감지 파이프라인의 종단간 생명주기를
/// Fluent DSL로 간결하고 강력하게 테스트할 수 있는 프레임워크.
/// </summary>
public sealed class AppScenarioPipeline : IDisposable
{
public string TempDir { get; }
public AppConfig Config { get; }
public DownloadManager DownloadManager { get; }
public MediaPlaylist Playlist { get; }
public TabManager TabManager { get; }
public List<DownloadItemViewModel> DetectedItems { get; } = new();
private AppScenarioPipeline()
{
TempDir = Path.Combine(Path.GetTempPath(), "VD_Test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(TempDir);
Environment.SetEnvironmentVariable("VD_DATA_DIR", TempDir);
Config = new AppConfig
{
OutputFolder = Path.Combine(TempDir, "downloads"),
RestoreSession = true,
PlaylistLoopMode = "all"
};
Directory.CreateDirectory(Config.OutputFolder);
DownloadManager = new DownloadManager(
Config,
_ => Task.FromResult(new List<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()),
_ => { });
DownloadManager.ItemDetected += item => DetectedItems.Add(item);
Playlist = new MediaPlaylist();
TabManager = new TabManager(() => new TabItem(), _ => { });
}
public static AppScenarioPipeline Create() => new();
public AppScenarioPipeline SimulateMediaDetection(string url, MediaKind kind, string? pageUrl = null)
{
DownloadManager.OnDetected(new DetectionContext(url, pageUrl ?? "https://test.local", kind));
return this;
}
public AppScenarioPipeline AddPlaylistTrack(string url, string title, string kind)
{
Playlist.Add(new MediaTrack(url, title, kind, DateTime.UtcNow));
return this;
}
public AppScenarioPipeline SimulateNextTrack()
{
Playlist.Next();
return this;
}
public AppScenarioPipeline SimulateTrackPlayed(MediaTrack track)
{
Playlist.MarkPlayed(track);
return this;
}
public AppScenarioPipeline OpenTab(string url)
{
TabManager.NewTab(url);
return this;
}
public SessionSnapshot SaveSession() => TabManager.CaptureSession();
public AppScenarioPipeline RestoreSession(SessionSnapshot snapshot)
{
TabManager.RestoreSession(snapshot);
return this;
}
public void AssertQueueCount(int count)
{
Xunit.Assert.Equal(count, DownloadManager.Items.Count);
}
public void AssertDetectedCount(int count)
{
Xunit.Assert.Equal(count, DetectedItems.Count);
}
public AppScenarioPipeline SetScheduleGate(Func<bool> gate)
{
DownloadManager.DownloadGate = gate;
return this;
}
public AppScenarioPipeline SelectItems(Func<DownloadItemViewModel, bool> predicate)
{
foreach (var item in DownloadManager.Items)
{
item.IsSelected = predicate(item);
}
return this;
}
public AppScenarioPipeline BatchPause()
{
foreach (var item in DownloadManager.Items.Where(i => i.IsSelected).ToList())
{
DownloadManager.Pause(item);
}
return this;
}
public AppScenarioPipeline BatchResume()
{
foreach (var item in DownloadManager.Items.Where(i => i.IsSelected).ToList())
{
DownloadManager.Resume(item);
}
return this;
}
public AppScenarioPipeline BatchCancel()
{
foreach (var item in DownloadManager.Items.Where(i => i.IsSelected).ToList())
{
DownloadManager.Cancel(item);
}
return this;
}
public AppScenarioPipeline BatchDelete()
{
foreach (var item in DownloadManager.Items.Where(i => i.IsSelected).ToList())
{
DownloadManager.Items.Remove(item);
}
return this;
}
public void AssertPlaylistCount(int count)
{
Xunit.Assert.Equal(count, Playlist.Tracks.Count);
}
public void AssertCurrentTrack(Func<MediaTrack?, bool> predicate)
{
Xunit.Assert.True(predicate(Playlist.Current), "현재 재생목록 트랙이 조건에 부합하지 않습니다.");
}
public void Dispose()
{
try
{
if (Directory.Exists(TempDir))
Directory.Delete(TempDir, recursive: true);
}
catch { }
}
}

View file

@ -0,0 +1,125 @@
using System.IO;
using Paca.App.Core;
using Paca.App.ViewModels;
using Paca.Browser;
using Paca.Browser.Tabs;
using Paca.Core.Config;
using Paca.Core.Detection;
using Paca.Core.Platform;
using Paca.Core.Utils;
namespace Paca.Tests.Infrastructure;
public sealed class MockPipelineProcessRunner : IProcessRunner
{
public List<ProcessSpec> Specs { get; } = new();
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
{
Specs.Add(spec);
return Task.FromResult(new ProcessResult(0, "OK", ""));
}
}
public sealed class MockPipelineCloudStorageClient : ICloudStorageClient
{
public List<string> UploadedList { get; } = new();
public Task<bool> UploadAsync(string localFilePath, string remoteDestination, Action<long, long>? onProgress, CancellationToken ct)
{
UploadedList.Add($"{localFilePath} -> {remoteDestination}");
return Task.FromResult(true);
}
public Task<bool> ExistsAsync(string remoteDestination, CancellationToken ct)
{
return Task.FromResult(UploadedList.Any(u => u.EndsWith(remoteDestination)));
}
}
/// <summary>
/// 40개 마일스톤 전 영역 통합 오케스트레이션 파이프라인 하네스.
/// </summary>
public sealed class GrandUnifiedPipeline : IDisposable
{
public string TempDir { get; }
public AppConfig Config { get; }
public TabManager TabManager { get; }
public AdBlockRuleEngine AdBlockEngine { get; }
public MemoryTurboGuard MemoryGuard { get; }
public DownloadManager DownloadManager { get; }
public VideoDeduplicator Deduplicator { get; }
public QoSTrafficShaper TrafficShaper { get; }
public ChaosMonkeyStressDefense ChaosDefense { get; }
public AudioNormalizer AudioNormalizer { get; }
public HdrToneMapper HdrToneMapper { get; }
public ChapterSplitter ChapterSplitter { get; }
public DualSubtitleEngine DualSubtitleEngine { get; }
public CloudSyncGateway CloudGateway { get; }
public MockPipelineProcessRunner ProcessRunner { get; }
public MockPipelineCloudStorageClient CloudClient { get; }
private readonly List<DownloadItemViewModel> _detectedItems = new();
private GrandUnifiedPipeline()
{
TempDir = Path.Combine(Path.GetTempPath(), "VD_Grand_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(TempDir);
Config = new AppConfig
{
OutputFolder = Path.Combine(TempDir, "downloads")
};
Directory.CreateDirectory(Config.OutputFolder);
ProcessRunner = new MockPipelineProcessRunner();
CloudClient = new MockPipelineCloudStorageClient();
TabManager = new TabManager(() => new TabItem(), _ => { });
AdBlockEngine = new AdBlockRuleEngine();
MemoryGuard = new MemoryTurboGuard(TimeSpan.FromMinutes(10));
DownloadManager = new DownloadManager(
Config,
_ => Task.FromResult(new List<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()),
_ => { });
DownloadManager.ItemDetected += item => _detectedItems.Add(item);
Deduplicator = new VideoDeduplicator(maxHammingThreshold: 5);
TrafficShaper = new QoSTrafficShaper(maxBandwidthBytesPerSec: 10_000_000);
ChaosDefense = new ChaosMonkeyStressDefense(failureRate: 0.3);
AudioNormalizer = new AudioNormalizer(ProcessRunner);
HdrToneMapper = new HdrToneMapper(ProcessRunner);
ChapterSplitter = new ChapterSplitter(ProcessRunner);
DualSubtitleEngine = new DualSubtitleEngine();
CloudGateway = new CloudSyncGateway(CloudClient);
}
public static GrandUnifiedPipeline Create() => new();
public IReadOnlyList<DownloadItemViewModel> DetectedItems => _detectedItems;
public void SimulateWebRequest(string url, MediaKind? mediaKind = null, string? pageUrl = null)
{
var uri = new Uri(url);
// 1. 광고 차단 검사
if (AdBlockEngine.ShouldBlock(uri))
return;
// 2. 미디어 감지
if (mediaKind.HasValue)
{
DownloadManager.OnDetected(new DetectionContext(url, pageUrl ?? "https://test.local", mediaKind.Value));
}
}
public void Dispose()
{
if (Directory.Exists(TempDir))
{
try { Directory.Delete(TempDir, true); } catch { }
}
}
}

View file

@ -0,0 +1,203 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Paca.Browser.Extensions;
using Paca.App.Browser.Features;
using Xunit;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// RED를 위한 RED: TDD RED 방법론 및 테스트 스위트 통폐합·무결점 메타 검증 게이트.
/// 100+ 학술/산업계 TDD 원칙(Meszaros, Fowler, Beck)을 바탕으로
/// 테스트 냄새(Test Smells), 동어반복(Tautological), 돌연변이 감지율(Mutation Score),
/// 중복 통폐합(Subsumption), 결정론적 격리성을 메타 레벨에서 검증합니다.
/// </summary>
public sealed class MetaTestQualityAndPruningTests
{
private static readonly string TestDir = Directory.Exists(Path.Combine(SourceTree.Root, "tests", "Paca.Tests")) ? Path.Combine(SourceTree.Root, "tests", "Paca.Tests") : Path.Combine(SourceTree.Root, "Paca.Tests");
// =========================================================================
// Gate 1: Test Smells & Anti-Pattern Detection Gate (테스트 냄새 전수 차단)
// =========================================================================
[Fact]
public void Meta001_All_Test_Files_Must_Contain_Valid_Assertions()
{
var testFiles = Directory.GetFiles(TestDir, "*Tests.cs", SearchOption.AllDirectories);
Assert.NotEmpty(testFiles);
foreach (var file in testFiles)
{
var content = File.ReadAllText(file);
var hasAssert = content.Contains("Assert.") || content.Contains("Should().") || content.Contains("Throws") || content.Contains("SourceTree.Usecase") || content.Contains("AssertContract");
Assert.True(hasAssert, $"테스트 파일 [{Path.GetFileName(file)}] 에 검증(Assert/Throws/Usecase) 문이 누락되었습니다.");
}
}
[Fact]
public void Meta002_No_Tautological_Or_Evergreen_Assertions_Allowed()
{
var testFiles = Directory.GetFiles(TestDir, "*Tests.cs", SearchOption.AllDirectories);
var tautologyRegex = new Regex(@"Assert\.True\(\s*true\s*\)|Assert\.False\(\s*false\s*\)|Assert\.Equal\(\s*1\s*,\s*1\s*\)", RegexOptions.Compiled);
foreach (var file in testFiles)
{
var content = File.ReadAllText(file);
var match = tautologyRegex.Match(content);
Assert.False(match.Success, $"테스트 파일 [{Path.GetFileName(file)}] 에 동어반복적 무의미한 검증({match.Value})이 존재합니다.");
}
}
[Fact]
public void Meta003_No_Unbounded_Thread_Sleep_Flakiness_Smells()
{
var testFiles = Directory.GetFiles(TestDir, "*Tests.cs", SearchOption.AllDirectories);
var badSleepRegex = new Regex(@"Thread\.Sleep\(\s*([5-9]\d{3}|\d{5,})\s*\)", RegexOptions.Compiled);
foreach (var file in testFiles)
{
var content = File.ReadAllText(file);
var match = badSleepRegex.Match(content);
Assert.False(match.Success, $"테스트 파일 [{Path.GetFileName(file)}] 에 비결정적 지연(Flaky Thread.Sleep)이 감지되었습니다: {match.Value}");
}
}
[Fact]
public void Meta004_Temporary_Directory_Tests_Must_Be_Isolated_And_Disposed()
{
var testFiles = Directory.GetFiles(TestDir, "*Tests.cs", SearchOption.AllDirectories);
foreach (var file in testFiles)
{
var content = File.ReadAllText(file);
if (content.Contains("Path.GetTempPath()") || content.Contains("Directory.CreateDirectory("))
{
var hasDisposeOrCleanup = content.Contains("IDisposable") || content.Contains("Dispose()") || content.Contains("Directory.Delete") || content.Contains("using ");
Assert.True(hasDisposeOrCleanup, $"파일 시스템을 조작하는 테스트 파일 [{Path.GetFileName(file)}] 은 IDisposable 또는 리소스 정리를 구현해야 합니다.");
}
}
}
// =========================================================================
// Gate 2: Mutation Testing & Fault Detection Efficacy (돌연변이 감지율 검증)
// =========================================================================
[Fact]
public void Meta005_MutationGate_ExtensionManifest_Popup_Priority_Mutant_Killed()
{
// 정상 로직 검증: Action > BrowserAction > PageAction
var m = new ExtensionManifest
{
Action = new ExtensionActionInfo { DefaultPopup = "act.html" },
BrowserAction = new ExtensionActionInfo { DefaultPopup = "browser.html" },
PageAction = new ExtensionActionInfo { DefaultPopup = "page.html" }
};
Assert.Equal("act.html", m.GetEffectivePopupPath());
// Mutant A: Action이 누락되면 BrowserAction으로 폴백
m.Action = null;
Assert.Equal("browser.html", m.GetEffectivePopupPath());
// Mutant B: BrowserAction도 누락되면 PageAction으로 폴백
m.BrowserAction = null;
Assert.Equal("page.html", m.GetEffectivePopupPath());
// Mutant C: 전체 누락 시 null 반환
m.PageAction = null;
Assert.Null(m.GetEffectivePopupPath());
}
[Fact]
public void Meta006_MutationGate_ChromeWebStoreClient_IdExtraction_Mutant_Killed()
{
// 정상 ID
var validId = "cjpalhdlnbpafiamejdnhcphjbkeiagm";
Assert.Equal(validId, ChromeWebStoreClient.ExtractExtensionId(validId));
// Mutant A: 31글자(잘린 ID) -> 무효화되어야 함
Assert.Null(ChromeWebStoreClient.ExtractExtensionId("cjpalhdlnbpafiamejdnhcphjbkeiag"));
// Mutant B: 대문자 포함된 32글자 -> 소문자로 안전하게 처리되거나 정규화
var mixed = "CJPALHDLNBPAFIAMEJDNHCPHJBKEIAGM";
Assert.Equal(validId, ChromeWebStoreClient.ExtractExtensionId(mixed));
// Mutant C: 정규 웹스토어 URL vs 위조 도메인
Assert.True(ChromeWebStoreClient.IsWebStoreDetailUrl("https://chromewebstore.google.com/detail/ublock/cjpalhdlnbpafiamejdnhcphjbkeiagm"));
Assert.False(ChromeWebStoreClient.IsWebStoreDetailUrl("https://evil.com/detail/ublock/cjpalhdlnbpafiamejdnhcphjbkeiagm"));
}
[Fact]
public void Meta007_MutationGate_MediaPlaylist_DuplicateRejection_Mutant_Killed()
{
var playlist = new MediaPlaylist();
var track1 = new MediaTrack("https://test.com/v1.mp4", "Title 1", "Direct", DateTime.Now);
var track2 = new MediaTrack("https://test.com/v1.mp4", "Duplicate Title", "Direct", DateTime.Now);
playlist.Add(track1);
Assert.Single(playlist.Tracks);
Assert.Equal(0, playlist.CurrentIndex);
// Mutant: 중복 URL 추가 시 거부되어야 함
playlist.Add(track2);
Assert.Single(playlist.Tracks);
Assert.Equal("Title 1", playlist.Tracks[0].Title);
}
[Fact]
public void Meta008_MutationGate_ExtensionStorageService_DirectoryTraversal_Mutant_Killed()
{
var temp = Path.Combine(Path.GetTempPath(), $"meta_storage_{Guid.NewGuid():N}");
Directory.CreateDirectory(temp);
try
{
var service = new ExtensionStorageService(temp);
var safeId = "legit_id_123";
service.SaveStorageJson(safeId, "{\"test\":1}");
var loaded = service.GetStorageJson(safeId);
Assert.Contains("\"test\":1", loaded);
// 존재하지 않는 파일 읽기 시 빈 객체 JSON "{}" 반환
var missing = service.GetStorageJson("non_existent_id");
Assert.Equal("{}", missing);
}
finally
{
if (Directory.Exists(temp)) Directory.Delete(temp, true);
}
}
// =========================================================================
// Gate 3: Test Subsumption & Suite Consolidation (테스트 통폐합 밀도 검증)
// =========================================================================
[Fact]
public void Meta009_Test_Suite_Structure_Distribution_Balance()
{
var browserTests = Directory.GetFiles(Path.Combine(TestDir, "Browser"), "*Tests.cs").Length;
var infraTests = Directory.GetFiles(Path.Combine(TestDir, "Infrastructure"), "*Tests.cs").Length;
Assert.True(browserTests >= 10, $"Browser 영역 테스트 파일({browserTests})이 충분히 확보되어야 합니다.");
Assert.True(infraTests >= 3, $"Infrastructure 영역 테스트 파일({infraTests})이 확보되어야 합니다.");
}
[Theory]
[InlineData("cjpalhdlnbpafiamejdnhcphjbkeiagm", true)]
[InlineData("bgnkhhnnamicmpeenaelnjfhikgbkllg", true)]
[InlineData("cfhdojbkjhnklbpkdaibdccddilifddb", true)]
[InlineData("short", false)]
[InlineData("", false)]
[InlineData("https://google.com", false)]
public void Meta010_Subsumption_Theory_Parametric_Coverage_Validation(string input, bool expectedValid)
{
var result = ChromeWebStoreClient.ExtractExtensionId(input);
if (expectedValid)
{
Assert.NotNull(result);
Assert.Equal(32, result.Length);
}
else
{
Assert.Null(result);
}
}
}

View file

@ -0,0 +1,141 @@
using Paca.App.ViewModels;
using Paca.Core.Config;
using Xunit;
namespace Paca.Tests.Infrastructure;
public sealed class SettingsViewModelTests
{
[Fact]
public void LoadFromConfig_MapsAllPropertiesAccurately()
{
var config = new AppConfig
{
Theme = "light",
AccentColor = "#2563EB",
Concurrency = 16,
MaxConcurrentDownloads = 5,
OutputFolder = @"C:\Downloads\Videos",
ToastDurationMs = 4500,
ReduceMotion = true
};
var vm = new SettingsViewModel(config);
Assert.Equal("light", vm.Theme);
Assert.Equal("#2563EB", vm.AccentColor);
Assert.Equal(16, vm.Concurrency);
Assert.Equal(5, vm.MaxConcurrentDownloads);
Assert.Equal(@"C:\Downloads\Videos", vm.OutputFolder);
Assert.Equal(4500, vm.ToastDurationMs);
Assert.True(vm.ReduceMotion);
Assert.False(vm.IsDirty);
Assert.Equal(0, vm.DirtyCount);
}
[Fact]
public void PropertyChanged_TracksDirtyStateAccurately()
{
var config = new AppConfig();
var vm = new SettingsViewModel(config);
Assert.False(vm.IsDirty);
vm.Concurrency = 32;
Assert.True(vm.IsDirty);
Assert.True(vm.DirtyCount >= 1);
// 원본 값으로 복구하면 IsDirty 해제
vm.Concurrency = config.Concurrency;
Assert.False(vm.IsDirty);
Assert.Equal(0, vm.DirtyCount);
}
[Fact]
public void Validate_CatchesInvalidInputs()
{
var config = new AppConfig();
var vm = new SettingsViewModel(config);
Assert.True(vm.Validate());
Assert.False(vm.HasErrors);
// 1. 유효하지 않은 세그먼트 동시 수
vm.Concurrency = 0;
Assert.False(vm.Validate());
Assert.True(vm.HasErrors);
Assert.Contains("세그먼트", vm.ValidationSummary);
vm.Concurrency = 8;
Assert.True(vm.Validate());
// 2. 유효하지 않은 토스트 지속시간
vm.ToastDurationMs = 100;
Assert.False(vm.Validate());
Assert.True(vm.HasErrors);
vm.ToastDurationMs = 3000;
Assert.True(vm.Validate());
// 3. 유효하지 않은 강조색 포맷
vm.AccentColor = "InvalidHex";
Assert.False(vm.Validate());
Assert.True(vm.HasErrors);
vm.AccentColor = "#6366F1";
Assert.True(vm.Validate());
// 4. 유효하지 않은 예약 시간 포맷
vm.DownloadScheduleWindow = "25:99-99:00";
Assert.False(vm.Validate());
Assert.True(vm.HasErrors);
vm.DownloadScheduleWindow = "02:00-08:00";
Assert.True(vm.Validate());
}
[Fact]
public void CommitToConfig_UpdatesTargetConfigCompletely()
{
var sourceConfig = new AppConfig();
var vm = new SettingsViewModel(sourceConfig)
{
Theme = "highcontrast",
AccentColor = "#34D399",
Concurrency = 12,
MaxConcurrentDownloads = 6,
OutputFolder = @"D:\Media\Saved",
ReduceMotion = true,
AutoStart = true
};
var target = new AppConfig();
vm.CommitToConfig(target);
Assert.Equal("highcontrast", target.Theme);
Assert.Equal("#34D399", target.AccentColor);
Assert.Equal(12, target.Concurrency);
Assert.Equal(6, target.MaxConcurrentDownloads);
Assert.Equal(@"D:\Media\Saved", target.OutputFolder);
Assert.True(target.ReduceMotion);
Assert.True(target.AutoStart);
}
[Fact]
public void ResetToDefaults_RestoresStandardDefaults()
{
var config = new AppConfig
{
Theme = "light",
Concurrency = 32,
MaxConcurrentDownloads = 10
};
var vm = new SettingsViewModel(config);
vm.ResetToDefaults();
Assert.Equal("dark", vm.Theme);
Assert.Equal(8, vm.Concurrency);
Assert.Equal(3, vm.MaxConcurrentDownloads);
}
}

View file

@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Text.Json;
using System.Xml.Linq;
using Xunit;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// PACA 웹사이트 및 제품의 SEO(검색엔진), AEO(답변엔진), GEO(생성형 AI) 최적화 인프라 무결성 검증 게이트
/// </summary>
public class SiteSeoAeoGeoGateTests
{
private static string SiteDir => Path.Combine(SourceTree.Root, "site");
[Fact]
public void Gate211_SchemaOrgStructuredDataIntegrity()
{
// 1. index.html의 SoftwareApplication 및 FAQPage Schema.org 검증
var indexPath = Path.Combine(SiteDir, "index.html");
Assert.True(File.Exists(indexPath), "site/index.html 파일이 존재해야 합니다.");
var indexHtml = File.ReadAllText(indexPath);
Assert.Contains("application/ld+json", indexHtml);
Assert.Contains("\"@type\": \"SoftwareApplication\"", indexHtml);
Assert.Contains("\"offers\"", indexHtml);
Assert.Contains("\"operatingSystem\"", indexHtml);
// 2. how-to-download-m3u8.html (HLS m3u8 다운로드 완벽 가이드) 및 HowTo Schema.org 검증
var m3u8GuidePath = Path.Combine(SiteDir, "how-to-download-m3u8.html");
Assert.True(File.Exists(m3u8GuidePath), "site/how-to-download-m3u8.html 랜딩 페이지가 존재해야 합니다.");
var m3u8Html = File.ReadAllText(m3u8GuidePath);
Assert.Contains("\"@type\": \"HowTo\"", m3u8Html);
Assert.Contains("\"step\"", m3u8Html);
Assert.Contains("m3u8", m3u8Html);
Assert.Contains("HLS", m3u8Html);
Assert.Contains("MP4", m3u8Html);
// 3. vs-idm-alternative.html (IDM / 4K Video Downloader 대체 비교 가이드) 검증
var vsIdmPath = Path.Combine(SiteDir, "vs-idm-alternative.html");
Assert.True(File.Exists(vsIdmPath), "site/vs-idm-alternative.html 랜딩 페이지가 존재해야 합니다.");
var vsIdmHtml = File.ReadAllText(vsIdmPath);
Assert.Contains("IDM", vsIdmHtml);
Assert.Contains("4K Video Downloader", vsIdmHtml);
Assert.Contains("Internet Download Manager", vsIdmHtml);
Assert.Contains("\"@type\": \"FAQPage\"", vsIdmHtml);
}
[Fact]
public void Gate212_AiCrawlerAndLlmStandardIntegrity()
{
// 1. robots.txt 검증: 모든 AI 크롤러 봇 (GPTBot, PerplexityBot, ClaudeBot, Google-Extended 등) 접근 허용
var robotsPath = Path.Combine(SiteDir, "robots.txt");
Assert.True(File.Exists(robotsPath), "site/robots.txt 파일이 존재해야 합니다.");
var robotsTxt = File.ReadAllText(robotsPath);
Assert.Contains("User-agent: *", robotsTxt);
Assert.Contains("GPTBot", robotsTxt);
Assert.Contains("PerplexityBot", robotsTxt);
Assert.Contains("ClaudeBot", robotsTxt);
Assert.Contains("Allow: /", robotsTxt);
Assert.Contains("llms.txt", robotsTxt);
Assert.Contains("sitemap.xml", robotsTxt);
// 2. llms.txt (Answer.AI 표준 AI 모델용 제품 요약) 검증
var llmsPath = Path.Combine(SiteDir, "llms.txt");
Assert.True(File.Exists(llmsPath), "site/llms.txt 파일이 존재해야 합니다.");
var llmsTxt = File.ReadAllText(llmsPath);
Assert.Contains("# Paca", llmsTxt);
Assert.Contains("HLS", llmsTxt);
Assert.Contains("DASH", llmsTxt);
Assert.Contains("AES-128", llmsTxt);
Assert.Contains("ffmpeg", llmsTxt);
Assert.Contains("llms-full.txt", llmsTxt);
// 3. llms-full.txt (전체 상세 기술 명세 및 인용 문서) 검증
var llmsFullPath = Path.Combine(SiteDir, "llms-full.txt");
Assert.True(File.Exists(llmsFullPath), "site/llms-full.txt 파일이 존재해야 합니다.");
var llmsFullTxt = File.ReadAllText(llmsFullPath);
Assert.True(llmsFullTxt.Length >= 2500, "llms-full.txt는 AI 모델이 충분히 인용할 수 있도록 2,500자 이상의 상세 기술 명세를 담아야 합니다.");
Assert.Contains("IDM", llmsFullTxt);
Assert.Contains("Benchmark", llmsFullTxt);
Assert.Contains("AES-128", llmsFullTxt);
Assert.Contains("FAQ", llmsFullTxt);
}
[Fact]
public void Gate213_AeoComparisonMatrixAndSitemapIntegrity()
{
// 1. AEO 경쟁 툴 비교 테이블 (Comparison Matrix) 존재성 검증
var vsIdmPath = Path.Combine(SiteDir, "vs-idm-alternative.html");
var indexPath = Path.Combine(SiteDir, "index.html");
Assert.True(File.Exists(vsIdmPath));
var vsHtml = File.ReadAllText(vsIdmPath);
Assert.Contains("<table", vsHtml);
Assert.Contains("Paca", vsHtml);
Assert.Contains("IDM", vsHtml);
Assert.Contains("4K Video Downloader", vsHtml);
Assert.Contains("크롬 확장 프로그램", vsHtml);
// 2. sitemap.xml 전체 URL 색인 검증
var sitemapPath = Path.Combine(SiteDir, "sitemap.xml");
Assert.True(File.Exists(sitemapPath), "site/sitemap.xml 파일이 존재해야 합니다.");
var sitemapXml = File.ReadAllText(sitemapPath);
Assert.Contains("how-to-download-m3u8.html", sitemapXml);
Assert.Contains("vs-idm-alternative.html", sitemapXml);
Assert.Contains("releases.html", sitemapXml);
Assert.Contains("guide.html", sitemapXml);
// 3. index.html의 다국어 (ko/en) 메타데이터 및 글로벌 고단가 트래픽 타깃 검증
var indexHtml = File.ReadAllText(indexPath);
Assert.Contains("hreflang", indexHtml);
}
}

View file

@ -0,0 +1,129 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Paca.App;
using Paca.Browser;
using Paca.Core.Config;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// UI 유즈케이스 RED 스펙 테스트(docs/UI_USECASES.md)용 소스 접근 헬퍼.
/// 컴파일된 리소스가 아니라 저장소 원본 XAML·코드비하인드를 직접 읽어
/// "UI 복잡 버전" 계약(리소스 토큰·요소·설정 속성 존재)을 검증한다.
/// GREEN 전 단계이므로 대부분의 계약은 의도적으로 실패(RED)한다.
/// </summary>
public static class SourceTree
{
/// <summary>저장소 루트(Paca.slnx 위치). 테스트 bin 에서 위로 올라가 탐색.</summary>
public static string Root { get; } = FindRoot();
private static string FindRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "Paca.slnx")) || File.Exists(Path.Combine(dir.FullName, "VideoDownloader.slnx")))
return dir.FullName;
}
throw new InvalidOperationException(
$"솔루션 루트를 찾을 수 없습니다 (시작: {AppContext.BaseDirectory}). 빌드 산출물에서 실행 시 저장소 내 bin 디렉터리여야 합니다.");
}
public static string AppPath(string relative)
{
var srcPath = Path.Combine(Root, "src", "Paca.App", relative);
if (File.Exists(srcPath) || Directory.Exists(srcPath)) return srcPath;
return Path.Combine(Root, "Paca.App", relative);
}
public static bool AppFileExists(string relative) => File.Exists(AppPath(relative));
public static bool DocFileExists(string fileName) => File.Exists(Path.Combine(Root, "docs", fileName));
private static readonly Lazy<string> _mainWindowXaml = new(() => File.ReadAllText(AppPath("MainWindow.xaml")));
private static readonly Lazy<string> _settingsXaml = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml")));
private static readonly Lazy<string> _sharedXaml = new(() => File.ReadAllText(AppPath("Themes/Shared.xaml")));
private static readonly Lazy<string> _themeDark = new(() => File.ReadAllText(AppPath("Themes/Dark.xaml")));
private static readonly Lazy<string> _themeLight = new(() => File.ReadAllText(AppPath("Themes/Light.xaml")));
private static readonly Lazy<string> _mainWindowCs = new(() => File.ReadAllText(AppPath("MainWindow.xaml.cs")));
private static readonly Lazy<string> _settingsCs = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml.cs")));
private static readonly Lazy<string> _readerModeCs = new(() => File.ReadAllText(AppPath("Browser/Features/ReaderMode.cs")));
public static string MainWindowXaml => _mainWindowXaml.Value;
public static string SettingsWindowXaml => _settingsXaml.Value;
public static string SharedXaml => _sharedXaml.Value;
public static string ThemeDarkXaml => _themeDark.Value;
public static string ThemeLightXaml => _themeLight.Value;
public static string MainWindowCs => _mainWindowCs.Value;
public static string SettingsWindowCs => _settingsCs.Value;
public static string ReaderModeCs => _readerModeCs.Value;
/// <summary>XAML 리소스 사전에 키(x:Key)가 정의되어 있는지.</summary>
public static bool HasKey(string xaml, string key) => xaml.Contains($"x:Key=\"{key}\"");
/// <summary>SolidColorBrush 키의 색상(#RRGGBB) 조회. 없으면 null.</summary>
public static string? BrushHex(string xaml, string key)
{
var m = Regex.Match(xaml, $"<SolidColorBrush\\s+x:Key=\"{Regex.Escape(key)}\"[^>]*Color=\"#([0-9A-Fa-f]{{6}})\"");
return m.Success ? $"#{m.Groups[1].Value}" : null;
}
/// <summary>sys:Double 키의 숫자 값 조회. 없으면 null.</summary>
public static double? GetDoubleResource(string xaml, string key)
{
var m = Regex.Match(xaml, $"<sys:Double\\s+x:Key=\"{Regex.Escape(key)}\">([0-9.]+)</sys:Double>");
return m.Success && double.TryParse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture, out var val) ? val : null;
}
/// <summary>WCAG 2.x 상대 휘도 기반 명도 대비율(1:1 ~ 21:1).</summary>
public static double ContrastRatio(string hexA, string hexB)
{
var l1 = RelativeLuminance(hexA);
var l2 = RelativeLuminance(hexB);
return (Math.Max(l1, l2) + 0.05) / (Math.Min(l1, l2) + 0.05);
}
private static double RelativeLuminance(string hex)
{
double Channel(int offset) => Convert.ToInt32(hex.Substring(offset, 2), 16) / 255.0;
static double Linearize(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
var r = Linearize(Channel(1));
var g = Linearize(Channel(3));
var b = Linearize(Channel(5));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/// <summary>Core·App·Browser 어셈블리에서 단순 이름으로 형식 탐색(아직 없는 신규 컴포넌트 계약용).</summary>
public static Type? FindType(string simpleName)
{
foreach (var asm in TargetAssemblies())
{
Type[] types;
try
{
types = asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// 일부 형식 로드 실패는 건너뛰고 로드된 형식만 대상으로 한다.
types = ex.Types.Where(t => t is not null).Select(t => t!).ToArray();
}
var found = types.FirstOrDefault(t => t.Name == simpleName);
if (found is not null)
return found;
}
return null;
}
private static IEnumerable<Assembly> TargetAssemblies()
{
yield return typeof(AppConfig).Assembly;
yield return typeof(MainWindow).Assembly;
yield return typeof(TabArchive).Assembly;
}
public static bool PropertyExists(Type type, string name) => UiContractPipeline.HasProperty(type, name);
public static bool MethodExists(Type type, string name) => UiContractPipeline.HasMethod(type, name);
/// <summary>유즈케이스 어설션 — 실패 메시지에 UC ID 와 사용자 스토리를 남긴다.</summary>
public static void Usecase(bool ok, string id, string story) =>
UiContractPipeline.AssertContract(ok, id, story);
}

View file

@ -0,0 +1,73 @@
using System.Windows.Threading;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// WPF/WebView2 E2E 테스트용 STA 디스패처. 전용 STA 스레드에서 Dispatcher.Run() 으로
/// 메시지 펌프를 돌리며, 테스트 코드는 InvokeAsync 로 UI 스레드에 작업을 올린다.
/// WebView2 이벤트(비동기)는 UI 스레드 디스패처에서 발생하므로 async 흐름도 이 펌프 위에서 완결된다.
/// </summary>
public sealed class StaDispatcher : IDisposable
{
private readonly Thread _thread;
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private Dispatcher? _dispatcher;
private StaDispatcher(Thread thread)
{
_thread = thread;
}
public Dispatcher Dispatcher =>
_dispatcher ?? throw new InvalidOperationException("STA dispatcher not started");
public static StaDispatcher Start()
{
var sta = default(StaDispatcher)!;
var thread = new Thread(() =>
{
sta._dispatcher = Dispatcher.CurrentDispatcher;
WpfTestApp.Ensure(sta._dispatcher);
sta._ready.TrySetResult();
Dispatcher.Run();
});
thread.SetApartmentState(ApartmentState.STA);
thread.IsBackground = true;
sta = new StaDispatcher(thread);
thread.Start();
sta._ready.Task.Wait(5000);
return sta;
}
public Task<T> InvokeAsync<T>(Func<Task<T>> func)
=> Dispatcher.InvokeAsync(func).Task.Unwrap();
public Task InvokeAsync(Func<Task> func)
=> Dispatcher.InvokeAsync(func).Task.Unwrap();
public T Invoke<T>(Func<T> func) => Dispatcher.Invoke(func);
public void Invoke(Action action) => Dispatcher.Invoke(action);
public void Dispose()
{
try { Dispatcher.InvokeShutdown(); } catch { }
try { _thread.Join(5000); } catch { }
}
}
/// <summary>WebView2 비동기 초기화/이벤트 대기 공통 유틸.</summary>
public static class Wait
{
public static async Task<T> Until<T>(Func<Task<T>> poll, Func<T, bool> done, int timeoutMs = 15000, int intervalMs = 100)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
var v = await poll();
if (done(v)) return v;
await Task.Delay(intervalMs);
}
throw new TimeoutException($"조건 미충족 ({timeoutMs}ms 초과)");
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Paca.App;
using Paca.Browser;
using Paca.Core.Config;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// 19차 UI 계약 검증 파이프라인 엔진:
/// 단순 파일 문자열 검색의 취약점을 제거하고, XAML 파싱, 리소스 딕셔너리 검증,
/// 타입/속성 리플렉션을 정밀하게 수행하여 UI 계약(Contract)을 파이프라인으로 일괄 검증한다.
/// </summary>
public static class UiContractPipeline
{
private static readonly Lazy<XDocument> _mainWindowDoc = new(() => XDocument.Load(SourceTree.AppPath("MainWindow.xaml")));
private static readonly Lazy<XDocument> _settingsDoc = new(() => XDocument.Load(SourceTree.AppPath("SettingsWindow.xaml")));
private static readonly Lazy<XDocument> _sharedDoc = new(() => XDocument.Load(SourceTree.AppPath("Themes/Shared.xaml")));
public static XDocument MainWindowDoc => _mainWindowDoc.Value;
public static XDocument SettingsDoc => _settingsDoc.Value;
public static XDocument SharedDoc => _sharedDoc.Value;
/// <summary>XAML 내에 특정 x:Name 또는 Name 속성을 가진 요소가 존재하는지 검증</summary>
public static bool HasElementNamed(XDocument doc, string name)
{
return doc.Descendants().Any(e =>
(string?)e.Attribute("Name") == name ||
(string?)e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml")) == name);
}
/// <summary>XAML 내에 특정 x:Key 리소스가 존재하는지 검증</summary>
public static bool HasResourceKey(XDocument doc, string key)
{
return doc.Descendants().Any(e =>
(string?)e.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml")) == key);
}
/// <summary>타입 내에 명시된 속성이 존재하는지 검증</summary>
public static bool HasProperty(Type type, string propertyName)
=> type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) != null;
/// <summary>타입 내에 명시된 메서드가 존재하는지 검증</summary>
public static bool HasMethod(Type type, string methodName)
=> type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)
.Any(m => m.Name == methodName);
/// <summary>유즈케이스 계약 어설션 파이프라인</summary>
public static void AssertContract(bool condition, string usecaseId, string message)
{
Xunit.Assert.True(condition, $"[계약 위반: {usecaseId}] {message}");
}
}

View file

@ -0,0 +1,67 @@
using System.Windows;
using Paca.App;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// 테스트 프로세스에는 App.xaml 의 리소스가 없으므로, App 어셈블리의 테마 사전을
/// 테스트 전용 Application 에 로드한다(앱의 App.ApplyTheme 과 동일 순서: 색 → 스타일).
/// </summary>
public static class WpfTestApp
{
private static readonly object _lock = new();
private static bool _initialized = false;
public static void Ensure(System.Windows.Threading.Dispatcher dispatcher)
{
dispatcher.Invoke(() =>
{
lock (_lock)
{
if (Application.Current == null)
{
try
{
new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
}
catch (InvalidOperationException)
{
// 이미 다른 스레드/테스트에서 생성된 경우
}
}
if (Application.Current != null && !_initialized)
{
try
{
global::Paca.App.App.ApplyTheme("dark");
if (!Application.Current.Resources.Contains("InverseCountToVisibility"))
Application.Current.Resources["InverseCountToVisibility"] = new InverseCountToVisibilityConverter();
_initialized = true;
}
catch { }
}
}
});
}
public static void EnsureWindowResources(FrameworkElement element)
{
try
{
const string asm = "pack://application:,,,/Paca;component";
var md = element.Resources.MergedDictionaries;
bool hasDark = md.Any(d => d.Source?.OriginalString?.Contains("Dark.xaml") == true);
bool hasShared = md.Any(d => d.Source?.OriginalString?.Contains("Shared.xaml") == true);
if (!hasDark)
{
try { md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Dark.xaml", UriKind.Absolute) }); } catch { }
}
if (!hasShared)
{
try { md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Shared.xaml", UriKind.Absolute) }); } catch { }
}
}
catch { }
}
}