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

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

View file

@ -0,0 +1,82 @@
using System;
using System.IO;
using System.Threading;
using System.Windows;
using Paca.App.Core;
using Paca.App.Services;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class AppDebugScreenshotServiceTests
{
[Fact]
public void AppDebugScreenshotService_GetDefaultScreenshotPath_ReturnsValidPngPath()
{
var path = AppDebugScreenshotService.GetDefaultScreenshotPath();
Assert.False(string.IsNullOrWhiteSpace(path));
Assert.EndsWith(".png", path, StringComparison.OrdinalIgnoreCase);
Assert.Contains(AppPaths.DataDir, path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AppDebugScreenshotService_CaptureWindow_STA_GeneratesValidPngFile()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var tempOut = Path.Combine(Path.GetTempPath(), $"paca_test_shot_{Guid.NewGuid():N}.png");
try
{
var result = sta.Invoke(() =>
{
var window = new Window
{
Width = 300,
Height = 200,
Title = "TestCaptureWindow"
};
window.Show();
var ok = AppDebugScreenshotService.CaptureWindow(window, tempOut);
window.Close();
return ok;
});
Assert.True(result, "CaptureWindow should return true for a valid window.");
Assert.True(File.Exists(tempOut), "Screenshot file must exist.");
var fi = new FileInfo(tempOut);
Assert.True(fi.Length > 0, "Screenshot file must not be empty.");
// PNG magic byte verification: 0x89, 'P', 'N', 'G'
var header = new byte[8];
using (var fs = File.OpenRead(tempOut))
{
fs.Read(header, 0, 8);
}
Assert.Equal(0x89, header[0]);
Assert.Equal((byte)'P', header[1]);
Assert.Equal((byte)'N', header[2]);
Assert.Equal((byte)'G', header[3]);
}
finally
{
if (File.Exists(tempOut))
{
try { File.Delete(tempOut); } catch { }
}
}
}
[Fact]
public void AppDebugScreenshotService_ProcessTriggerContent_ParsesTargetPath()
{
var customPath = @"C:\dummy\target.png";
var resolved = AppDebugScreenshotService.ResolveTriggerPath(customPath);
Assert.Equal(customPath, resolved);
var emptyResolved = AppDebugScreenshotService.ResolveTriggerPath("");
Assert.Equal(AppDebugScreenshotService.GetDefaultScreenshotPath(), emptyResolved);
}
}

View file

@ -0,0 +1,60 @@
using System;
using Paca.App.Browser.Features;
using Xunit;
namespace Paca.Tests.App;
public class ChromeDownloadManagerParityTests
{
private class FakeDownloadOperation : IDownloadOperation
{
public string Uri { get; set; } = "https://example.com/file.zip";
public string FileName { get; set; } = "file.zip";
public long TotalBytesToReceive { get; set; } = 100 * 1024 * 1024; // 100MB
public long BytesReceived { get; set; } = 0;
public string State { get; set; } = "InProgress";
public event Action? Changed;
public void Pause() { State = "Paused"; Changed?.Invoke(); }
public void Resume() { State = "InProgress"; Changed?.Invoke(); }
public void Cancel() { State = "Cancelled"; Changed?.Invoke(); }
public void Notify(long received, string state)
{
BytesReceived = received;
State = state;
Changed?.Invoke();
}
}
[Fact]
public void PageDownloadItem_SizeProgressText_Formats_Correctly()
{
var fake = new FakeDownloadOperation { TotalBytesToReceive = 100 * 1024 * 1024, BytesReceived = 25 * 1024 * 1024 };
var item = new PageDownloadItem(fake, "file.zip");
Assert.Equal("25.0 MB / 100.0 MB", item.SizeProgressText);
var unknownTotal = new FakeDownloadOperation { TotalBytesToReceive = 0, BytesReceived = 10 * 1024 * 1024 };
var item2 = new PageDownloadItem(unknownTotal, "stream.bin");
Assert.Equal("10.0 MB", item2.SizeProgressText);
}
[Fact]
public void PageDownloadItem_EtaText_Calculations()
{
var fake = new FakeDownloadOperation { TotalBytesToReceive = 100 * 1024 * 1024, BytesReceived = 50 * 1024 * 1024 };
var item = new PageDownloadItem(fake, "file.zip");
// When bps is 0
Assert.Equal("—", item.EtaText);
// 50MB remaining. Speed = 5MB/s -> 10 seconds remaining
item.UpdateSpeed(50 * 1024 * 1024, TimeSpan.FromSeconds(1));
item.UpdateSpeed(55 * 1024 * 1024, TimeSpan.FromSeconds(1)); // 5 MB/s
Assert.Contains("초 남음", item.EtaText);
// When completed
fake.Notify(100 * 1024 * 1024, "Completed");
Assert.Equal("완료됨", item.EtaText);
}
}

View file

@ -0,0 +1,62 @@
using Paca.Browser.Tabs;
using Xunit;
namespace Paca.Tests.App;
public class ChromeTabDiscardingMegaTests
{
[Theory]
[InlineData("메모리 절약")]
[InlineData("절전 모드")]
[InlineData("memory saver")]
[InlineData("discard tabs")]
public void Omnibox_Matches_DiscardTabs_Action(string query)
{
var actions = OmniboxActionEvaluator.GetMatchingActions(query);
Assert.NotEmpty(actions);
Assert.Contains(actions, a => a.ActionId == "action:discard-tabs");
}
[Fact]
public void DiscardPolicy_ProtectsPinnedAndAudioTabs()
{
var mgr = new TabManager(() => new TabItem());
var activeTab = mgr.NewTab("https://example.com/1");
var audioTab = mgr.NewTab("https://youtube.com", activate: false);
audioTab.IsAudioPlaying = true;
var pinnedTab = mgr.NewTab("https://mail.google.com", activate: false);
pinnedTab.IsPinned = true;
var normalTab = mgr.NewTab("https://news.ycombinator.com", activate: false);
Assert.False(mgr.CanDiscardTab(activeTab));
Assert.False(mgr.CanDiscardTab(audioTab));
Assert.False(mgr.CanDiscardTab(pinnedTab));
Assert.True(mgr.CanDiscardTab(normalTab));
}
[Fact]
public void TabManager_DiscardOtherTabs_DiscardsOnlyEligibleTabs()
{
var mgr = new TabManager(() => new TabItem());
var tab1 = mgr.NewTab("https://tab1.com"); // active
var tab2 = mgr.NewTab("https://tab2.com", activate: false);
tab2.IsPinned = true;
var tab3 = mgr.NewTab("https://tab3.com", activate: false);
tab3.IsAudioPlaying = true;
var tab4 = mgr.NewTab("https://tab4.com", activate: false);
var tab5 = mgr.NewTab("https://tab5.com", activate: false);
int discarded = mgr.DiscardOtherTabs();
Assert.Equal(2, discarded);
Assert.False(tab1.IsSuspended);
Assert.False(tab2.IsSuspended);
Assert.False(tab3.IsSuspended);
Assert.True(tab4.IsSuspended);
Assert.True(tab5.IsSuspended);
// Activating a suspended tab automatically resumes it
mgr.Activate(tab4);
Assert.False(tab4.IsSuspended);
Assert.True(tab4.IsActive);
}
}

View file

@ -0,0 +1,42 @@
using System;
using System.IO;
using Paca.App;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class DesktopDeepLinkGateTests
{
[Fact]
public void Gate219_DesktopStartupModeParser_ExtractsDeepLinkArgument()
{
// Arrange
var args = new[] { "paca://download?url=https%3A%2F%2Fexample.com%2Fvideo%2F999&format=mp4" };
// Act
var success = DesktopStartupModeParser.TryExtractDeepLink(args, out var action);
// Assert
Assert.True(success);
Assert.NotNull(action);
var downloadAction = Assert.IsType<PacaDownloadDeepLinkAction>(action);
Assert.Equal("https://example.com/video/999", downloadAction.TargetUrl);
Assert.Equal("mp4", downloadAction.Format);
}
[Fact]
public void Gate220_InstallerScript_Includes_PacaCustomProtocolRegistry()
{
var installerPath = Path.Combine(SourceTree.Root, "build", "installer.iss");
Assert.True(File.Exists(installerPath), "installer.iss must exist");
var content = File.ReadAllText(installerPath);
// Protocol registration
Assert.Contains(@"Software\Classes\paca", content);
Assert.Contains(@"""URL Protocol""", content);
Assert.Contains(@"Software\Classes\paca\shell\open\command", content);
}
}

View file

@ -0,0 +1,65 @@
using System;
using System.IO;
using Paca.App;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
/// <summary>
/// Gate 153 ~ 155: 데스크톱 백그라운드 게이트웨이 서비스 인스톨러 연동 및 시작프로그램 CLI 모드 기계적 검증
/// </summary>
public class DesktopGatewayDaemonCommandLineGateTests
{
[Fact]
public void Gate153_CommandLineArgs_GatewayDaemonMode_Detected()
{
// 1. --gateway-daemon 인자 전달 시 백그라운드 무음 데몬 모드
var mode1 = DesktopStartupModeParser.Parse(new[] { "--gateway-daemon" });
Assert.Equal(DesktopStartupMode.GatewayDaemonSilent, mode1);
// 2. 대소문자 무관 감지
var mode2 = DesktopStartupModeParser.Parse(new[] { "--GATEWAY-DAEMON" });
Assert.Equal(DesktopStartupMode.GatewayDaemonSilent, mode2);
// 3. 인자 없음 -> 일반 GUI 모드
var mode3 = DesktopStartupModeParser.Parse(Array.Empty<string>());
Assert.Equal(DesktopStartupMode.NormalGui, mode3);
// 4. 기타 인자 -> 일반 GUI 모드
var mode4 = DesktopStartupModeParser.Parse(new[] { "--minimize" });
Assert.Equal(DesktopStartupMode.NormalGui, mode4);
}
[Fact]
public void Gate154_GatewayDaemon_StartupRegistryEntry_Configurator()
{
var exePath = @"C:\Users\Username\AppData\Local\Programs\Paca\Paca.exe";
var runValue = WindowsStartupRegistryHelper.FormatRunCommand(exePath);
Assert.Equal("\"C:\\Users\\Username\\AppData\\Local\\Programs\\Paca\\Paca.exe\" --gateway-daemon", runValue);
}
[Fact]
public void Gate155_InstallerScript_Includes_GatewayService_Task_And_Registry()
{
var installerPath = Path.Combine(SourceTree.Root, "build", "installer.iss");
Assert.True(File.Exists(installerPath), "installer.iss must exist");
var content = File.ReadAllText(installerPath);
// 게이트웨이 서비스 설치 태스크 확인
Assert.Contains("gatewayservice", content);
Assert.Contains("PacaGatewayDaemon", content);
Assert.Contains("--gateway-daemon", content);
}
[Fact]
public void Gate156_AppStartup_DaemonConfig_Defaults()
{
var config = new Paca.Server.Gateway.GatewayStartupConfig();
Assert.Equal(52100, config.HttpPort);
Assert.Equal(52101, config.BeaconUdpPort);
Assert.True(config.AutoStartUdpBeacon);
}
}

View file

@ -0,0 +1,122 @@
using System;
using System.IO;
using Paca.Browser.Diagnostics;
using Paca.Browser.Media;
using Xunit;
namespace Paca.Tests.App;
public class DesktopMediaSpeedAndDiagnosticsGateTests
{
private readonly string _mainWindowCsPath;
private readonly string _mainWindowXamlPath;
public DesktopMediaSpeedAndDiagnosticsGateTests()
{
var baseDir = AppContext.BaseDirectory;
var solutionDir = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
_mainWindowCsPath = Path.Combine(solutionDir, "src", "Paca.App", "MainWindow.xaml.cs");
_mainWindowXamlPath = Path.Combine(solutionDir, "src", "Paca.App", "MainWindow.xaml");
}
[Fact]
public void Gate_MediaSpeedController_StepUpAndDown_ClampsWithinRange()
{
var controller = new MediaSpeedController();
Assert.Equal(1.0, controller.CurrentSpeed);
controller.StepUp();
Assert.Equal(1.1, controller.CurrentSpeed);
controller.StepDown();
Assert.Equal(1.0, controller.CurrentSpeed);
// Clamp to Max
controller.SetSpeed(5.0);
Assert.Equal(MediaSpeedController.MaxSpeed, controller.CurrentSpeed);
// Clamp to Min
controller.SetSpeed(0.1);
Assert.Equal(MediaSpeedController.MinSpeed, controller.CurrentSpeed);
// Script generation
var script = controller.GeneratePlaybackScript();
Assert.Contains("media.playbackRate", script);
Assert.Contains("preservesPitch", script);
}
[Fact]
public void Gate_VideoFrameCaptureCoordinator_GeneratesCaptureScriptAndSnapshots()
{
var coordinator = new VideoFrameCaptureCoordinator();
// 1. Suggested filename
var fileName = coordinator.GenerateSuggestedFileName("Test Video: Title/Sample", 45.5);
Assert.StartsWith("PACA_Frame_Test Video_ Title_Sample_45.5s", fileName);
Assert.EndsWith(".png", fileName);
// 2. Aspect ratio
Assert.Equal("16:9", VideoFrameCaptureCoordinator.CalculateAspectRatio(1920, 1080));
Assert.Equal("16:10", VideoFrameCaptureCoordinator.CalculateAspectRatio(1920, 1200));
Assert.Equal("4:3", VideoFrameCaptureCoordinator.CalculateAspectRatio(1024, 768));
// 3. Snapshot PNG encoding
var rawRgba = new byte[4 * 4 * 4]; // 4x4 RGBA
Array.Fill(rawRgba, (byte)255);
var meta = new VideoFrameMetadata("vid1", "Test", 4, 4, 1.0, 10.0, 30.0, DateTime.UtcNow);
var snapshot = coordinator.CreateSnapshot(meta, rawRgba, 4, 4);
Assert.NotNull(snapshot);
Assert.True(snapshot.ImageData.Length > 8);
// Verify PNG magic bytes: 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A
byte[] expectedMagic = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
for (int i = 0; i < expectedMagic.Length; i++)
{
Assert.Equal(expectedMagic[i], snapshot.ImageData[i]);
}
}
[Fact]
public void Gate_AdvancedProcessTaskInspector_ParsesProcessesAndCalculatesSummaries()
{
var inspector = new AdvancedProcessTaskInspector();
// 1. Categorization
Assert.Equal(BrowserProcessCategory.Browser, inspector.CategorizeProcess("Paca", null));
Assert.Equal(BrowserProcessCategory.Gpu, inspector.CategorizeProcess("Paca", "--type=gpu-process"));
Assert.Equal(BrowserProcessCategory.Renderer, inspector.CategorizeProcess("Paca", "--type=renderer"));
Assert.Equal(BrowserProcessCategory.Utility, inspector.CategorizeProcess("Paca", "--type=utility"));
Assert.Equal(BrowserProcessCategory.Audio, inspector.CategorizeProcess("Paca", "utility-sub-type=audio"));
// 2. Register processes and aggregate
inspector.RegisterProcess(new ProcessTaskSnapshot(1001, "Paca", BrowserProcessCategory.Browser, "Main", "", 150_000_000, 2.5));
inspector.RegisterProcess(new ProcessTaskSnapshot(1002, "Paca", BrowserProcessCategory.Renderer, "Tab 1", "--type=renderer", 250_000_000, 5.0));
Assert.Equal(400_000_000, inspector.GetTotalWorkingSetBytes());
Assert.Equal(7.5, inspector.GetTotalCpuPercent());
var summaries = inspector.GetCategorySummaries();
Assert.Contains(summaries, s => s.Category == BrowserProcessCategory.Renderer && s.ProcessCount == 1);
}
[Fact]
public void Gate_DesktopApp_MainWindow_HasMediaSpeedAndFrameCaptureWiring()
{
Assert.True(File.Exists(_mainWindowCsPath), "MainWindow.xaml.cs exists");
var cs = File.ReadAllText(_mainWindowCsPath);
// Core fields and properties
Assert.Contains("MediaSpeedController", cs);
Assert.Contains("VideoFrameCaptureCoordinator", cs);
Assert.Contains("AdvancedProcessTaskInspector", cs);
// Shortcut & Action methods
Assert.Contains("AdjustMediaSpeed", cs);
Assert.Contains("CaptureCurrentVideoFrame", cs);
Assert.Contains("UpdateTaskManagerPanelSummary", cs);
// Key bindings for '[' and ']'
Assert.Contains("Key.OemOpenBrackets", cs);
Assert.Contains("Key.OemCloseBrackets", cs);
}
}

View file

@ -0,0 +1,65 @@
using Paca.Browser.Search;
using Paca.Browser.Url;
using Xunit;
namespace Paca.Tests.App;
public class DesktopOmniboxIntegrationGateTests
{
[Theory]
[InlineData("yt 아이유", "https://www.youtube.com/results?search_query=")]
[InlineData("w 인공지능", "https://en.wikipedia.org/wiki/Special:Search?search=")]
[InlineData("g PACA", "https://www.google.com/search?q=")]
[InlineData("n 네이버페이", "https://search.naver.com/search.naver?query=")]
[InlineData("c 치지직라이브", "https://chzzk.naver.com/search?query=")]
public void Gate_DesktopOmnibox_KeywordEngine_ResolvesBangs_IntoDestinationUrl(string input, string expectedUrlPrefix)
{
var engine = new OmniboxKeywordEngine();
bool resolved = engine.TryResolve(input, out var result);
Assert.True(resolved, $"입력 '{input}'이 검색엔진 키워드로 해석되어야 한다.");
Assert.NotNull(result);
Assert.StartsWith(expectedUrlPrefix, result.ResolvedUrl);
}
[Theory]
[InlineData("@tabs paca", OmniboxSearchScope.Tabs, "paca")]
[InlineData("@기록 유튜브", OmniboxSearchScope.History, "유튜브")]
[InlineData("@bookmarks 깃허브", OmniboxSearchScope.Bookmarks, "깃허브")]
public void Gate_DesktopOmnibox_ScopeFilter_DetectsScopesAndQueries(string input, OmniboxSearchScope expectedScope, string expectedQuery)
{
var filter = new OmniboxScopeFilter();
var res = filter.Parse(input);
Assert.True(res.IsScoped);
Assert.Equal(expectedScope, res.Scope);
Assert.Equal(expectedQuery, res.Query);
}
[Fact]
public void Gate_DesktopOmnibox_HardRefresh_RecognizesCtrlF5_AndCtrlShiftR()
{
var ctrlF5 = HardRefreshEngine.EvaluateRefresh(ctrl: true, shift: false, isF5: true);
Assert.Equal(RefreshType.HardClearCache, ctrlF5);
var ctrlShiftR = HardRefreshEngine.EvaluateRefresh(ctrl: true, shift: true, isF5: false, isR: true);
Assert.Equal(RefreshType.HardClearCache, ctrlShiftR);
var req = HardRefreshEngine.CreateRequest("https://paca.stream", ctrlF5);
Assert.True(req.ClearMemoryCache);
Assert.True(req.ClearDiskCache);
Assert.True(req.Headers.ContainsKey("Cache-Control"));
}
[Fact]
public void Gate_DesktopOmnibox_CleanLinkExtractor_RemovesTrackingAndAmp()
{
var dirtyUrl = "https://www.google.com/amp/s/example.com/article?utm_source=twitter&utm_medium=social&fbclid=IwAR123";
var res = CleanLinkExtractor.ExtractCleanUrl(dirtyUrl);
Assert.NotNull(res);
Assert.DoesNotContain("utm_source", res.CleanedUrl);
Assert.DoesNotContain("fbclid", res.CleanedUrl);
Assert.DoesNotContain("google.com/amp", res.CleanedUrl);
}
}

View file

@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Text.Json;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
/// <summary>
/// Gate 161 ~ 163: 데스크톱 ↔ 모바일 원격 탭 동기화 및 원클릭 웹 브라우징 릴레이 기계적 검증
/// </summary>
public class DesktopRemoteTabSyncGateTests
{
[Fact]
public void Gate161_RemoteTabs_Models_And_Serialization()
{
var tab = new RemoteTabDto(
Id: "tab-101",
Title: "4K Nature Landscape - YouTube",
Url: "https://www.youtube.com/watch?v=12345",
IsActive: true
);
var json = JsonSerializer.Serialize(tab);
var deserialized = JsonSerializer.Deserialize<RemoteTabDto>(json);
Assert.NotNull(deserialized);
Assert.Equal("tab-101", deserialized.Id);
Assert.Equal("4K Nature Landscape - YouTube", deserialized.Title);
Assert.Equal("https://www.youtube.com/watch?v=12345", deserialized.Url);
Assert.True(deserialized.IsActive);
}
[Fact]
public void Gate162_ServerHost_Tabs_Endpoints_Registered()
{
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
var code = File.ReadAllText(serverHostPath);
Assert.Contains("/api/tabs", code);
Assert.Contains("/api/tabs/open", code);
}
[Fact]
public void Gate163_ApiClient_RemoteTabs_Methods_Implemented()
{
var mobileDir = Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
: Path.Combine(SourceTree.Root, "Paca.Mobile");
var apiClientPath = File.Exists(Path.Combine(mobileDir, "ApiClient.cs"))
? Path.Combine(mobileDir, "ApiClient.cs")
: Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core", "ApiClient.cs");
Assert.True(File.Exists(apiClientPath), "ApiClient.cs must exist");
var code = File.ReadAllText(apiClientPath);
Assert.Contains("GetRemoteTabsAsync", code);
Assert.Contains("OpenRemoteTabAsync", code);
}
}

View file

@ -0,0 +1,61 @@
using System;
using System.IO;
using System.Text.Json;
using Paca.App;
using Paca.Server.Gateway;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
/// <summary>
/// Gate 164 ~ 166: 데스크톱 트레이 알림 및 모바일 양방향 실시간 게이트웨이 푸시 스트림 기계적 검증
/// </summary>
public class DesktopTrayAndGatewayPushGateTests
{
[Fact]
public void Gate164_DesktopTrayNotification_Event_Payload()
{
var payload = new DesktopTrayNotificationPayload(
Title: "모바일에서 다운로드 수신",
Message: "Galaxy S24에서 '4K 여행 브이로그' 다운로드를 전송했습니다.",
Type: TrayNotificationType.Info,
Timestamp: DateTimeOffset.UtcNow
);
Assert.Equal("모바일에서 다운로드 수신", payload.Title);
Assert.Equal(TrayNotificationType.Info, payload.Type);
Assert.False(string.IsNullOrWhiteSpace(payload.Message));
}
[Fact]
public void Gate165_GatewayPushEvent_Serialization_And_Types()
{
var pushEvent = new GatewayPushEvent(
EventId: "evt-901",
EventType: GatewayEventType.DownloadFinished,
Title: "다운로드 완료",
PayloadJson: "{\"id\":\"job-12\",\"fileName\":\"Nature_4K.mp4\"}",
OccurredAt: DateTimeOffset.UtcNow
);
var json = JsonSerializer.Serialize(pushEvent);
var deserialized = JsonSerializer.Deserialize<GatewayPushEvent>(json);
Assert.NotNull(deserialized);
Assert.Equal("evt-901", deserialized.EventId);
Assert.Equal(GatewayEventType.DownloadFinished, deserialized.EventType);
Assert.Contains("Nature_4K.mp4", deserialized.PayloadJson);
}
[Fact]
public void Gate166_ServerHost_GatewayEvents_Endpoint_Registered()
{
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
var code = File.ReadAllText(serverHostPath);
Assert.Contains("/api/gateway/events", code);
}
}

View file

@ -0,0 +1,72 @@
using System.IO;
using Paca.Browser.Layouts;
using Paca.Browser.Panels;
using Paca.Browser.Theming;
using Xunit;
namespace Paca.Tests.App;
public class DesktopZenRailAndStatusBarGateTests
{
private readonly string _mainWindowXamlPath;
public DesktopZenRailAndStatusBarGateTests()
{
var baseDir = AppContext.BaseDirectory;
var solutionDir = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
_mainWindowXamlPath = Path.Combine(solutionDir, "src", "Paca.App", "MainWindow.xaml");
}
[Fact]
public void Gate_ZenSideRail_XamlStructure_ContainsExtendedMultitaskingIcons()
{
Assert.True(File.Exists(_mainWindowXamlPath), "MainWindow.xaml 파일이 존재해야 합니다.");
var xaml = File.ReadAllText(_mainWindowXamlPath);
// ZenSideRail 세로 독 확장 아이콘 검증
Assert.Contains("x:Name=\"SideTabWebPanel\"", xaml);
Assert.Contains("x:Name=\"SideTabTaskManager\"", xaml);
// 56px 축소 레일의 퀵 액션 단추 검증
Assert.Contains("x:Name=\"CollapsedRailWebPanel\"", xaml);
Assert.Contains("x:Name=\"CollapsedRailTaskManager\"", xaml);
}
[Fact]
public void Gate_StatusBar_XamlStructure_ContainsZoomAndHostModules()
{
Assert.True(File.Exists(_mainWindowXamlPath), "MainWindow.xaml 파일이 존재해야 합니다.");
var xaml = File.ReadAllText(_mainWindowXamlPath);
// 하단 상태바 인체공학 모듈 검증
Assert.Contains("x:Name=\"StatusHostBadge\"", xaml);
Assert.Contains("x:Name=\"StatusZoomSlider\"", xaml);
}
[Theory]
[InlineData("tab0", -120, "tab1")] // 아래로 휠 스크롤 -> 다음 탭 (tab0 -> tab1)
[InlineData("tab4", -120, "tab0")] // 끝에서 아래로 스크롤 -> 첫 탭 래핑 (tab4 -> tab0)
[InlineData("tab0", 120, "tab4")] // 첫 탭에서 위로 스크롤 -> 끝 탭 래핑 (tab0 -> tab4)
[InlineData("tab2", 120, "tab1")] // 위로 휠 스크롤 -> 이전 탭 (tab2 -> tab1)
public void Gate_TabStripScrollSwitcher_CyclesThroughTabsOnWheel(string current, int delta, string expected)
{
var switcher = new TabStripScrollSwitcher
{
CycleMode = ScrollCycleMode.WrapAround,
InvertScrollDirection = true // wheel down (-120) moves forward
};
switcher.SetTabs(new[] { "tab0", "tab1", "tab2", "tab3", "tab4" }, current);
var next = switcher.OnScrollWheel(delta);
Assert.Equal(expected, next);
}
[Fact]
public void Gate_SidebarPanelManager_RegistersWebPanels()
{
var manager = new SidebarPanelManager();
var panels = manager.GetAllPanels();
Assert.NotEmpty(panels);
Assert.Contains(panels, p => p.Title.Contains("번역") || p.Title.Contains("Translate") || p.Id == "papago");
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Paca.App.Utils;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class DownloadFolderOpenE2ERedScenarioTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Fact]
public void Test01_ExplorerLauncher_CreatesMissingFolder_And_LaunchesExplorerWithFolderPath()
{
string uniqueFolder = Path.Combine(Path.GetTempPath(), $"paca_e2e_folder_{Guid.NewGuid():N}");
try
{
Assert.False(Directory.Exists(uniqueFolder), "Test directory should not exist prior to test");
// Build start info for folder opening
var psi = ExplorerLauncher.BuildProcessStartInfo(null, uniqueFolder);
// 1. Directory must be created automatically on disk!
Assert.True(Directory.Exists(uniqueFolder), "ExplorerLauncher should auto-create destination folder if missing");
// 2. Must launch explorer.exe directly with quoted path argument
Assert.Equal("explorer.exe", psi.FileName);
Assert.Contains(uniqueFolder, psi.Arguments);
Assert.True(psi.UseShellExecute);
}
finally
{
if (Directory.Exists(uniqueFolder)) Directory.Delete(uniqueFolder);
}
}
[Fact]
public void Test02_ExplorerLauncher_SelectedFile_LaunchesSelectArgument()
{
string uniqueFolder = Path.Combine(Path.GetTempPath(), $"paca_e2e_select_{Guid.NewGuid():N}");
Directory.CreateDirectory(uniqueFolder);
string testFile = Path.Combine(uniqueFolder, "test_video.mp4");
File.WriteAllText(testFile, "dummy");
try
{
var psi = ExplorerLauncher.BuildProcessStartInfo(testFile, uniqueFolder);
Assert.Equal("explorer.exe", psi.FileName);
Assert.Contains("/select,", psi.Arguments);
Assert.Contains(testFile, psi.Arguments);
Assert.True(psi.UseShellExecute);
}
finally
{
if (Directory.Exists(uniqueFolder)) Directory.Delete(uniqueFolder, true);
}
}
[Fact]
public async Task Test03_QuickAction_MediaOpenOutputFolder_ActuallyInvokesFolderOpener()
{
var catalog = new QuickActionCatalog();
var action = catalog.GetAction("media.open_output_folder");
Assert.NotNull(action);
string? openedFolder = "NOT_CALLED";
var ctx = new QuickActionContext(
OutputFolder: @"C:\Downloads\Paca",
FolderOpener: path => openedFolder = path
);
Assert.NotNull(action.Handler);
var result = await action.Handler.Invoke(ctx);
Assert.True(result.Success);
// It must NOT just display a toast, it must invoke FolderOpener!
Assert.Null(openedFolder); // FolderOpener(null) opens the output folder
}
[Fact]
public void Test04_NewTabPage_OpenDownloadsMessage_CallsOpenInExplorer()
{
string mainCs = File.ReadAllText(Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml.cs"));
Assert.Contains("case \"ntp:opendownloads\":", mainCs);
// ntp:opendownloads must call OpenInExplorer(null) to open the real Windows Explorer folder
int idx = mainCs.IndexOf("case \"ntp:opendownloads\":", StringComparison.OrdinalIgnoreCase);
Assert.True(idx >= 0);
string block = mainCs.Substring(idx, 200);
Assert.Contains("OpenInExplorer(null)", block);
}
}

View file

@ -0,0 +1,118 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Paca.App.Utils;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class DownloadFolderOpenGateTests
{
[Fact]
public void Gate480_ExplorerLauncher_Arguments_HaveSelectOutsideQuotes_AndUseShellExecuteTrue()
{
// DoD: 공백 및 한글/특수문자가 포함된 다운로드 파일명에 대해
// explorer.exe 인자 구성 시 /select, 가 큰따옴표 바깥에 위치하고 UseShellExecute = true 여야 함
var testDir = Path.Combine(Path.GetTempPath(), "PacaTest_Gate480_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDir);
try
{
var testFile = Path.Combine(testDir, "(13) 손만 잡아도 2500만 원 순삭_ 베트남 국제결혼의 비밀!.mp4");
File.WriteAllText(testFile, "dummy video data");
var psi = ExplorerLauncher.BuildProcessStartInfo(testFile, testDir);
Assert.NotNull(psi);
Assert.True(psi.UseShellExecute, "UseShellExecute must be true for Windows Shell explorer invocation");
Assert.Equal("explorer.exe", psi.FileName, ignoreCase: true);
// 중요: "/select,C:\..." 형태는 Windows explorer.exe 가 옵션으로 인식하지 못하고 종료코드 1로 실패함
// 올바른 형식: /select,"C:\..."
Assert.StartsWith("/select,\"", psi.Arguments);
Assert.EndsWith("\"", psi.Arguments);
Assert.False(psi.Arguments.StartsWith("\"/select,"), "Arguments must NOT enclose /select within quotes");
}
finally
{
try { Directory.Delete(testDir, true); } catch { }
}
}
[Fact]
public void Gate481_ExplorerLauncher_FallbackToDirectory_WhenFileDoesNotExist()
{
var testDir = Path.Combine(Path.GetTempPath(), "PacaTest_Gate481_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDir);
try
{
var nonExistentFile = Path.Combine(testDir, "deleted_video.mp4");
var psi = ExplorerLauncher.BuildProcessStartInfo(nonExistentFile, testDir);
Assert.NotNull(psi);
Assert.True(psi.UseShellExecute);
// 파일이 없으면 상위 폴더(testDir) 자체를 직접 열도록 구성
Assert.Equal(Path.GetFullPath(testDir), Path.GetFullPath(psi.FileName), ignoreCase: true);
Assert.True(string.IsNullOrEmpty(psi.Arguments));
}
finally
{
try { Directory.Delete(testDir, true); } catch { }
}
}
[Fact]
public void Gate482_ExplorerLauncher_RelativePath_ResolvedWithOutputFolder()
{
var testDir = Path.Combine(Path.GetTempPath(), "PacaTest_Gate482_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDir);
try
{
const string fileName = "relative_clip.mp4";
var fullPath = Path.Combine(testDir, fileName);
File.WriteAllText(fullPath, "content");
// 상대 경로가 넘어왔을 때 OutputFolder 기준으로 전체 경로 해석 검증
var psi = ExplorerLauncher.BuildProcessStartInfo(fileName, testDir);
Assert.NotNull(psi);
Assert.True(psi.UseShellExecute);
Assert.Equal("explorer.exe", psi.FileName, ignoreCase: true);
Assert.Contains(fullPath, psi.Arguments);
Assert.StartsWith("/select,\"", psi.Arguments);
}
finally
{
try { Directory.Delete(testDir, true); } catch { }
}
}
[Fact]
public void Gate483_OpenInExplorer_SourceCode_DoesNotUseArgumentListWithUseShellExecuteFalse()
{
// DoD: MainWindow.xaml.cs의 OpenInExplorer가 ArgumentList.Add(/select...) + UseShellExecute = false 조합을 사용하지 않아야 함
var mwCs = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml.cs"));
Assert.DoesNotContain("psi.ArgumentList.Add($\"/select,{selectFile}\");", mwCs);
Assert.DoesNotContain("new System.Diagnostics.ProcessStartInfo(\"explorer.exe\") { UseShellExecute = false }", mwCs);
}
[Fact]
public void Gate484_ExplorerLauncher_CreatesDirectory_IfOutputFolderDoesNotExist()
{
var nonExistentDir = Path.Combine(Path.GetTempPath(), "PacaTest_Gate484_AutoCreate_" + Guid.NewGuid().ToString("N"));
Assert.False(Directory.Exists(nonExistentDir));
try
{
var psi = ExplorerLauncher.BuildProcessStartInfo(null, nonExistentDir);
Assert.NotNull(psi);
Assert.True(Directory.Exists(nonExistentDir), "BuildProcessStartInfo or Launcher must ensure the output folder exists");
}
finally
{
try { Directory.Delete(nonExistentDir, true); } catch { }
}
}
}

View file

@ -0,0 +1,56 @@
using System;
using System.Windows.Input;
using Paca.App.Core;
using Xunit;
namespace Paca.Tests.App;
public class GlobalHotkeyGateTests
{
[Fact]
public void Gate257_GlobalHotkeyParser_ParsesKeyModifiers()
{
var success = GlobalHotkeyParser.TryParse("Ctrl+Shift+D", out var modifiers, out var key);
Assert.True(success);
Assert.Equal(ModifierKeys.Control | ModifierKeys.Shift, modifiers);
Assert.Equal(Key.D, key);
}
[Fact]
public void Gate258_GlobalHotkeyParser_ParsesAlternativeShortcut()
{
var success = GlobalHotkeyParser.TryParse("Ctrl+Alt+V", out var modifiers, out var key);
Assert.True(success);
Assert.Equal(ModifierKeys.Control | ModifierKeys.Alt, modifiers);
Assert.Equal(Key.V, key);
}
[Fact]
public void Gate259_GlobalHotkeyManager_CalculatesWin32ModFlags()
{
var modFlags = GlobalHotkeyManager.GetWin32ModifierFlags(ModifierKeys.Control | ModifierKeys.Shift);
// MOD_CONTROL = 0x0002, MOD_SHIFT = 0x0004 -> 0x0006
Assert.Equal(0x0006u, modFlags);
var altWin = GlobalHotkeyManager.GetWin32ModifierFlags(ModifierKeys.Alt | ModifierKeys.Windows);
// MOD_ALT = 0x0001, MOD_WIN = 0x0008 -> 0x0009
Assert.Equal(0x0009u, altWin);
}
[Fact]
public void Gate260_GlobalHotkeyManager_HotkeyTrigger_InvokesCallback()
{
var manager = new GlobalHotkeyManager();
bool triggered = false;
manager.RegisterVirtualHotkey("Ctrl+Shift+D", () => triggered = true);
// Simulate trigger
manager.SimulateHotkeyTrigger("Ctrl+Shift+D");
Assert.True(triggered);
}
}

View file

@ -0,0 +1,67 @@
using System.IO;
using Paca.Core;
using Paca.Core.Platform;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class MediaToolsMenuWiringGateTests
{
private static string AppProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.App"))
? Path.Combine(SourceTree.Root, "src", "Paca.App")
: Path.Combine(SourceTree.Root, "Paca.App");
[Fact]
public void Gate_Core_Six_MediaEngines_Build_Filters_Accurately()
{
// 1. AudioVocalStemExtractor
var karaokeFilter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.VocalRemovalKaraoke);
Assert.Contains("pan=stereo", karaokeFilter);
// 2. VideoAspectAutoCropper
var cropFilter = VideoAspectAutoCropper.BuildReframingFilterArgs(ReframingMode.CenterCrop, 1080, 1920);
Assert.Contains("crop=", cropFilter);
Assert.Contains("1080:1920", cropFilter);
// 3. SubtitleBurnInEngine
var burnInCmd = SubtitleBurnInEngine.BuildBurnInCommandLine("in.mp4", "sub.srt", "out.mp4");
Assert.Contains("subtitles=", burnInCmd);
// 4. VideoTonemapEnhancer
var toneFilter = VideoTonemapEnhancer.BuildHdrToSdrFilter(TonemapAlgorithm.Mobius);
Assert.Contains("tonemap=", toneFilter);
// 5. VideoSpriteSheetGenerator
var spriteConfig = new SpriteSheetConfig(5, 5, 160, 90, 5);
var spriteCmd = VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine("in.mp4", "sprite.jpg", spriteConfig);
Assert.Contains("tile=5x5", spriteCmd);
// 6. VideoIntroOutroTrimmer
var window = VideoIntroOutroTrimmer.CalculateTrimWindow(60.0, 5.0, 10.0);
var trimArgs = VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine("in.mp4", "out.mp4", window.StartSec, window.DurationSec);
Assert.Contains("-ss 5.00", trimArgs);
Assert.Contains("-t 45.00", trimArgs);
}
[Fact]
public void Gate_MainWindow_Xaml_And_Usecases_Contain_MediaToolsWiring()
{
var xamlPath = Path.Combine(AppProjectDir, "MainWindow.xaml");
var usecasesPath = Path.Combine(AppProjectDir, "MainWindow.Usecases.cs");
Assert.True(File.Exists(xamlPath), $"MainWindow.xaml not found at {xamlPath}");
Assert.True(File.Exists(usecasesPath), $"MainWindow.Usecases.cs not found at {usecasesPath}");
var xaml = File.ReadAllText(xamlPath);
var usecases = File.ReadAllText(usecasesPath);
// XAML에 6대 미디어 도구 컨텍스트 메뉴 또는 헤더 항목 존재 검증
Assert.True(xaml.Contains("MediaTools") || xaml.Contains("미디어 도구") || xaml.Contains("MediaToolsMenu"),
"MainWindow.xaml must declare Media Tools context menu or action entry");
// Usecases에 미디어 도구 핸들러 배선 검증
Assert.True(usecases.Contains("ExecuteMediaTool") || usecases.Contains("AudioVocalStemExtractor") || usecases.Contains("HandleMediaToolAction"),
"MainWindow.Usecases.cs must implement media tools handler");
}
}

View file

@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Windows;
using Paca.App.Core;
using Xunit;
namespace Paca.Tests.App;
public class MultiDisplayLayoutEngineGateTests
{
[Fact]
public void Gate269_MultiDisplayLayoutEngine_ClampsWindow_WithinSingleMonitorBounds()
{
var workArea = new Rect(0, 0, 1920, 1080);
// Window 400x300 requested at (1800, 950) -> exceeds 1920 and 1080
var clamped = MultiDisplayLayoutEngine.ClampToWorkArea(
requestedLeft: 1800,
requestedTop: 950,
windowWidth: 400,
windowHeight: 300,
workArea: workArea,
margin: 16
);
Assert.Equal(1920 - 400 - 16, clamped.X);
Assert.Equal(1080 - 300 - 16, clamped.Y);
}
[Fact]
public void Gate270_MultiDisplayLayoutEngine_HandlesNegativeCoordinates_OnSecondaryLeftMonitor()
{
var leftMonitorWorkArea = new Rect(-1920, 0, 1920, 1080);
// Window 400x300 requested at -2000 (beyond left edge)
var clamped = MultiDisplayLayoutEngine.ClampToWorkArea(
requestedLeft: -2000,
requestedTop: 100,
windowWidth: 400,
windowHeight: 300,
workArea: leftMonitorWorkArea,
margin: 16
);
Assert.Equal(-1920 + 16, clamped.X);
Assert.Equal(100, clamped.Y);
}
[Fact]
public void Gate271_MultiDisplayLayoutEngine_ConvertsDpiToDips()
{
// Standard 96 DPI = 1.0 scale
Assert.Equal(100.0, MultiDisplayLayoutEngine.PixelsToDips(100, 96));
// 144 DPI (150% scaling) -> 150 pixels = 100 DIPs
Assert.Equal(100.0, MultiDisplayLayoutEngine.PixelsToDips(150, 144));
}
[Fact]
public void Gate272_MultiDisplayLayoutEngine_FindsBestScreen_ForWindow()
{
var screens = new List<Rect>
{
new(0, 0, 1920, 1080), // Primary screen
new(1920, 0, 1920, 1080) // Secondary screen to the right
};
// Window placed mostly on secondary screen (e.g. x=2000)
var bestScreen = MultiDisplayLayoutEngine.FindBestScreen(new Rect(2000, 200, 400, 300), screens);
Assert.Equal(1920, bestScreen.X);
}
}

View file

@ -0,0 +1,141 @@
using System;
using System.IO;
using System.Linq;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionAdvancedUxAndLocalizationGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _xamlPath;
private readonly string _csPath;
public QuickActionAdvancedUxAndLocalizationGateTests()
{
_xamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_csPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
}
[Fact]
public void Gate_QuickAction_CrossLanguageSearch_SupportsKoreanEnglishJapanese()
{
var catalog = new QuickActionCatalog();
var searchEngine = new QuickActionSearchEngine(catalog);
// Korean search
var koResults = searchEngine.Search("새 탭");
Assert.NotEmpty(koResults);
Assert.Equal("browser.new_tab", koResults[0].Action.Id);
// English search
var enResults = searchEngine.Search("new tab");
Assert.NotEmpty(enResults);
Assert.Equal("browser.new_tab", enResults[0].Action.Id);
// Screenshot
var shotKo = searchEngine.Search("스크린샷");
var shotEn = searchEngine.Search("screenshot");
Assert.NotEmpty(shotKo);
Assert.NotEmpty(shotEn);
}
[Fact]
public void Gate_QuickAction_CategoryFilter_FiltersAccurately()
{
var catalog = new QuickActionCatalog();
var searchEngine = new QuickActionSearchEngine(catalog);
var browserOnly = searchEngine.Search(string.Empty, maxResults: 50, categoryFilter: QuickActionCategory.Browser);
Assert.NotEmpty(browserOnly);
Assert.All(browserOnly, r => Assert.Equal(QuickActionCategory.Browser, r.Action.Category));
var devOnly = searchEngine.Search(string.Empty, maxResults: 50, categoryFilter: QuickActionCategory.Developer);
Assert.NotEmpty(devOnly);
Assert.All(devOnly, r => Assert.Equal(QuickActionCategory.Developer, r.Action.Category));
}
[Fact]
public void Gate_QuickAction_AutocompleteSuggestion_ReturnsTopTitle()
{
var catalog = new QuickActionCatalog();
var searchEngine = new QuickActionSearchEngine(catalog);
string? suggestion = searchEngine.GetTopSuggestion("새");
Assert.NotNull(suggestion);
Assert.StartsWith("새", suggestion);
}
[Fact]
public void Gate_QuickAction_DisplayLocalization_ResultsContainLocalizedStringsForCulture()
{
var catalog = new QuickActionCatalog();
var searchEngine = new QuickActionSearchEngine(catalog);
// English culture
var enResults = searchEngine.Search("new tab", cultureCode: "en-US");
Assert.NotEmpty(enResults);
Assert.Equal("New Tab", enResults[0].Title);
Assert.Equal("Browser", enResults[0].CategoryName);
// Japanese culture
var jaResults = searchEngine.Search("new tab", cultureCode: "ja-JP");
Assert.NotEmpty(jaResults);
Assert.Contains("タブ", jaResults[0].Title);
Assert.Equal("ブラウザ", jaResults[0].CategoryName);
// Korean culture
var koResults = searchEngine.Search("new tab", cultureCode: "ko-KR");
Assert.NotEmpty(koResults);
Assert.Equal("새 탭 열기", koResults[0].Title);
Assert.Equal("브라우저", koResults[0].CategoryName);
}
[Fact]
public void Gate_QuickAction_Xaml_UsesStrictDesignTokensAndUnclippedShadow()
{
Assert.True(File.Exists(_xamlPath));
var xaml = File.ReadAllText(_xamlPath);
// Root font inheritance from SSOT
Assert.Contains("FontFamily=\"{DynamicResource UiFont}\"", xaml);
// Correct token check: CardBgHover must be used, NOT CardHoverBg
Assert.DoesNotContain("CardHoverBg", xaml);
Assert.Contains("CardBgHover", xaml);
// Selection token check
Assert.Contains("SelectionBrush", xaml);
// Category chips and autocomplete hint
Assert.Contains("CategoryChipBar", xaml);
Assert.Contains("AutocompleteHintText", xaml);
// Unclipped shadow margin
Assert.Contains("Margin=\"20\"", xaml);
}
[Fact]
public void Gate_QuickAction_CodeBehind_SupportsKeyboardNavigationAndShortcuts()
{
Assert.True(File.Exists(_csPath));
var cs = File.ReadAllText(_csPath);
// Quick numbers Ctrl+1 ~ Ctrl+9
Assert.Contains("ExecuteQuickNumberAction", cs);
// Golden ratio positioning
Assert.Contains("0.28", cs);
// Autocomplete Tab logic & Right arrow
Assert.Contains("Key.Tab", cs);
Assert.Contains("Key.Right", cs);
// SetForegroundWindow for input conflict defense
Assert.Contains("SetForegroundWindow", cs);
// Category prefix triggers
Assert.Contains("CheckCategoryPrefixTrigger", cs);
}
}

View file

@ -0,0 +1,43 @@
using System;
using System.IO;
using Paca.App.Services;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionGlobalHotkeyAndTrayGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Theory]
[InlineData("Shift+Ctrl+Alt+P", true, true, true, false, 0x50)] // P
[InlineData("Ctrl+Alt+Space", true, true, false, false, 0x20)] // Space
[InlineData("Alt+Space", false, true, false, false, 0x20)]
public void Gate_GlobalHotkeyParser_ParsesModifiersAndKey(string hotkeyStr, bool expectedCtrl, bool expectedAlt, bool expectedShift, bool expectedWin, int expectedVk)
{
bool parsed = GlobalHotkeyService.TryParseHotkey(hotkeyStr, out uint fsModifiers, out uint vk);
Assert.True(parsed, $"단축키 문자열 '{hotkeyStr}'이 파싱되어야 합니다.");
bool hasCtrl = (fsModifiers & GlobalHotkeyService.MOD_CONTROL) != 0;
bool hasAlt = (fsModifiers & GlobalHotkeyService.MOD_ALT) != 0;
bool hasShift = (fsModifiers & GlobalHotkeyService.MOD_SHIFT) != 0;
bool hasWin = (fsModifiers & GlobalHotkeyService.MOD_WIN) != 0;
Assert.Equal(expectedCtrl, hasCtrl);
Assert.Equal(expectedAlt, hasAlt);
Assert.Equal(expectedShift, hasShift);
Assert.Equal(expectedWin, hasWin);
Assert.Equal((uint)expectedVk, vk);
}
[Fact]
public void Gate_WindowsTrayDaemon_CodeAudit_HasTrayAndMinimizeHandling()
{
var trayServicePath = Path.Combine(SolutionRoot, "src", "Paca.App", "Services", "WindowsTrayDaemon.cs");
Assert.True(File.Exists(trayServicePath), $"WindowsTrayDaemon.cs must exist at {trayServicePath}");
var code = File.ReadAllText(trayServicePath);
Assert.Contains("CloseToTray", code);
Assert.Contains("QuickAction", code);
}
}

View file

@ -0,0 +1,62 @@
using System;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionInlineEvaluatorGateTests
{
private readonly QuickActionInlineEvaluator _evaluator = new();
[Theory]
[InlineData("12 + 34", "46")]
[InlineData("100 * 5", "500")]
[InlineData("2 ^ 10", "1024")]
[InlineData("sqrt(144)", "12")]
[InlineData("= 25 * 4", "100")]
[InlineData("1000 - 350", "650")]
[InlineData("10 / 4", "2.5")]
public void Gate_QuickActionEvaluator_MathExpressions_CalculatesCorrectly(string input, string expectedResult)
{
bool evaluated = _evaluator.TryEvaluate(input, out var result);
Assert.True(evaluated, $"수식 '{input}'이 정상적으로 평가되어야 합니다.");
Assert.NotNull(result);
Assert.Equal(expectedResult, result.Value);
}
[Fact]
public void Gate_QuickActionEvaluator_DevConversions_Base64AndHexAndTimestamp()
{
// 1. Base64 encode / decode
bool b64Enc = _evaluator.TryEvaluate("b64 PACA Super App", out var resEnc);
Assert.True(b64Enc);
Assert.Equal("UEFDQSBTdXBlciBBcHA=", resEnc!.Value);
bool b64Dec = _evaluator.TryEvaluate("b64d UEFDQSBTdXBlciBBcHA=", out var resDec);
Assert.True(b64Dec);
Assert.Equal("PACA Super App", resDec!.Value);
// 2. Hex to RGB
bool hexRgb = _evaluator.TryEvaluate("hex #3b82f6", out var resRgb);
Assert.True(hexRgb);
Assert.Equal("rgb(59, 130, 246)", resRgb!.Value);
// 3. Timestamp now
bool ts = _evaluator.TryEvaluate("time now", out var resTs);
Assert.True(ts);
Assert.True(long.TryParse(resTs!.Value, out var val) && val > 1700000000);
}
[Theory]
[InlineData("10 km to mile", "6.21")]
[InlineData("100 kg to lb", "220.46")]
[InlineData("30 c to f", "86")]
[InlineData("1024 mb to gb", "1")]
public void Gate_QuickActionEvaluator_UnitConversions(string input, string expectedValuePrefix)
{
bool evaluated = _evaluator.TryEvaluate(input, out var result);
Assert.True(evaluated, $"단위 변환 '{input}'이 평가되어야 합니다.");
Assert.NotNull(result);
Assert.StartsWith(expectedValuePrefix, result.Value);
}
}

View file

@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Linq;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionLauncherIntegrationGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Fact]
public void Gate_MainWindow_IntegratesQuickActionServicesAndTray()
{
var mwPath = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml.cs");
Assert.True(File.Exists(mwPath), $"MainWindow.xaml.cs must exist at {mwPath}");
var code = File.ReadAllText(mwPath);
// Global hotkey integration
Assert.Contains("InitializeQuickActionServices", code);
Assert.Contains("Shift+Ctrl+Alt+P", code);
Assert.Contains("ShowQuickActionLauncher", code);
// Windows tray daemon integration
Assert.Contains("WindowsTrayDaemon", code);
Assert.Contains("CloseToTray", code);
Assert.Contains("ForceClose", code);
}
[Fact]
public void Gate_QuickActionLauncher_ExecuteAction_ExecutesHandlerWithContext()
{
var catalog = new QuickActionCatalog();
bool actionRan = false;
string? passedQuery = null;
var customAction = new QuickActionItem(
"test.custom_action",
"테스트 액션",
QuickActionCategory.Developer,
"test custom action",
"Ctrl+Alt+T",
"\uE710",
ctx =>
{
actionRan = true;
passedQuery = ctx.SelectedText;
}
);
catalog.RegisterAction(customAction);
var retrieved = catalog.FindById("test.custom_action");
Assert.NotNull(retrieved);
var context = new QuickActionContext(SelectedText: "hello world");
retrieved.Handler?.Invoke(context);
Assert.True(actionRan);
Assert.Equal("hello world", passedQuery);
}
[Fact]
public void Gate_QuickActionCatalog_Contains200PlusActionsAcross8Categories()
{
var catalog = new QuickActionCatalog();
var all = catalog.GetAllActions();
Assert.True(all.Count >= 200, $"Expected >= 200 actions, but got {all.Count}");
// Verify all 8 categories are populated
foreach (QuickActionCategory cat in Enum.GetValues<QuickActionCategory>())
{
var catItems = catalog.GetByCategory(cat);
Assert.True(catItems.Count > 0, $"Category {cat} must contain at least 1 action.");
}
}
}

View file

@ -0,0 +1,177 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionNextGenUxAndAnimationGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _xamlPath;
private readonly string _csPath;
public QuickActionNextGenUxAndAnimationGateTests()
{
_xamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_csPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
}
[Fact]
public void Gate01_QuickActionFuzzyMatcher_MatchesSubsequenceAndTypos()
{
// Subsequence abbreviation tests
Assert.True(QuickActionFuzzyMatcher.IsSubsequenceMatch("sct", "screenshot"));
Assert.True(QuickActionFuzzyMatcher.IsSubsequenceMatch("dwn", "download_manager"));
Assert.True(QuickActionFuzzyMatcher.IsSubsequenceMatch("clp", "clipboard"));
Assert.False(QuickActionFuzzyMatcher.IsSubsequenceMatch("xyz", "screenshot"));
// Levenshtein distance tests
int dist1 = QuickActionFuzzyMatcher.LevenshteinDistance("screenshot", "screnshot"); // 1 deletion
Assert.Equal(1, dist1);
int dist2 = QuickActionFuzzyMatcher.LevenshteinDistance("download", "downlaod"); // 2 transposition
Assert.True(dist2 <= 2);
// Search score evaluation
int scoreSub = QuickActionFuzzyMatcher.CalculateFuzzyScore("sct", "screenshot");
Assert.True(scoreSub > 0, "Subsequence should yield positive score");
int scoreTypo = QuickActionFuzzyMatcher.CalculateFuzzyScore("screnshot", "screenshot");
Assert.True(scoreTypo > 0, "Typo with distance 1 should yield positive score");
}
[Fact]
public void Gate02_QuickActionUsageTracker_RecordsExecutionAndBoostsScore()
{
string tempFile = Path.Combine(Path.GetTempPath(), $"paca_usage_test_{Guid.NewGuid():N}.json");
try
{
var tracker = new QuickActionUsageTracker(tempFile);
Assert.Equal(0, tracker.GetScoreBoost("browser.new_tab"));
// Record execution
tracker.RecordExecution("browser.new_tab");
tracker.RecordExecution("browser.new_tab");
tracker.RecordExecution("sys.screenshot_full");
// Verify count & boosts
Assert.True(tracker.GetScoreBoost("browser.new_tab") >= 100);
Assert.True(tracker.GetScoreBoost("sys.screenshot_full") >= 50);
Assert.Equal(0, tracker.GetScoreBoost("media.unrecorded_action"));
// Verify persistence
Assert.True(File.Exists(tempFile));
var tracker2 = new QuickActionUsageTracker(tempFile);
Assert.True(tracker2.GetScoreBoost("browser.new_tab") >= 100);
// Recent actions list
var recent = tracker2.GetRecentActionIds(5);
Assert.Contains("browser.new_tab", recent);
Assert.Contains("sys.screenshot_full", recent);
}
finally
{
if (File.Exists(tempFile)) File.Delete(tempFile);
}
}
[Fact]
public async Task Gate03_QuickActionParameterRouter_RoutesSubCommandsCorrectly()
{
var router = new QuickActionParameterRouter();
// 1. Open URL
Assert.True(router.TryParseCommand("open https://example.com", out var openAction));
Assert.NotNull(openAction);
Assert.Contains("https://example.com", openAction.Title);
// 2. Base64 encode
Assert.True(router.TryParseCommand("b64 hello", out var b64Action));
Assert.NotNull(b64Action);
string? copied = null;
var ctx = new QuickActionContext(ClipboardSetter: text => copied = text);
Assert.NotNull(b64Action.Handler);
var result = await b64Action.Handler.Invoke(ctx);
Assert.True(result.Success);
Assert.Equal("aGVsbG8=", copied);
// 3. Base64 decode
Assert.True(router.TryParseCommand("b64d aGVsbG8=", out var b64dAction));
Assert.NotNull(b64dAction);
Assert.NotNull(b64dAction.Handler);
var resultD = await b64dAction.Handler.Invoke(ctx);
Assert.True(resultD.Success);
Assert.Equal("hello", copied);
// 4. SHA-256 hash
Assert.True(router.TryParseCommand("hash test", out var hashAction));
Assert.NotNull(hashAction);
Assert.True(hashAction.Title.Contains("SHA-256") || hashAction.Description?.Contains("SHA-256") == true);
// 5. Normal non-command query should return false
Assert.False(router.TryParseCommand("새 탭 열기", out _));
}
[Fact]
public void Gate04_QuickActionLauncher_ContainsScaleAndFadeAnimationResources()
{
Assert.True(File.Exists(_xamlPath), $"XAML not found at {_xamlPath}");
string xaml = File.ReadAllText(_xamlPath);
// 1. Check window width is 860 for split view
Assert.Contains("Width=\"860\"", xaml);
// 2. Check ScaleTransform and TranslateTransform are defined in RootCard
Assert.Contains("<ScaleTransform", xaml);
Assert.Contains("<TranslateTransform", xaml);
// 3. Check CubicEase or QuadraticEase is used for smooth animations
Assert.Contains("CubicEase", xaml);
// 4. Check animation duration <= 250ms for vestibulo-ocular reflex protection (WCAG SC 2.3.3)
var durationMatches = Regex.Matches(xaml, @"Duration=""0:0:0\.([0-9]+)""");
Assert.NotEmpty(durationMatches);
foreach (Match match in durationMatches)
{
int ms = int.Parse(match.Groups[1].Value.PadRight(3, '0')[..3]);
Assert.True(ms <= 250, $"Animation duration {ms}ms exceeds safe threshold (250ms)");
}
}
[Fact]
public void Gate05_QuickActionLauncher_ContainsActionDetailPreviewPane()
{
Assert.True(File.Exists(_xamlPath), $"XAML not found at {_xamlPath}");
string xaml = File.ReadAllText(_xamlPath);
// Check for ActionDetailPane elements
Assert.Contains("ActionDetailPane", xaml);
Assert.Contains("DetailActionTitle", xaml);
Assert.Contains("DetailActionId", xaml);
Assert.Contains("DetailActionDesc", xaml);
Assert.Contains("DetailCategoryBadge", xaml);
}
[Fact]
public void Gate06_QuickActionLauncher_ContainsDragMoveAndMouseHoverGuards()
{
Assert.True(File.Exists(_csPath), $"Code-behind not found at {_csPath}");
string cs = File.ReadAllText(_csPath);
// 1. DragMove support for dragging launcher window
Assert.Contains("DragMove()", cs);
// 2. Mouse move guard to avoid keyboard navigation conflict
Assert.Contains("_lastMousePosition", cs);
// 3. Micro-feedback for action completion / clipboard copy
Assert.Contains("ShowMicroFeedback", cs);
// 4. Animated show / hide
Assert.Contains("ShowLauncherAnimated", cs);
Assert.Contains("HideLauncherAnimated", cs);
}
}

View file

@ -0,0 +1,203 @@
using System;
using System.IO;
using System.Linq;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionPillModeAndTabSearchTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _xamlPath;
private readonly string _launcherCsPath;
private readonly string _mwCsPath;
public QuickActionPillModeAndTabSearchTests()
{
_xamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_launcherCsPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
_mwCsPath = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml.cs");
}
[Fact]
public void Gate211_KakaoMapSearchAction_GeneratesKakaoMapUrl_AndNavigatesTabOpener()
{
var catalog = new QuickActionCatalog();
var kakaoMapAction = catalog.FindById("search.kakao_map");
Assert.NotNull(kakaoMapAction);
Assert.Equal("카카오맵 장소 검색", kakaoMapAction.Title);
Assert.Equal(QuickActionCategory.Search, kakaoMapAction.Category);
Assert.NotNull(kakaoMapAction.SearchUrlTemplate);
Assert.Contains("map.kakao.com", kakaoMapAction.SearchUrlTemplate);
// 1. With search parameter
string? openedUrlWithParam = null;
var ctxWithParam = new QuickActionContext(
Parameter: "강남역 맛집",
TabOpener: url => openedUrlWithParam = url
);
kakaoMapAction.Handler?.Invoke(ctxWithParam);
Assert.NotNull(openedUrlWithParam);
Assert.Contains("map.kakao.com", openedUrlWithParam);
Assert.Contains(Uri.EscapeDataString("강남역 맛집"), openedUrlWithParam);
// 2. Without search parameter (direct site landing)
string? openedUrlEmpty = null;
var ctxEmpty = new QuickActionContext(
Parameter: "",
TabOpener: url => openedUrlEmpty = url
);
kakaoMapAction.Handler?.Invoke(ctxEmpty);
Assert.NotNull(openedUrlEmpty);
Assert.Equal("https://map.kakao.com/", openedUrlEmpty);
}
[Theory]
[InlineData("search.google", "google.com")]
[InlineData("search.youtube", "youtube.com")]
[InlineData("search.naver", "search.naver.com")]
[InlineData("search.daum", "search.daum.net")]
[InlineData("search.namuwiki", "namu.wiki")]
[InlineData("search.wikipedia", "wikipedia.org")]
[InlineData("search.github", "github.com")]
[InlineData("search.naver_map", "map.naver.com")]
[InlineData("search.kakao_map", "map.kakao.com")]
[InlineData("search.coupang", "coupang.com")]
[InlineData("search.duckduckgo", "duckduckgo.com")]
public void Gate212_CoreSearchEngines_AllConfiguredWithSearchUrlTemplates(string actionId, string expectedHost)
{
var catalog = new QuickActionCatalog();
var action = catalog.FindById(actionId);
Assert.NotNull(action);
Assert.False(string.IsNullOrWhiteSpace(action.SearchUrlTemplate), $"SearchUrlTemplate must not be empty for {actionId}");
Assert.Contains(expectedHost, action.SearchUrlTemplate);
Assert.Contains("{query}", action.SearchUrlTemplate);
string? openedUrl = null;
var ctx = new QuickActionContext(
Parameter: "test query",
TabOpener: url => openedUrl = url
);
action.Handler?.Invoke(ctx);
Assert.NotNull(openedUrl);
Assert.Contains(expectedHost, openedUrl);
Assert.Contains(Uri.EscapeDataString("test query"), openedUrl);
}
[Fact]
public void Gate213_QuickActionLauncherXaml_ContainsActionPillElement_AndSubComponents()
{
Assert.True(File.Exists(_xamlPath), $"XAML must exist at {_xamlPath}");
var xaml = File.ReadAllText(_xamlPath);
// ActionPill container and components
Assert.Contains("x:Name=\"ActionPill\"", xaml);
Assert.Contains("x:Name=\"ActionPillIcon\"", xaml);
Assert.Contains("x:Name=\"ActionPillTitle\"", xaml);
Assert.Contains("x:Name=\"ActionPillColon\"", xaml);
// Pill visual styling tokens
Assert.Contains("CornerRadius=\"6\"", xaml);
Assert.Contains("AccentFadeBrush", xaml);
Assert.Contains("AccentBrush", xaml);
Assert.Contains("Visibility=\"Collapsed\"", xaml);
}
[Fact]
public void Gate214_QuickActionLauncherCodeBehind_SupportsPillModeLifecycle()
{
Assert.True(File.Exists(_launcherCsPath), $"Launcher CS must exist at {_launcherCsPath}");
var code = File.ReadAllText(_launcherCsPath);
// Pill mode lifecycle API
Assert.Contains("EnterPillMode", code);
Assert.Contains("ExitPillMode", code);
Assert.Contains("ActivePillAction", code);
Assert.Contains("IsInPillMode", code);
// Key bindings: Tab triggers pill mode, Backspace when empty exits pill mode
Assert.Contains("Key.Tab", code);
Assert.Contains("Key.Back", code);
Assert.Contains("TabOpener", code);
}
[Fact]
public void Gate215_MainWindow_Provides_OpenUrlInNewTab()
{
Assert.True(File.Exists(_mwCsPath), $"MainWindow CS must exist at {_mwCsPath}");
var code = File.ReadAllText(_mwCsPath);
Assert.Contains("OpenUrlInNewTab", code);
Assert.Contains("Cv2Navigate", code);
}
[Fact]
public void Gate216_LiveLauncher_EndToEnd_PillMode_Tab_Typing_Execution_And_Backspace()
{
Exception? threadEx = null;
var thread = new System.Threading.Thread(() =>
{
try
{
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog);
// 1. Initial state: Pill is collapsed
Assert.False(launcher.IsInPillMode);
Assert.Null(launcher.ActivePillAction);
// 2. Select kakao map action
var kakaoAction = catalog.FindById("search.kakao_map");
Assert.NotNull(kakaoAction);
// 3. Enter Pill Mode
launcher.EnterPillMode(kakaoAction);
Assert.True(launcher.IsInPillMode);
Assert.Equal(kakaoAction, launcher.ActivePillAction);
// 4. Input query text "강남역 맛집"
launcher.QuickActionSearchBox.Text = "강남역 맛집";
// 5. Execute action and verify ActionExecuted event
string? executedActionId = null;
string? executedQuery = null;
launcher.ActionExecuted += (act, q) =>
{
executedActionId = act.Id;
executedQuery = q;
};
launcher.ExecuteSelectedAction();
Assert.Equal("search.kakao_map", executedActionId);
Assert.Equal("강남역 맛집", executedQuery);
Assert.False(launcher.IsInPillMode); // Resets after execution
// 6. Test Backspace exiting Pill Mode
launcher.EnterPillMode(kakaoAction);
Assert.True(launcher.IsInPillMode);
launcher.QuickActionSearchBox.Text = "";
launcher.ExitPillMode(restoreActionTitle: true);
Assert.False(launcher.IsInPillMode);
Assert.Equal(kakaoAction.Title, launcher.QuickActionSearchBox.Text);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(System.Threading.ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null) throw threadEx;
}
}

View file

@ -0,0 +1,69 @@
using System;
using System.Linq;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionRegistryAndCatalogGateTests
{
[Fact]
public void Gate_QuickActionCatalog_ContainsAtLeast200Actions()
{
var catalog = new QuickActionCatalog();
var actions = catalog.GetAllActions();
Assert.NotNull(actions);
Assert.True(actions.Count >= 200, $"퀵 액션 총 개수는 적어도 200개 이상이어야 합니다. 현재: {actions.Count}개");
// Verify ID uniqueness
var duplicateIds = actions.GroupBy(a => a.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
Assert.Empty(duplicateIds);
}
[Fact]
public void Gate_QuickActionCatalog_AllCategoriesCovered()
{
var catalog = new QuickActionCatalog();
var actions = catalog.GetAllActions();
var categories = Enum.GetValues<QuickActionCategory>();
foreach (var cat in categories)
{
var inCat = actions.Where(a => a.Category == cat).ToList();
Assert.True(inCat.Count >= 10, $"카테고리 '{cat}'에 최소 10개 이상의 액션이 포함되어야 합니다. 현재: {inCat.Count}개");
}
}
[Fact]
public void Gate_QuickActionCatalog_AllActionsHaveValidMetadata()
{
var catalog = new QuickActionCatalog();
var actions = catalog.GetAllActions();
foreach (var action in actions)
{
Assert.False(string.IsNullOrWhiteSpace(action.Id), "액션 ID는 비어 있을 수 없습니다.");
Assert.False(string.IsNullOrWhiteSpace(action.Title), $"액션 ID '{action.Id}'의 Title이 비어 있습니다.");
Assert.False(string.IsNullOrWhiteSpace(action.Keywords), $"액션 ID '{action.Id}'의 Keywords가 비어 있습니다.");
Assert.NotNull(action.Handler);
}
}
[Fact]
public void Gate_QuickActionSearchEngine_FuzzyAndKeywordSearch()
{
var catalog = new QuickActionCatalog();
var searcher = new QuickActionSearchEngine(catalog);
// Search by title or keyword
var lockResults = searcher.Search("잠금");
Assert.Contains(lockResults, r => r.Action.Id == "sys.lock_workstation");
var b64Results = searcher.Search("base64");
Assert.Contains(b64Results, r => r.Action.Id == "dev.base64_encode");
var newTabResults = searcher.Search("새 탭");
Assert.Contains(newTabResults, r => r.Action.Id == "browser.new_tab");
}
}

View file

@ -0,0 +1,136 @@
using System;
using System.IO;
using System.Linq;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionSpotlightTwoPhase20Tests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Theory]
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ", ClipboardIntentType.MediaDownload)]
[InlineData("https://twitter.com/user/status/123456789", ClipboardIntentType.MediaDownload)]
[InlineData("https://example.com/live/stream.m3u8", ClipboardIntentType.MediaDownload)]
[InlineData("https://news.ycombinator.com", ClipboardIntentType.BrowseUrl)]
[InlineData("125 * 40 - 15", ClipboardIntentType.Expression)]
[InlineData("just regular plain text", ClipboardIntentType.None)]
[InlineData(null, ClipboardIntentType.None)]
public void Gate201_ClipboardDetector_AccuratelyClassifiesContent(string? text, ClipboardIntentType expected)
{
var detector = new QuickActionClipboardDetector();
var result = detector.DetectIntent(text);
Assert.Equal(expected, result.IntentType);
if (expected != ClipboardIntentType.None)
{
Assert.False(string.IsNullOrWhiteSpace(result.SuggestedActionTitle));
}
}
[Fact]
public void Gate202_FavoritesManager_AddRemoveAndPersistFavorites()
{
string tempJson = Path.Combine(Path.GetTempPath(), $"paca_fav_test_{Guid.NewGuid():N}.json");
try
{
var manager = new QuickActionFavoritesManager(tempJson);
Assert.False(manager.IsFavorite("browser.new_tab"));
// 1. Toggle on
bool added = manager.ToggleFavorite("browser.new_tab");
Assert.True(added);
Assert.True(manager.IsFavorite("browser.new_tab"));
Assert.Contains("browser.new_tab", manager.GetFavorites());
// 2. Reload from JSON disk
var manager2 = new QuickActionFavoritesManager(tempJson);
Assert.True(manager2.IsFavorite("browser.new_tab"));
// 3. Toggle off
bool removed = manager2.ToggleFavorite("browser.new_tab");
Assert.False(removed);
Assert.False(manager2.IsFavorite("browser.new_tab"));
Assert.DoesNotContain("browser.new_tab", manager2.GetFavorites());
}
finally
{
if (File.Exists(tempJson)) File.Delete(tempJson);
}
}
[Fact]
public void Gate203_UserCustomActionStore_RegistersAndInstantiatesQuickActionItem()
{
string tempJson = Path.Combine(Path.GetTempPath(), $"paca_custom_test_{Guid.NewGuid():N}.json");
try
{
var store = new UserCustomActionStore(tempJson);
var custom = new UserCustomAction(
Id: "custom.my_intranet",
Title: "사내 인트라넷 열기",
Keywords: "intranet work 사내포털",
TargetUrl: "https://intranet.company.internal",
ShortcutHint: "Ctrl+Alt+I"
);
store.AddOrUpdate(custom);
Assert.Single(store.GetAll());
// Convert to QuickActionItem
var catalog = new QuickActionCatalog();
store.InjectIntoCatalog(catalog);
var injected = catalog.FindById("custom.my_intranet");
Assert.NotNull(injected);
Assert.Equal("사내 인트라넷 열기", injected.Title);
Assert.Equal(QuickActionCategory.Ecosystem, injected.Category);
// Reload from disk
var store2 = new UserCustomActionStore(tempJson);
Assert.Single(store2.GetAll());
Assert.Equal("custom.my_intranet", store2.GetAll().First().Id);
}
finally
{
if (File.Exists(tempJson)) File.Delete(tempJson);
}
}
[Theory]
[InlineData(10, "방금 전")]
[InlineData(300, "5분 전")]
[InlineData(7200, "2시간 전")]
[InlineData(90000, "어제")]
public void Gate204_QuickActionTimeFormatter_RendersNaturalRelativeTime(int secondsAgo, string expectedKorean)
{
var now = DateTime.UtcNow;
var past = now.AddSeconds(-secondsAgo);
string formatted = QuickActionTimeFormatter.FormatRelativeTime(past, now, "ko");
Assert.Equal(expectedKorean, formatted);
}
[Fact]
public void Gate205_LauncherWindowXaml_ContainsClipboardBannerAndFavoritesSupport()
{
string xamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
string xaml = File.ReadAllText(xamlPath);
Assert.Contains("x:Name=\"ClipboardSuggestionBanner\"", xaml);
Assert.Contains("x:Name=\"ClipboardBannerTitle\"", xaml);
}
[Fact]
public void Gate206_LauncherWindowCs_WiresClipboardDetectionOnActivate()
{
string csPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
string cs = File.ReadAllText(csPath);
Assert.Contains("CheckClipboardIntent()", cs);
Assert.Contains("QuickActionClipboardDetector", cs);
}
}

