672 lines
26 KiB
C#
672 lines
26 KiB
C#
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<InvalidOperationException>(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<bool>();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
|
|
|