releases/tests/Paca.Tests/Mobile/MobileResponsivenessAndAnrPreventionGateTests.cs

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