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:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
606
tests/Paca.Tests/E2E/ComprehensiveE2EScenarioTests.cs
Normal file
606
tests/Paca.Tests/E2E/ComprehensiveE2EScenarioTests.cs
Normal 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
|
||||
}
|
||||
352
tests/Paca.Tests/E2E/CoreAndServerHardeningPhase3Tests.cs
Normal file
352
tests/Paca.Tests/E2E/CoreAndServerHardeningPhase3Tests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
155
tests/Paca.Tests/E2E/DesignSystemCommandmentsGateTests.cs
Normal file
155
tests/Paca.Tests/E2E/DesignSystemCommandmentsGateTests.cs
Normal 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) 범위 내여야 합니다.");
|
||||
}
|
||||
}
|
||||
118
tests/Paca.Tests/E2E/DesignSystemCommandmentsPhase2Tests.cs
Normal file
118
tests/Paca.Tests/E2E/DesignSystemCommandmentsPhase2Tests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
61
tests/Paca.Tests/E2E/GpuAndBatterySettingsGateTests.cs
Normal file
61
tests/Paca.Tests/E2E/GpuAndBatterySettingsGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
401
tests/Paca.Tests/E2E/IconGlyphDesignSystemTests.cs
Normal file
401
tests/Paca.Tests/E2E/IconGlyphDesignSystemTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
tests/Paca.Tests/E2E/MainWindowSmokeE2ETests.cs
Normal file
153
tests/Paca.Tests/E2E/MainWindowSmokeE2ETests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
164
tests/Paca.Tests/E2E/ProfileAvatarPresetAndUploadGateTests.cs
Normal file
164
tests/Paca.Tests/E2E/ProfileAvatarPresetAndUploadGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1559
tests/Paca.Tests/E2E/UiContractUseCasesMegaPipelineTests.cs
Normal file
1559
tests/Paca.Tests/E2E/UiContractUseCasesMegaPipelineTests.cs
Normal file
File diff suppressed because it is too large
Load diff
511
tests/Paca.Tests/E2E/UiFeatureInteractionsPipelineTests.cs
Normal file
511
tests/Paca.Tests/E2E/UiFeatureInteractionsPipelineTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
288
tests/Paca.Tests/E2E/UiUsecaseLiveE2ETests.cs
Normal file
288
tests/Paca.Tests/E2E/UiUsecaseLiveE2ETests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2125
tests/Paca.Tests/E2E/VisualDesignGateTests.cs
Normal file
2125
tests/Paca.Tests/E2E/VisualDesignGateTests.cs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue