feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
82
tests/Paca.Tests/App/AppDebugScreenshotServiceTests.cs
Normal file
82
tests/Paca.Tests/App/AppDebugScreenshotServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
tests/Paca.Tests/App/ChromeDownloadManagerParityTests.cs
Normal file
60
tests/Paca.Tests/App/ChromeDownloadManagerParityTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
62
tests/Paca.Tests/App/ChromeTabDiscardingMegaTests.cs
Normal file
62
tests/Paca.Tests/App/ChromeTabDiscardingMegaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
tests/Paca.Tests/App/DesktopDeepLinkGateTests.cs
Normal file
42
tests/Paca.Tests/App/DesktopDeepLinkGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
122
tests/Paca.Tests/App/DesktopMediaSpeedAndDiagnosticsGateTests.cs
Normal file
122
tests/Paca.Tests/App/DesktopMediaSpeedAndDiagnosticsGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
tests/Paca.Tests/App/DesktopOmniboxIntegrationGateTests.cs
Normal file
65
tests/Paca.Tests/App/DesktopOmniboxIntegrationGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
64
tests/Paca.Tests/App/DesktopRemoteTabSyncGateTests.cs
Normal file
64
tests/Paca.Tests/App/DesktopRemoteTabSyncGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
61
tests/Paca.Tests/App/DesktopTrayAndGatewayPushGateTests.cs
Normal file
61
tests/Paca.Tests/App/DesktopTrayAndGatewayPushGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
tests/Paca.Tests/App/DesktopZenRailAndStatusBarGateTests.cs
Normal file
72
tests/Paca.Tests/App/DesktopZenRailAndStatusBarGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
118
tests/Paca.Tests/App/DownloadFolderOpenGateTests.cs
Normal file
118
tests/Paca.Tests/App/DownloadFolderOpenGateTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
56
tests/Paca.Tests/App/GlobalHotkeyGateTests.cs
Normal file
56
tests/Paca.Tests/App/GlobalHotkeyGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
67
tests/Paca.Tests/App/MediaToolsMenuWiringGateTests.cs
Normal file
67
tests/Paca.Tests/App/MediaToolsMenuWiringGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
71
tests/Paca.Tests/App/MultiDisplayLayoutEngineGateTests.cs
Normal file
71
tests/Paca.Tests/App/MultiDisplayLayoutEngineGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
62
tests/Paca.Tests/App/QuickActionInlineEvaluatorGateTests.cs
Normal file
62
tests/Paca.Tests/App/QuickActionInlineEvaluatorGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
203
tests/Paca.Tests/App/QuickActionPillModeAndTabSearchTests.cs
Normal file
203
tests/Paca.Tests/App/QuickActionPillModeAndTabSearchTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
136
tests/Paca.Tests/App/QuickActionSpotlightTwoPhase20Tests.cs
Normal file
136
tests/Paca.Tests/App/QuickActionSpotlightTwoPhase20Tests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
tests/Paca.Tests/App/QuickActionSpotlightUiGateTests.cs
Normal file
49
tests/Paca.Tests/App/QuickActionSpotlightUiGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
66
tests/Paca.Tests/App/QuickCaptureHudGateTests.cs
Normal file
66
tests/Paca.Tests/App/QuickCaptureHudGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
82
tests/Paca.Tests/App/QuickCaptureHudWindowGateTests.cs
Normal file
82
tests/Paca.Tests/App/QuickCaptureHudWindowGateTests.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
109
tests/Paca.Tests/App/ScrollBarAndSectionBorderGateTests.cs
Normal file
109
tests/Paca.Tests/App/ScrollBarAndSectionBorderGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
181
tests/Paca.Tests/App/SpotlightRecentQueryAndScopeGateTests.cs
Normal file
181
tests/Paca.Tests/App/SpotlightRecentQueryAndScopeGateTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
100
tests/Paca.Tests/App/ThumbnailCacheServiceGateTests.cs
Normal file
100
tests/Paca.Tests/App/ThumbnailCacheServiceGateTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
436
tests/Paca.Tests/App/UiUxFiftyPolishAndPrecisionGateTests.cs
Normal file
436
tests/Paca.Tests/App/UiUxFiftyPolishAndPrecisionGateTests.cs
Normal 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
|
||||
}
|
||||
144
tests/Paca.Tests/App/WindowDragAndDoubleClickGateTests.cs
Normal file
144
tests/Paca.Tests/App/WindowDragAndDoubleClickGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
88
tests/Paca.Tests/App/ZenBrowserSideRailGateTests.cs
Normal file
88
tests/Paca.Tests/App/ZenBrowserSideRailGateTests.cs
Normal 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("", content); // 다운로드 아이콘
|
||||
Assert.Contains("", content); // 방문 기록 아이콘
|
||||
Assert.Contains("", 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue