using System; using System.Collections.Generic; using System.IO; using System.Linq; using Paca.Browser.Theming; using Xunit; namespace Paca.Tests; public sealed class ModernBrowsingMilestone13Phase113Tests : IDisposable { private readonly string _tempDir; public ModernBrowsingMilestone13Phase113Tests() { _tempDir = Path.Combine(Path.GetTempPath(), "paca_m13_test_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } } [Fact] public void Gate189_ToolbarPaletteManager_ManagesOrderVisibilityAndPersistence() { // Arrange var configPath = Path.Combine(_tempDir, "toolbar_palette.json"); var manager = new ToolbarPaletteManager(configPath); // Act 1: Verify default items var items = manager.GetAllItems(); Assert.NotEmpty(items); Assert.Contains(items, x => x.Id == "back"); Assert.Contains(items, x => x.Id == "forward"); Assert.Contains(items, x => x.Id == "omnibox"); Assert.Contains(items, x => x.Id == "settings"); Assert.Contains(items, x => x.Id == "bookmarks"); // Act 2: Visibility toggle var layoutChangedFired = false; manager.LayoutChanged += (_, _) => layoutChangedFired = true; var bookmarksItem = manager.GetItem("bookmarks"); Assert.NotNull(bookmarksItem); Assert.True(bookmarksItem.IsVisible); var toggled = manager.SetVisibility("bookmarks", false); Assert.True(toggled); Assert.True(layoutChangedFired); var visibleItems = manager.GetVisibleItems(); Assert.DoesNotContain(visibleItems, x => x.Id == "bookmarks"); Assert.Contains(manager.GetAllItems(), x => x.Id == "bookmarks" && !x.IsVisible); // Omnibox is essential, cannot be hidden if CanHide is false var omniboxItem = manager.GetItem("omnibox"); Assert.NotNull(omniboxItem); Assert.False(omniboxItem.CanHide); var hideOmniboxResult = manager.SetVisibility("omnibox", false); Assert.False(hideOmniboxResult); Assert.True(manager.GetItem("omnibox")!.IsVisible); // Act 3: Move item (drag and drop) // Move "settings" to index 0 var moved = manager.MoveItem("settings", 0); Assert.True(moved); Assert.Equal("settings", manager.GetAllItems()[0].Id); // Act 4: Bulk reorder var currentIds = manager.GetAllItems().Select(i => i.Id).Reverse().ToList(); var reordered = manager.Reorder(currentIds); Assert.True(reordered); Assert.Equal(currentIds, manager.GetAllItems().Select(i => i.Id).ToList()); // Act 5: Save & reload persistence manager.Save(); Assert.True(File.Exists(configPath)); var reloaded = new ToolbarPaletteManager(configPath); Assert.Equal(currentIds, reloaded.GetAllItems().Select(i => i.Id).ToList()); Assert.False(reloaded.GetItem("bookmarks")!.IsVisible); // Act 6: Reset to default reloaded.ResetToDefault(); Assert.True(reloaded.GetItem("bookmarks")!.IsVisible); Assert.Equal("back", reloaded.GetAllItems()[0].Id); } [Fact] public void Gate190_StatusBarModuleCoordinator_CoordinatesZoomHostAndResourceStats() { // Arrange var coordinator = new StatusBarModuleCoordinator(); var stateChangedCount = 0; coordinator.StateChanged += (_, _) => stateChangedCount++; // Act 1: Zoom slider defaults and controls Assert.Equal(1.0, coordinator.Zoom.ZoomFactor); Assert.Equal(100, coordinator.Zoom.ZoomPercentage); Assert.True(coordinator.IsZoomModuleVisible); coordinator.ZoomIn(); Assert.True(coordinator.Zoom.ZoomFactor > 1.0); Assert.True(stateChangedCount > 0); coordinator.ZoomOut(); coordinator.ZoomOut(); Assert.True(coordinator.Zoom.ZoomFactor < 1.0); coordinator.SetZoom(2.5); Assert.Equal(2.5, coordinator.Zoom.ZoomFactor); Assert.Equal(250, coordinator.Zoom.ZoomPercentage); // Zoom clamping coordinator.SetZoom(10.0); Assert.Equal(coordinator.Zoom.MaxZoom, coordinator.Zoom.ZoomFactor); coordinator.SetZoom(0.01); Assert.Equal(coordinator.Zoom.MinZoom, coordinator.Zoom.ZoomFactor); coordinator.ResetZoom(); Assert.Equal(1.0, coordinator.Zoom.ZoomFactor); Assert.Equal(100, coordinator.Zoom.ZoomPercentage); // Act 2: Host / IP Indicator coordinator.UpdateHost("github.com", "140.82.121.4", isSecure: true, port: 443); Assert.Equal("github.com", coordinator.HostIndicator.Host); Assert.Equal("140.82.121.4", coordinator.HostIndicator.IpAddress); Assert.True(coordinator.HostIndicator.IsSecure); Assert.Equal(443, coordinator.HostIndicator.Port); Assert.Contains("github.com", coordinator.HostIndicator.DisplayText); Assert.Contains("140.82.121.4", coordinator.HostIndicator.DisplayText); coordinator.ClearHost(); Assert.Equal(string.Empty, coordinator.HostIndicator.Host); Assert.Null(coordinator.HostIndicator.IpAddress); // Act 3: Resource Size & Request Counter coordinator.AddResource(1024 * 512); // 512 KB Assert.Equal(512 * 1024, coordinator.ResourceCounter.TransferredBytes); Assert.Equal(1, coordinator.ResourceCounter.RequestCount); Assert.Contains("512 KB", coordinator.ResourceCounter.FormattedSize); coordinator.AddResource(1024 * 1024 * 2); // +2 MB -> ~2.5 MB Assert.Equal(2, coordinator.ResourceCounter.RequestCount); Assert.Contains("2.5 MB", coordinator.ResourceCounter.FormattedSize); Assert.Contains("2 requests", coordinator.ResourceCounter.FormattedSummary); coordinator.ResetResourceStats(); Assert.Equal(0, coordinator.ResourceCounter.TransferredBytes); Assert.Equal(0, coordinator.ResourceCounter.RequestCount); Assert.Equal("0 B", coordinator.ResourceCounter.FormattedSize); // Act 4: Module Visibility Toggles coordinator.IsResourceCounterVisible = false; Assert.False(coordinator.IsResourceCounterVisible); } [Fact] public void Gate191_ThemeAutoScheduler_CalculatesScheduledAndSolarThemes() { // Arrange var scheduler = new ThemeAutoScheduler(); // Act 1: Explicit theme modes scheduler.Mode = ThemeMode.Light; Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 23:00:00"))); scheduler.Mode = ThemeMode.Dark; Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 12:00:00"))); scheduler.Mode = ThemeMode.System; Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(DateTime.Now, systemTheme: AppTheme.Light)); Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Now, systemTheme: AppTheme.Dark)); // Act 2: Scheduled Window spanning midnight (20:00 to 07:00 is Dark) scheduler.Mode = ThemeMode.Scheduled; scheduler.ScheduledWindow = new TimeWindow( DarkStartTime: new TimeOnly(20, 0), DarkEndTime: new TimeOnly(7, 0)); // 20:00 -> Dark Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 20:00:00"))); // 23:30 -> Dark Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 23:30:00"))); // 03:15 -> Dark Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 03:15:00"))); // 06:59 -> Dark Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 06:59:59"))); // 07:00 -> Light Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 07:00:00"))); // 12:00 -> Light Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 12:00:00"))); // 19:59 -> Light Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(DateTime.Parse("2026-09-13 19:59:59"))); // Act 3: Solar Sunrise/Sunset calculation (Seoul, Lat 37.5665, Lon 126.9780) scheduler.Mode = ThemeMode.Solar; scheduler.Coordinates = new SolarCoordinates(37.5665, 126.9780); var date = new DateTime(2026, 9, 13); var solarTimes = scheduler.CalculateSolarTimes(date, 37.5665, 126.9780); // Sunrise should be morning (~6 AM), Sunset evening (~18:30 PM) Assert.True(solarTimes.Sunrise.Hour >= 5 && solarTimes.Sunrise.Hour <= 7, $"Sunrise was {solarTimes.Sunrise}"); Assert.True(solarTimes.Sunset.Hour >= 18 && solarTimes.Sunset.Hour <= 20, $"Sunset was {solarTimes.Sunset}"); // Midday 12:00 -> Light var noon = new DateTime(2026, 9, 13, 12, 0, 0); Assert.Equal(AppTheme.Light, scheduler.GetEffectiveTheme(noon)); // Midnight 00:00 -> Dark var midnight = new DateTime(2026, 9, 13, 0, 0, 0); Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(midnight)); // 23:00 -> Dark var night = new DateTime(2026, 9, 13, 23, 0, 0); Assert.Equal(AppTheme.Dark, scheduler.GetEffectiveTheme(night)); } [Fact] public void Gate192_SpeedDialFolderManager_ManagesThumbnailsAndHierarchies() { // Arrange var configPath = Path.Combine(_tempDir, "speed_dial.json"); var manager = new SpeedDialFolderManager(configPath); // Act 1: Add root tiles var githubTile = manager.AddTile("GitHub", "https://github.com"); var hnTile = manager.AddTile("Hacker News", "https://news.ycombinator.com"); Assert.NotNull(githubTile); Assert.NotNull(hnTile); Assert.Equal(2, manager.GetTilesInFolder(null).Count); // Act 2: Custom thumbnail override var customThumb = "https://assets.example.com/thumbnails/gh-custom.png"; var thumbUpdated = manager.SetCustomThumbnail(githubTile.Id, customThumb); Assert.True(thumbUpdated); Assert.Equal(customThumb, manager.GetTile(githubTile.Id)!.CustomThumbnailUrl); // Act 3: Create folder grouping hierarchies var devFolder = manager.CreateFolder("Development"); var newsFolder = manager.CreateFolder("News"); var aiFolder = manager.CreateFolder("AI Tools", parentFolderId: devFolder.Id); Assert.NotNull(devFolder); Assert.NotNull(newsFolder); Assert.NotNull(aiFolder); // Subfolder query var devSubfolders = manager.GetSubFolders(devFolder.Id); Assert.Single(devSubfolders); Assert.Equal("AI Tools", devSubfolders[0].Name); // Breadcrumbs for nested folder var breadcrumbs = manager.GetBreadcrumbs(aiFolder.Id); Assert.Equal(2, breadcrumbs.Count); Assert.Equal("Development", breadcrumbs[0].Name); Assert.Equal("AI Tools", breadcrumbs[1].Name); // Act 4: Move tile into folders manager.MoveTile(githubTile.Id, devFolder.Id); var openaiTile = manager.AddTile("OpenAI", "https://openai.com", folderId: aiFolder.Id); var devTiles = manager.GetTilesInFolder(devFolder.Id); Assert.Single(devTiles); Assert.Equal("GitHub", devTiles[0].Title); var aiTiles = manager.GetTilesInFolder(aiFolder.Id); Assert.Single(aiTiles); Assert.Equal("OpenAI", aiTiles[0].Title); // Root tiles now only contains Hacker News var rootTiles = manager.GetTilesInFolder(null); Assert.Single(rootTiles); Assert.Equal("Hacker News", rootTiles[0].Title); // Act 5: Persistence save & reload manager.Save(); Assert.True(File.Exists(configPath)); var reloaded = new SpeedDialFolderManager(configPath); Assert.Equal(customThumb, reloaded.GetTile(githubTile.Id)!.CustomThumbnailUrl); Assert.Equal(devFolder.Id, reloaded.GetTile(githubTile.Id)!.FolderId); Assert.Single(reloaded.GetTilesInFolder(aiFolder.Id)); // Act 6: Folder deletion handling (without deleting children: moves children to parent folder) var removed = reloaded.RemoveFolder(aiFolder.Id, deleteChildren: false); Assert.True(removed); // OpenAI tile should now be moved up to Development folder var devTilesAfterDelete = reloaded.GetTilesInFolder(devFolder.Id); Assert.Contains(devTilesAfterDelete, t => t.Title == "OpenAI"); } [Fact] public void Gate193_UiLanguageSwitcher_SwitchesLanguagesAndLocalizesStrings() { // Arrange var switcher = new UiLanguageSwitcher(defaultCulture: "en-US"); // Act 1: Default English strings Assert.Equal("en-US", switcher.CurrentLanguage); Assert.Contains("ko-KR", switcher.SupportedLanguages); Assert.Contains("ja-JP", switcher.SupportedLanguages); Assert.Equal("Search or enter address", switcher.GetString("Omnibox_Placeholder")); Assert.Equal("Settings", switcher["Settings_Title"]); // Act 2: Switch to Korean (ko-KR) string? previousLang = null; string? newLang = null; switcher.LanguageChanged += (_, e) => { previousLang = e.PreviousLanguage; newLang = e.NewLanguage; }; var switchResultKo = switcher.SetLanguage("ko-KR"); Assert.True(switchResultKo); Assert.Equal("ko-KR", switcher.CurrentLanguage); Assert.Equal("en-US", previousLang); Assert.Equal("ko-KR", newLang); Assert.Equal("검색어 또는 웹 주소 입력", switcher.GetString("Omnibox_Placeholder")); Assert.Equal("설정", switcher["Settings_Title"]); Assert.Equal("새 탭", switcher["NewTab_Title"]); // Act 3: Switch to Japanese (ja-JP) var switchResultJa = switcher.SetLanguage("ja-JP"); Assert.True(switchResultJa); Assert.Equal("ja-JP", switcher.CurrentLanguage); Assert.Equal("検索またはアドレスを入力", switcher.GetString("Omnibox_Placeholder")); Assert.Equal("設定", switcher["Settings_Title"]); // Act 4: Fallback for missing string // If a key doesn't exist in ja-JP, it falls back to en-US, or returns key itself Assert.Equal("NonExistent_Key_XYZ", switcher.GetString("NonExistent_Key_XYZ")); // Act 5: Custom translation registration and string formatting switcher.RegisterTranslations("fr-FR", new Dictionary { ["Omnibox_Placeholder"] = "Rechercher ou entrer une adresse", ["Tab_Count_Format"] = "{0} onglets ouverts" }); switcher.SetLanguage("fr-FR"); Assert.Equal("Rechercher ou entrer une adresse", switcher.GetString("Omnibox_Placeholder")); Assert.Equal("5 onglets ouverts", switcher.GetString("Tab_Count_Format", 5)); } [Fact] public void Gate194_ScrollAutoHideCoordinator_ThresholdsAndAnimatesHeader() { // Arrange // Header height = 60, scroll threshold = 20 var coordinator = new ScrollAutoHideCoordinator(headerHeight: 60.0, threshold: 20.0); var offsetEvents = new List(); coordinator.OffsetChanged += (_, offset) => offsetEvents.Add(offset); // Act 1: Initial state Assert.Equal(0.0, coordinator.CurrentOffset); Assert.Equal(1.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.FullyVisible, coordinator.State); // Act 2: Downward scroll below threshold (10px < 20px threshold) coordinator.ProcessScrollDelta(deltaY: 10.0, currentScrollY: 100.0); Assert.Equal(0.0, coordinator.CurrentOffset); Assert.Equal(1.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.FullyVisible, coordinator.State); // Act 3: Downward scroll exceeding threshold (+25px total delta > 20px threshold -> 15px over) // Accumulated 10 + 25 = 35 -> 15px over threshold -> offset = -15 coordinator.ProcessScrollDelta(deltaY: 25.0, currentScrollY: 125.0); Assert.Equal(-15.0, coordinator.CurrentOffset); Assert.Equal(45.0 / 60.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.PartiallyHidden, coordinator.State); Assert.NotEmpty(offsetEvents); // Act 4: Scroll down further until fully hidden coordinator.ProcessScrollDelta(deltaY: 100.0, currentScrollY: 225.0); Assert.Equal(-60.0, coordinator.CurrentOffset); Assert.Equal(0.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.FullyHidden, coordinator.State); // Act 5: Upward scroll below threshold (-10px upward < 20px threshold) coordinator.ProcessScrollDelta(deltaY: -10.0, currentScrollY: 215.0); Assert.Equal(-60.0, coordinator.CurrentOffset); Assert.Equal(HeaderVisibilityState.FullyHidden, coordinator.State); // Act 6: Upward scroll exceeding threshold (-25px upward -> accumulated -35 -> 15px over) // Offset moves from -60 to -60 + 15 = -45 coordinator.ProcessScrollDelta(deltaY: -25.0, currentScrollY: 190.0); Assert.Equal(-45.0, coordinator.CurrentOffset); Assert.Equal(15.0 / 60.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.PartiallyHidden, coordinator.State); // Act 7: Snap to fully visible when scrolled to top of page (currentScrollY <= 0) coordinator.ProcessScrollDelta(deltaY: 5.0, currentScrollY: 0.0); Assert.Equal(0.0, coordinator.CurrentOffset); Assert.Equal(1.0, coordinator.VisibilityFraction); Assert.Equal(HeaderVisibilityState.FullyVisible, coordinator.State); // Act 8: Pinning disables auto-hide coordinator.IsPinned = true; coordinator.ProcessScrollDelta(deltaY: 200.0, currentScrollY: 500.0); Assert.Equal(0.0, coordinator.CurrentOffset); Assert.Equal(HeaderVisibilityState.FullyVisible, coordinator.State); } }