using System; using System.IO; using System.Threading; using System.Threading.Tasks; using Paca.Mobile; using Paca.Mobile.Core; using Xunit; namespace Paca.Tests.Mobile; public class MobileResponsivenessAndAnrPreventionGateTests { private static readonly string RepoRoot = FindRepoRoot(); private static readonly string MobileCoreDir = Path.Combine(RepoRoot, "src", "Paca.Mobile.Core"); private static readonly string MobileDir = Path.Combine(RepoRoot, "src", "Paca.Mobile"); private static string FindRepoRoot() { var dir = AppContext.BaseDirectory; while (!string.IsNullOrEmpty(dir)) { if (File.Exists(Path.Combine(dir, "Paca.slnx")) || Directory.Exists(Path.Combine(dir, "src", "Paca.Mobile.Core"))) return dir; var parent = Directory.GetParent(dir); if (parent == null) break; dir = parent.FullName; } return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); } [Fact] public void Gate190_InteractionDebouncer_PreventsRapidDoubleTap_AndAllowsAfterCooldown() { MobileInteractionDebouncer.Reset(); // First tap succeeds Assert.True(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350)); // Immediate subsequent tap (within 350ms) is blocked to prevent concurrent modal pushes / ANR Assert.False(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350)); // After cooldown, interaction is allowed again Thread.Sleep(360); Assert.True(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350)); } [Fact] public void Gate191_ScrollThrottler_EnforcesRateLimit_AndFiltersHighFrequencyNoise() { var throttler = new MobileScrollThrottler(minIntervalMs: 16, minDeltaThreshold: 10); // 1. Initial scroll event passes Assert.True(throttler.ShouldProcess(scrollY: 50, deltaY: 20)); // 2. High-frequency tick within 16ms window is throttled Assert.False(throttler.ShouldProcess(scrollY: 52, deltaY: 2)); // 3. Tick with negligible delta (< minDeltaThreshold) is ignored Thread.Sleep(20); Assert.False(throttler.ShouldProcess(scrollY: 53, deltaY: 1)); // 4. Meaningful scroll after frame window passes Thread.Sleep(20); Assert.True(throttler.ShouldProcess(scrollY: 85, deltaY: 32)); // 5. Scroll to top (scrollY <= 20) always forces pass regardless of delta Assert.True(throttler.ShouldProcess(scrollY: 5, deltaY: -5)); } [Fact] public void Gate192_SafeAreaCoordinator_DeadbandHysteresis_RejectsSubpixelJitter() { var coordinator = new MobileSafeAreaCoordinator(); int eventCount = 0; coordinator.InsetsChanged += () => eventCount++; // 1. Initial non-zero update fires event coordinator.UpdateInsets(bottomDp: 48.0, topDp: 42.0, keyboardDp: 0.0); Assert.Equal(1, eventCount); Assert.Equal(48.0, coordinator.BottomInsetDp); Assert.Equal(42.0, coordinator.TopInsetDp); // 2. Subpixel jitter (< 1.0dp difference) must NOT fire event (prevents infinite layout loops) coordinator.UpdateInsets(bottomDp: 48.3, topDp: 42.2, keyboardDp: 0.0); Assert.Equal(1, eventCount); // Still 1! No new event fired // 3. Significant inset change (>= 1.0dp) must fire event coordinator.UpdateInsets(bottomDp: 0.0, topDp: 42.0, keyboardDp: 0.0); Assert.Equal(2, eventCount); Assert.Equal(0.0, coordinator.BottomInsetDp); // 4. Keyboard open (IME insets) fires event coordinator.UpdateInsets(bottomDp: 0.0, topDp: 42.0, keyboardDp: 280.0); Assert.Equal(3, eventCount); Assert.Equal(280.0, coordinator.KeyboardInsetDp); } [Fact] public void Gate193_PlatformSourceAudit_ProhibitsHighFrequencyLoggingInScrollAndLayout() { var customizerCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidWebViewCustomizer.cs")); var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); // Native scroll listener must NOT log verbose lines on every 120Hz tick Assert.DoesNotContain("Log.Info(\"PacaTrace\", $\">>> NativeScroll", customizerCs); // BrowserPage AnimateTopBar must protect against re-entrancy and layout oscillation Assert.Contains("show == !_isTopBarHidden || _isTopBarAnimating", browserCs); // Menu button and quick actions must employ interaction debouncing Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction", browserCs); } [Fact] public async Task Gate194_StoreSaveConcurrency_SemaphoreSlimProtection_PreventsIoCollision() { var tempFile = Path.Combine(Path.GetTempPath(), $"paca_history_test_{Guid.NewGuid():N}.json"); try { var store = new MobileHistoryStore(tempFile); // 30 concurrent rapid visit writes var tasks = new Task[30]; for (int i = 0; i < 30; i++) { int index = i; tasks[i] = Task.Run(async () => { await store.AddVisitAsync($"https://example.com/item/{index}", $"Item {index}"); }); } await Task.WhenAll(tasks); var items = store.GetRecent(50); Assert.Equal(30, items.Count); } finally { try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { } try { if (File.Exists(tempFile + ".tmp")) File.Delete(tempFile + ".tmp"); } catch { } } } [Fact] public async Task Gate195_BookmarkStore_SaveConcurrency_SemaphoreSlimProtection() { var tempFile = Path.Combine(Path.GetTempPath(), $"paca_bookmark_test_{Guid.NewGuid():N}.json"); try { var store = new MobileBookmarkStore(tempFile); // 30 concurrent bookmark additions var tasks = new Task[30]; for (int i = 0; i < 30; i++) { int index = i; tasks[i] = Task.Run(async () => { await store.AddOrUpdateBookmarkAsync($"https://example.com/b/{index}", $"Bookmark {index}"); }); } await Task.WhenAll(tasks); var all = store.GetAll(); Assert.Equal(30, all.Count); } finally { try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { } try { if (File.Exists(tempFile + ".tmp")) File.Delete(tempFile + ".tmp"); } catch { } } } [Fact] public void Gate196_AllToolbarButtons_AndActions_CoveredByDebouncer() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); var menuCs = File.ReadAllText(Path.Combine(MobileDir, "MobileMenuModal.cs")); // Toolbar buttons must check debouncer Assert.Contains("ToolbarTabsBtn", browserCs); Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction()", browserCs); // Menu tiles and rows must check debouncer Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction()", menuCs); } [Fact] public void Gate197_NoDoubleDebouncing_BetweenToolbarButtonAndMenuOpen() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); // OpenMoreMenuAsync must NOT call TryBeginInteraction because CreateToolbarButton already performs it! // (Calling it twice in the same call stack causes immediate self-blocking within 0ms) var methodIdx = browserCs.IndexOf("private async Task OpenMoreMenuAsync()"); Assert.True(methodIdx > 0); var methodBody = browserCs.Substring(methodIdx, 300); Assert.DoesNotContain("MobileInteractionDebouncer.TryBeginInteraction", methodBody); } [Fact] public void Gate198_NavigationGuard_EnforcesSingleFlight_AndAutoRecoversOnTimeout() { MobileNavigationGuard.Reset(); // 1. Initial navigation begins successfully Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200)); // 2. Concurrent second navigation is rejected (prevents FragmentManager collision / ANR) Assert.False(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200)); // 3. Normal completion frees lock immediately MobileNavigationGuard.EndNavigation(); Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200)); // 4. If navigation hangs or crashes without EndNavigation, auto-recovery frees lock after timeout Thread.Sleep(250); Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200)); MobileNavigationGuard.EndNavigation(); } [Fact] public async Task Gate199_NavigationGuard_SafeModalToPageSequencing_CoordinatesTransitions() { int sequence = 0; int dismissStep = 0; int pushStep = 0; await MobileNavigationGuard.CoordinateModalToPageTransitionAsync( dismissModalAsync: async () => { await Task.Delay(20); dismissStep = ++sequence; }, pushPageAsync: async () => { await Task.Delay(10); pushStep = ++sequence; }, interTransitionDelayMs: 30 ); Assert.Equal(1, dismissStep); Assert.Equal(2, pushStep); Assert.True(pushStep > dismissStep); } [Fact] public void Gate200_DownloadProgressThrottler_FiltersRapidFlooding_AndGuaranteesTerminalTransitions() { var throttler = new MobileDownloadProgressThrottler(minIntervalMs: 100, minDeltaPercent: 1.0); const string taskId = "task-101"; // 1. Initial active download tick passes Assert.True(throttler.ShouldDispatchProgress(taskId, 5.0, MobileDownloadStatus.Downloading)); // 2. Rapid ticks (within 100ms and < 1% delta) are throttled out Assert.False(throttler.ShouldDispatchProgress(taskId, 5.2, MobileDownloadStatus.Downloading)); Assert.False(throttler.ShouldDispatchProgress(taskId, 5.5, MobileDownloadStatus.Downloading)); // 3. Significant delta (>= 1.0%) passes immediately even within interval Assert.True(throttler.ShouldDispatchProgress(taskId, 6.6, MobileDownloadStatus.Downloading)); // 4. After interval passes, even minor delta passes Thread.Sleep(120); Assert.True(throttler.ShouldDispatchProgress(taskId, 6.8, MobileDownloadStatus.Downloading)); // 5. Terminal status (Completed/Failed/Cancelled) ALWAYS passes immediately Assert.True(throttler.ShouldDispatchProgress(taskId, 100.0, MobileDownloadStatus.Completed)); } [Fact] public async Task Gate201_WebViewScriptGuard_TimesOutHungScript_AndReturnsFallbackWithoutCrash() { // 1. Fast script succeeds var fastResult = await MobileWebViewScriptGuard.ExecuteWithTimeoutAsync( async ct => { await Task.Delay(10, ct); return "{\"ready\":true}"; }, timeoutMs: 200, fallback: "fallback" ); Assert.Equal("{\"ready\":true}", fastResult); // 2. Hung script (e.g. alert dialog or infinite loop in JS) times out and returns fallback safely var hungResult = await MobileWebViewScriptGuard.ExecuteWithTimeoutAsync( async ct => { await Task.Delay(500, ct); return "never"; }, timeoutMs: 100, fallback: "fallback_timeout" ); Assert.Equal("fallback_timeout", hungResult); } [Fact] public async Task Gate202_AsyncShield_CatchesAndLogsExceptions_WithoutCrashingProcessOrFreezing() { Exception? caught = null; await MobileAsyncShield.ExecuteSafeAsync( async () => { await Task.Yield(); throw new InvalidOperationException("Simulated navigation collision"); }, onError: ex => caught = ex, tag: "TestGate202" ); Assert.NotNull(caught); Assert.IsType(caught); Assert.Equal("Simulated navigation collision", caught.Message); } [Fact] public void Gate203_CodebaseAudit_ToolbarAndNavigation_GuardedAgainstReentrancy() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); var menuCs = File.ReadAllText(Path.Combine(MobileDir, "MobileMenuModal.cs")); // BrowserPage must reference MobileNavigationGuard Assert.Contains("MobileNavigationGuard", browserCs); // MobileMenuModal must reference MobileNavigationGuard Assert.Contains("MobileNavigationGuard", menuCs); } [Fact] public void Gate204_CodebaseAudit_DownloadsPage_UsesProgressThrottling() { var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs")); // DownloadsPage must not blindly re-render on every raw progress tick without throttler Assert.Contains("MobileDownloadProgressThrottler", downloadsCs); } [Fact] public void Gate205_CodebaseAudit_AllToolbarActions_ProtectedByAsyncShield() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); // CreateToolbarButton must use MobileAsyncShield to prevent unobserved task crash Assert.Contains("MobileAsyncShield.ExecuteSafeAsync", browserCs); } [Fact] public void Gate206_ApiClient_Architecture_PureConstructor_And_LazyInitialization() { var sw = System.Diagnostics.Stopwatch.StartNew(); var storage = new InMemoryKeyValueStorage(); var client = new ApiClient(storage: storage); sw.Stop(); // Constructor must be O(1) pure, under 5ms, zero I/O Assert.True(sw.ElapsedMilliseconds < 5, $"ApiClient constructor took too long: {sw.ElapsedMilliseconds}ms"); Assert.False(client.IsConfigured); // Configure persists to storage bool configured = client.Configure("http://192.168.1.100:52100/#t=test_token_123&cc=7777"); Assert.True(configured); Assert.True(client.IsConfigured); Assert.Equal("http://192.168.1.100:52100", client.BaseUrl); Assert.Equal("test_token_123", client.Token); Assert.Equal("7777", client.ConnectCode); Assert.Equal("http://192.168.1.100:52100", storage.Get("vd_base", "")); // New client restoring from storage var restoredClient = new ApiClient(storage: storage); Assert.False(restoredClient.IsConfigured); // Not configured until restored restoredClient.Restore(); Assert.True(restoredClient.IsConfigured); Assert.Equal("http://192.168.1.100:52100", restoredClient.BaseUrl); } [Fact] public async Task Gate207_ApiClient_HighConcurrency_NoDeadlock_UnderMultiThreadAccess() { var storage = new InMemoryKeyValueStorage(); storage.Set("vd_base", "http://10.0.0.1:52100"); storage.Set("vd_token", "concurrent_token"); var client = new ApiClient(storage: storage); client.Restore(); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var tasks = new Task[50]; for (int i = 0; i < tasks.Length; i++) { tasks[i] = Task.Run(() => { for (int j = 0; j < 1000; j++) { cts.Token.ThrowIfCancellationRequested(); var isConf = client.IsConfigured; if (j % 100 == 0) { var url = client.BaseUrl; } } }, cts.Token); } // All 50 threads must finish 50,000 accesses within 2 seconds with zero deadlock await Task.WhenAll(tasks); Assert.True(client.IsConfigured); } [Fact] public async Task Gate208_ApiClient_NetworkResilience_TimeoutAndFailureHandling() { var client = new ApiClient(); // Unconfigured client Assert.False(client.IsConfigured); var queue = await client.GetQueueAsync(); Assert.Empty(queue); var addResult = await client.AddAsync("https://example.com/video.mp4"); Assert.False(addResult); var lib = await client.GetLibraryAsync(); Assert.Empty(lib); } [Fact] public void Gate209_CodebaseAudit_ZeroSynchronousTaskBlockingOnMainThread() { // Forbid .Result, .Wait(), and .GetAwaiter().GetResult() across Mobile codebase to prevent ANR var csFiles = Directory.GetFiles(MobileDir, "*.cs", SearchOption.AllDirectories); var forbiddenPatterns = new[] { ".GetAwaiter().GetResult()", ".Wait()", ".Result" }; foreach (var file in csFiles) { var fileName = Path.GetFileName(file); // Skip designer/generated/interop files if any if (fileName.EndsWith(".g.cs") || fileName.Contains("Resource")) continue; var content = File.ReadAllText(file); foreach (var pattern in forbiddenPatterns) { // Ensure no synchronous task blocking in mobile UI code if (content.Contains(pattern)) { // Allow legitimate pattern matches if guarded or in comments var lines = content.Split('\n'); for (int i = 0; i < lines.Length; i++) { var line = lines[i].Trim(); if (line.StartsWith("//") || line.StartsWith("/*")) continue; if (line.Contains(pattern)) { // If it's a Property named Result (like DialogResult, HttpResult), ignore if (pattern == ".Result" && (line.Contains("DialogResult") || line.Contains("r.Result") == false && line.Contains("Task") == false && line.Contains("task.") == false)) continue; Assert.Fail($"Forbidden synchronous blocking call '{pattern}' found in {fileName}:{i + 1} -> '{line}'. This causes Android ANR!"); } } } } } } [Fact] public void Gate210_MobileMenuContext_SnapshotGeneration_NonBlockingAndInstant() { // Warm-up to avoid cold JIT measurement artifact _ = new MobileMenuContext("https://warmup", "Title", false, MobileThemeMode.Dark, false, 0, false); var sw = System.Diagnostics.Stopwatch.StartNew(); var context = new MobileMenuContext( CurrentUrl: "https://youtube.com/watch?v=123", CurrentTitle: "Awesome Video", IsDesktopMode: false, ThemeMode: MobileThemeMode.Dark, IsBookmarked: true, SniffedVideoCount: 3, IsP2pConnected: false ); sw.Stop(); Assert.True(sw.ElapsedMilliseconds < 10, "Menu context creation must be instant (< 10ms)"); Assert.True(context.IsBookmarked); Assert.Equal(3, context.SniffedVideoCount); Assert.False(context.IsP2pConnected); } [Fact] public void Gate211_CodebaseAudit_ApiClient_PureConstructor_NoDirectPreferencesInConstructor() { var coreApiClient = Path.Combine(MobileCoreDir, "ApiClient.cs"); var mobileApiClient = Path.Combine(MobileDir, "ApiClient.cs"); var targetPath = File.Exists(coreApiClient) ? coreApiClient : mobileApiClient; var content = File.ReadAllText(targetPath); // Constructor must NOT contain Preferences.Get or direct I/O var ctorIndex = content.IndexOf("public ApiClient("); if (ctorIndex == -1) ctorIndex = content.IndexOf("ApiClient("); Assert.True(ctorIndex >= 0, "ApiClient must declare a constructor"); // The constructor block must not call Preferences.Get or Restore() var ctorBlock = content.Substring(ctorIndex, Math.Min(300, content.Length - ctorIndex)); Assert.DoesNotContain("Preferences.Get", ctorBlock); Assert.DoesNotContain("Restore();", ctorBlock); } [Fact] public void Gate212_CodebaseAudit_MobilePages_EventUnsubscriptionLifecycle() { var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs")); // DownloadsPage must override OnDisappearing and unsubscribe from singleton events to prevent memory leaks and ghost refreshes Assert.Contains("protected override void OnDisappearing()", downloadsCs); Assert.Contains("TasksChanged -=", downloadsCs); Assert.Contains("TaskProgressUpdated -=", downloadsCs); Assert.Contains("InsetsChanged -=", downloadsCs); } [Fact] public void Gate213_CodebaseAudit_AllBackButtons_And_Pops_GuardedAgainstReentrancy() { var pagesToCheck = new[] { "DownloadsPage.cs", "HistoryBookmarksPage.cs", "SyncSettingsPage.cs", "TabManagerPage.cs" }; foreach (var pageFile in pagesToCheck) { var content = File.ReadAllText(Path.Combine(MobileDir, pageFile)); // Must reference MobileNavigationGuard.SafePopAsync to prevent concurrent FragmentManager pops / ANR Assert.Contains("MobileNavigationGuard.SafePopAsync", content); } } [Fact] public void Gate214_CodebaseAudit_DownloadsPage_SeparateTabRendering_ActiveDoesNotTouchOffline() { var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs")); // Must have separate RenderActiveTasks and RenderOfflineLibrary methods to avoid scanning disk during active download progress Assert.Contains("RenderActiveTasks()", downloadsCs); Assert.Contains("RenderOfflineLibrary()", downloadsCs); // TaskProgressUpdated must only re-render active tasks if active segment is selected Assert.Contains("RenderActiveTasks", downloadsCs); } [Fact] public async Task Gate215_OfflineStore_HighConcurrency_ThreadSafe_NoDeadlock() { var tempDir = Path.Combine(Path.GetTempPath(), $"paca_offline_store_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { var store = new OfflineStore(tempDir); Directory.CreateDirectory(store.Directory); // 50 concurrent tasks performing Record, List, and Delete var tasks = new Task[50]; for (int i = 0; i < 50; i++) { int index = i; tasks[i] = Task.Run(() => { var id = $"item_{index}"; var dummyFile = store.PathFor(id); File.WriteAllText(dummyFile, "dummy data payload"); store.Record(id, $"video_{index}.mp4"); Assert.True(store.Exists(id)); var list = store.List(); Assert.NotEmpty(list); if (index % 2 == 0) { store.Delete(id); } }); } // Must finish within 3 seconds with zero deadlock or file access collision var completed = await Task.WhenAny(Task.WhenAll(tasks), Task.Delay(3000)); Assert.NotEqual(completed, Task.Delay(3000)); // Did not timeout await Task.WhenAll(tasks); } finally { try { Directory.Delete(tempDir, recursive: true); } catch { } } } [Fact] public async Task Gate216_MobileNavigationGuard_SafePopAsync_PreventsConcurrentPops() { MobileNavigationGuard.Reset(); int popCount = 0; var tcs = new TaskCompletionSource(); // 1. Launch first pop that is suspended on tcs var firstPopTask = MobileNavigationGuard.SafePopAsync(async () => { popCount++; await tcs.Task; }); // 2. Second concurrent pop must be rejected immediately by guard bool secondPopExecuted = false; await MobileNavigationGuard.SafePopAsync(async () => { secondPopExecuted = true; await Task.Yield(); }); Assert.False(secondPopExecuted, "Concurrent pop must be safely rejected to protect FragmentManager"); Assert.Equal(1, popCount); // 3. Complete first pop tcs.SetResult(true); await firstPopTask; // 4. Subsequent pop after first completes is accepted bool thirdPopExecuted = false; await MobileNavigationGuard.SafePopAsync(async () => { thirdPopExecuted = true; await Task.Yield(); }); Assert.True(thirdPopExecuted); } [Fact] public void Gate217_LanDiscoveryProber_TimeoutAndSafeCancellation() { using var prober = new LanDiscoveryProber(); // Feed sample payloads prober.HandleReceivedPayload("PACA_DISCOVERY|PONG|Desktop-1|192.168.1.50:52100|1|fp123", "192.168.1.50"); prober.HandleReceivedPayload("PACA_DISCOVERY|PONG|Desktop-2|:52100|0|fp456", "192.168.1.60"); // Missing IP auto-corrected var devices = prober.GetActiveDevices(TimeSpan.FromSeconds(5)); Assert.Equal(2, devices.Count); Assert.Equal("http://192.168.1.50:52100", devices[0].HttpBaseUrl); Assert.Equal("192.168.1.60:52100", devices[1].Endpoint); // Safe stop without exceptions prober.Stop(); } [Fact] public void Gate218_CodebaseAudit_NoRawFileIoInsideUiProgressLoops() { var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs")); // Inside TaskProgressUpdated handler, there must not be any direct calls to AppOfflineStore.Default.List() or FileInfo var progressHandlerIdx = downloadsCs.IndexOf("TaskProgressUpdated +="); Assert.True(progressHandlerIdx > 0); var progressHandlerBlock = downloadsCs.Substring(progressHandlerIdx, Math.Min(300, downloadsCs.Length - progressHandlerIdx)); Assert.DoesNotContain("AppOfflineStore.Default.List()", progressHandlerBlock); Assert.DoesNotContain("FileInfo", progressHandlerBlock); } }