Files
scadaproj/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs
T
Joseph Doherty da276ff15a feat(overview): show MXAccess session counts on the gateway card
The gateway publishes its live MXAccess session counts, and the dashboard
could not reach them. Four things stood between the data and the card, and
only the last one is cosmetic:

The active tier was probed only for instances declared HasActiveRole. A
standalone gateway has no active/standby role, so its active tier was never
requested at all. The tempting fix is to declare mxgw HasActiveRole, and it
is wrong: that flag also paints an Active/Standby badge, so it would buy the
data by asserting a role that does not exist. The probe is now unconditional
for any reachable instance while ObserveActive stays role-gated, separating
"what does this tier report" from "am I the active half".

The active probe's parsed body was then discarded - only ready.Report was
kept - so InstanceSnapshot gains ActiveReport. The two tiers stay separate
rather than merged, because a check name is only unique within a tier.

The card's one data-rendering path, ClusterDataLine, is located by the Akka
leader/activeNode keys and would ignore session data even once present. The
new row follows that helper's own precedent and locates the check by the data
it carries - the total/ready/faulted triple - not by name, requiring all three
so a future check publishing a lone "total" cannot be mistaken for it.

Rendered on the card FACE, not inside the collapsed check list: a count behind
a disclosure triangle is one nobody reads. Zero is shown rather than hidden,
since a gateway holding no sessions is the interesting case and suppressing it
would make the row appear only when the news is good.

Cost, stated plainly: every reachable instance now makes one extra HTTP
request per poll. The unreachable-instance skip is untouched, so the
worst-case sweep duration - which is set by timeouts, not by healthy
round-trips - is unchanged. Four cadence tests were counting raw requests as a
proxy for "instances polled"; they now count ready-tier probes, which is what
they always meant and is robust to tier changes.

The Wonder registry comment claiming the active tier ran an empty check set
was true when written and stopped being true when the gateway added the
check; it now warns against the HasActiveRole shortcut instead.

183 tests pass (177 + 6), including a positive control on the absence
assertion so "row not rendered" cannot pass vacuously.
2026-08-12 04:47:06 -04:00

326 lines
13 KiB
C#

using Bunit;
using ZB.MOM.WW.Overview.Components.Widgets;
using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// 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.
/// </summary>
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<InstanceCard> Render(InstanceSnapshot instance, bool isLeader = false) =>
RenderComponent<InstanceCard>(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", "<img src=x onerror=alert(1)>"))));
var data = card.Find(".check-data");
Assert.Empty(data.QuerySelectorAll("img"));
Assert.Contains("<img src=x onerror=alert(1)>", 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<ZbHealthReport>(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<ZbHealthReport>(
HealthBody.WithData(
"Healthy",
("mxaccess-sessions", "Healthy", new Dictionary<string, object>(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]"));
}
[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<ZbHealthReport>(
HealthBody.WithData(
"Healthy",
("active-node", "Healthy", new Dictionary<string, object>(StringComparer.Ordinal)
{
["activeNode"] = "site-a-a",
["total"] = 3,
})))!;
Assert.Empty(Render(Card(activeReport: activeNode)).FindAll("[data-testid=mxaccess-sessions]"));
}
}