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,606 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Windows;
using Paca.App;
using Paca.Browser;
using Paca.Browser.Extensions;
using Paca.Browser.Tabs;
using Paca.Core;
using Paca.Core.Hls;
using Paca.Server;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
[Collection("E2E")]
public sealed class ComprehensiveE2EScenarioTests : IDisposable
{
private readonly string _testRoot;
private readonly string _dataDir;
private readonly string _libDir;
public ComprehensiveE2EScenarioTests()
{
_testRoot = Path.Combine(Path.GetTempPath(), "VD_ComprehensiveE2E_" + Guid.NewGuid().ToString("N"));
_dataDir = Path.Combine(_testRoot, "data");
_libDir = Path.Combine(_testRoot, "library");
Directory.CreateDirectory(_dataDir);
Directory.CreateDirectory(_libDir);
Environment.SetEnvironmentVariable("VD_DATA_DIR", _dataDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testRoot))
Directory.Delete(_testRoot, true);
}
catch { }
}
/// <summary>
/// 시나리오 1: 크롬 확장 프로그램 대규모 병렬 설치, 악의적/손상 패키지 방어,
/// 10,000회 고부하 검색 스트레스 및 수명주기 무결성 E2E
/// </summary>
[Fact]
public async Task Scenario1_ChromeExtensions_MassiveLifecycle_And_Resilience_E2E()
{
var extStorage = Path.Combine(_dataDir, "extensions");
var host = new TestExtensionHost();
var manager = new ExtensionManager(extStorage, host);
// 1. 20개의 가상 크롬 확장 패키지 생성 및 병렬 설치 스트레스
const int extCount = 20;
var installTasks = Enumerable.Range(1, extCount).Select(async i =>
{
var extId = $"test_extension_{i:D4}_abcdefghijklmnop";
var extName = $"Super Extension {i}";
var extVer = $"1.{i}.0";
var desc = $"Heavy load test extension number {i}";
var crxBytes = CreateSampleCrx3Bytes(extName, extVer, desc,
("manifest.json", CreateManifestJson(extName, extVer, desc)),
("content.js", Encoding.UTF8.GetBytes($"console.log('ext_{i}');")),
("icons/icon128.png", new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02, 0x03 }));
using var ms = new MemoryStream(crxBytes);
return await manager.InstallFromPackageStreamAsync(ms, explicitId: extId);
});
var installedItems = await Task.WhenAll(installTasks);
Assert.Equal(extCount, installedItems.Length);
Assert.Equal(extCount, manager.Extensions.Count);
// 2. 활성화/비활성화 100회 토글 스트레스
var rng = new Random(42);
for (int i = 0; i < 100; i++)
{
var target = manager.Extensions[rng.Next(manager.Extensions.Count)];
var newState = !target.IsEnabled;
await manager.SetEnabledAsync(target.Id, newState);
Assert.Equal(newState, target.IsEnabled);
}
// 3. 악의적/손상된 패키지 주입 방어 (Resilience)
// 3-a) 잘못된 Magic Byte
using (var badMagic = new MemoryStream(new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 }))
{
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(badMagic));
}
// 3-b) Zip Slip 공격 경로 포함 패키지
var maliciousZip = CreateMaliciousZipSlipBytes();
using (var maliciousStream = new MemoryStream(maliciousZip))
{
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(maliciousStream));
}
// 기존 20개 확장이 손상되지 않고 유지되는지 확인
Assert.Equal(extCount, manager.Extensions.Count);
// 4. 마켓플레이스 10,000회 동시 고속 검색 및 카테고리 필터링 스트레스
var searchQueries = new[] { "adblock", "youtube", "dark", "volume", "speed", "번역", "비밀번호", "cjpalhdlnbpafiamejdnhcphjbkeiagm", "none_existing_xyz" };
var installedIds = manager.Extensions.Select(x => x.Id).ToList();
var searchTasks = Enumerable.Range(0, 10_000).Select(i => Task.Run(() =>
{
var q = searchQueries[i % searchQueries.Length];
var results = ExtensionMarketplace.Search(q, installedIds: installedIds);
Assert.NotNull(results);
}));
await Task.WhenAll(searchTasks);
// 5. 일괄 삭제 및 스토리지 무결성 확인
foreach (var item in installedItems.ToList())
{
await manager.UninstallAsync(item.Id);
}
Assert.Empty(manager.Extensions);
var subDirs = Directory.GetDirectories(extStorage);
Assert.Empty(subDirs);
}
/// <summary>
/// 시나리오 2: HLS 다중 세그먼트 가상 스트리밍 서버 구축, 결함 주입(404, 500, Retry) 및 무결 복구 E2E
/// </summary>
[Fact]
public async Task Scenario2_HlsDownloader_FaultInjection_And_Recovery_E2E()
{
// 1. 가상 HLS 미디어 HTTP 서버 구동 (HttpListener)
const int segmentCount = 20;
var port = GetFreePort();
using var server = new SimpleTestHlsServer(port, segmentCount);
server.Start();
var playlistUrl = $"http://127.0.0.1:{port}/playlist.m3u8";
var outputDir = Path.Combine(_testRoot, "hls_output");
Directory.CreateDirectory(outputDir);
var outputPath = Path.Combine(outputDir, "output_video.ts");
// 2. 다운로드 엔진 설정 (FakeMediaMuxer 주입하여 순수 파이프라인 무결성 검증)
using var http = new HttpClient();
var config = new Paca.Core.Config.AppConfig { Concurrency = 4 };
var muxer = new FakeMediaMuxer();
var downloader = new HlsDownloader(http, config, muxer);
var progressUpdates = new List<double>();
var progress = new Progress<Paca.Core.Models.DownloadProgress>(p =>
{
progressUpdates.Add(p.Percent);
});
// 3. 결함 주입 환경에서 다운로드 실행
server.InjectFaults = true; // 일부 세그먼트 첫 요청 시 500 반환 후 재시도 시 200 반환
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var outputBase = Path.Combine(outputDir, "output_video");
var resultPath = await downloader.DownloadAsync(new Uri(playlistUrl), outputBase, progress, cts.Token);
// 4. 검증: 최종 파일이 온전히 생성되었고 세그먼트 데이터가 모두 병합되었는지 확인
Assert.True(File.Exists(resultPath), "최종 다운로드 파일이 존재해야 합니다.");
var fileInfo = new FileInfo(resultPath);
Assert.True(fileInfo.Length >= segmentCount * 1000, "전체 세그먼트 데이터가 결함 복구 후 온전히 병합되어야 합니다.");
Assert.NotEmpty(progressUpdates);
Assert.True(progressUpdates.Max() >= 99.0, "진행률 최대치가 100%에 도달해야 합니다.");
}
/// <summary>
/// 시나리오 3: 원격 Kestrel 서버 기동, 30개 동시 클라이언트 병렬 페어링,
/// 큐 CRUD 제어, 라이브러리 조회 및 HTTP 206 Partial Content 스트리밍 고부하 E2E
/// </summary>
[Fact]
public async Task Scenario3_RemoteServer_HighConcurrency_MobilePairing_And_Streaming_E2E()
{
// 1. 가짜 미디어 라이브러리 파일 생성 (2MB)
var sampleMedia = Path.Combine(_libDir, "test_movie.mp4");
var testBytes = new byte[2 * 1024 * 1024];
new Random(1234).NextBytes(testBytes);
File.WriteAllBytes(sampleMedia, testBytes);
var queue = new TestScenarioQueueAdapter();
const string authToken = "super-secret-e2e-token-2026";
await using var server = await ServerHost.StartAsync(new ServerOptions
{
Port = 0,
Token = authToken,
LibraryFolder = _libDir,
Queue = queue
});
// 2. 30개의 동시 가상 클라이언트 병렬 고부하 요청
const int clientCount = 30;
var clientTasks = Enumerable.Range(1, clientCount).Select(async clientId =>
{
using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
// A) 큐 추가 (POST /api/queue)
var addResp = await http.PostAsJsonAsync("/api/queue", new { url = $"https://youtube.com/watch?v=client_{clientId}", title = $"Video from Client {clientId}" });
Assert.True(addResp.IsSuccessStatusCode);
var queueJson = await addResp.Content.ReadFromJsonAsync<JsonElement>();
var queueId = queueJson.GetProperty("id").GetString()!;
// B) 큐 일시정지 & 재개 (POST /api/queue/{id}/pause, resume)
var pauseResp = await http.PostAsync($"/api/queue/{queueId}/pause", null);
Assert.True(pauseResp.IsSuccessStatusCode);
var resumeResp = await http.PostAsync($"/api/queue/{queueId}/resume", null);
Assert.True(resumeResp.IsSuccessStatusCode);
// C) 라이브러리 목록 조회 (GET /api/library)
var libResp = await http.GetAsync("/api/library");
Assert.True(libResp.IsSuccessStatusCode);
var libItems = await libResp.Content.ReadFromJsonAsync<List<LibraryItemDto>>();
Assert.NotNull(libItems);
Assert.NotEmpty(libItems);
var mediaId = libItems[0].Id;
// D) HTTP 206 Partial Content 미디어 범위 스트리밍 (GET /stream/{id})
var rangeRequest = new HttpRequestMessage(HttpMethod.Get, $"/stream/{mediaId}");
rangeRequest.Headers.Range = new RangeHeaderValue(1000, 5000); // 4001 bytes
var streamResp = await http.SendAsync(rangeRequest);
Assert.Equal(HttpStatusCode.PartialContent, streamResp.StatusCode);
var streamBytes = await streamResp.Content.ReadAsByteArrayAsync();
Assert.Equal(4001, streamBytes.Length);
// 원본 바이트와 범위 데이터 일치 검증
var expectedSlice = testBytes.AsSpan(1000, 4001).ToArray();
Assert.Equal(expectedSlice, streamBytes);
// E) 큐 취소 (POST /api/queue/{id}/cancel)
var cancelResp = await http.PostAsync($"/api/queue/{queueId}/cancel", null);
Assert.True(cancelResp.IsSuccessStatusCode);
});
await Task.WhenAll(clientTasks);
Assert.Equal(clientCount * 4, queue.TotalOperations);
}
/// <summary>
/// 시나리오 4: 브라우저 대규모 탭 생성, 닫은 탭 복원(LIFO), 탭 아카이브 500개 누적 및 세션 영속 복원 E2E
/// </summary>
[Fact]
public void Scenario4_BrowserTabSystem_MassiveTabs_Archive_And_SessionRecovery_E2E()
{
var tabManager = new TabManager(() => new TabItem());
// 1. 50개 탭 생성 및 속성 설정
for (int i = 1; i <= 50; i++)
{
var tab = tabManager.NewTab(isPrivate: i % 5 == 0);
tab.Url = $"https://example.com/page/{i}";
tab.Title = $"Example Page {i}";
}
Assert.Equal(50, tabManager.Tabs.Count);
// 2. 20개 탭 닫기 및 닫은 탭 LIFO 복원 스택 검증
var closedUrls = new List<string>();
for (int i = 0; i < 20; i++)
{
var target = tabManager.Tabs.Last();
closedUrls.Add(target.Url);
tabManager.CloseTab(target);
}
Assert.Equal(30, tabManager.Tabs.Count);
// 10개 탭 복원
for (int i = 0; i < 10; i++)
{
var reopened = tabManager.ReopenClosedTab();
Assert.NotNull(reopened);
Assert.Equal(closedUrls[closedUrls.Count - 1 - i], reopened.Url);
}
Assert.Equal(40, tabManager.Tabs.Count);
// 3. 세션 저장(session.json) 및 완전 복원 무결성 검증
var sessionPath = Path.Combine(_dataDir, "session.json");
var snapshot = new SessionSnapshot(
tabManager.Tabs.Select(t => t.Url).ToList(),
0,
tabManager.Tabs.Select(t => t.IsPinned).ToList()
);
TabSessionStore.Save(sessionPath, snapshot);
var restoredSnap = TabSessionStore.Load(sessionPath);
Assert.NotNull(restoredSnap);
Assert.Equal(tabManager.Tabs.Count, restoredSnap.Urls.Count);
for (int i = 0; i < tabManager.Tabs.Count; i++)
{
Assert.Equal(tabManager.Tabs[i].Url, restoredSnap.Urls[i]);
Assert.Equal(tabManager.Tabs[i].IsPinned, restoredSnap.Pinned[i]);
}
// 4. TabArchive 500개 누적 및 검색 속도/상한 검증
var archivePath = Path.Combine(_dataDir, "archive.json");
var archiveStore = new TabArchive(archivePath);
for (int i = 1; i <= 600; i++)
{
archiveStore.Add($"Archived Item {i}", $"https://site.org/item/{i}");
}
var searchHits = archiveStore.Search("Item 250");
Assert.NotEmpty(searchHits);
Assert.Contains(searchHits, x => x.Title.Contains("Item 250"));
}
/// <summary>
/// 시나리오 5: 테마 핫 스위칭, ExtensionManagerWindow UI 상태 및 마켓플레이스 연동 E2E (STA)
/// </summary>
[Fact]
public void Scenario5_DesktopApp_ThemeSwitching_And_ExtensionUi_E2E()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
sta.Invoke(() =>
{
// 1. 다크 테마 ↔ 라이트 테마 10회 연속 핫 스위칭
for (int i = 0; i < 10; i++)
{
Paca.App.App.ApplyTheme(i % 2 == 0 ? "dark" : "light");
Assert.NotNull(Application.Current.Resources["CardBg"]);
Assert.NotNull(Application.Current.Resources["TextPrimary"]);
Assert.NotNull(Application.Current.Resources["AccentBrush"]);
}
// 2. ExtensionManagerWindow 생성 및 데이터 바인딩 무결성 검증
var host = new TestExtensionHost();
var manager = new ExtensionManager(Path.Combine(_dataDir, "ui_exts"), host);
var window = new ExtensionManagerWindow(manager);
Assert.NotNull(window);
Assert.NotNull(window.ExtensionList);
Assert.NotNull(window.StoreList);
// 초기 상태: 설치된 확장 0개, EmptyState 표시
Assert.Equal(Visibility.Visible, window.EmptyState.Visibility);
Assert.Contains("설치된 확장 (0)", window.TabInstalledText.Text);
// 개발자 모드 토글
window.DevModeCheck.IsChecked = true;
window.DevModeCheck.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ToggleButton.ClickEvent));
Assert.Equal(Visibility.Visible, window.DevModeBar.Visibility);
// 스토어 탭 전환
window.TabStoreRadio.IsChecked = true;
window.TabStoreRadio.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.RadioButton.CheckedEvent));
Assert.Equal(Visibility.Visible, window.StoreView.Visibility);
Assert.Equal(Visibility.Collapsed, window.InstalledView.Visibility);
// 스토어 검색창 입력
window.StoreSearchBox.Text = "uBlock";
var storeItems = window.StoreList.ItemsSource as IEnumerable<MarketplaceItem>;
Assert.NotNull(storeItems);
Assert.Contains(storeItems, x => x.Name.Contains("uBlock Origin"));
});
}
#region Helper Classes and Methods
private static int GetFreePort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static byte[] CreateSampleCrx3Bytes(string name, string version, string description, params (string Path, byte[] Content)[] files)
{
using var zipMs = new MemoryStream();
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (path, content) in files)
{
var entry = archive.CreateEntry(path);
using var s = entry.Open();
s.Write(content, 0, content.Length);
}
}
var zipBytes = zipMs.ToArray();
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 }); // "Cr24"
bw.Write((uint)3); // CRX3
bw.Write((uint)16); // Header length
bw.Write(new byte[16]); // Header
bw.Write(zipBytes);
bw.Flush();
return ms.ToArray();
}
private static byte[] CreateManifestJson(string name, string version, string description)
{
var dict = new Dictionary<string, object>
{
["manifest_version"] = 3,
["name"] = name,
["version"] = version,
["description"] = description
};
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dict));
}
private static byte[] CreateMaliciousZipSlipBytes()
{
using var zipMs = new MemoryStream();
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
{
var attackEntry = archive.CreateEntry("../../../../../etc/passwd");
using var s = attackEntry.Open();
s.Write(Encoding.UTF8.GetBytes("attack payload"));
}
var zipBytes = zipMs.ToArray();
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 });
bw.Write((uint)3);
bw.Write((uint)8);
bw.Write(new byte[8]);
bw.Write(zipBytes);
bw.Flush();
return ms.ToArray();
}
private sealed class TestExtensionHost : IBrowserExtensionHost
{
public Task<string?> AddExtensionAsync(string folderPath, string extensionId) => Task.FromResult<string?>(extensionId);
public Task<bool> RemoveExtensionAsync(string extensionId) => Task.FromResult(true);
public Task<bool> SetExtensionEnabledAsync(string extensionId, bool enabled) => Task.FromResult(true);
}
private sealed class TestScenarioQueueAdapter : IQueueAdapter
{
private readonly List<QueueItemDto> _items = new();
public int TotalOperations { get; private set; }
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(_items.ToList());
public Task<string> AddAsync(string url, string? title)
{
lock (_items)
{
var id = Guid.NewGuid().ToString("N");
_items.Add(new QueueItemDto(id, title ?? url, url, "다운로드중", 50));
TotalOperations++;
return Task.FromResult(id);
}
}
public Task PauseAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
public Task ResumeAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
public Task CancelAsync(string id) { lock (_items) { _items.RemoveAll(x => x.Id == id); TotalOperations++; } return Task.CompletedTask; }
public event Action? Changed { add { } remove { } }
}
private sealed class DirectPassthroughProcessRunner : Paca.Core.Platform.IProcessRunner
{
public Task<Paca.Core.Platform.ProcessResult> RunAsync(
Paca.Core.Platform.ProcessSpec spec,
Action<string, string>? onLine = null,
CancellationToken ct = default)
{
return Task.FromResult(new Paca.Core.Platform.ProcessResult(0, "OK", ""));
}
}
private sealed class FakeMediaMuxer : Paca.Core.Platform.IMediaMuxer
{
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
File.Copy(input, output, true);
return Task.CompletedTask;
}
public Task DownloadDashAsync(
string ffmpeg, Uri mpdUrl, string output, double durationSec,
HttpClient http, IProgress<Paca.Core.Models.DownloadProgress>? progress, CancellationToken ct)
{
return Task.CompletedTask;
}
public string? ResolveFfmpeg(Paca.Core.Config.AppConfig config) => "fake_ffmpeg.exe";
}
private sealed class SimpleTestHlsServer : IDisposable
{
private readonly HttpListener _listener;
private readonly int _segmentCount;
private readonly HashSet<int> _failedOnce = new();
private bool _disposed;
public bool InjectFaults { get; set; }
public SimpleTestHlsServer(int port, int segmentCount)
{
_segmentCount = segmentCount;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
}
public void Start()
{
_listener.Start();
Task.Run(ListenLoop);
}
private async Task ListenLoop()
{
while (!_disposed && _listener.IsListening)
{
try
{
var ctx = await _listener.GetContextAsync();
ProcessRequest(ctx);
}
catch { break; }
}
}
private void ProcessRequest(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath ?? "";
if (path == "/playlist.m3u8")
{
var sb = new StringBuilder();
sb.AppendLine("#EXTM3U");
sb.AppendLine("#EXT-X-VERSION:3");
sb.AppendLine("#EXT-X-TARGETDURATION:2");
for (int i = 0; i < _segmentCount; i++)
{
sb.AppendLine("#EXTINF:2.0,");
sb.AppendLine($"segment_{i}.ts");
}
sb.AppendLine("#EXT-X-ENDLIST");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
ctx.Response.ContentType = "application/vnd.apple.mpegurl";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.Close();
return;
}
if (path.StartsWith("/segment_") && path.EndsWith(".ts"))
{
var segNumStr = path.Replace("/segment_", "").Replace(".ts", "");
if (int.TryParse(segNumStr, out var segNum))
{
if (InjectFaults && segNum % 4 == 0 && _failedOnce.Add(segNum))
{
// 결함 주입: 500 에러 반환 후 다음 재시도에서 정상 제공
ctx.Response.StatusCode = 500;
ctx.Response.Close();
return;
}
// 정상 세그먼트 데이터 반환 (1KB)
var dummyBytes = new byte[1024];
Array.Fill(dummyBytes, (byte)(segNum & 0xFF));
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "video/mp2t";
ctx.Response.ContentLength64 = dummyBytes.Length;
ctx.Response.OutputStream.Write(dummyBytes, 0, dummyBytes.Length);
ctx.Response.Close();
return;
}
}
ctx.Response.StatusCode = 404;
ctx.Response.Close();
}
catch { }
}
public void Dispose()
{
_disposed = true;
try { _listener.Stop(); _listener.Close(); } catch { }
}
}
#endregion
}

View file

@ -0,0 +1,352 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Paca.Core.Config;
using Paca.Core.Dash;
using Paca.Core.Hls;
using Paca.Core.Models;
using Paca.Core.Platform;
using Paca.Server;
using Paca.Server.Gateway;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// Phase 3 심화 하드닝 게이트 (TDD 선행 RED):
/// - Gate 301: 실시간 게이트웨이 SSE 지속 스트림 및 브로드캐스트 무결성
/// - Gate 302: Passkey 챌린지 DoS 방어 메모리 상한(Bounded Capacity)
/// - Gate 303: 원격 읽기 목록(Reading List) 동시 컬렉션 수정 방어 스냅샷
/// - Gate 304: DASH 다운로더 MPD 매니페스트 취득 일시 장애(503/Timeout) 재시도
/// - Gate 305: HLS 다운로더 AES-128 암호화 키 취득 일시 장애 재시도
/// </summary>
public class CoreAndServerHardeningPhase3Tests : IDisposable
{
private readonly string _tempDir;
public CoreAndServerHardeningPhase3Tests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "paca_phase3_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); }
catch { }
}
// =========================================================================
// Gate 301: 실시간 게이트웨이 SSE 지속 스트림 및 이벤트 브로드캐스트
// =========================================================================
[Fact]
public async Task Gate301_Server_SseStream_KeepsConnectionAlive_And_BroadcastsEvents()
{
var options = new ServerOptions
{
Port = 0,
Token = "sse-token-test",
LibraryFolder = _tempDir,
Queue = new FakeQueue()
};
await using var server = await ServerHost.StartAsync(options);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sse-token-test");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var streamReq = new HttpRequestMessage(HttpMethod.Get, "/api/gateway/events");
var response = await client.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead, cts.Token);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync(cts.Token));
// 1) 최초 연결 이벤트 수신 확인
var firstLine = await streamReader.ReadLineAsync(cts.Token);
Assert.NotNull(firstLine);
Assert.StartsWith("data: ", firstLine);
Assert.Contains("Gateway Connected", firstLine);
// 2) 서버에서 신규 이벤트 브로드캐스트 트리거
var testEvent = new GatewayPushEvent(
Guid.NewGuid().ToString("N"),
GatewayEventType.DownloadStarted,
"Download Started E2E",
"{\"url\":\"https://example.com/test.mp4\"}",
DateTimeOffset.UtcNow
);
server.BroadcastGatewayEvent(testEvent);
// 3) SSE 스트림으로 브로드캐스트된 이벤트가 도달하는지 수신 검증
string? receivedLine = null;
while (!streamReader.EndOfStream && (receivedLine == null || !receivedLine.Contains("Download Started E2E")))
{
var line = await streamReader.ReadLineAsync(cts.Token);
if (line != null && line.Contains("Download Started E2E"))
{
receivedLine = line;
break;
}
}
Assert.NotNull(receivedLine);
Assert.Contains("Download Started E2E", receivedLine);
}
// =========================================================================
// Gate 302: Passkey 챌린지 DoS 방어 메모리 상한(Bounded Capacity)
// =========================================================================
[Fact]
public async Task Gate302_Server_ActiveChallenges_BoundedCapacity_Prevents_OOM()
{
var options = new ServerOptions
{
Port = 0,
Token = "passkey-token-test",
LibraryFolder = _tempDir,
Queue = new FakeQueue()
};
await using var server = await ServerHost.StartAsync(options);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
// 1,050개의 챌린지를 연속 발급 요청
for (int i = 0; i < 1050; i++)
{
var res = await client.GetAsync("/api/auth/passkey/challenge");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
// 서버 내부의 ActiveChallengeCount 가 최대 상한(예: 1000개) 이하로 유지되는지 검증
Assert.True(server.ActiveChallengeCount <= 1000,
$"ActiveChallengeCount({server.ActiveChallengeCount})는 DoS 방어를 위해 1000개 이하로 제한되어야 합니다.");
}
// =========================================================================
// Gate 303: 원격 읽기 목록(Reading List) 동시 컬렉션 수정 방어 스냅샷
// =========================================================================
[Fact]
public async Task Gate303_Server_ReadingList_ConcurrentModificationDefense_Audit()
{
var mutatingList = new List<Paca.Mobile.Core.ReadingListItemDto>
{
new("1", "Item 1", "https://example.com/1", DateTime.UtcNow, false)
};
var options = new ServerOptions
{
Port = 0,
Token = "reading-token",
LibraryFolder = _tempDir,
Queue = new FakeQueue(),
ReadingListProvider = () => mutatingList
};
await using var server = await ServerHost.StartAsync(options);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "reading-token");
// 백그라운드에서 컬렉션을 무작위로 계속 수정하면서 동시 웹 요청 수행
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var mutatorTask = Task.Run(async () =>
{
int counter = 2;
while (!cts.IsCancellationRequested)
{
lock (mutatingList)
{
mutatingList.Add(new(counter.ToString(), $"Item {counter}", $"https://example.com/{counter}", DateTime.UtcNow, false));
if (mutatingList.Count > 10) mutatingList.RemoveAt(0);
}
counter++;
await Task.Yield();
}
});
// 20개 동시 요청
var clientTasks = Enumerable.Range(0, 20).Select(async _ =>
{
var res = await client.GetAsync("/api/reading-list");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
});
await Task.WhenAll(clientTasks);
cts.Cancel();
await mutatorTask;
}
// =========================================================================
// Gate 304: DASH 다운로더 MPD 매니페스트 취득 일시 장애(503/Timeout) 재시도
// =========================================================================
[Fact]
public async Task Gate304_DashDownloader_Retries_Transient_Mpd_NetworkFailure()
{
int attempt = 0;
var mpdXml = @"<MPD xmlns=""urn:mpeg:dash:schema:mpd:2011"" mediaPresentationDuration=""PT5S"">
<Period>
<AdaptationSet mimeType=""video/mp4"">
<Representation id=""v1"" bandwidth=""1000000"">
<BaseURL>segment.mp4</BaseURL>
</Representation>
</AdaptationSet>
</Period>
</MPD>";
var handler = new DelegateMockHttp(req =>
{
if (req.RequestUri!.ToString().EndsWith(".mpd"))
{
attempt++;
if (attempt == 1)
{
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); // 1차 503 일시 장애
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(mpdXml, Encoding.UTF8, "application/dash+xml")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[100])
};
});
using var http = new HttpClient(handler);
var config = new AppConfig();
var fakeMuxer = new DashFakeMuxer();
var downloader = new DashDownloader(http, config, fakeMuxer);
var outputBase = Path.Combine(_tempDir, "dash_test");
var result = await downloader.DownloadAsync(new Uri("https://paca.test/manifest.mpd"), outputBase);
Assert.True(File.Exists(result));
Assert.True(attempt >= 2, "MPD 조회 시 일시 오류가 발생하면 최소 1회 이상 재시도되어야 합니다.");
}
// =========================================================================
// Gate 305: HLS 다운로더 AES-128 암호화 키 취득 일시 장애 재시도
// =========================================================================
[Fact]
public async Task Gate305_HlsDownloader_Retries_AesKey_Transient_Errors()
{
int keyAttempts = 0;
var m3u8 = @"#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-KEY:METHOD=AES-128,URI=""https://paca.test/enc.key"",IV=0x00000000000000000000000000000001
#EXTINF:10.0,
segment1.ts
#EXT-X-ENDLIST";
var keyBytes = new byte[16];
new Random(42).NextBytes(keyBytes);
var handler = new DelegateMockHttp(req =>
{
var uri = req.RequestUri!.ToString();
if (uri.EndsWith(".m3u8"))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(m3u8, Encoding.UTF8, "application/vnd.apple.mpegurl")
};
}
if (uri.EndsWith("enc.key"))
{
keyAttempts++;
if (keyAttempts == 1)
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError); // 1차 500 일시 장애
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(keyBytes)
};
}
// 세그먼트 (AES 암호화된 TS)
var plain = new byte[160];
using var aes = System.Security.Cryptography.Aes.Create();
aes.Key = keyBytes;
aes.IV = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
using var enc = aes.CreateEncryptor();
var encrypted = enc.TransformFinalBlock(plain, 0, plain.Length);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(encrypted)
};
});
using var http = new HttpClient(handler);
var config = new AppConfig();
var fakeMuxer = new HlsFakeMuxer();
var downloader = new HlsDownloader(http, config, fakeMuxer);
var outputBase = Path.Combine(_tempDir, "hls_aes_test");
var result = await downloader.DownloadAsync(new Uri("https://paca.test/video.m3u8"), outputBase);
Assert.True(File.Exists(result));
Assert.True(keyAttempts >= 2, "AES 키 취득 실패 시 재시도를 수행하여 복호화를 완수해야 합니다.");
}
private sealed class DelegateMockHttp : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public DelegateMockHttp(Func<HttpRequestMessage, HttpResponseMessage> handler) => _handler = handler;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
private sealed class FakeQueue : IQueueAdapter
{
public event Action? Changed { add { } remove { } }
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
public Task<string> AddAsync(string url, string? title = null) => Task.FromResult(Guid.NewGuid().ToString());
public Task PauseAsync(string id) => Task.CompletedTask;
public Task ResumeAsync(string id) => Task.CompletedTask;
public Task CancelAsync(string id) => Task.CompletedTask;
}
private sealed class DashFakeMuxer : IMediaMuxer
{
public string? ResolveFfmpeg(AppConfig config) => "fake_ffmpeg";
public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
File.WriteAllBytes(output, new byte[200]);
return Task.CompletedTask;
}
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct = default) => Task.CompletedTask;
public Task MuxVideoAudioAsync(string ffmpeg, string video, string audio, string output, CancellationToken ct = default) => Task.CompletedTask;
public Task<bool> IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true);
public Task ConcatAsync(string ffmpeg, IReadOnlyList<string> inputs, string output, CancellationToken ct = default) => Task.CompletedTask;
public Task ExtractAudioAsync(string ffmpeg, string input, string output, string format = "mp3", CancellationToken ct = default) => Task.CompletedTask;
}
private sealed class HlsFakeMuxer : IMediaMuxer
{
public string? ResolveFfmpeg(AppConfig config) => "fake_ffmpeg";
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct = default)
{
File.Copy(input, output, true);
return Task.CompletedTask;
}
public Task MuxVideoAudioAsync(string ffmpeg, string video, string audio, string output, CancellationToken ct = default) => Task.CompletedTask;
public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress<DownloadProgress>? progress = null, CancellationToken ct = default) => Task.CompletedTask;
public Task<bool> IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true);
public Task ConcatAsync(string ffmpeg, IReadOnlyList<string> inputs, string output, CancellationToken ct = default) => Task.CompletedTask;
public Task ExtractAudioAsync(string ffmpeg, string input, string output, string format = "mp3", CancellationToken ct = default) => Task.CompletedTask;
}
}

