using Bunit; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.Overview.Polling; namespace ZB.MOM.WW.Overview.Tests; /// /// The page as a whole: the KPI strip an operator scans first, the group headers that carry the /// split-brain warning, and the pause control — which has to freeze the view without pretending /// the view is still live. /// public class OverviewPageTests : TestContext { private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero); private readonly OverviewSnapshotStore _store = new(); private readonly FakeTimeProvider _time = new(Now); public OverviewPageTests() { Services.AddSingleton(_store); Services.AddSingleton(_time); } private static InstanceSnapshot Node( string name, InstanceStatus? status, string? group = null, string? leader = null, DateTimeOffset? lastPolled = null) => new() { Application = "ScadaBridge", Instance = name, Group = group, BaseUrl = $"http://{name}:8084", HasActiveRole = group is not null, Status = status, LastPolledUtc = lastPolled ?? Now, LastReachableUtc = lastPolled ?? Now, StaleAfter = TimeSpan.FromSeconds(45), Report = leader is null ? null : System.Text.Json.JsonSerializer.Deserialize( HealthBody.Ready("Healthy", ("akka-cluster", "Healthy", leader))), }; private static OverviewSnapshot Snapshot(params InstanceSnapshot[] instances) { var applications = instances .GroupBy(i => i.Application, StringComparer.Ordinal) .Select(g => new ApplicationSnapshot(g.Key, g.ToList(), LeaderResolver.Resolve(g.ToList()))) .ToList(); return new OverviewSnapshot(Now, applications); } private IRenderedComponent RenderPage() => RenderComponent(); [Fact] public void EmptyStore_RendersAnExplicitEmptyState() { // Before the first sweep. It must say so rather than render a KPI strip full of zeroes, // which would read as "the whole fleet is registered and nothing is up". var page = RenderPage(); Assert.NotNull(page.Find("[data-testid=empty-registry]")); Assert.Empty(page.FindAll(".agg-grid")); } [Fact] public void KpiStrip_CountsEachStatus() { _store.Publish(Snapshot( Node("a", InstanceStatus.Up), Node("b", InstanceStatus.Up), Node("c", InstanceStatus.Degraded), Node("d", InstanceStatus.Down), Node("e", InstanceStatus.Unreachable))); var page = RenderPage(); Assert.Equal("2", page.Find("[data-testid=kpi-up]").TextContent); Assert.Equal("1", page.Find("[data-testid=kpi-degraded]").TextContent); Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent); Assert.Equal("1", page.Find("[data-testid=kpi-unreachable]").TextContent); Assert.Equal("0", page.Find("[data-testid=kpi-stale]").TextContent); } [Fact] public void StaleInstances_CountAsStaleAndNotAlsoAsUp() { // Double-counting would make the KPI totals exceed the instance count and would let a // frozen dashboard keep reporting a healthy fleet. _store.Publish(Snapshot( Node("a", InstanceStatus.Up), Node("b", InstanceStatus.Up, lastPolled: Now.AddMinutes(-5)))); var page = RenderPage(); Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent); Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent); } [Fact] public void GroupHeader_NamesTheLeader() { const string leader = "akka.tcp://scadabridge@a:8082"; _store.Publish(Snapshot( Node("a", InstanceStatus.Up, "site-a", leader), Node("b", InstanceStatus.Up, "site-a", leader))); Assert.Contains("Leader · a", RenderPage().Markup, StringComparison.Ordinal); } [Fact] public void GroupHeader_WarnsOnDisagreement() { // The one output on this page that means "go look right now". _store.Publish(Snapshot( Node("a", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@a:8082"), Node("b", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@b:8082"))); Assert.Contains("Split brain", RenderPage().Markup, StringComparison.Ordinal); } [Fact] public void AgreeingGroup_ShowsNoWarning() { const string leader = "akka.tcp://scadabridge@a:8082"; _store.Publish(Snapshot( Node("a", InstanceStatus.Up, "site-a", leader), Node("b", InstanceStatus.Up, "site-a", leader))); Assert.DoesNotContain("Split brain", RenderPage().Markup, StringComparison.Ordinal); } [Fact] public void PublishedSnapshot_ReRendersThePage() { _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var page = RenderPage(); Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent); _store.Publish(Snapshot(Node("a", InstanceStatus.Down))); page.WaitForAssertion(() => Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent)); Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent); } [Fact] public void Paused_StopsAdoptingNewSnapshots() { _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var page = RenderPage(); page.Find("[data-testid=pause-toggle]").Click(); _store.Publish(Snapshot(Node("a", InstanceStatus.Down))); Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent); Assert.Contains("Resume auto-refresh", page.Markup, StringComparison.Ordinal); } [Fact] public void Resumed_AdoptsWhateverTheStoreHoldsNow() { // Resume must not replay the snapshots missed while paused — it must jump to current. _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var page = RenderPage(); page.Find("[data-testid=pause-toggle]").Click(); _store.Publish(Snapshot(Node("a", InstanceStatus.Down))); page.Find("[data-testid=pause-toggle]").Click(); Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent); } [Fact] public void PausedView_AgesIntoStaleOnItsOwn() { // This is why pause freezes adoption rather than stopping the clock: a paused dashboard // greys out by itself, so it cannot be mistaken for a live one. _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var page = RenderPage(); page.Find("[data-testid=pause-toggle]").Click(); _time.Advance(TimeSpan.FromMinutes(5)); page.Render(); Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent); Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent); } [Fact] public void Disposal_UnsubscribesFromTheStore() { // A leaked handler would call StateHasChanged on a disposed renderer every sweep, for the // life of the process — one leak per page load. _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var page = RenderPage(); page.Instance.Dispose(); var exception = Record.Exception(() => _store.Publish(Snapshot(Node("a", InstanceStatus.Down)))); Assert.Null(exception); } [Fact] public void ApplicationSection_CarriesAnAnchorMatchingTheRailLink() { // The rail's application links are fragment anchors; a mismatch makes every one dead. _store.Publish(Snapshot(Node("a", InstanceStatus.Up))); var section = RenderPage().Find("[data-testid=application]"); Assert.Equal( ZB.MOM.WW.Overview.Components.Layout.MainLayout.Slug("ScadaBridge"), section.GetAttribute("id")); } [Fact] public void OneCardPerRegisteredInstance() { _store.Publish(Snapshot( Node("a", InstanceStatus.Up, "site-a"), Node("b", InstanceStatus.Up, "site-a"), Node("gw", InstanceStatus.Up))); Assert.Equal(3, RenderPage().FindAll("[data-testid=instance-card]").Count); } [Fact] public void UngroupedInstances_RenderAfterTheClusterGroups() { _store.Publish(Snapshot( Node("gw", InstanceStatus.Up), Node("a", InstanceStatus.Up, "site-a"))); // Enumerated, not indexed: bunit 1.40 was built against AngleSharp 1.2 and its // RefreshableElementCollection indexer calls an IHtmlCollection member whose signature // changed by 1.5.2 — the version this project pins for GHSA reasons. LINQ goes through // IEnumerable and is unaffected. var cards = RenderPage() .FindAll("[data-testid=instance-card]") .Select(c => c.GetAttribute("data-instance")) .ToList(); Assert.Equal(["a", "gw"], cards); } }