Files
scadaproj/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/InstanceCardTests.cs
T
Joseph Doherty 846b77f5b4 feat(overview): show historian session-pool depths on the HistorianGateway card
Asked whether the gateway's new session row also covered HistorianGateway.
It did not, and could not: HistorianGateway publishes its pool depths on a
different TIER with a different KEY SET.

  mxaccessgw       active tier   total / ready / faulted        one check
  historiangw      READY  tier   warmSessions / pool / …        TWO checks

The session row is located by the total/ready/faulted triple, so it correctly
ignored these rather than half-matching them. This adds a sibling row instead
of unifying the two behind a shared shape, because there is no such shape -
inventing one would mean rendering a contract neither app publishes.

No poller change was needed: the ready tier's body is already retained, so
this is entirely a rendering change.

Labelled "Sessions pooled", deliberately not "connections". warmSessions is
pool DEPTH - warm pre-authenticated sessions held ready - and sessions are
leased on demand, so the event pool legitimately sits at 0 until something
reads events. Calling that a connection count would make a normal idle pool
read as an outage, so 0 is shown plainly and the row goes red only on
consecutiveFactoryFailures, which is an actual fault.

Pools render read-then-event rather than in dictionary order, so the line is
stable across polls and the pool an operator cares about comes first.

188 tests (183 + 5): ordering, that an idle pool is not painted red, that a
failing factory is, a positive control on the omission assertion, and one
pinning that neither app's row can render the other's data.
2026-08-12 05:16:25 -04:00

395 lines
16 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]"));
}
// ---- 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<ZbHealthReport>(
HealthBody.WithData(
"Healthy",
pools.Select(p => (
$"historian-{p.Pool}-session-pool",
"Healthy",
(Dictionary<string, object>?)new Dictionary<string, object>(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<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]"));
}
}