View file

@ -0,0 +1,155 @@
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using Paca.App;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// AGENTS.md 25대 디자인/인터랙션/접근성 헌법 중 미구현되었던 6대 게이트를 전수 기계적으로 검증하는 종합 테스트 게이트.
/// - Gate 7: Flexbox / Grid 레이아웃 왜곡 및 0px 찌그러짐 방지
/// - Gate 18: WCAG 2.2 SC 2.5.7 드래그 조작 대안 (단일 클릭 및 단축키 대안)
/// - Gate 19: WCAG 2.2 SC 3.3.7 중복 입력 방지 (클립보드 자동 인식 / 히스토리 / 자동채움)
/// - Gate 23: WCAG 2.2 SC 2.4.11/2.4.12 포커스 비가림 (Focus Not Obscured)
/// - Gate 24: 모달 포커스 트랩 및 Esc 안전 복원 (Focus Restoration)
/// - Gate 25: 게슈탈트 근접성 및 최적 행폭 (CPL 50~75) 독서 인체공학
/// </summary>
public class DesignSystemCommandmentsGateTests
{
private static readonly string AppDir = Path.Combine(SourceTree.Root, "src", "Paca.App");
// =========================================================================
// Gate 7: Flexbox / Grid 레이아웃 왜곡 및 0px 찌그러짐 방지
// =========================================================================
[Fact]
public void Gate07_Grid_Layout_Distortion_Prevention_Audit()
{
// 메인 윈도우 및 주요 분할 화면의 ColumnDefinition 에 최소 폭(MinWidth)이 설정되어
// 창 크기가 축소되더라도 메인 뷰포트나 사이드 패널이 0px로 압축 붕괴되지 않는지 검증
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
Assert.True(File.Exists(Path.Combine(AppDir, "MainWindow.xaml")), "MainWindow.xaml must exist");
// 메인 뷰포트 컬럼과 우측 패널 컬럼에 MinWidth 가 필수 선언되어 있어야 함
Assert.Contains("x:Name=\"MainGrid\"", mwXaml);
Assert.Contains("MinWidth=\"360\"", mwXaml);
Assert.Contains("MinWidth=\"56\"", mwXaml);
// CredentialVaultWindow 또한 3단 분할 레이아웃이 안전한 컬럼 규격을 가져야 함
var vaultXaml = File.ReadAllText(Path.Combine(AppDir, "CredentialVaultWindow.xaml"));
Assert.Contains("Width=\"320\"", vaultXaml);
}
// =========================================================================
// Gate 18: WCAG 2.2 SC 2.5.7 드래그 대안 (Single Pointer / Keyboard Alternative)
// =========================================================================
[Fact]
public void Gate18_WCAG22_SC257_DraggingMovements_SinglePointerAlternative_Audit()
{
// 탭 이동, 다운로드 큐 순서 변경 등의 드래그 조작에 대해
// 마우스 우클릭 컨텍스트 메뉴 또는 단축키를 통한 비-드래그 조작 대안이 제공되어야 함
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
// 탭 닫기 및 탭 조작에 대한 단축키와 클릭 대안
Assert.Contains("TabClose_Click", mwXaml);
Assert.Contains("ToolTip=\"탭 닫기 (Ctrl+W)\"", mwXaml);
Assert.Contains("x:Key=\"ToolbarButtonContextMenu\"", mwXaml);
Assert.Contains("x:Key=\"BookmarkItemContextMenu\"", mwXaml);
}
// =========================================================================
// Gate 19: WCAG 2.2 SC 3.3.7 중복 입력 방지 (Redundant Entry Prevention)
// =========================================================================
[Fact]
public void Gate19_WCAG22_SC337_RedundantEntry_Prevention_Audit()
{
// 사용자가 이미 입력했던 정보(검색어, URL, 자격증명)를 다시 입력하지 않도록
// 클리어 버튼, 히스토리 팝업, 자격증명 자동 채움, 클립보드 자동 인식 지원 검증
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
// 1. 다운로드 검색창 클리어 버튼 제공
Assert.Contains("x:Name=\"DownloadSearchBox\"", mwXaml);
Assert.Contains("x:Name=\"DownloadSearchClearBtn\"", mwXaml);
// 2. 주소창 히스토리/제안 팝업 및 자동 완성 연동
Assert.True(mwXaml.Contains("Name=\"AddressBox\"") || mwXaml.Contains("x:Name=\"AddressBox\""),
"AddressBox must be declared with Name or x:Name");
Assert.True(mwXaml.Contains("Name=\"HistoryPopup\"") || mwXaml.Contains("x:Name=\"HistoryPopup\""),
"HistoryPopup must be declared with Name or x:Name");
// 3. 클립보드 자동 감지 타이머/핸들러 연동
var mwUsecasesCs = File.ReadAllText(Path.Combine(AppDir, "MainWindow.Usecases.cs"));
Assert.Contains("_clipboardTimer", mwUsecasesCs);
}
// =========================================================================
// Gate 23: WCAG 2.2 SC 2.4.11/2.4.12 포커스 비가림 (Focus Not Obscured)
// =========================================================================
[Fact]
public void Gate23_WCAG22_SC2411_FocusNotObscured_ScrollViewer_Audit()
{
// 스크롤 가능한 뷰포트 내에서 포커스된 컨트롤이 고정 헤더나 툴바에 의해 가려지지 않도록
// ScrollViewer 또는 항목 컨테이너에 적절한 여백/패딩 버퍼가 제공되는지 검증
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
// 다운로드 목록 스크롤 뷰어가 고정 헤더 아래에서 여백을 유지하고 있는지 검증
Assert.Contains("VerticalScrollBarVisibility=\"Auto\"", mwXaml);
Assert.Contains("MaxHeight=\"360\"", mwXaml);
// 설정 창 내부 스크롤 뷰어도 포커스가 잘리지 않도록 ScrollViewer 패딩 확보
var settingsXaml = File.ReadAllText(Path.Combine(AppDir, "SettingsWindow.xaml"));
Assert.Contains("<ScrollViewer", settingsXaml);
}
// =========================================================================
// Gate 24: 모달 포커스 트랩 및 Esc 안전 복원 (Focus Trap & Restoration)
// =========================================================================
[Fact]
public void Gate24_Modal_FocusTrap_And_EscapeDismiss_Audit()
{
// 모든 주요 모달 창은 키보드 포커스가 외부로 탈출하지 않도록 TabNavigation="Cycle"을 선언하고,
// Esc 키를 눌렀을 때 안전하게 닫히도록 IsCancel="True" 버튼을 구비해야 함
var modalFiles = new[]
{
"SettingsWindow.xaml",
"CredentialVaultWindow.xaml",
"BookmarkManagerWindow.xaml",
"ExtensionManagerWindow.xaml",
"LogViewerWindow.xaml",
"CrashReportDialog.xaml"
};
foreach (var modalFile in modalFiles)
{
var path = Path.Combine(AppDir, modalFile);
Assert.True(File.Exists(path), $"{modalFile} 파일이 존재해야 합니다.");
var content = File.ReadAllText(path);
Assert.Contains("KeyboardNavigation.TabNavigation=\"Cycle\"", content);
var hasCancel = content.Contains("IsCancel=\"True\"") || content.Contains("Key=\"Escape\"");
Assert.True(hasCancel, $"{modalFile} 에 Esc 라이트 디스미스(IsCancel=True 또는 Key=Escape)가 선언되어야 합니다.");
}
}
// =========================================================================
// Gate 25: 게슈탈트 근접성 및 최적 행폭 (CPL 50~75) 독서 인체공학
// =========================================================================
[Fact]
public void Gate25_Gestalt_OptimalMeasure_CPL_ReaderMode_Audit()
{
// 텍스트 블록의 한 줄 글자 수(Characters Per Line: 50~75자)가 시각적 피로를 유발하지 않도록
// 리더 모드 및 문서/로그 뷰어의 본문 컨테이너 MaxWidth 가 적절한 범위(680px~840px)로 제한되어 있는지 검증
var readerModeCs = File.ReadAllText(Path.Combine(AppDir, "Browser", "Features", "ReaderMode.cs"));
// ReaderMode HTML CSS 에 max-width 가 선언되어 텍스트가 화면 전체로 무한히 늘어지지 않아야 함
var match = Regex.Match(readerModeCs, @"max-width:\s*(\d+)px");
Assert.True(match.Success, "ReaderMode.cs 에 본문 max-width 제한이 선언되어야 합니다.");
var maxWidth = int.Parse(match.Groups[1].Value);
Assert.True(maxWidth >= 600 && maxWidth <= 850,
$"ReaderMode 본문 MaxWidth({maxWidth}px)는 CPL 50~75 최적 독서 규격(600~850px) 범위 내여야 합니다.");
}
}

View file

@ -0,0 +1,118 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Windows.Media;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// AGENTS.md 25대 헌법 Phase 2 심화 게이트 테스트:
/// - Gate 201: 하위 대화상자 인터랙티브 컨트롤 UIA Name 전수 보증 (헌법 제20조)
/// - Gate 202: 고대비 테마(WHCM) 상태 브러시 WCAG AAA 7.0:1 보증 (헌법 제21조)
/// - Gate 203: TabHost 순백(#FFFFFF) 발광 및 하드코딩 색상 차단 (헌법 제13조)
/// - Gate 206: 최대화 시 피츠의 법칙 툴바 상단 패딩 0px 전환 (헌법 제16조)
/// - Gate 207: SettingsWindow 보더리스 최대화 WM_GETMINMAXINFO 훅 보증
/// </summary>
public class DesignSystemCommandmentsPhase2Tests
{
private static readonly string AppDir = Path.Combine(SourceTree.Root, "src", "Paca.App");
// =========================================================================
// Gate 201: 하위 대화상자 인터랙티브 컨트롤 UIA Name 전수 보증
// =========================================================================
[Theory]
[InlineData("ClearBrowsingDataDialog.xaml", new[] { "TimeRangeCombo", "ClearHistoryCheck", "ClearCookiesCheck", "ClearCacheCheck", "ClearDownloadsCheck", "ClearBtn", "CancelBtn" })]
[InlineData("CrashReportDialog.xaml", new[] { "DetailBox", "CopyDetailBtn", "CloseBtn" })]
[InlineData("QrCodeDialog.xaml", new[] { "UrlDisplayBox", "CopyUrlBtn", "DoneBtn" })]
[InlineData("BrowserTaskManagerWindow.xaml", new[] { "RefreshBtn", "TaskList", "EndTaskBtn", "CloseBtn" })]
public void Gate201_SecondaryDialogs_Interactive_Controls_UiaName_Audit(string xamlFile, string[] controlNames)
{
var path = Path.Combine(AppDir, xamlFile);
Assert.True(File.Exists(path), $"{xamlFile} must exist");
var xaml = File.ReadAllText(path);
foreach (var name in controlNames)
{
// 각 컨트롤 태그 선언부 주변에 AutomationProperties.Name 이 선언되어 있는지 검증
var pattern = $@"(?:<[A-Za-z]+[^>]*x:Name=""{name}""[^>]*>|<[A-Za-z]+[^>]*Name=""{name}""[^>]*>)";
var match = Regex.Match(xaml, pattern, RegexOptions.Singleline);
Assert.True(match.Success, $"컨트롤 '{name}' 태그를 {xamlFile} 에서 찾을 수 있어야 합니다.");
Assert.True(match.Value.Contains("AutomationProperties.Name="),
$"{xamlFile} 의 컨트롤 '{name}' 에 AutomationProperties.Name 이 필수 지정되어야 합니다.");
}
}
// =========================================================================
// Gate 202: 고대비 테마(WHCM) 상태 브러시 WCAG AAA 7.0:1 보증
// =========================================================================
[Fact]
public void Gate202_HighContrast_ErrorBrush_WCAG_AAA_Contrast_Audit()
{
var hcXaml = File.ReadAllText(Path.Combine(AppDir, "Themes", "HighContrast.xaml"));
// ErrorBrush 의 색상 코드 추출
var match = Regex.Match(hcXaml, @"<SolidColorBrush\s+x:Key=""ErrorBrush""\s+Color=""(#?[0-9A-Fa-f]{6,8})""");
Assert.True(match.Success, "HighContrast.xaml 에 ErrorBrush 가 선언되어 있어야 합니다.");
var hex = match.Groups[1].Value.TrimStart('#');
if (hex.Length == 8) hex = hex[2..]; // ARGB -> RGB
var r = Convert.ToByte(hex[..2], 16) / 255.0;
var g = Convert.ToByte(hex.Substring(2, 2), 16) / 255.0;
var b = Convert.ToByte(hex.Substring(4, 2), 16) / 255.0;
// sRGB 상대 휘도 계산
static double Lum(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
var lError = 0.2126 * Lum(r) + 0.7152 * Lum(g) + 0.0722 * Lum(b);
// CardBgHover (#2D2D2D) 휘도 계산
var bgVal = 0x2D / 255.0;
var lBg = 0.2126 * Lum(bgVal) + 0.7152 * Lum(bgVal) + 0.0722 * Lum(bgVal);
var contrast = (lError + 0.05) / (lBg + 0.05);
Assert.True(contrast >= 7.0, $"HighContrast ErrorBrush 의 명도 대비({contrast:F2}:1)는 WCAG AAA 7.0:1 이상이어야 합니다.");
}
// =========================================================================
// Gate 203: TabHost 순백(#FFFFFF) 발광 및 하드코딩 색상 차단
// =========================================================================
[Fact]
public void Gate203_TabHost_NoWhiteBackground_Hardcode_Audit()
{
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
// TabHost 주변에 Background="#FFFFFF" 하드코딩이 없어야 함
var match = Regex.Match(mwXaml, @"<Grid[^>]*x:Name=""TabHost""[^>]*>");
Assert.True(match.Success, "MainWindow.xaml 에 TabHost 가 존재해야 합니다.");
Assert.DoesNotContain("Background=\"#FFFFFF\"", match.Value);
Assert.DoesNotContain("Background=\"White\"", match.Value);
}
// =========================================================================
// Gate 206: 최대화 시 피츠의 법칙 툴바 상단 패딩 0px 전환 보증
// =========================================================================
[Fact]
public void Gate206_FittsLaw_Toolbar_Padding_Zero_On_Maximized_Audit()
{
var mwCs = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml.cs"));
// WindowStateChanged 핸들러에서 Maximized 시 ToolbarRow.Padding 상단을 0으로 전환하는 로직 보증
Assert.Contains("WindowState.Maximized", mwCs);
Assert.Contains("ToolbarRow", mwCs);
}
// =========================================================================
// Gate 207: SettingsWindow 보더리스 최대화 WM_GETMINMAXINFO 훅 보증
// =========================================================================
[Fact]
public void Gate207_SettingsWindow_Has_WmGetMinMaxInfo_Hook_Audit()
{
var settingsCs = File.ReadAllText(Path.Combine(AppDir, "SettingsWindow.xaml.cs"));
// 작업표시줄 가림 방지 및 모니터 작업영역 클램핑 윈도우 메시지 훅 검증
Assert.Contains("WM_GETMINMAXINFO", settingsCs);
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Paca.App.Services;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// Phase 4 심화 하드닝 게이트 (TDD 선행 RED):
/// - Gate 401: MainWindow의 Ctrl+Shift+P 전체 일시정지 단축키(pauseall) 배선 무결성
/// - Gate 402: 미디어 팝업 및 사이트 정보 내 하드코딩 색상(#FF4444, #10B981)의 시맨틱 브러시 일체화
/// - Gate 403: ShortcutManager 기본 단축키 목록과 MainWindow 키 라우터 전수 패리티 검증
/// </summary>
public class DesktopUiAndShortcutHardeningPhase4Tests
{
private static readonly string AppDir = Path.Combine(SourceTree.Root, "src", "Paca.App");
// =========================================================================
// Gate 401: MainWindow Ctrl+Shift+P 단축키 배선 검증
// =========================================================================
[Fact]
public void Gate401_MainWindow_PauseAll_Shortcut_CtrlShiftP_Audit()
{
var mwCs = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml.cs"));
// OnPreviewKeyDown 에서 Ctrl+Shift+P 입력 시 PauseAllDownloads() 호출 보증
var pattern = @"ModifierKeys\.Control\s*\|\s*ModifierKeys\.Shift\)\s*&&\s*e\.Key\s*==\s*Key\.P\b";
Assert.Matches(pattern, mwCs);
Assert.Contains("PauseAllDownloads", mwCs);
}
// =========================================================================
// Gate 402: 하드코딩 색상(#FF4444, #10B981) 시맨틱 브러시 일체화
// =========================================================================
[Fact]
public void Gate402_MainWindow_SemanticBrushes_NoHardcodedHex_In_Popups()
{
var mwXaml = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml"));
// 1) 라인 2695 미디어 팝업 헤더 텍스트 하드코딩 #FF4444 금지
Assert.DoesNotContain("Foreground=\"#FF4444\"", mwXaml);
// 2) 라인 2908 사이트 보안 안전한 연결 텍스트 하드코딩 #10B981 금지
Assert.DoesNotContain("Foreground=\"#10B981\"", mwXaml);
// 3) 해당 위치가 시맨틱 브러시(ErrorBrush, SuccessBrush)로 대체되었는지 검증
Assert.Contains("Foreground=\"{DynamicResource ErrorBrush}\"", mwXaml);
Assert.Contains("Foreground=\"{DynamicResource SuccessBrush}\"", mwXaml);
}
// =========================================================================
// Gate 403: ShortcutManager 기본 단축키와 MainWindow 라우터 전수 패리티
// =========================================================================
[Fact]
public void Gate403_ShortcutManager_AllDefaultShortcuts_AreHandled_OrMappable()
{
var mwCs = File.ReadAllText(Path.Combine(AppDir, "MainWindow.xaml.cs"));
// ShortcutManager 에 선언된 핵심 기본 단축키 액션들이 MainWindow 에 실제로 배선되어 있는지 검증
var coreActions = new[]
{
"newtab", "closetab", "reopentab", "find", "address",
"downloads", "palette", "devtools", "fullscreen", "refresh", "print", "pauseall"
};
foreach (var action in coreActions)
{
Assert.True(ShortcutManager.Defaults.ContainsKey(action),
$"ShortcutManager.Defaults 에 '{action}' 이 선언되어 있어야 합니다.");
}
// pauseall 이 MainWindow.xaml.cs 에 단축키 키 핸들러로 등록되어 있는지 확인
Assert.Contains("PauseAllDownloads", mwCs);
}
}

View file

@ -0,0 +1,61 @@
using System;
using Paca.Core.Config;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
public class GpuAndBatterySettingsGateTests
{
[Fact]
public void Gate253_AppConfig_GpuAndBatterySettings_PropertiesExist()
{
var config = new AppConfig();
Assert.True(config.EnableGpuHardwareAcceleration);
Assert.Equal("Auto", config.PreferredGpuBackend);
Assert.Equal("Auto", config.PreferredVideoCodec);
Assert.True(config.EnableBatterySaver);
Assert.Equal(20, config.BatterySaverThresholdPercent);
Assert.True(config.BatterySaverThrottleBandwidth);
}
[Fact]
public void Gate254_SettingsWindow_GpuAndBatteryControls_ExistInXaml()
{
var xaml = SourceTree.SettingsWindowXaml;
// GPU 하드웨어 가속 컨트롤 존재 확인
Assert.Contains("x:Name=\"EnableGpuAccelerationBox\"", xaml);
Assert.Contains("x:Name=\"PreferredGpuBackendBox\"", xaml);
Assert.Contains("x:Name=\"PreferredVideoCodecBox\"", xaml);
// 배터리 세이버 전력 관리 컨트롤 존재 확인
Assert.Contains("x:Name=\"EnableBatterySaverBox\"", xaml);
Assert.Contains("x:Name=\"BatterySaverThresholdBox\"", xaml);
Assert.Contains("x:Name=\"BatterySaverThrottleBox\"", xaml);
// 접근성 및 스크린 리더 계약 검증
Assert.Contains("AutomationProperties.Name=\"GPU 하드웨어 가속 활성화\"", xaml);
Assert.Contains("AutomationProperties.Name=\"선호 GPU 인코더 백엔드\"", xaml);
Assert.Contains("AutomationProperties.Name=\"선호 대상 비디오 코덱\"", xaml);
Assert.Contains("AutomationProperties.Name=\"배터리 세이버 전력 관리 활성화\"", xaml);
Assert.Contains("AutomationProperties.Name=\"배터리 절전 임계치\"", xaml);
Assert.Contains("AutomationProperties.Name=\"절전 시 다운로드 대역폭 제한\"", xaml);
}
[Fact]
public void Gate255_SettingsWindow_Cs_GpuAndBatterySettings_LoadAndSaveBinding()
{
var cs = SourceTree.SettingsWindowCs;
Assert.Contains("EnableGpuAccelerationBox.IsChecked", cs);
Assert.Contains("PreferredGpuBackendBox.SelectedValue", cs);
Assert.Contains("PreferredVideoCodecBox.SelectedValue", cs);
Assert.Contains("EnableBatterySaverBox.IsChecked", cs);
Assert.Contains("BatterySaverThresholdBox.Text", cs);
Assert.Contains("BatterySaverThrottleBox.IsChecked", cs);
}
}

View file

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

View file

@ -0,0 +1,153 @@
using System.Windows;
using Paca.App;
using Paca.Tests.Infrastructure;
namespace Paca.Tests.E2E;
/// <summary>
/// E2E (Phase A): 실제 MainWindow 기동 → WebView2 초기화 → about:blank 탐색 →
/// 다중 탭(생성/전환/닫기/Ctrl+Shift+T 복원) + 검색 바 + 전체화면까지 실 창에서 검증.
/// </summary>
[Collection("E2E")]
public class MainWindowSmokeE2ETests
{
[Fact]
public async Task MainWindow_기동_탐색_탭전환_닫기_복원_검색바_전체화면_E2E()
{
// VD_DATA_DIR 격리: 실사용자 설정/세션/프로필과 분리된 임시 데이터 디렉터리 (결정적 테스트)
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_e2e_data_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = await sta.InvokeAsync(async () =>
{
var window = new MainWindow();
window.Show();
// 첫 탭 WebView2 초기화 대기
var first = await Wait.Until(
() => Task.FromResult(window.Tabs.ActiveTab),
t => t?.Core != null, 30000);
Assert.NotNull(first);
var cv1 = first.Core!;
// about:blank 탐색 (네트워크 불필요)
window.NavigateTo("about:blank");
await Wait.Until(() => Task.FromResult(cv1.Source ?? ""), s => s.Contains("about:blank"), 15000);
// ── Phase A: 다중 탭 ──
var tab2 = window.Tabs.NewTab();
var cv2 = await Wait.Until(() => Task.FromResult(tab2.Core), c => c != null, 30000);
Assert.NotNull(cv2);
cv2.NavigateToString("<html><body><p id='x'>hello-tab2</p></body></html>");
await WaitUntilNavCompleted(cv2);
Assert.NotSame(first, window.Tabs.ActiveTab); // NewTab 이 활성화
Assert.Equal(2, window.Tabs.Tabs.Count);
// 탭 전환: 첫 탭으로
window.Tabs.Activate(first);
var switched = ReferenceEquals(window.Tabs.ActiveTab, first);
// 탭 닫기 → 오른쪽 이웃 활성화 → Ctrl+Shift+T 복원
window.Tabs.CloseTab(tab2);
var countAfterClose = window.Tabs.Tabs.Count;
var reopened = window.Tabs.ReopenClosedTab();
var countAfterReopen = window.Tabs.Tabs.Count;
// ── T1-3 검색 바 ──
window.OpenFind();
var findVisible = window.FindBar.Visibility == Visibility.Visible;
window.CloseFind();
var findCollapsed = window.FindBar.Visibility == Visibility.Collapsed;
// ── T1-11 전체화면 ──
window.ToggleFullscreen();
var toolbarHidden = window.ToolbarRow.Visibility == Visibility.Collapsed
&& window.TabStripRow.Visibility == Visibility.Collapsed;
window.ToggleFullscreen();
var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
// ── T3-13 컴팩트 모드: 크롬 행 높이 0 → 해제 시 복원 ──
window.ToggleCompactMode();
var compactRowZero = window.MainGrid.RowDefinitions[0].Height.Value == 0;
window.ToggleCompactMode();
var compactRowBack = window.MainGrid.RowDefinitions[0].Height.IsAuto;
// ── T3-12 노트: 패널 열림 + 현재 페이지 추가 → 목록 1건 ──
window.ToggleNotes();
var notesOpen = window.NotesPopup.IsOpen;
window.NotesAddPage_Click(window, new RoutedEventArgs());
var notesCount = window.Notes.List().Count;
window.NotesPopup.IsOpen = false;
// ── T3-2 아카이브: 위에서 닫았던 탭이 영속 아카이브에 기록 ──
// ── T3-2 아카이브: URL 있는 탭을 닫으면 영속 아카이브에 기록 ──
var tab3 = window.Tabs.NewTab();
await Wait.Until(() => Task.FromResult(tab3.Core), c => c != null, 30000);
tab3.Url = "https://e2e.test/archived-page"; // URL 확정 상태 시뮬레이션(실제는 SourceChanged 가 설정)
window.Tabs.CloseTab(tab3);
await Task.Delay(800); // 진행 중이던 초기 네비게이션 이벤트가 dispose 이후 터지지 않게 여유
var archivedCount = window.Archive.Search("").Count;
var archivedHasUrl = window.Archive.Search("").Any(a => a.Url.Contains("e2e.test"));
// ── T3-6 페이지 액션: 실제 HTTP 문서에서 흑백 적용 → 부스트 저장 ──
var port = Random.Shared.Next(21000, 59000);
using var server = new System.Net.HttpListener();
server.Prefixes.Add($"http://127.0.0.1:{port}/");
server.Start();
var serveTask = Task.Run(async () =>
{
var ctx = await server.GetContextAsync();
var bytes = System.Text.Encoding.UTF8.GetBytes("<html><body><p>page-action-e2e</p></body></html>");
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
});
window.Tabs.Activate(first);
cv1.Navigate($"http://127.0.0.1:{port}/");
await Wait.Until(() => Task.FromResult(cv1.Source ?? ""), s => s.Contains("127.0.0.1"), 15000);
await serveTask.WaitAsync(TimeSpan.FromSeconds(10));
window.ApplyPageFilter(Paca.App.Browser.Features.PageFilter.Grayscale);
var filterSaved = window.BoostStore.Get($"http://127.0.0.1:{port}/").Filter;
server.Stop();
window.Close();
return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen,
findVisible, findCollapsed, toolbarHidden, toolbarBack,
compactRowZero, compactRowBack, notesOpen, notesCount, archivedCount, archivedHasUrl, filterSaved);
});
Assert.True(ok.init, "WebView2 초기화 실패");
Assert.True(ok.switched, "탭 전환 실패");
Assert.True(ok.countAfterClose == 1, $"탭 닫기 후 개수 오류: {ok.countAfterClose}");
Assert.True(ok.reopened, "닫은 탭 복원 실패");
Assert.True(ok.countAfterReopen == 2, $"복원 후 개수 오류: {ok.countAfterReopen}");
Assert.True(ok.findVisible, "검색 바 미표시");
Assert.True(ok.findCollapsed, "검색 바 미닫힘");
Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김");
Assert.True(ok.toolbarBack, "전체화면 복원 실패");
Assert.True(ok.compactRowZero, "컴팩트 모드에서 크롬 행이 숨겨지지 않음");
Assert.True(ok.compactRowBack, "컴팩트 모드 해제 후 크롬 행 미복원");
Assert.True(ok.notesOpen, "노트 패널 미열림");
Assert.True(ok.notesCount == 1, $"노트 추가 실패: {ok.notesCount}");
Assert.True(ok.archivedCount >= 1 && ok.archivedHasUrl, $"탭 아카이브 미기록: {ok.archivedCount}");
Assert.Equal(Paca.App.Browser.Features.PageFilter.Grayscale, ok.filterSaved);
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, true); } catch { }
}
}
private static Task WaitUntilNavCompleted(Microsoft.Web.WebView2.Core.CoreWebView2 cv)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<Microsoft.Web.WebView2.Core.CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}

