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