using Bunit; using ZB.MOM.WW.Overview.Components.Widgets; using ZB.MOM.WW.Overview.Polling; namespace ZB.MOM.WW.Overview.Tests; /// /// The instance card is where a reading becomes a decision, so the tests pin what an operator can /// actually distinguish at a glance: which of the four states is shown, whether it is still /// current, and whether the raw evidence behind it is visible. /// public class InstanceCardTests : TestContext { private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero); private static InstanceSnapshot Card( InstanceStatus? status = InstanceStatus.Up, ActiveState active = ActiveState.NotApplicable, DateTimeOffset? lastPolled = null, DateTimeOffset? lastReachable = null, TimeSpan? staleAfter = null, string? error = null, ZbHealthReport? report = null, ZbHealthReport? activeReport = null, string? managementUrl = null) => new() { Application = "ScadaBridge", Instance = "site-a-a", Group = "site-a", BaseUrl = "http://site-a-a:8084", ManagementUrl = managementUrl, ManagementLabel = "AdminUI", HasActiveRole = active != ActiveState.NotApplicable, Status = status, Active = active, LastPolledUtc = lastPolled ?? Now, LastReachableUtc = lastReachable ?? Now, Latency = TimeSpan.FromMilliseconds(18), Error = error, Report = report, ActiveReport = activeReport, StaleAfter = staleAfter ?? TimeSpan.FromSeconds(45), }; private IRenderedComponent Render(InstanceSnapshot instance, bool isLeader = false) => RenderComponent(p => p .Add(c => c.Instance, instance) .Add(c => c.Now, Now) .Add(c => c.IsLeader, isLeader)); [Theory] [InlineData(InstanceStatus.Up, "up", "Up")] [InlineData(InstanceStatus.Degraded, "degraded", "Degraded")] [InlineData(InstanceStatus.Down, "down", "Down")] [InlineData(InstanceStatus.Unreachable, "unreachable", "Unreachable")] public void Card_CarriesItsStatusAsBothClassAndLabel(InstanceStatus status, string cssClass, string label) { var card = Render(Card(status)); Assert.Contains(cssClass, card.Find("article").ClassList, StringComparer.Ordinal); Assert.Contains(label, card.Markup, StringComparison.Ordinal); } [Fact] public void UnreachableAndDown_AreVisuallyDistinct_NotJustDifferentWords() { // The CSS gives Unreachable a dashed border precisely because both are red. If the class // did not differ, the two would be indistinguishable on a wall display. var down = Render(Card(InstanceStatus.Down)).Find("article").ClassList; var unreachable = Render(Card(InstanceStatus.Unreachable)).Find("article").ClassList; Assert.NotEqual(down, unreachable); } [Fact] public void NeverPolled_RendersPending_NotHealthy() { var card = Render(Card(status: null, lastPolled: null)); Assert.Contains("pending", card.Find("article").ClassList, StringComparer.Ordinal); Assert.Contains("Pending", card.Markup, StringComparison.Ordinal); Assert.DoesNotContain(">Up<", card.Markup, StringComparison.Ordinal); } [Fact] public void StaleCard_IsMarkedStaleButKeepsItsLastKnownStatusClass() { // Both facts matter: the reading was Up, and it is no longer current. Dropping either one // loses information the operator needs. var card = Render(Card(lastPolled: Now.AddMinutes(-5))); var classes = card.Find("article").ClassList; Assert.Contains("stale", classes, StringComparer.Ordinal); Assert.Contains("up", classes, StringComparer.Ordinal); Assert.Contains("Stale", card.Markup, StringComparison.Ordinal); } [Fact] public void StaleCard_CountsFromLastContact_NotFromTheLastPollAttempt() { // The poller kept trying for ten minutes and kept failing. "Stale since 5 s ago" would // suggest the instance was fine moments ago; it has actually been gone for ten minutes. var card = Render(Card( status: InstanceStatus.Unreachable, lastPolled: Now.AddSeconds(-5), lastReachable: Now.AddMinutes(-10), staleAfter: TimeSpan.FromSeconds(1))); Assert.Contains("Stale since", card.Markup, StringComparison.Ordinal); Assert.Contains("10 min ago", card.Markup, StringComparison.Ordinal); } [Theory] [InlineData(ActiveState.Active, "Active")] [InlineData(ActiveState.Standby, "Standby")] [InlineData(ActiveState.Unknown, "Role ?")] public void ActiveState_IsBadged(ActiveState state, string expected) { Assert.Contains(expected, Render(Card(active: state)).Markup, StringComparison.Ordinal); } [Fact] public void NoActiveRole_ShowsNoActivityBadgeAtAll() { // A single-instance gateway has no active/standby concept; a badge would invent one. var markup = Render(Card(active: ActiveState.NotApplicable)).Markup; Assert.DoesNotContain("Standby", markup, StringComparison.Ordinal); Assert.DoesNotContain("Role ?", markup, StringComparison.Ordinal); Assert.DoesNotContain("· active", markup, StringComparison.Ordinal); } [Fact] public void LeaderBadge_IsShownOnlyWhenTheCardIsTheLeader() { Assert.Contains("Leader", Render(Card(), isLeader: true).Markup, StringComparison.Ordinal); Assert.DoesNotContain("Leader", Render(Card(), isLeader: false).Markup, StringComparison.Ordinal); } [Fact] public void Checks_AreListedWithAHealthyCount() { var card = Render(Card(report: Report(("localdb", "Healthy", null), ("akka-cluster", "Unhealthy", null)))); Assert.Contains("1 / 2 healthy", card.Markup, StringComparison.Ordinal); Assert.Equal(2, card.FindAll(".check-row").Count); } [Fact] public void ClusterData_IsRenderedAsRawEvidence() { var card = Render(Card(report: Report(("akka-cluster", "Healthy", "akka.tcp://scadabridge@site-a-a:8082")))); var data = card.Find(".check-data"); Assert.Contains("akka.tcp://scadabridge@site-a-a:8082", data.TextContent, StringComparison.Ordinal); Assert.Contains("leader", data.TextContent, StringComparison.Ordinal); } [Fact] public void ClusterData_FromAnInstanceWithoutIt_IsOmittedEntirely() { // Every pre-0.2.0 node, and every check that publishes no data. The card must simply not // show the line rather than showing an empty one. var card = Render(Card(report: Report(("localdb", "Healthy", null)))); Assert.Empty(card.FindAll(".check-data")); } [Fact] public void ClusterData_IsHtmlEscaped() { // The value is remote input from another application. Operationally trusted, but a health // check that ever emits markup must not be able to inject it into this page. var card = Render(Card(report: Report(("akka-cluster", "Healthy", "")))); var data = card.Find(".check-data"); Assert.Empty(data.QuerySelectorAll("img")); Assert.Contains("", data.TextContent, StringComparison.Ordinal); } [Fact] public void ManagementLink_OpensInANewTabWithNoopener() { var link = Render(Card(managementUrl: "http://site-a-a:9000/")).Find(".panel-foot a"); Assert.Equal("http://site-a-a:9000/", link.GetAttribute("href")); Assert.Equal("_blank", link.GetAttribute("target")); Assert.Equal("noopener", link.GetAttribute("rel")); Assert.Contains("AdminUI", link.TextContent, StringComparison.Ordinal); } [Fact] public void NoManagementUrl_RendersNoDeadLink() { var card = Render(Card(managementUrl: null)); Assert.Empty(card.FindAll(".panel-foot a")); Assert.Contains("no management link", card.Markup, StringComparison.Ordinal); } [Theory] [InlineData(InstanceStatus.Up, ActiveState.Active, "ready 200 · active 200")] [InlineData(InstanceStatus.Up, ActiveState.Standby, "ready 200 · active 503")] [InlineData(InstanceStatus.Down, ActiveState.NotApplicable, "ready 503")] [InlineData(InstanceStatus.Unreachable, ActiveState.NotApplicable, "ready fail")] public void RawSignalLine_ShowsWhatWasActuallyReceived( InstanceStatus status, ActiveState active, string expected) { // The footer is the escape hatch from every derived label above it: when the dashboard and // a curl disagree, this line is what reconciles them. Assert.Contains( expected, Render(Card(status, active)).Find("[data-testid=raw-signals]").TextContent, StringComparison.Ordinal); } [Fact] public void ProbeError_IsSurfacedOnTheCard() { var card = Render(Card(InstanceStatus.Unreachable, error: "Connection refused")); Assert.Contains("Connection refused", card.Markup, StringComparison.Ordinal); } [Fact] public void Host_IsShownWithoutTheScheme() { Assert.Contains("site-a-a:8084", Render(Card()).Find(".inst-host").TextContent, StringComparison.Ordinal); } [Theory] [InlineData(4, "4 s ago")] [InlineData(90, "2 min ago")] [InlineData(7200, "2 h ago")] [InlineData(172800, "2 d ago")] public void Relative_ScalesTheUnit(int seconds, string expected) { Assert.Equal(expected, InstanceCard.Relative(TimeSpan.FromSeconds(seconds))); } [Fact] public void Relative_NegativeAge_ReadsAsNow_NotAsTheFuture() { // Clock skew between this host and a probed one is routine; "-3 s ago" is nonsense. Assert.Equal("0 s ago", InstanceCard.Relative(TimeSpan.FromSeconds(-3))); } private static ZbHealthReport Report(params (string Name, string Status, string? Leader)[] entries) => System.Text.Json.JsonSerializer.Deserialize(HealthBody.Ready("Healthy", entries))!; // ---- MXAccess session counts (active tier) ------------------------------------------------- // The gateway publishes live MXAccess session counts on its ACTIVE tier. These pin that the // number reaches the card face, since a count behind a disclosure triangle is one nobody reads. private static ZbHealthReport SessionReport(int total, int ready, int faulted = 0, int starting = 0) => System.Text.Json.JsonSerializer.Deserialize( HealthBody.WithData( "Healthy", ("mxaccess-sessions", "Healthy", new Dictionary(StringComparer.Ordinal) { ["total"] = total, ["ready"] = ready, ["faulted"] = faulted, ["starting"] = starting, ["closing"] = 0, })))!; [Fact] public void Card_ShowsMxAccessSessionCount_FromTheActiveTier() { var card = Render(Card(activeReport: SessionReport(total: 2, ready: 2))); var row = card.Find("[data-testid=mxaccess-sessions]"); Assert.Contains("MXAccess", row.TextContent, StringComparison.Ordinal); Assert.Contains("2 · 2 ready", row.TextContent, StringComparison.Ordinal); } [Fact] public void Card_ShowsZeroSessions_RatherThanHidingTheRow() { // A gateway holding no MXAccess sessions is the interesting case. Suppressing zero would // make the row appear only when the news is good, which is when nobody needs it. var card = Render(Card(activeReport: SessionReport(total: 0, ready: 0))); Assert.Equal("0", card.Find("[data-testid=mxaccess-sessions] .v").TextContent.Trim()); } [Fact] public void Card_CallsOutFaultedSessions() { var card = Render(Card(activeReport: SessionReport(total: 3, ready: 2, faulted: 1))); var value = card.Find("[data-testid=mxaccess-sessions] .v"); Assert.Contains("1 faulted", value.TextContent, StringComparison.Ordinal); Assert.Contains("bad", value.ClassList, StringComparer.Ordinal); } [Fact] public void Card_OmitsTheMxAccessRow_ForAppsThatPublishNoSessionCounts() { // Positive control first: the SAME assertion must find the row when the data IS present, // or "not found" below would prove nothing about the omission. Assert.NotEmpty(Render(Card(activeReport: SessionReport(1, 1))).FindAll("[data-testid=mxaccess-sessions]")); Assert.Empty(Render(Card()).FindAll("[data-testid=mxaccess-sessions]")); } // ---- HistorianGateway session pools (ready tier) ------------------------------------------- // A different app, a different tier and a different key set, so this is a sibling row rather // than a shared abstraction neither app actually publishes. private static ZbHealthReport PoolReport(params (string Pool, int Warm, int Failures)[] pools) => System.Text.Json.JsonSerializer.Deserialize( HealthBody.WithData( "Healthy", pools.Select(p => ( $"historian-{p.Pool}-session-pool", "Healthy", (Dictionary?)new Dictionary(StringComparer.Ordinal) { ["pool"] = p.Pool, ["warmSessions"] = p.Warm, ["consecutiveFactoryFailures"] = p.Failures, })).ToArray()))!; [Fact] public void Card_ShowsHistorianPoolDepths_FromTheReadyTier() { var card = Render(Card(report: PoolReport(("read", 1, 0), ("event", 0, 0)))); var row = card.Find("[data-testid=historian-pools]"); // read before event: the order an operator cares about, not dictionary order. Assert.Contains("read 1 · event 0", row.TextContent, StringComparison.Ordinal); } [Fact] public void Card_DoesNotPaintAnIdlePoolAsBroken() { // 0 warm sessions is the normal idle state - sessions are leased on demand. Colouring it // red would train operators to ignore the one colour that should mean something. var card = Render(Card(report: PoolReport(("event", 0, 0)))); Assert.DoesNotContain("bad", card.Find("[data-testid=historian-pools] .v").ClassList, StringComparer.Ordinal); } [Fact] public void Card_FlagsAPoolWhoseSessionFactoryIsFailing() { var card = Render(Card(report: PoolReport(("read", 0, 3)))); var value = card.Find("[data-testid=historian-pools] .v"); Assert.Contains("3 fail", value.TextContent, StringComparison.Ordinal); Assert.Contains("bad", value.ClassList, StringComparer.Ordinal); } [Fact] public void Card_OmitsThePoolRow_ForAppsWithNoSessionPools() { // Positive control first, or the omission below proves nothing. Assert.NotEmpty(Render(Card(report: PoolReport(("read", 1, 0)))).FindAll("[data-testid=historian-pools]")); Assert.Empty(Render(Card(report: Report(("db", "Healthy", null)))).FindAll("[data-testid=historian-pools]")); } [Fact] public void Card_KeepsTheTwoSessionRowsIndependent() { // The gateway's active-tier session count and the historian's ready-tier pool depths use // different keys on different tiers; neither may render the other's row. var gateway = Render(Card(activeReport: SessionReport(total: 2, ready: 2))); Assert.Empty(gateway.FindAll("[data-testid=historian-pools]")); var historian = Render(Card(report: PoolReport(("read", 1, 0)))); Assert.Empty(historian.FindAll("[data-testid=mxaccess-sessions]")); } [Fact] public void Card_IgnoresActiveTierChecksThatAreNotSessionCounts() { // The row is located by the total/ready/faulted triple, not by check name. An active-node // check publishes its own data on the same tier and must not be mistaken for a session count. var activeNode = System.Text.Json.JsonSerializer.Deserialize( HealthBody.WithData( "Healthy", ("active-node", "Healthy", new Dictionary(StringComparer.Ordinal) { ["activeNode"] = "site-a-a", ["total"] = 3, })))!; Assert.Empty(Render(Card(activeReport: activeNode)).FindAll("[data-testid=mxaccess-sessions]")); } }