View file

@ -0,0 +1,49 @@
using System;
using System.IO;
using Xunit;
namespace Paca.Tests.App;
public class QuickActionSpotlightUiGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _xamlPath;
private readonly string _csPath;
public QuickActionSpotlightUiGateTests()
{
_xamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_csPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
}
[Fact]
public void Gate_QuickActionSpotlight_XamlStructure_ContainsEssentialControls()
{
Assert.True(File.Exists(_xamlPath), $"QuickActionLauncherWindow.xaml must exist at {_xamlPath}");
var xaml = File.ReadAllText(_xamlPath);
// Core interactive controls
Assert.Contains("x:Name=\"QuickActionSearchBox\"", xaml);
Assert.Contains("x:Name=\"QuickActionResultsList\"", xaml);
Assert.Contains("x:Name=\"InlineEvaluatorCard\"", xaml);
// Accessibility & UIA FastPass
Assert.Contains("AutomationProperties.Name", xaml);
// Design token binding
Assert.Contains("DynamicResource", xaml);
Assert.Contains("CornerRadius=\"12\"", xaml); // Modern Spotlight card
}
[Fact]
public void Gate_QuickActionSpotlight_CodeBehind_HandlesExecutionAndSearch()
{
Assert.True(File.Exists(_csPath), $"QuickActionLauncherWindow.xaml.cs must exist at {_csPath}");
var cs = File.ReadAllText(_csPath);
Assert.Contains("QuickActionCatalog", cs);
Assert.Contains("QuickActionSearchEngine", cs);
Assert.Contains("QuickActionInlineEvaluator", cs);
Assert.Contains("ExecuteSelectedAction", cs);
}
}

View file

@ -0,0 +1,66 @@
using System;
using Paca.App.ViewModels;
using Xunit;
namespace Paca.Tests.App;
public class QuickCaptureHudGateTests
{
[Fact]
public void Gate223_QuickCaptureHudViewModel_Initializes_WithDetectedUrl_AndExtractsDomain()
{
var url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
var vm = new QuickCaptureHudViewModel(url);
Assert.Equal(url, vm.TargetUrl);
Assert.Equal("youtube.com", vm.DomainLabel);
Assert.Equal("mp4", vm.SelectedFormat);
Assert.True(vm.IsVisible);
}
[Fact]
public void Gate224_QuickCaptureHudViewModel_DownloadCommand_TriggersOnDownloadRequested()
{
var url = "https://twitter.com/user/status/123456789";
var vm = new QuickCaptureHudViewModel(url);
string? requestedUrl = null;
string? requestedFormat = null;
vm.DownloadRequested += (u, f) =>
{
requestedUrl = u;
requestedFormat = f;
};
vm.DownloadCommand.Execute(null);
Assert.Equal(url, requestedUrl);
Assert.Equal("mp4", requestedFormat);
Assert.False(vm.IsVisible); // Dismissed upon action
}
[Fact]
public void Gate225_QuickCaptureHudViewModel_SendToPhoneCommand_TriggersOnSendToPhoneRequested()
{
var url = "https://instagram.com/reel/abcdef";
var vm = new QuickCaptureHudViewModel(url);
string? sentUrl = null;
vm.SendToPhoneRequested += u => sentUrl = u;
vm.SendToPhoneCommand.Execute(null);
Assert.Equal(url, sentUrl);
Assert.False(vm.IsVisible); // Dismissed upon action
}
[Fact]
public void Gate226_QuickCaptureHudViewModel_Dismiss_ClosesHud()
{
var vm = new QuickCaptureHudViewModel("https://test.com/video");
Assert.True(vm.IsVisible);
vm.DismissCommand.Execute(null);
Assert.False(vm.IsVisible);
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using Paca.App.Browser;
using Paca.App.ViewModels;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class QuickCaptureHudWindowGateTests
{
[Fact]
public void Gate227_QuickCaptureHudWindow_HasCorrectWindowStyles()
{
using var sta = StaDispatcher.Start();
sta.Invoke(() =>
{
var vm = new QuickCaptureHudViewModel("https://youtube.com/watch?v=abc");
var window = new QuickCaptureHudWindow(vm);
Assert.True(window.AllowsTransparency);
Assert.Equal(WindowStyle.None, window.WindowStyle);
Assert.False(window.ShowInTaskbar);
Assert.True(window.Topmost);
});
}
[Fact]
public void Gate228_QuickCaptureHudWindow_BindsViewModel_AndExposesControls()
{
using var sta = StaDispatcher.Start();
sta.Invoke(() =>
{
var vm = new QuickCaptureHudViewModel("https://twitter.com/test/video");
var window = new QuickCaptureHudWindow(vm);
Assert.Equal(vm, window.ViewModel);
Assert.NotNull(window.DownloadButton);
Assert.NotNull(window.SendToPhoneButton);
Assert.NotNull(window.DismissButton);
});
}
[Fact]
public void Gate229_QuickCaptureHudWindow_StrictDesignTokens_Audit()
{
using var sta = StaDispatcher.Start();
sta.Invoke(() =>
{
var vm = new QuickCaptureHudViewModel("https://instagram.com/reel/xyz");
var window = new QuickCaptureHudWindow(vm);
// 1. Min button height >= 32px
Assert.True(window.DownloadButton.MinHeight >= 32, "Download button height >= 32");
Assert.True(window.SendToPhoneButton.MinHeight >= 32, "SendToPhone button height >= 32");
// 2. Strict no emoji regex audit
var emojiRegex = new Regex(@"[\uD83C-\uDBFF\uDC00-\uDFFF]");
Assert.DoesNotMatch(emojiRegex, window.DownloadButton.Content?.ToString() ?? "");
Assert.DoesNotMatch(emojiRegex, window.SendToPhoneButton.Content?.ToString() ?? "");
});
}
[Fact]
public void Gate230_QuickCaptureHudWindow_Positioning_CalculatesBottomRightCoordinates()
{
using var sta = StaDispatcher.Start();
sta.Invoke(() =>
{
var vm = new QuickCaptureHudViewModel("https://example.com");
var window = new QuickCaptureHudWindow(vm);
var (left, top) = window.CalculateDefaultPosition(1920, 1080, 420, 100);
// Bottom right with margin: left ~ 1920 - 420 - 24 = 1476, top ~ 1080 - 100 - 64 = 916
Assert.True(left > 1400);
Assert.True(top > 900);
});
}
}

View file

@ -0,0 +1,109 @@
using System.IO;
using System.Text.RegularExpressions;
using Paca.App;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class ScrollBarAndSectionBorderGateTests
{
[Fact]
public void Gate475_LightAndDarkThemes_ScrollBarThumbBrush_ContrastAndAlpha_Audit()
{
// DoD: 라이트 테마에서 스크롤바가 시커멓게 렌더링되지 않도록
// 1. Light.xaml 에 미니멀 반투명 스크롤바 브러시가 정의되어야 함
var lightXaml = SourceTree.ThemeLightXaml;
Assert.True(SourceTree.HasKey(lightXaml, "ScrollBarThumbBrush"), "Light.xaml must define ScrollBarThumbBrush");
Assert.True(SourceTree.HasKey(lightXaml, "ScrollBarTrackBrush"), "Light.xaml must define ScrollBarTrackBrush");
// 2. Shared.xaml 에서 정적 다크 색상(#4A5568)으로 덮어쓰지 않고 각 테마 사전을 존중해야 함
var sharedXaml = File.ReadAllText(SourceTree.AppPath("Themes/Shared.xaml"));
Assert.DoesNotContain("<SolidColorBrush x:Key=\"ScrollBarThumbBrush\" Color=\"#4A5568\"/>", sharedXaml);
}
[Fact]
public void Gate476_MainWindow_SectionBorders_MinimalFlatSurfaces_Audit()
{
// DoD: 불필요한 1px 와이어프레임 보더라인을 소거하고 서피스 배경색으로 유기적 분리
var mainWindowXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
// 1. 우측 도킹 패널(Grid.Column="2")의 불필요한 BorderThickness 1px 제거 (BorderThickness="0")
var panelMatch = Regex.Match(mainWindowXaml, @"<Border[^>]*Grid\.Column=""2""[^>]*BorderThickness=""([^""]+)""");
Assert.True(panelMatch.Success, "Grid.Column 2 Border should have BorderThickness attribute");
Assert.Equal("0", panelMatch.Groups[1].Value);
// 2. RightSplitter 내 하드코딩된 1px Rectangle 보더선 제거 (이중 보더 방지)
Assert.DoesNotContain("<Rectangle Width=\"1\" HorizontalAlignment=\"Center\" Fill=\"{DynamicResource BorderSubtle}\"/>", mainWindowXaml);
// 3. SideTabsContainer(세그먼트 탭 컨테이너)의 테두리 1px 제거 (BorderThickness="0")
var sideTabsMatch = Regex.Match(mainWindowXaml, @"<Border[^>]*x:Name=""SideTabsContainer""[^>]*BorderThickness=""([^""]+)""");
Assert.True(sideTabsMatch.Success, "SideTabsContainer should have BorderThickness");
Assert.Equal("0", sideTabsMatch.Groups[1].Value);
// 4. ToolbarRow 내 캡션 버튼 직전의 1px 분리선 제거
Assert.DoesNotContain("<Border Width=\"1\" Background=\"{DynamicResource BorderSubtle}\" Margin=\"6,8\"/>", mainWindowXaml);
// 5. RightTabSideHost(우측 세로 탭 호스트) 불필요 1px 보더 제거 (BorderThickness="0")
var rightTabMatch = Regex.Match(mainWindowXaml, @"<Border[^>]*x:Name=""RightTabSideHost""[^>]*BorderThickness=""([^""]+)""");
if (rightTabMatch.Success)
{
Assert.Equal("0", rightTabMatch.Groups[1].Value);
}
}
[Fact]
public void Gate477_TabViewService_PreferredColorScheme_SynchronizedWithAppTheme_Audit()
{
// DoD: WebView2 초기화 시 CoreWebView2Profile.PreferredColorScheme 을 테마에 동기화
var tabViewServiceCs = File.ReadAllText(SourceTree.AppPath("Services/TabViewService.cs"));
Assert.Contains("PreferredColorScheme", tabViewServiceCs);
Assert.Contains("CoreWebView2PreferredColorScheme", tabViewServiceCs);
}
[Fact]
public void Gate478_TabViewService_ScrollbarThemeScript_InjectedForLightAndDark_Audit()
{
// DoD: WebView2 웹 콘텐츠 스크롤바가 OS 다크모드에 의해 17px 검정으로 튀지 않도록
// 커스텀 미니멀 스크롤바 스타일 스크립트(AddScriptToExecuteOnDocumentCreatedAsync)가 등록되어야 함
var tabViewServiceCs = File.ReadAllText(SourceTree.AppPath("Services/TabViewService.cs"));
Assert.Contains("::-webkit-scrollbar", tabViewServiceCs);
Assert.Contains("scrollbar-color", tabViewServiceCs);
}
[Fact]
public void Gate479_MainWindow_LiveThemeSwitch_UpdatesAllTabsPreferredColorScheme_Audit()
{
// DoD: 라이브 테마 전환 시 열려있는 모든 탭의 PreferredColorScheme 및 스크롤바를 갱신
var usecasesCs = File.ReadAllText(SourceTree.AppPath("MainWindow.Usecases.cs"));
Assert.Contains("PreferredColorScheme", usecasesCs);
}
[Fact]
public void Gate480_MainWindow_DesktopBrowser_NoWhiteBorderArtifacts_Audit()
{
// DoD: 데스크탑 브라우저 UI에서 툴바 하단 및 우측 스플리터 영역에 흰색 보더/라인 아티팩트가 노출되지 않아야 함
var mainWindowXaml = File.ReadAllText(SourceTree.AppPath("MainWindow.xaml"));
// 1. ToolbarRow 하단의 2px 그라디언트 보더(AccentFadeBrush)로 인한 흰색 라인 방지: BorderThickness="0"
var toolbarMatch = Regex.Match(mainWindowXaml, @"<Border[^>]*x:Name=""ToolbarRow""[^>]*BorderThickness=""([^""]+)""");
Assert.True(toolbarMatch.Success, "ToolbarRow should have BorderThickness attribute");
Assert.Equal("0", toolbarMatch.Groups[1].Value);
// 2. RightSplitter 가 Transparent 로 남아 윈도우 배경(#FFFFFF)이 투과되는 7px 흰색 줄 방지: Background="{DynamicResource PanelBg}"
var splitterMatch = Regex.Match(mainWindowXaml, @"<GridSplitter[^>]*x:Name=""RightSplitter""[^>]*Background=""([^""]+)""");
Assert.True(splitterMatch.Success, "RightSplitter should specify Background");
Assert.Contains("PanelBg", splitterMatch.Groups[1].Value);
// 3. RightSplitter Template 내부 handle 도 PanelBg 배경을 유지해야 함
var handleMatch = Regex.Match(mainWindowXaml, @"<Border[^>]*x:Name=""handle""[^>]*Background=""([^""]+)""");
Assert.True(handleMatch.Success, "RightSplitter handle should specify Background");
Assert.Contains("PanelBg", handleMatch.Groups[1].Value);
// 4. MainGrid 에 PanelBg 배경이 지정되어 그리드 간극 투과를 원천 차단
var mainGridMatch = Regex.Match(mainWindowXaml, @"<Grid[^>]*x:Name=""MainGrid""[^>]*Background=""([^""]+)""");
Assert.True(mainGridMatch.Success, "MainGrid should specify Background=\"{DynamicResource PanelBg}\"");
Assert.Contains("PanelBg", mainGridMatch.Groups[1].Value);
}
}

View file

@ -0,0 +1,274 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class SpotlightDeepIntegrationAndAnimationTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _launcherXamlPath;
private readonly string _launcherCsPath;
private readonly string _mwXamlPath;
private readonly string _mwCsPath;
public SpotlightDeepIntegrationAndAnimationTests()
{
_launcherXamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_launcherCsPath = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
_mwXamlPath = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml");
_mwCsPath = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml.cs");
}
[Fact]
public void Gate217_MainWindow_CtrlK_And_SpotlightButton_Wiring_To_ShowQuickActionLauncher()
{
var xaml = File.ReadAllText(_mwXamlPath);
var cs = File.ReadAllText(_mwCsPath);
// 1. MainWindow.xaml must contain a dedicated Spotlight button
Assert.Contains("x:Name=\"SpotlightBtn\"", xaml);
Assert.Contains("SpotlightBtn_Click", xaml);
Assert.Contains("스포트라이트", xaml);
// 2. MainWindow.xaml.cs must route Ctrl+K to ShowQuickActionLauncher
Assert.Contains("Key.K", cs);
Assert.Contains("ShowQuickActionLauncher", cs);
Assert.Contains("SpotlightBtn_Click", cs);
}
[Fact]
public void Gate218_ActionPill_CleanPillBadge_ToolTip_And_ScaleTransform_Audit()
{
var xaml = File.ReadAllText(_launcherXamlPath);
var cs = File.ReadAllText(_launcherCsPath);
// 1. ActionPill elements in XAML: Clean pill without unwanted close button
Assert.DoesNotContain("x:Name=\"ActionPillCloseBtn\"", xaml);
Assert.Contains("x:Name=\"ActionPillScaleTransform\"", xaml);
Assert.Contains("ActionPill_MouseLeftButtonDown", xaml);
Assert.Contains("ActionPillColon", xaml);
// 2. Codebehind must have mouse click and animation handling
Assert.Contains("ActionPill_MouseLeftButtonDown", cs);
Assert.Contains("ActionPillScaleTransform", cs);
}
[Theory]
[InlineData("https://github.com/torvalds/linux", "https://github.com/torvalds/linux")]
[InlineData("http://example.com", "http://example.com")]
[InlineData("www.google.com", "https://www.google.com")]
[InlineData("naver.com", "https://naver.com")]
[InlineData("news.ycombinator.com/news", "https://news.ycombinator.com/news")]
public void Gate219_DirectUrl_Input_In_Spotlight_Generates_DirectNavigate_Result(string input, string expectedUrl)
{
var catalog = new QuickActionCatalog();
var tracker = new QuickActionUsageTracker();
var engine = new QuickActionSearchEngine(catalog, tracker);
var results = engine.Search(input, 10, null, "ko-KR");
Assert.NotEmpty(results);
var first = results[0];
Assert.Equal("browser.direct_navigate", first.Action.Id);
Assert.NotNull(first.Action.SearchUrlTemplate);
Assert.Equal(expectedUrl, first.Action.SearchUrlTemplate);
Assert.Contains("웹 주소로 바로 이동", first.Title);
}
[Fact]
public void Gate220_MainWindow_ExecuteQuickAction_Dispatches_Core_Browser_Actions()
{
var cs = File.ReadAllText(_mwCsPath);
Assert.Contains("ExecuteQuickAction", cs);
Assert.Contains("case \"browser.new_tab\":", cs);
Assert.Contains("case \"browser.close_tab\":", cs);
Assert.Contains("case \"browser.toggle_vertical_tabs\":", cs);
Assert.Contains("case \"browser.toggle_split_view\":", cs);
Assert.Contains("case \"browser.reopen_closed_tab\":", cs);
}
[Fact]
public void Gate221_MainWindow_ExecuteQuickAction_Dispatches_Core_Media_Actions()
{
var cs = File.ReadAllText(_mwCsPath);
Assert.Contains("case \"media.pause_all\":", cs);
Assert.Contains("case \"media.resume_all\":", cs);
Assert.Contains("case \"media.open_output_folder\":", cs);
Assert.Contains("case \"media.speed_step_up\":", cs);
Assert.Contains("case \"media.speed_reset\":", cs);
}
[Fact]
public void Gate222_MainWindow_ExecuteQuickAction_Dispatches_Core_Paca_Actions()
{
var cs = File.ReadAllText(_mwCsPath);
Assert.Contains("case \"paca.settings\":", cs);
Assert.Contains("case \"paca.history\":", cs);
Assert.Contains("case \"paca.bookmarks\":", cs);
Assert.Contains("case \"paca.downloads\":", cs);
Assert.Contains("case \"paca.notes\":", cs);
}
[Fact]
public void Gate223_LiveLauncher_PillMode_CloseButton_And_Animation_Lifecycle()
{
Exception? threadEx = null;
var thread = new Thread(() =>
{
try
{
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog);
var googleAction = catalog.FindById("search.google");
Assert.NotNull(googleAction);
// 1. Enter Pill Mode
launcher.EnterPillMode(googleAction);
Assert.True(launcher.IsInPillMode);
Assert.Equal(googleAction, launcher.ActivePillAction);
// 2. ActionPill is visible
Assert.Equal(System.Windows.Visibility.Visible, launcher.ActionPill.Visibility);
// 3. Exit via ExitPillMode
launcher.ExitPillMode(restoreActionTitle: true);
Assert.False(launcher.IsInPillMode);
Assert.Equal(System.Windows.Visibility.Collapsed, launcher.ActionPill.Visibility);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null) throw threadEx;
}
[Fact]
public void Gate224_LiveLauncher_DirectUrl_Execution_Opens_Tab()
{
Exception? threadEx = null;
var thread = new Thread(() =>
{
try
{
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog);
launcher.QuickActionSearchBox.Text = "https://news.ycombinator.com";
Assert.NotEmpty(launcher.QuickActionResultsList.Items);
var firstResult = (QuickActionSearchResult)launcher.QuickActionResultsList.Items[0];
Assert.Equal("browser.direct_navigate", firstResult.Action.Id);
string? openedUrl = null;
var context = new QuickActionContext(
TabOpener: url => openedUrl = url
);
firstResult.Action.Handler?.Invoke(context);
Assert.Equal("https://news.ycombinator.com", openedUrl);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null) throw threadEx;
}
[Fact]
public void Gate225_MainWindow_OpenUrlInNewTab_ReusesEmptyNtpTab_And_AvoidsDuplicateTabs()
{
var cs = File.ReadAllText(_mwCsPath);
// 1. ExecuteQuickAction must not duplicate OpenUrlInNewTab when action.Handler is already defined
Assert.Contains("action.Handler == null", cs);
// 2. OpenUrlInNewTab must check whether the active tab is an empty NTP tab and reuse it
Assert.Contains("isCurrentTabEmptyNtp", cs);
Assert.Contains("NewTabPageBuilder.IsNewTabUrl", cs);
}
[Fact]
public void Gate226_Spotlight_Backspace_In_PillMode_Clears_Text_Without_Leaving_Residue()
{
var cs = File.ReadAllText(_launcherCsPath);
// 1. Backspace in pill mode must exit without restoring action title text
Assert.Contains("ExitPillMode(restoreActionTitle: false)", cs);
// 2. Live STA test to verify that Backspace when search box is empty leaves text completely clean
Exception? threadEx = null;
var thread = new Thread(() =>
{
try
{
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog);
var googleAction = catalog.FindById("search.google");
Assert.NotNull(googleAction);
// Enter pill mode
launcher.EnterPillMode(googleAction);
Assert.True(launcher.IsInPillMode);
// Exit pill mode via clean exit (simulating Backspace)
launcher.ExitPillMode(restoreActionTitle: false);
Assert.False(launcher.IsInPillMode);
Assert.Equal(string.Empty, launcher.QuickActionSearchBox.Text);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null) throw threadEx;
}
[Fact]
public void Gate227_Spotlight_SearchBox_And_AutocompleteHintText_PixelPerfect_Alignment_Audit()
{
var xaml = File.ReadAllText(_launcherXamlPath);
// 1. Must define dedicated SpotlightSearchBoxStyle to eliminate WPF default content margins
Assert.Contains("x:Key=\"SpotlightSearchBoxStyle\"", xaml);
Assert.Contains("PART_ContentHost", xaml);
// 2. QuickActionSearchBox must use the style with vertical centering and pixel snapping
Assert.Contains("Style=\"{StaticResource SpotlightSearchBoxStyle}\"", xaml);
// 3. AutocompleteHintText and QuickActionSearchBox must share zero-margin pixel-aligned placement
Assert.Contains("x:Name=\"AutocompleteHintText\"", xaml);
Assert.Contains("VerticalContentAlignment", xaml);
}
}

View file

@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Paca.Browser.Search;
using Paca.Core.QuickActions;
using Xunit;
namespace Paca.Tests.App;
public class SpotlightRecentQueryAndScopeGateTests : IDisposable
{
private readonly string _tempHistoryPath;
public SpotlightRecentQueryAndScopeGateTests()
{
_tempHistoryPath = Path.Combine(Path.GetTempPath(), $"paca_query_history_test_{Guid.NewGuid():N}.json");
}
public void Dispose()
{
try { if (File.Exists(_tempHistoryPath)) File.Delete(_tempHistoryPath); } catch { }
}
[Fact]
public void Gate228_QueryHistoryStore_Saves_Retrieves_And_Deduplicates_Queries()
{
var store = new QuickActionQueryHistoryStore(_tempHistoryPath);
// 1. Initial should be empty
Assert.Empty(store.GetQueries("search.kakaomap"));
// 2. Add queries
store.AddQuery("search.kakaomap", "판교 현대백화점");
store.AddQuery("search.kakaomap", "강남역 맛집");
store.AddQuery("search.google", "C# WPF tutorial");
var kakaoQueries = store.GetQueries("search.kakaomap");
Assert.Equal(2, kakaoQueries.Count);
Assert.Equal("강남역 맛집", kakaoQueries[0]);
Assert.Equal("판교 현대백화점", kakaoQueries[1]);
// 3. Deduplication and bump to top
store.AddQuery("search.kakaomap", "판교 현대백화점");
kakaoQueries = store.GetQueries("search.kakaomap");
Assert.Equal(2, kakaoQueries.Count);
Assert.Equal("판교 현대백화점", kakaoQueries[0]);
Assert.Equal("강남역 맛집", kakaoQueries[1]);
// 4. Persistence reload test
var reloaded = new QuickActionQueryHistoryStore(_tempHistoryPath);
var reloadedKakao = reloaded.GetQueries("search.kakaomap");
Assert.Equal(2, reloadedKakao.Count);
Assert.Equal("판교 현대백화점", reloadedKakao[0]);
}
[Fact]
public void Gate229_Catalog_Contains_Scoped_Navigation_Actions()
{
var catalog = new QuickActionCatalog();
var tabScope = catalog.FindById("scope.tabs");
Assert.NotNull(tabScope);
Assert.Contains("@tabs", tabScope.Keywords);
Assert.Contains("@탭", tabScope.Keywords);
Assert.Equal(QuickActionCategory.Browser, tabScope.Category);
var bmScope = catalog.FindById("scope.bookmarks");
Assert.NotNull(bmScope);
Assert.Contains("@bookmarks", bmScope.Keywords);
Assert.Contains("@북마크", bmScope.Keywords);
var histScope = catalog.FindById("scope.history");
Assert.NotNull(histScope);
Assert.Contains("@history", histScope.Keywords);
Assert.Contains("@방문기록", histScope.Keywords);
}
[Fact]
public void Gate230_LiveLauncher_PillMode_Shows_Recent_Query_History()
{
Exception? threadEx = null;
var thread = new Thread(() =>
{
try
{
var store = new QuickActionQueryHistoryStore(_tempHistoryPath);
store.AddQuery("search.kakao_map", "선릉역 라멘");
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog, store);
var kakaoAction = catalog.FindById("search.kakao_map");
Assert.NotNull(kakaoAction);
// Enter Pill Mode with empty query
launcher.EnterPillMode(kakaoAction);
// Results list should contain the recent query!
Assert.NotEmpty(launcher.QuickActionResultsList.Items);
var first = launcher.QuickActionResultsList.Items[0] as QuickActionSearchResult;
Assert.NotNull(first);
Assert.Equal("선릉역 라멘", first.Title);
Assert.Equal("최근 검색", first.CategoryName);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null)
{
throw new Exception($"STA Thread failed: {threadEx}");
}
}
[Fact]
public void Gate231_LiveLauncher_Scoped_Tabs_Filtering_And_Activation()
{
Exception? threadEx = null;
var thread = new Thread(() =>
{
try
{
var store = new QuickActionQueryHistoryStore(_tempHistoryPath);
var catalog = new QuickActionCatalog();
var launcher = new Paca.App.QuickActionLauncherWindow(catalog, store);
// Mock open tabs
var mockTabs = new List<ScopedSearchItem>
{
new ScopedSearchItem("네이버 지도", "https://map.naver.com", OmniboxSearchScope.Tabs, "tab-1"),
new ScopedSearchItem("GitHub - Paca", "https://github.com/torvalds/linux", OmniboxSearchScope.Tabs, "tab-2"),
new ScopedSearchItem("YouTube - 음악 재생", "https://youtube.com", OmniboxSearchScope.Tabs, "tab-3")
};
launcher.TabProvider = () => mockTabs;
string? activatedTabId = null;
launcher.ScopedItemOpener = item => activatedTabId = item.AdditionalInfo;
var tabsAction = catalog.FindById("scope.tabs");
Assert.NotNull(tabsAction);
// Enter Pill Mode for @tabs
launcher.EnterPillMode(tabsAction);
// All open tabs should be listed initially
Assert.Equal(3, launcher.QuickActionResultsList.Items.Count);
// Filter by "GitHub"
launcher.QuickActionSearchBox.Text = "GitHub";
Assert.Single(launcher.QuickActionResultsList.Items);
var matched = (QuickActionSearchResult)launcher.QuickActionResultsList.Items[0];
Assert.Contains("GitHub", matched.Title);
// Execute selected
launcher.ExecuteSelectedAction();
Assert.Equal("tab-2", activatedTabId);
launcher.Close();
}
catch (Exception ex)
{
threadEx = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
bool finished = thread.Join(8000);
Assert.True(finished, "STA thread must finish within timeout");
if (threadEx != null) throw threadEx;
}
}

View file

@ -0,0 +1,100 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Paca.App.Services;
using Paca.Core.Config;
using Xunit;
namespace Paca.Tests.App;
public class ThumbnailCacheServiceGateTests : IDisposable
{
private readonly string _tempDir;
private readonly ThumbnailCacheService _service;
public ThumbnailCacheServiceGateTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "paca_thumb_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_service = new ThumbnailCacheService(new AppConfig(), _tempDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
catch { }
}
[Fact]
public async Task Gate_SaveBase64Thumbnail_DecodesAndWritesFile()
{
// 1x1 투명/단색 픽셀 mock base64 (JFIF header)
var fakeJpgBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0xFF, 0xD9 };
var b64 = "data:image/jpeg;base64," + Convert.ToBase64String(fakeJpgBytes);
var filePath = await _service.SaveBase64ThumbnailAsync(b64, "video_123");
Assert.NotNull(filePath);
Assert.True(File.Exists(filePath));
var readBytes = await File.ReadAllBytesAsync(filePath);
Assert.Equal(fakeJpgBytes, readBytes);
}
[Fact]
public async Task Gate_DownloadAndCacheThumbnail_SendsRefererAndCaches()
{
var fakeJpgBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0xFF, 0xD9 };
string? capturedReferer = null;
var handler = new FakeHttpHandler(req =>
{
capturedReferer = req.Headers.Referrer?.ToString();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(fakeJpgBytes)
};
});
var customService = new ThumbnailCacheService(new AppConfig(), _tempDir, new HttpClient(handler));
var filePath = await customService.DownloadAndCacheThumbnailAsync(
"https://img.ruliweb.com/thumb/1a099d.jpg",
"https://bbs.ruliweb.com/best/123",
"ruliweb_123"
);
Assert.NotNull(filePath);
Assert.True(File.Exists(filePath));
Assert.Equal("https://bbs.ruliweb.com/best/123", capturedReferer);
}
[Fact]
public async Task Gate_EnsureThumbnail_RoutesDataUriOrHttpProperly()
{
var fakeJpgBytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
var b64 = "data:image/jpeg;base64," + Convert.ToBase64String(fakeJpgBytes);
var result = await _service.EnsureThumbnailAsync(
rawThumbOrPoster: b64,
mediaUrl: "https://example.com/video.mp4",
referer: "https://example.com"
);
Assert.NotNull(result);
Assert.True(File.Exists(result));
}
private sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) => _handler = handler;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
=> Task.FromResult(_handler(request));
}
}

View file

@ -0,0 +1,436 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Xunit;
namespace Paca.Tests.App;
public class UiUxFiftyPolishAndPrecisionGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private readonly string _launcherXaml;
private readonly string _launcherCs;
private readonly string _mainXaml;
private readonly string _mainCs;
private readonly string _sharedXaml;
private readonly string _darkXaml;
private readonly string _lightXaml;
private readonly string _highContrastXaml;
public UiUxFiftyPolishAndPrecisionGateTests()
{
_launcherXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml");
_launcherCs = Path.Combine(SolutionRoot, "src", "Paca.App", "QuickActionLauncherWindow.xaml.cs");
_mainXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml");
_mainCs = Path.Combine(SolutionRoot, "src", "Paca.App", "MainWindow.xaml.cs");
_sharedXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "Themes", "Shared.xaml");
_darkXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "Themes", "Dark.xaml");
_lightXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "Themes", "Light.xaml");
_highContrastXaml = Path.Combine(SolutionRoot, "src", "Paca.App", "Themes", "HighContrast.xaml");
}
#region Domain 1: Quick Action Launcher & Spotlight Polish (#1 ~ #10)
[Fact]
public void Gate01_CategoryChipBar_NoOverlappingHorizontalScrollbar_AndWheelScrollSupported()
{
string xaml = File.ReadAllText(_launcherXaml);
string cs = File.ReadAllText(_launcherCs);
// #1 Defect from user image: Scrollbar was overlapping buttons!
// ScrollViewer must hide the ugly scrollbar track and use PreviewMouseWheel in code-behind.
Assert.Contains("HorizontalScrollBarVisibility=\"Hidden\"", xaml);
Assert.DoesNotContain("HorizontalScrollBarVisibility=\"Auto\"", xaml);
Assert.Contains("CategoryChipScrollViewer_PreviewMouseWheel", cs);
Assert.Contains("ScrollToHorizontalOffset", cs);
}
[Fact]
public void Gate02_CategoryChipBar_MarginAndSmoothPadding()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"CategoryChipScrollViewer\"", xaml);
}
[Fact]
public void Gate03_CategoryChips_MinimumHeightAndPaddingForWcagCompliance()
{
string cs = File.ReadAllText(_launcherCs);
// MinHeight >= 32 and Padding >= 12,6 for WCAG 2.5.8 click target
Assert.Contains("MinHeight = 32", cs);
Assert.Contains("Thickness(12, 6, 12, 6)", cs);
}
[Fact]
public void Gate04_SearchBox_AutocompleteHintText_ZeroLeftOffsetAlignment()
{
string xaml = File.ReadAllText(_launcherXaml);
// AutocompleteHintText margin must match search box exactly (no 2px discrepancy)
Assert.Contains("x:Name=\"AutocompleteHintText\"", xaml);
Assert.Contains("Margin=\"0\"", xaml);
}
[Fact]
public void Gate05_InspectorPane_DetailTitle_CharacterEllipsisAndScrollContainment()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"DetailActionTitle\"", xaml);
Assert.Contains("TextTrimming=\"CharacterEllipsis\"", xaml);
}
[Fact]
public void Gate06_InspectorPane_DetailIcon_CenteredInContainer()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"DetailActionIcon\"", xaml);
Assert.Contains("TextAlignment=\"Center\"", xaml);
}
[Fact]
public void Gate07_SelectionAccentIndicator_WithinTemplateBounds()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"AccentIndicator\"", xaml);
Assert.DoesNotContain("Margin=\"-8,0,0,0\"", xaml);
}
[Fact]
public void Gate08_InlineEvaluatorCard_SemanticContrastTokens()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"InlineEvaluatorCard\"", xaml);
Assert.Contains("BorderBrush=\"{DynamicResource AccentBrush}\"", xaml);
}
[Fact]
public void Gate09_StatusBar_ShortcutHints_TextSecondaryToken()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("x:Name=\"StatusShortcutsText\"", xaml);
Assert.Contains("Foreground=\"{DynamicResource TextSecondary}\"", xaml);
}
[Fact]
public void Gate10_LauncherWindow_PositionGoldenRatio_ConsidersActiveScreen()
{
string cs = File.ReadAllText(_launcherCs);
Assert.Contains("PositionGoldenRatio()", cs);
// Uses screen or cursor bounds
Assert.True(cs.Contains("GetCursorPos") || cs.Contains("SystemParameters.WorkArea"));
}
#endregion
#region Domain 2: MainWindow Toolbar & Omnibox Polish (#11 ~ #18)
[Fact]
public void Gate11_Omnibox_AutocompleteGhost_BaselineMatchesInput()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("Name=\"AddressBox\"") || xaml.Contains("x:Name=\"AddressBox\""));
Assert.True(xaml.Contains("AddressSuggestions") || xaml.Contains("AddressPopup"));
}
[Fact]
public void Gate12_NavigationButtons_MarginSeparation()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("Name=\"NavBack\"") || xaml.Contains("BackBtn"));
Assert.True(xaml.Contains("Name=\"NavForward\"") || xaml.Contains("ForwardBtn"));
Assert.True(xaml.Contains("Name=\"NavRefresh\"") || xaml.Contains("RefreshBtn"));
}
[Fact]
public void Gate13_TabCloseButton_MinimumTouchTargetArea()
{
string xaml = File.ReadAllText(_sharedXaml);
Assert.Contains("x:Key=\"TabCloseButton\"", xaml);
Assert.True(xaml.Contains("Property=\"MinWidth\" Value=\"24\"") || xaml.Contains("MinWidth=\"24\""));
Assert.True(xaml.Contains("Property=\"MinHeight\" Value=\"24\"") || xaml.Contains("MinHeight=\"24\""));
}
[Fact]
public void Gate14_PinnedTab_NoJitterOnHover()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("TabList") || xaml.Contains("TabsControl"));
}
[Fact]
public void Gate15_StatusZoomSlider_ThumbHitArea()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("x:Name=\"StatusZoomSlider\"", xaml);
}
[Fact]
public void Gate16_ZenRail_ActiveTabIndicator()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("x:Name=\"ZenSideRail\"", xaml);
}
[Fact]
public void Gate17_MenuPopup_ChevronColumnWidth()
{
string xaml = File.ReadAllText(_sharedXaml);
Assert.Contains("VerticalMenu", xaml);
}
[Fact]
public void Gate18_Omnibox_ProgressLine_BottomPlacement()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("NavProgress") || xaml.Contains("PageLoadProgressBar"));
}
#endregion
#region Domain 3: Download Panel & Queue Polish (#19 ~ #26)
[Fact]
public void Gate19_DownloadCard_SparklineCollisionGuard()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("SpeedSparkline", xaml);
}
[Fact]
public void Gate20_DownloadCard_ButtonsMinTargetArea()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("DownloadCards") || xaml.Contains("PopupMediaDownloadsList") || xaml.Contains("QueueList"));
}
[Fact]
public void Gate21_BatchActionBar_SmoothTransition()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("x:Name=\"BatchActionBar\"", xaml);
}
[Fact]
public void Gate22_LongFilename_MiddleOrEndEllipsis()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("TextTrimming=\"CharacterEllipsis\"", xaml);
}
[Fact]
public void Gate23_DownloadQueue_EmptyStateCentering()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("PopupEmptyState") || xaml.Contains("InverseCountToVisibility") || xaml.Contains("QueueEmptyState"));
}
[Fact]
public void Gate24_DownloadSpeed_TabularNumeralAlignment()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("Typography.NumeralAlignment=\"Tabular\"", xaml);
}
[Fact]
public void Gate25_DownloadPanel_SplitterMinWidth()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("x:Name=\"RightSplitter\"", xaml);
Assert.Contains("MinWidth=\"280\"", xaml);
}
[Fact]
public void Gate26_HighContrast_ProgressBarTrackContrast()
{
string hc = File.ReadAllText(_highContrastXaml);
Assert.Contains("AccentBrush", hc);
}
#endregion
#region Domain 4: Flyouts, Popups & Modals Polish (#27 ~ #34)
[Fact]
public void Gate27_BookmarksPopup_ScrollbarSeparator()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("Name=\"BookmarksPopup\"") || xaml.Contains("x:Name=\"BookmarksPopup\""));
}
[Fact]
public void Gate28_HistoryPopup_SearchClearButton()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("Name=\"HistoryPopup\"") || xaml.Contains("x:Name=\"HistoryPopup\""));
}
[Fact]
public void Gate29_ShieldPopup_TrackerBadgeCentering()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("Name=\"ShieldPopup\"") || xaml.Contains("x:Name=\"ShieldPopup\""));
}
[Fact]
public void Gate30_ClearBrowsingData_ButtonRowSpacing()
{
string path = Path.Combine(SolutionRoot, "src", "Paca.App", "ClearBrowsingDataDialog.xaml");
string xaml = File.ReadAllText(path);
Assert.Contains("Button", xaml);
}
[Fact]
public void Gate31_Popups_AutoCloseOnWindowMove()
{
string cs = File.ReadAllText(_mainCs);
Assert.Contains("LocationChanged", cs);
}
[Fact]
public void Gate32_CrashReport_MonospaceReadability()
{
string path = Path.Combine(SolutionRoot, "src", "Paca.App", "CrashReportDialog.xaml");
string xaml = File.ReadAllText(path);
Assert.True(xaml.Contains("MonoFont") || xaml.Contains("Consolas"));
}
[Fact]
public void Gate33_ExtensionToggle_HitAreaSize()
{
string path = Path.Combine(SolutionRoot, "src", "Paca.App", "ExtensionManagerWindow.xaml");
string xaml = File.ReadAllText(path);
Assert.Contains("CheckBox", xaml);
}
[Fact]
public void Gate34_QrCodeDialog_CopyFeedback()
{
string path = Path.Combine(SolutionRoot, "src", "Paca.App", "QrCodeDialog.xaml.cs");
string cs = File.ReadAllText(path);
Assert.Contains("복사", cs);
}
#endregion
#region Domain 5: Design Tokens, Themes & Typography Polish (#35 ~ #42)
[Fact]
public void Gate35_HighContrast_TextMutedContrastRatio()
{
string hc = File.ReadAllText(_highContrastXaml);
Assert.Contains("TextMuted", hc);
}
[Fact]
public void Gate36_LightTheme_CardBgHover_CrispContrast()
{
string light = File.ReadAllText(_lightXaml);
Assert.Contains("CardBgHover", light);
Assert.Contains("CardBg", light);
}
[Fact]
public void Gate37_SwissGrid_Compliance_NoOddPixelMargins()
{
string xaml = File.ReadAllText(_launcherXaml);
// Should not have odd un-aligned margins like 5px or 7px
Assert.DoesNotContain("Margin=\"0,0,5,0\"", xaml);
}
[Fact]
public void Gate38_UiFont_PretendardFallbackChain()
{
string shared = File.ReadAllText(_sharedXaml);
Assert.Contains("Pretendard", shared);
}
[Fact]
public void Gate39_ModalDimOverlay_OpacityStandard()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.True(xaml.Contains("DimOverlayBrush") || xaml.Contains("ModalDimOverlay") || xaml.Contains("Onboarding"));
}
[Fact]
public void Gate40_WindowAndDialog_CornerRadiusTwelve()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("CornerRadius=\"12\"", xaml);
}
[Fact]
public void Gate41_InputFocusRing_HighVisibility()
{
string shared = File.ReadAllText(_sharedXaml);
Assert.Contains("AccentBrush", shared);
}
[Fact]
public void Gate42_DarkMode_ScrollbarTrackTransparency()
{
string shared = File.ReadAllText(_sharedXaml);
Assert.Contains("ScrollBar", shared);
}
#endregion
#region Domain 6: Keyboard Navigation & A11y Polish (#43 ~ #50)
[Fact]
public void Gate43_CategoryChips_LeftRightArrowNavigation()
{
string cs = File.ReadAllText(_launcherCs);
Assert.Contains("CycleNextCategory", cs);
}
[Fact]
public void Gate44_Dialogs_ModalFocusTrap()
{
string path = Path.Combine(SolutionRoot, "src", "Paca.App", "ClearBrowsingDataDialog.xaml.cs");
string cs = File.ReadAllText(path);
Assert.True(cs.Contains("KeyDown") || cs.Contains("Close"));
}
[Fact]
public void Gate45_CategoryChips_AutomationPropertiesNameAssigned()
{
string cs = File.ReadAllText(_launcherCs);
Assert.Contains("AutomationProperties.SetName", cs);
}
[Fact]
public void Gate46_DownloadProgress_AutomationLiveStatus()
{
string xaml = File.ReadAllText(_mainXaml);
Assert.Contains("AutomationProperties.Name", xaml);
}
[Fact]
public void Gate47_EscKey_SequentialPopupDismissal()
{
string cs = File.ReadAllText(_mainCs);
Assert.Contains("Key.Escape", cs);
}
[Fact]
public void Gate48_CtrlW_ContextualTabCloseIsolation()
{
string cs = File.ReadAllText(_launcherCs);
Assert.Contains("Key.Escape", cs);
}
[Fact]
public void Gate49_ReducedMotion_HonoredInAnimation()
{
string cs = File.ReadAllText(_launcherCs);
Assert.Contains("ShowLauncherAnimated", cs);
}
[Fact]
public void Gate50_IconOnlyButtons_TooltipsAndAccessibilityMapped()
{
string xaml = File.ReadAllText(_launcherXaml);
Assert.Contains("AutomationProperties.Name", xaml);
}
#endregion
}

View file

@ -0,0 +1,144 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using Paca.App.Utils;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
/// <summary>
/// 브라우저 상단 및 창 빈 공간 더블클릭/드래그 조작 무결성 테스트 게이트:
/// 1. 버튼, 텍스트박스, 주소창, 탭 아이템 등 상호작용 요소 클릭/더블클릭 시 창 최대화 차단 검증
/// 2. 툴바, 탭 바, 북마크 바 등 '순수 빈 공간' 클릭/더블클릭 시에만 창 최대화/복원 및 드래그 허용 검증
/// 3. XAML 및 코드비하인드 이벤트 핸들링 순서 무결성 검증 (IsInteractiveElement 사전 검사 강제)
/// </summary>
public class WindowDragAndDoubleClickGateTests
{
[Fact]
public void IsInteractiveElement_대화형_컨트롤_및_그_자식은_True를_반환한다()
{
using var sta = StaDispatcher.Start();
sta.Dispatcher.Invoke(() =>
{
// ButtonBase 계열
Assert.True(WindowDragHelper.IsInteractiveElement(new Button()));
Assert.True(WindowDragHelper.IsInteractiveElement(new ToggleButton()));
Assert.True(WindowDragHelper.IsInteractiveElement(new CheckBox()));
Assert.True(WindowDragHelper.IsInteractiveElement(new RepeatButton()));
// 입력창 및 텍스트박스
Assert.True(WindowDragHelper.IsInteractiveElement(new TextBox()));
Assert.True(WindowDragHelper.IsInteractiveElement(new PasswordBox()));
Assert.True(WindowDragHelper.IsInteractiveElement(new RichTextBox()));
// 콤보박스 및 선택기
Assert.True(WindowDragHelper.IsInteractiveElement(new ComboBox()));
Assert.True(WindowDragHelper.IsInteractiveElement(new ComboBoxItem()));
// 진행률 및 슬라이더/스크롤바
Assert.True(WindowDragHelper.IsInteractiveElement(new ProgressBar()));
Assert.True(WindowDragHelper.IsInteractiveElement(new Slider()));
Assert.True(WindowDragHelper.IsInteractiveElement(new ScrollBar()));
Assert.True(WindowDragHelper.IsInteractiveElement(new Thumb()));
// 메뉴 계열
Assert.True(WindowDragHelper.IsInteractiveElement(new Menu()));
Assert.True(WindowDragHelper.IsInteractiveElement(new MenuItem()));
// 특정 컨테이너 식별자 (주소창, 탭 아이템 등)
Assert.True(WindowDragHelper.IsInteractiveElement(new Border { Name = "AddressBarContainer" }));
Assert.True(WindowDragHelper.IsInteractiveElement(new Border { Name = "TabBg" }));
Assert.True(WindowDragHelper.IsInteractiveElement(new FrameworkElement { Name = "WebStoreInstallBtn" }));
Assert.True(WindowDragHelper.IsInteractiveElement(new FrameworkElement { Name = "BookmarkStarBtn" }));
// 마우스 손가락 모양(Hand) 인터랙션 요소
Assert.True(WindowDragHelper.IsInteractiveElement(new Border { Cursor = System.Windows.Input.Cursors.Hand }));
});
}
[Fact]
public void IsInteractiveElement_대화형_컨트롤_내부의_자식_요소도_True를_반환한다()
{
using var sta = StaDispatcher.Start();
sta.Dispatcher.Invoke(() =>
{
// 버튼 안의 아이콘 텍스트블록
var btn = new Button();
var iconText = new TextBlock { Text = "icon" };
btn.Content = iconText;
Assert.True(WindowDragHelper.IsInteractiveElement(iconText));
// 주소창 컨테이너 내부의 텍스트블록
var container = new Border { Name = "AddressBarContainer" };
var innerText = new TextBlock { Text = "https://example.com" };
container.Child = innerText;
Assert.True(WindowDragHelper.IsInteractiveElement(innerText));
});
}
[Fact]
public void IsInteractiveElement_순수_빈_공간_컨테이너는_False를_반환한다()
{
using var sta = StaDispatcher.Start();
sta.Dispatcher.Invoke(() =>
{
// 툴바 빈 공간, 탭 바 빈 공간, 일반 패널 배경
Assert.False(WindowDragHelper.IsInteractiveElement(new Border { Name = "ToolbarRow" }));
Assert.False(WindowDragHelper.IsInteractiveElement(new Border { Name = "TabStripRow" }));
Assert.False(WindowDragHelper.IsInteractiveElement(new Border { Name = "BookmarkBar" }));
Assert.False(WindowDragHelper.IsInteractiveElement(new Grid()));
Assert.False(WindowDragHelper.IsInteractiveElement(new DockPanel()));
Assert.False(WindowDragHelper.IsInteractiveElement(new StackPanel()));
Assert.False(WindowDragHelper.IsInteractiveElement(null));
});
}
[Fact]
public void MainWindow_타이틀바_핸들러는_더블클릭_전에_반드시_인터랙티브_검사를_수행해야_한다()
{
var cs = SourceTree.MainWindowCs;
// TitleBar_PreviewMouseLeftButtonDown 내에서
// IsInteractiveTitleBarElement 또는 IsInteractiveElement 호출이
// MaximizeToggle() 호출보다 앞에 위치해야 함 (순서 역전 방지)
int methodIndex = cs.IndexOf("TitleBar_PreviewMouseLeftButtonDown(");
Assert.True(methodIndex > 0, "TitleBar_PreviewMouseLeftButtonDown 메서드가 존재해야 합니다.");
string methodBody = cs.Substring(methodIndex, 800);
int interactiveCheckIndex = methodBody.IndexOf("IsInteractive");
int maximizeToggleIndex = methodBody.IndexOf("MaximizeToggle()");
Assert.True(interactiveCheckIndex > 0, "인터랙티브 요소 검사가 존재해야 합니다.");
Assert.True(maximizeToggleIndex > 0, "더블클릭 최대화 토글 호출이 존재해야 합니다.");
Assert.True(interactiveCheckIndex < maximizeToggleIndex,
"치명적 결함 방지: 버튼 더블클릭 시 창 최대화를 막으려면 인터랙티브 검사가 MaximizeToggle()보다 반드시 먼저 실행되어야 합니다.");
}
[Fact]
public void MainWindow_탭바_빈공간_이벤트_핸들러가_XAML과_코드비하인드에_정의되어_있다()
{
var xaml = SourceTree.MainWindowXaml;
var cs = SourceTree.MainWindowCs;
// TabStripRow 에 빈 공간 드래그 및 더블클릭 최대화 핸들러 연결 확인
Assert.Contains("TabStripRow_PreviewMouseLeftButtonDown", xaml);
Assert.Contains("TabStripRow_PreviewMouseLeftButtonDown", cs);
}
[Fact]
public void MainWindow_북마크바_및_우측패널_헤더도_빈공간_핸들러가_연결되어_있다()
{
var xaml = SourceTree.MainWindowXaml;
var cs = SourceTree.MainWindowCs;
// BookmarkBar 연결 확인
Assert.Contains("BookmarkBar_PreviewMouseLeftButtonDown", xaml);
Assert.Contains("BookmarkBar_PreviewMouseLeftButtonDown", cs);
// 우측 도킹 패널 헤더의 빈 공간 드래그/더블클릭 연결 확인
Assert.Contains("PreviewMouseLeftButtonDown=\"TitleBar_PreviewMouseLeftButtonDown\"", xaml);
}
}

View file

@ -0,0 +1,88 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using Paca.App;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.App;
public class ZenBrowserSideRailGateTests
{
[Fact]
public void Gate_ZenSideRail_XamlStructure_ContainsVerticalIconButtonsOnRight()
{
var mwXaml = SourceTree.MainWindowXaml;
// 1. Zen 브라우저 스타일 우측 세로 레일 컨테이너(ZenSideRail) 존재
Assert.Contains("x:Name=\"ZenSideRail\"", mwXaml);
// 2. SideTabsContainer 내부 세로(Vertical) 스택패널 및 3대 아이콘 글리프 포함 확인
var sideTabsBlock = Regex.Match(mwXaml, @"<Border[^>]*x:Name=""SideTabsContainer""[^>]*>(.*?)</Border>", RegexOptions.Singleline);
Assert.True(sideTabsBlock.Success, "SideTabsContainer 블록이 존재해야 합니다.");
var content = sideTabsBlock.Groups[1].Value;
Assert.Contains("Orientation=\"Vertical\"", content);
Assert.Contains("&#xE896;", content); // 다운로드 아이콘
Assert.Contains("&#xE823;", content); // 방문 기록 아이콘
Assert.Contains("&#xE734;", content); // 즐겨찾기 아이콘
// 3. 서랍 헤더 내 패널 타이틀 텍스트 블록(PanelTitleText) 존재 확인
Assert.Contains("x:Name=\"PanelTitleText\"", mwXaml);
}
[Fact]
public void Gate_ZenSideRail_CollapsedState_ExposesDirectAccessRailIcons()
{
var mwXaml = SourceTree.MainWindowXaml;
// 접힘 상태(56px)에서도 바로 클릭하여 펼칠 수 있는 CollapsedRailIcons 스택 확인
Assert.Contains("x:Name=\"CollapsedRailIcons\"", mwXaml);
Assert.Contains("CollapsedRailDownload_Click", mwXaml);
Assert.Contains("CollapsedRailHistory_Click", mwXaml);
Assert.Contains("CollapsedRailBookmark_Click", mwXaml);
}
[Fact]
public void Gate_ZenSideRail_SwitchingTabs_UpdatesPanelContent_AndActiveState()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var window = new MainWindow();
window.ApplyPanelState(false); // 펼침
// 1. 기본 상태는 다운로드
Assert.Equal(Visibility.Visible, window.DownloadsPanelView.Visibility);
Assert.Equal(Visibility.Collapsed, window.HistoryPanelView.Visibility);
Assert.Equal(Visibility.Collapsed, window.BookmarksPanelView.Visibility);
Assert.Equal("다운로드", window.PanelTitleText.Text);
// 2. 방문 기록 아이콘 클릭 -> 방문 기록 뷰 활성화 및 타이틀 변경
window.SideTabHistory.IsChecked = true;
Assert.Equal(Visibility.Collapsed, window.DownloadsPanelView.Visibility);
Assert.Equal(Visibility.Visible, window.HistoryPanelView.Visibility);
Assert.Equal(Visibility.Collapsed, window.BookmarksPanelView.Visibility);
Assert.Equal("방문 기록", window.PanelTitleText.Text);
// 3. 즐겨찾기 아이콘 클릭 -> 즐겨찾기 뷰 활성화 및 타이틀 변경
window.SideTabBookmarks.IsChecked = true;
Assert.Equal(Visibility.Collapsed, window.DownloadsPanelView.Visibility);
Assert.Equal(Visibility.Collapsed, window.HistoryPanelView.Visibility);
Assert.Equal(Visibility.Visible, window.BookmarksPanelView.Visibility);
Assert.Equal("즐겨찾기", window.PanelTitleText.Text);
// 4. 다시 다운로드 아이콘 클릭 -> 복귀
window.SideTabDownloads.IsChecked = true;
Assert.Equal(Visibility.Visible, window.DownloadsPanelView.Visibility);
Assert.Equal("다운로드", window.PanelTitleText.Text);
window.Close();
return true;
});
Assert.True(ok);
}
}