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; } }