using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// /// Pins the teardown ordering of the two self-polling pages (/fleet, /hosts). /// Both schedule their refresh as _ = InvokeAsync(...) from a /// callback, so await _timer.DisposeAsync() awaits only the SCHEDULING of a tick, never the /// tick itself: a query can still be running against _cts.Token after that await returns. /// Three properties follow, and none of them is visible in a passing render test. /// /// /// Source scans rather than rendering assertions because the repo has no bUnit (same technique as /// HostsPanelWiringTests). A scan proves the shape of the teardown, not that a real circuit /// tears down cleanly — the fix itself is inspection-verified against the disposal race. /// [Trait("Category", "Unit")] public sealed class PollTimerTeardownOrderingTests { private static string ReadPageSource(string fileName) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent; dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)"); return File.ReadAllText(Path.Combine( dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI", "Components", "Pages", fileName)); } /// /// The CTS must be cancelled BEFORE the timer is drained. Reversed, a tick that is in flight /// during teardown runs its queries to completion against a component that is already gone /// instead of aborting on a cancelled token. /// [Theory] [InlineData("Fleet.razor")] [InlineData("Hosts.razor")] public void The_page_cancels_its_token_source_before_draining_the_poll_timer(string page) { var source = ReadPageSource(page); var cancelIndex = source.IndexOf("_cts.Cancel();", StringComparison.Ordinal); var drainIndex = source.IndexOf("_timer.DisposeAsync();", StringComparison.Ordinal); cancelIndex.ShouldBeGreaterThan(-1, $"{page} no longer cancels its page-lifetime CancellationTokenSource"); drainIndex.ShouldBeGreaterThan(-1, $"{page} no longer drains its poll timer on dispose"); cancelIndex.ShouldBeLessThan(drainIndex, $"{page} must cancel before draining — a tick in flight has to observe cancellation, not " + "run its queries out against a torn-down component"); } /// /// Disposing the CTS is gated on the overlap guard. Ungated it can be disposed out from under a /// still-running tick, which converts the clean OperationCanceledException the cancel /// above arranged into an ObjectDisposedException thrown mid-query. /// [Theory] [InlineData("Fleet.razor")] [InlineData("Hosts.razor")] public void The_page_only_disposes_its_token_source_when_no_tick_holds_it(string page) { ReadPageSource(page).ShouldContain( "if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();", Case.Sensitive, customMessage: $"{page} disposes its CancellationTokenSource without checking the overlap guard — " + "the timer callback discards its InvokeAsync task, so DisposeAsync awaited only the " + "scheduling of the tick and a query may still hold the token"); } /// /// The StateHasChanged in the refresh finally sits outside the try, so it has no /// catch to fall into; rendering into a disposed renderer throws inside a task nobody awaits. /// [Theory] [InlineData("Fleet.razor")] [InlineData("Hosts.razor")] public void The_page_skips_the_teardown_render_once_disposed(string page) { ReadPageSource(page).ShouldContain( "if (!_disposed) StateHasChanged();", Case.Sensitive, customMessage: $"{page}'s finally-block StateHasChanged is unguarded — it is outside the try, so a " + "render against a torn-down circuit escapes into a discarded task"); } /// /// THE regression pin for the /hosts swallow-and-clear: an ObjectDisposedException raised /// by teardown must rethrow as a clean exit, never reach the generic catch that blanks /// _nodes/_instances. That blanking is permanent — a torn-down page never ticks /// again to refill them — and its own comment forbids it. /// [Fact] public void The_hosts_config_load_rethrows_a_disposal_race_instead_of_blanking_its_cache() { var source = ReadPageSource("Hosts.razor"); var rethrowIndex = source.IndexOf( "catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)", StringComparison.Ordinal); // Anchored on the warning line, not on `_nodes = new();` — that assignment also appears as a // field initializer far earlier in the file, which would make the ordering check vacuous. var blankIndex = source.IndexOf( "/hosts: failed to load driver-instance config", StringComparison.Ordinal); rethrowIndex.ShouldBeGreaterThan(-1, "/hosts LoadConfigAsync no longer rethrows the teardown exceptions — an ObjectDisposedException " + "from disposing _cts under a live tick falls into the generic catch and blanks the cached config"); blankIndex.ShouldBeGreaterThan(-1, "/hosts LoadConfigAsync no longer has the swallow-and-clear fallback"); rethrowIndex.ShouldBeLessThan(blankIndex, "the rethrow filter must precede the blanking catch, or it never runs"); } }