View file

@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Paca.App;
using Paca.Browser.Profiles;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// 20종 추상 아바타 프리셋 및 사용자 커스텀 이미지 업로드 TDD 검증 게이트
/// </summary>
public sealed class ProfileAvatarPresetAndUploadGateTests
{
private static string AppAssetsAvatarDir =>
Path.Combine(SourceTree.Root, "src", "Paca.App", "Assets", "Avatars");
[Fact]
public void Gate1_PresetAvatars_20Images_ExistAndValidBitmap_Audit()
{
// 1. Assets/Avatars 디렉터리가 존재해야 함
Assert.True(Directory.Exists(AppAssetsAvatarDir), $"Avatars directory missing: {AppAssetsAvatarDir}");
// 2. avatar_01.png ~ avatar_20.png 20종 존재 검증
for (int i = 1; i <= 20; i++)
{
string fileName = $"avatar_{i:D2}.png";
string filePath = Path.Combine(AppAssetsAvatarDir, fileName);
Assert.True(File.Exists(filePath), $"Preset avatar missing: {fileName}");
var fileInfo = new FileInfo(filePath);
Assert.True(fileInfo.Length >= 1024, $"Avatar file {fileName} is too small ({fileInfo.Length} bytes)");
// 유효한 이미지인지 디코딩 무결성 검증
using var stream = File.OpenRead(filePath);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
Assert.True(decoder.Frames.Count > 0, $"Avatar file {fileName} has no valid frames");
Assert.True(decoder.Frames[0].PixelWidth > 0 && decoder.Frames[0].PixelHeight > 0);
}
}
[Fact]
public void Gate2_ProfileAvatarHelper_GetPresetAvatars_Returns20Items()
{
var presets = ProfileAvatarHelper.GetPresetAvatarPaths();
Assert.NotNull(presets);
Assert.Equal(20, presets.Count);
Assert.All(presets, p => Assert.True(File.Exists(p) || p.StartsWith("pack://")));
}
[Fact]
public void Gate3_ProfileManager_PreservesAvatarImagePath_OnSaveAndReload_Audit()
{
var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_avatars_" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(tempDir);
var manager = new ProfileManager(tempDir);
var dummyImagePath = Path.Combine(tempDir, "custom_avatar.png");
File.WriteAllBytes(dummyImagePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); // minimal png header
var prof = manager.AddProfile("아바타 테스트", "#4F46E5", "\ue19f", dummyImagePath);
Assert.Equal(dummyImagePath, prof.AvatarImagePath);
// 다시 로드하여 영속화 일치 검증
var reloadedManager = new ProfileManager(tempDir);
var reloadedProf = reloadedManager.GetProfile(prof.Id);
Assert.NotNull(reloadedProf);
Assert.Equal(dummyImagePath, reloadedProf.AvatarImagePath);
// 업데이트 시 이미지 변경 및 제거 검증
reloadedManager.UpdateProfile(prof.Id, "이름 변경", "#059669", "\ue0d7", null);
var clearedProf = reloadedManager.GetProfile(prof.Id);
Assert.NotNull(clearedProf);
Assert.Null(clearedProf.AvatarImagePath);
}
finally
{
if (Directory.Exists(tempDir))
{
try { Directory.Delete(tempDir, true); } catch { }
}
}
}
[Fact]
public async Task Gate4_ProfileAvatarBadge_RendersImageBrush_WhenImagePathSet_Audit()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var success = await sta.Dispatcher.InvokeAsync(() =>
{
// 더미 16x16 PNG 비트맵 생성
var rtb = new RenderTargetBitmap(16, 16, 96, 96, PixelFormats.Pbgra32);
var dv = new DrawingVisual();
using (var dc = dv.RenderOpen())
{
dc.DrawRectangle(Brushes.Indigo, null, new Rect(0, 0, 16, 16));
}
rtb.Render(dv);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
var tempPng = Path.Combine(Path.GetTempPath(), $"paca_avatar_render_{Guid.NewGuid():N}.png");
using (var fs = File.Create(tempPng))
{
encoder.Save(fs);
}
try
{
var brush = ProfileAvatarHelper.CreateAvatarBrush(tempPng);
Assert.NotNull(brush);
Assert.Equal(Stretch.UniformToFill, brush.Stretch);
var badge = new System.Windows.Controls.Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(12),
Background = brush
};
badge.Measure(new Size(24, 24));
badge.Arrange(new Rect(0, 0, 24, 24));
badge.UpdateLayout();
var testRtb = new RenderTargetBitmap(24, 24, 96, 96, PixelFormats.Pbgra32);
testRtb.Render(badge);
var px = new byte[24 * 24 * 4];
testRtb.CopyPixels(px, 24 * 4, 0);
// 중앙 픽셀(12, 12)이 Indigo 계열(투명이 아님)인지 확인
int centerIdx = (12 * 24 + 12) * 4;
return px[centerIdx + 3] > 0; // Alpha > 0
}
finally
{
try { File.Delete(tempPng); } catch { }
}
});
Assert.True(success);
}
[Theory]
[InlineData(".png", true)]
[InlineData(".jpg", true)]
[InlineData(".jpeg", true)]
[InlineData(".webp", true)]
[InlineData(".bmp", true)]
[InlineData(".exe", false)]
[InlineData(".bat", false)]
[InlineData(".txt", false)]
public void Gate5_ProfileAvatarUpload_ValidatesExtension_Audit(string ext, bool expectedValid)
{
var isValid = ProfileAvatarHelper.IsSupportedImageExtension(ext);
Assert.Equal(expectedValid, isValid);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,511 @@
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Web.WebView2.Core;
using Paca.App;
using Paca.App.Browser.Features;
using Paca.App.ViewModels;
using Paca.Browser;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
/// <summary>
/// UI 기능 상호작용 & 웹뷰 컨트롤 E2E 파이프라인 (레이아웃, 퍼미션 트래커, 리더 모드, 설정창 라이브 갱신, 검색창).
/// </summary>
[Collection("E2E")]
public class LayoutAndInteractionE2ETests
{
[Fact]
public void TabItem_IsPrivate_PropertyChanged_알림_발생()
{
var tab = new TabItem();
string? changedProp = null;
tab.PropertyChanged += (s, e) => changedProp = e.PropertyName;
tab.IsPrivate = true;
Assert.True(tab.IsPrivate);
Assert.Equal(nameof(TabItem.IsPrivate), changedProp);
}
[Fact]
public void NavProgressTracker_로딩중_카운트_증감_및_리셋()
{
var tracker = new NavProgressTracker();
Assert.False(tracker.IsNavigating);
tracker.Started();
Assert.True(tracker.IsNavigating);
tracker.Started();
Assert.True(tracker.IsNavigating);
tracker.Completed();
Assert.True(tracker.IsNavigating);
tracker.Completed();
Assert.False(tracker.IsNavigating);
// 초과 완료 호출에도 안전
tracker.Completed();
Assert.False(tracker.IsNavigating);
}
[Fact]
public void CrashReportDialog_생성_시_오류_요약_및_상세_표시()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
sta.Invoke(() =>
{
var testEx = new InvalidOperationException("테스트용 치명적 오류 메시지", new ArgumentException("내부 예외"));
var dlg = new CrashReportDialog(testEx);
Assert.NotNull(dlg.DetailBox);
Assert.Contains("InvalidOperationException", dlg.DetailBox.Text);
Assert.Contains("테스트용 치명적 오류 메시지", dlg.DetailBox.Text);
Assert.Contains("ArgumentException", dlg.DetailBox.Text);
});
}
[Fact]
public async Task MainWindow_최대화_및_InPrivate_탭_생성_E2E()
{
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_e2e_layout_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
await sta.InvokeAsync(async () =>
{
var window = new MainWindow();
window.Show();
// 1. 최대화 토글 및 복원 검증
Assert.Equal(WindowState.Normal, window.WindowState);
window.WindowState = WindowState.Maximized;
Assert.Equal(WindowState.Maximized, window.WindowState);
window.WindowState = WindowState.Normal;
Assert.Equal(WindowState.Normal, window.WindowState);
// 2. InPrivate 탭 생성
var privateTab = window.Tabs.NewTab(isPrivate: true);
Assert.True(privateTab.IsPrivate, "생성된 탭의 IsPrivate은 true여야 합니다.");
// 3. 닫기 시 안전 종료
window.Close();
});
}
finally
{
try { if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true); } catch { }
}
}
}
/// <summary>
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
/// </summary>
[Collection("E2E")]
public class PermissionTrackerE2ETests
{
[Fact]
public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
var result = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
// TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억)
var gate = new PermissionGate();
cv.PermissionRequested += (_, e) =>
{
var o = Paca.App.Services.TabViewService.OriginOf(e.Uri);
gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책
var verdict = gate.Decide(e.PermissionKind.ToString(), o);
e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
e.Handled = true;
};
var html = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></html>";
cv.NavigateToString(html);
await WaitUntilNavCompleted(cv);
var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""),
t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000);
window.Close();
return title;
});
try { Directory.Delete(udf, true); } catch { }
Assert.Equal("PERM=granted", result);
}
[Fact]
public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
var result = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
var bl = new TrackerBlocklist(new[] { "blocked.test" });
var counter = new TrackerCounter();
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
cv.WebResourceRequested += (_, e) =>
{
if (!bl.Matches(new Uri(e.Request.Uri))) return;
e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", "");
counter.Record(e.Request.Uri);
};
var html = "<html><body><p>tracker-e2e</p>" +
"<script src=\"https://www.blocked.test/a.js\"></script>" +
"<script src=\"https://www.blocked.test/b.js\"></script>" +
"<script src=\"https://ok.example.com/c.js\"></script>" +
"</body></html>";
cv.NavigateToString(html);
await WaitUntilNavCompleted(cv);
await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000);
window.Close();
return counter.Total;
});
try { Directory.Delete(udf, true); } catch { }
Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음
}
private static async Task WaitUntilNavCompleted(CoreWebView2 cv)
{
var tcs = new TaskCompletionSource();
EventHandler<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
cv.NavigationCompleted += h;
if (tcs.Task.IsCompleted) return;
await Task.WhenAny(tcs.Task, Task.Delay(15000));
cv.NavigationCompleted -= h;
}
}
/// <summary>
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
/// </summary>
[Collection("E2E")]
public class ReaderModeE2ETests
{
private const string ArticleHtml = """
<html><head><title>테스트 기사</title></head><body>
<nav>광고 광고 광고 내비게이션</nav>
<article>
<h1>리더 모드 E2E 기사</h1>
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
</article>
<footer>푸터 링크 모음</footer>
</body></html>
""";
[Fact]
public async Task 기사를_리더로_추출하고_원본으로_복귀한다()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}");
var ok = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
cv.NavigateToString(ArticleHtml);
await WaitNav(cv);
var tab = new TabItem { View = browser };
// 1) 리더 진입
var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false);
await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000);
await Wait.Until(async () => await cv.ExecuteScriptAsync("document.body.innerText"), b => b.Contains("마커텍스트-고유문구-레인보우4747"), 15000);
var titleAfter = cv.DocumentTitle ?? "";
var bodyText = await cv.ExecuteScriptAsync("document.body.innerText");
var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747");
var hasNav = bodyText.Contains("광고 광고 광고");
var isReader = tab.IsReader;
var originalSaved = tab.ReaderOriginalUrl;
// 2) 복귀
var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false);
var restoredReader = tab.IsReader == false;
window.Close();
return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader);
});
try { Directory.Delete(udf, true); } catch { }
Assert.True(ok.entered, "리더 진입 실패");
Assert.Contains("테스트 기사", ok.titleAfter);
Assert.True(ok.hasMarker, "본문 마커 누락");
Assert.True(ok.navRemoved, "내비/광고 제거 안됨");
Assert.True(ok.isReader);
Assert.True(ok.exited);
Assert.True(ok.restoredReader);
}
private static Task WaitNav(CoreWebView2 cv)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}
/// <summary>
/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) —
/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증.
/// </summary>
[Collection("E2E")]
public class SettingsUpdateUiE2ETests
{
[Fact]
public void 설정창_업데이트_저장소_로드와_형식오류_안내()
{
// VD_DATA_DIR 격리: SettingsWindow 가 참조하는 프로필 경로를 실사용자와 분리
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_set_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
sta.Invoke(() =>
{
var config = new AppConfig { UpdateRepo = "user/videodownloader" };
var win = new SettingsWindow(config);
// 설정 → UI 로드 배선
Assert.Equal("user/videodownloader", win.UpdateRepoBox.Text);
// owner/repo 형식이 아니면 네트워크 호출 없이 즉시 안내
win.UpdateRepoBox.Text = "invalid-format";
win.CheckUpdate_Click(null, new RoutedEventArgs());
Assert.Contains("owner/repo", win.UpdateResultText.Text);
win.Close();
});
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch (DirectoryNotFoundException) { }
}
}
}
/// <summary>
/// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3).
/// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다.
/// </summary>
[Collection("E2E")]
public class WebView2FindE2ETests
{
[Fact]
public async Task 네이티브_Find로_매치_수와_활성_인덱스를_얻는다()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
var result = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
cv.Settings.AreDefaultScriptDialogsEnabled = false;
var html = "<html><body><p>hello world hello brave hello</p></body></html>";
cv.NavigateToString(html);
await WaitUntilNavCompleted(cv);
// 네이티브 Find API — SuppressDefaultFindDialog 로 자체 UI 전제 검증
int matchCount = -1, activeIndex = -1;
cv.Find.MatchCountChanged += (_, _) => matchCount = cv.Find.MatchCount;
cv.Find.ActiveMatchIndexChanged += (_, _) => activeIndex = cv.Find.ActiveMatchIndex;
var options = env.CreateFindOptions();
options.FindTerm = "hello";
options.ShouldHighlightAllMatches = true;
options.SuppressDefaultFindDialog = true;
await cv.Find.StartAsync(options);
await Wait.Until(() => Task.FromResult(matchCount), c => c > 0, 10000);
var countAfterStart = matchCount;
cv.Find.FindNext();
await Wait.Until(() => Task.FromResult(activeIndex), i => i >= 1, 10000);
cv.Find.Stop();
window.Close();
return (countAfterStart, activeIndex);
});
try { Directory.Delete(udf, true); } catch { }
Assert.Equal(3, result.countAfterStart);
Assert.True(result.activeIndex >= 1, $"활성 인덱스가 진행되지 않음: {result.activeIndex}");
}
private static Task WaitUntilNavCompleted(CoreWebView2 cv)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}
/// <summary>
/// 실제 사용자 상호작용(인풋창 타이핑, 버튼 클릭, 상태 전이) 복합 유즈케이스 시나리오 E2E 테스트.
/// TDD RED 체계 진화: 단편적 검증을 실질적 사용자 스토리로 통폐합.
/// </summary>
[Collection("E2E")]
public class UserJourneyInteractiveE2ETests
{
[Fact]
public async Task Journey01_AddressBox_InputAndSearch_Interaction_E2E()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"vd_journey01_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", tempDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
await sta.InvokeAsync(() =>
{
var window = new MainWindow();
window.Show();
// 1. 주소창 인풋에 텍스트 기록 (URL 타이핑 시뮬레이션)
Assert.NotNull(window.AddressBox);
window.AddressBox.Text = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
Assert.Equal("https://www.youtube.com/watch?v=dQw4w9WgXcQ", window.AddressBox.Text);
// 2. 텍스트 변경에 따른 도메인 또는 북마크 별표 버튼 갱신 확인
Assert.NotNull(window.BookmarkStarBtn);
// 3. 인풋창 클리어 시뮬레이션
window.AddressBox.Text = string.Empty;
Assert.Empty(window.AddressBox.Text);
window.Close();
return Task.CompletedTask;
});
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public async Task Journey02_SidePanel_TabsSwitching_And_FilterInput_E2E()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"vd_journey02_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", tempDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
await sta.InvokeAsync(() =>
{
var window = new MainWindow();
window.Show();
// 1. 사이드탭 3종(다운로드, 방문기록, 북마크) 존재 확인
Assert.NotNull(window.SideTabDownloads);
Assert.NotNull(window.SideTabHistory);
Assert.NotNull(window.SideTabBookmarks);
// 2. 방문 기록 탭 클릭(IsChecked = true) 시 방문기록 뷰 활성화 검증
window.SideTabHistory.IsChecked = true;
Assert.True(window.SideTabHistory.IsChecked);
Assert.NotNull(window.HistoryPanelView);
Assert.Equal(Visibility.Visible, window.HistoryPanelView.Visibility);
// 방문기록 검색창에 필터 텍스트 입력
Assert.NotNull(window.HistorySearchBox);
window.HistorySearchBox.Text = "Github";
Assert.Equal("Github", window.HistorySearchBox.Text);
// 3. 북마크 탭 클릭 시 북마크 뷰 활성화 검증
window.SideTabBookmarks.IsChecked = true;
Assert.True(window.SideTabBookmarks.IsChecked);
Assert.NotNull(window.BookmarksPanelView);
Assert.Equal(Visibility.Visible, window.BookmarksPanelView.Visibility);
// 북마크 검색창에 필터 텍스트 입력
Assert.NotNull(window.BookmarkSearchBox);
window.BookmarkSearchBox.Text = "React";
Assert.Equal("React", window.BookmarkSearchBox.Text);
// 4. 다운로드 탭 복귀
window.SideTabDownloads.IsChecked = true;
Assert.NotNull(window.DownloadsPanelView);
Assert.Equal(Visibility.Visible, window.DownloadsPanelView.Visibility);
window.Close();
return Task.CompletedTask;
});
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
}

