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