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