View file

@ -0,0 +1,288 @@
using System.Windows;
using System.Windows.Controls;
using Paca.App;
using Paca.App.Browser.Features;
using Paca.App.Core;
using Paca.App.Services;
using Paca.App.ViewModels;
using Paca.Core.Config;
using Paca.Tests.Infrastructure;
using WpfApp = Paca.App.App;
namespace Paca.Tests.E2E;
/// <summary>
/// E2E: "UI 복잡 버전"(docs/UI_USECASES.md §A~§J) GREEN 구현의 실창 실증 —
/// MainWindowSmokeE2ETests 와 동일한 StaDispatcher/WpfTestApp 패턴으로 실제 윈도우를 띄워
/// ① 신규 유즈케이스 컨트롤이 실 트리에 존재하는지 ② 테마가 재시작 없이 라이브 전환되는지(UC-T03/T04)
/// ③ 다운로드 일시정지/재개 상태 전이(UC-D06/D07) ④ 설정창 검색 필터·테마 즉시 적용(UC-S01)
/// ⑤ 토스트 방해 금지 억제·알림센터 히스토리(UC-N14/N15)를 검증한다.
/// </summary>
[Collection("E2E")]
public class UiUsecaseLiveE2ETests
{
private static string TempDataDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"vd_uc_e2e_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dir);
return dir;
}
[Fact]
public async Task MainWindow_유즈케이스_컨트롤_실존과_라이브_테마_전환_E2E()
{
var dataDir = TempDataDir();
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = await sta.InvokeAsync(async () =>
{
var window = new MainWindow();
window.Show();
var first = await Wait.Until(
() => Task.FromResult(window.Tabs.ActiveTab),
t => t?.Core != null, 30000);
// ── §C/§D/§E/§I/§J 신규 컨트롤이 실 트리에 존재(최상위 x:Name 필드) ──
var mustExist = new FrameworkElement?[]
{
// 툴바
window.CompactModeBtn, window.LibraryBtn, window.PairingBtn,
window.DownloadsBtnBadge, window.UpdateBadge,
// 탭 스트립
window.WorkspaceIndicator, window.WorkspaceSwitcher, window.NewTabMoreBtn,
window.ArchiveBtn, window.TabInsertIndicator,
// 다운로드 패널
window.DownloadFilterTabs, window.DownloadSearchBox, window.QueueSummaryText,
window.GlobalSpeedText, window.SpeedSparkline,
// 상태바·오버레이
window.StatusRight, window.ZoomIndicator, window.ServerStatusIcon,
window.NotificationCenter, window.ShortcutHelpBtn, window.AggregateProgress,
window.ToastHost, window.ErrorBanner, window.ErrorDetailExpander,
window.ClipboardHint, window.Onboarding,
// 팝업 본문
window.ArchivePopup, window.ArchiveSearchBox,
window.LibraryPopup, window.LibrarySearchBox, window.LibraryViewToggle, window.LibrarySortMenu,
window.BookmarkSearchBox, window.HistorySearchBox, window.NotesSearchBox,
window.PlaylistSearchBox, window.PlaylistShuffle,
window.ShortcutHelpPopup, window.NotificationCenterPopup, window.TabVolumePopup,
};
var missing = mustExist.Count(e => e == null);
// ── §J 초기 상태 계약: 온보딩 표시(세션 없음)·오류배너·클립보드힌트 숨김 ──
var onboardingShown = window.Onboarding.Visibility == Visibility.Visible;
var errorHidden = window.ErrorBanner.Visibility == Visibility.Collapsed;
var clipboardHidden = window.ClipboardHint.Visibility == Visibility.Collapsed;
// ── §A 라이브 테마 전환(UC-T03/T04) — 창을 다시 만들지 않고 즉시 ──
window.ApplyTheme("light");
var lightApplied = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
var aliveInLight = window.IsLoaded;
window.ApplyTheme("dark");
var darkApplied = WpfApp.CurrentTheme == "dark" && !WpfApp.IsLightTheme;
var aliveInDark = window.IsLoaded;
var lightRestore = WpfApp.CurrentTheme == "dark"; // 원복 확인
window.Close();
await Task.Delay(800); // WebView2 파일 핸들 해제 여유(smoke 테스트 패턴)
var fails = new List<string>();
if (missing != 0) fails.Add($"컨트롤 누락 {missing}개");
if (!onboardingShown) fails.Add("온보딩 미표시");
if (!errorHidden) fails.Add("오류배너 초기 미숨김");
if (!clipboardHidden) fails.Add("클립보드힌트 초기 미숨김");
if (!lightApplied) fails.Add("라이트 테마 미적용");
if (!aliveInLight) fails.Add("라이트 전환 후 창 사망");
if (!darkApplied) fails.Add("다크 원복 미적용");
if (!aliveInDark) fails.Add("다크 전환 후 창 사망");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"MainWindow 유즈케이스 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void DownloadManager_전체_일시정지_및_재개_상태전이_E2E()
{
var dataDir = TempDataDir();
var outDir = Path.Combine(dataDir, "out");
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var config = new AppConfig { OutputFolder = outDir, MaxConcurrentDownloads = 2 };
var manager = new DownloadManager(
config,
_ => Task.FromResult(new List<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()),
_ => { });
var running = new DownloadItemViewModel("진행 중 항목")
{
Status = "다운로드 중",
Started = true,
MediaUrl = new Uri("https://127.0.0.1:9/video.mp4"), // 비라우팅 — 재개가 네트워크를 치지 않게
OutputBase = Path.Combine(outDir, "video"),
};
var queued = new DownloadItemViewModel("대기 항목")
{
Status = "대기",
MediaUrl = new Uri("https://example.com/video2.mp4"),
OutputBase = Path.Combine(outDir, "video2"),
};
manager.Items.Add(running);
manager.Items.Add(queued);
// UC-D06: 전체 일시정지 — 진행 중만 "일시정지" 전이, 대기 항목은 그대로
manager.PauseAll();
var pausedStatus = running.Status == "일시정지" && running.PausedForResume;
var queuedUntouched = queued.Status == "대기";
// UC-D07: 전체 재개 — 일시정지 항목만 재투입(PausedForResume 해제 + Started 재설정)
manager.ResumeAll();
var resumed = !running.PausedForResume && running.Started; // 재시작 큐 진입
manager.Cancel(running); // 실측 목적 달성 — 원격 접속 시도 중단
var queuedStillQueued = queued.Started == false && queued.PausedForResume == false;
var fails = new List<string>();
if (!pausedStatus) fails.Add($"PauseAll 후 상태={running.Status}/PausedForResume={running.PausedForResume}");
if (!queuedUntouched) fails.Add($"대기 항목이 건드려짐(상태={queued.Status})");
if (!resumed) fails.Add($"ResumeAll 후 재투입 안 됨(Started={running.Started}/PausedForResume={running.PausedForResume})");
if (!queuedStillQueued) fails.Add("대기 항목이 재개 대상이 됨");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"일시정지/재개 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void SettingsWindow_검색_필터와_테마_즉시적용_E2E()
{
var dataDir = TempDataDir();
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var config = new AppConfig(); // 기본 테마 dark
var win = new SettingsWindow(config);
// 생성 시점 로드: 테마 dark 반영 + 검색창 비어 전 섹션 표시
var initialTheme = WpfApp.CurrentTheme == "dark";
var allVisible = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Visible;
// UC-T01/T02: 콤보박스로 라이트 선택 → 앱 테마가 즉시 전환(재시작 없음)
win.ThemeModeBox.SelectedValue = "light";
var lightLive = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
win.ThemeModeBox.SelectedValue = "dark";
var backToDark = WpfApp.CurrentTheme == "dark";
// UC-S01: 설정 검색 — 해당 섹션만 노출
win.SettingsSearchBox.Text = "글꼴";
var fontOnly = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Collapsed
&& win.SectionNotify.Visibility == Visibility.Collapsed;
win.SettingsSearchBox.Text = "알림";
var downloadOnly = win.SectionNotify.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Collapsed
&& win.SectionAccessibility.Visibility == Visibility.Collapsed;
win.SettingsSearchBox.Text = "";
var restored = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Visible;
win.Close();
var fails = new List<string>();
if (!initialTheme) fails.Add($"초기 테마 dark 아님(현재 {WpfApp.CurrentTheme})");
if (!allVisible) fails.Add("초기 전 섹션 표시 아님");
if (!lightLive) fails.Add($"라이트 즉시 적용 실패(현재 {WpfApp.CurrentTheme}/{WpfApp.IsLightTheme})");
if (!backToDark) fails.Add($"다크 원복 실패(현재 {WpfApp.CurrentTheme})");
if (!fontOnly) fails.Add("'글꼴' 검색 시 글꼴 섹션만 표시 아님");
if (!downloadOnly) fails.Add("'알림' 검색 시 알림 섹션만 표시 아님");
if (!restored) fails.Add("빈 검색어로 복원 안 됨");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"설정창 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void ToastNotifier_방해금지_억제와_알림센터_히스토리_E2E()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var host = new StackPanel();
var toast = new ToastNotifier(host) { Duration = TimeSpan.FromMilliseconds(500) };
// 일반 표시: 토스트 1장 + 히스토리 1건(UC-N02)
toast.Show("완료: 첫 번째", "success");
var shown = host.Children.Count == 1 && toast.History.Count == 1;
// UC-N15 방해 금지: 표시는 억제되고 히스토리에만 기록(UC-N14 알림 센터 원천)
toast.DoNotDisturb = true;
toast.Show("실패: 두 번째", "error");
var suppressed = host.Children.Count == 1 && toast.History.Count == 2;
return shown && suppressed;
});
Assert.True(ok, "토스트 방해 금지/히스토리 계약 위반");
}
[Fact]
public void MainWindow_우측패널_접기_시_모든내부요소_숨김_E2E()
{
var dataDir = TempDataDir();
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var window = new MainWindow();
// 1. 펼침 상태 확인
window.ApplyPanelState(false);
Assert.Equal(Visibility.Visible, window.SideTabsContainer.Visibility);
Assert.Equal(Visibility.Visible, window.PanelBodyContainer.Visibility);
Assert.Equal(Visibility.Visible, window.PanelCountBadge.Visibility);
Assert.Equal(Visibility.Visible, window.RightSplitter.Visibility);
// 2. 접힘 상태 확인 - 툴바, 요약 텍스트, 바디, 세그먼트 탭 등 모든 내부 요소가 완전히 숨겨져야 함
window.ApplyPanelState(true);
Assert.Equal(Visibility.Collapsed, window.SideTabsContainer.Visibility);
Assert.Equal(Visibility.Collapsed, window.PanelBodyContainer.Visibility);
Assert.Equal(Visibility.Collapsed, window.PanelCountBadge.Visibility);
Assert.Equal(Visibility.Collapsed, window.RightSplitter.Visibility);
Assert.Equal(56, window.RightCol.Width.Value);
window.Close();
return true;
});
Assert.True(ok);
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { }
}
}
}

File diff suppressed because it is too large Load diff