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
|
|
@ -0,0 +1,672 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue