Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/DriverStatusByNodeTests.cs
T
Joseph Doherty b40f08e187 feat(adminui): driver Controls status panel shows one entry per reporting node (#851)
DriverStatusPanel (the /hosts Controls dialog) showed one snapshot per driver - the most
recently published across both nodes of the redundant pair - so a driver Faulted on one node
and Healthy on its peer flipped on every publish. It now primes from the store's GetAll() and
keeps one entry per ReportingNodeId (DriverStatusByNode): per-node state chip in the header,
per-node last-success / 5-min errors / last error, per-node stale dimming. A snapshot with no
node id (pre-#847 publisher) renders unlabelled, sorted last. A primed copy older than a racing
live publish for the same node is dropped.
2026-11-11 06:16:06 -05:00

141 lines
6.0 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
/// <summary>
/// Covers <see cref="DriverStatusByNode"/> — the per-node view behind the /hosts Controls dialog's
/// <c>DriverStatusPanel</c> (lmxopcua#851). Before it the panel showed ONE snapshot per driver, the most
/// recently published across both nodes of the redundant pair, so a driver Faulted on central-1 and Healthy on
/// central-2 flipped between the two on every publish.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverStatusByNodeTests
{
private static readonly DateTime T0 = new(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc);
private static DriverHealthChanged Snap(string driver, string state, DateTime published, string? node, string? error = null) =>
new("MAIN", driver, state, null, error, error is null ? 0 : 1, published, ReportingNodeId: node);
[Fact]
public void Two_nodes_reporting_one_driver_yield_two_entries_ordered_by_node()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Healthy", T0, "central-2"), T0).ShouldBeTrue();
view.Upsert(Snap("drv", "Faulted", T0.AddSeconds(1), "central-1", "boom"), T0).ShouldBeTrue();
var entries = view.Entries;
entries.Select(e => e.NodeId).ShouldBe(["central-1", "central-2"]);
entries.Select(e => e.Snapshot.State).ShouldBe(["Faulted", "Healthy"]);
entries.Select(e => e.Key).Distinct().Count().ShouldBe(2);
}
[Fact]
public void A_later_publish_replaces_only_its_own_nodes_entry()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Faulted", T0, "central-1", "x"), T0);
view.Upsert(Snap("drv", "Healthy", T0, "central-2"), T0);
view.Upsert(Snap("drv", "Faulted", T0.AddSeconds(5), "central-1", "y"), T0.AddSeconds(5));
var entries = view.Entries;
entries.Count.ShouldBe(2);
entries.Single(e => e.NodeId == "central-1").Snapshot.LastError.ShouldBe("y");
entries.Single(e => e.NodeId == "central-2").Snapshot.State.ShouldBe("Healthy");
}
[Fact]
public void Snapshots_for_other_drivers_are_ignored()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("other", "Faulted", T0, "central-1"), T0).ShouldBeFalse();
view.Entries.ShouldBeEmpty();
}
[Fact]
public void A_snapshot_from_a_pre_847_node_with_no_node_id_still_renders_unlabelled_and_sorts_last()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Healthy", T0, node: null), T0).ShouldBeTrue();
view.Upsert(Snap("drv", "Faulted", T0, "central-1"), T0);
var entries = view.Entries;
entries.Count.ShouldBe(2);
entries[0].NodeId.ShouldBe("central-1");
entries[1].NodeId.ShouldBeNull();
entries[1].Label.ShouldBe("—");
entries[1].Key.ShouldBe("");
entries[1].Snapshot.State.ShouldBe("Healthy");
}
[Fact]
public void A_blank_node_id_is_the_same_unlabelled_entry_as_a_missing_one()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Healthy", T0, node: null), T0);
view.Upsert(Snap("drv", "Faulted", T0.AddSeconds(1), node: " "), T0);
view.Entries.ShouldHaveSingleItem().Snapshot.State.ShouldBe("Faulted");
}
[Fact]
public void An_older_publish_arriving_late_does_not_overwrite_a_newer_one_for_the_same_node()
{
// The panel subscribes, THEN primes from the store's GetAll(): a live publish can land before the primed
// copy of the previous snapshot. The primed (older) copy must not win.
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Healthy", T0.AddSeconds(10), "central-1"), T0).ShouldBeTrue();
view.Upsert(Snap("drv", "Faulted", T0, "central-1"), T0).ShouldBeFalse();
view.Entries.ShouldHaveSingleItem().Snapshot.State.ShouldBe("Healthy");
}
[Fact]
public void Staleness_is_per_node()
{
var view = new DriverStatusByNode("drv");
view.Upsert(Snap("drv", "Healthy", T0, "central-1"), receivedUtc: T0);
view.Upsert(Snap("drv", "Healthy", T0, "central-2"), receivedUtc: T0.AddSeconds(40));
var now = T0.AddSeconds(45);
var stale = TimeSpan.FromSeconds(30);
var entries = view.Entries;
entries.Single(e => e.NodeId == "central-1").IsStale(now, stale).ShouldBeTrue();
entries.Single(e => e.NodeId == "central-2").IsStale(now, stale).ShouldBeFalse();
}
[Fact]
public void Priming_from_the_store_picks_up_every_node_of_the_driver()
{
// The store keys per (driver, node) since #847; the panel primes from GetAll(), not TryGet() (which
// answers only the most recent across nodes).
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Snap("drv", "Faulted", T0, "central-1", "boom"));
store.Upsert(Snap("drv", "Healthy", T0.AddSeconds(1), "central-2"));
store.Upsert(Snap("other", "Healthy", T0, "central-1"));
var view = new DriverStatusByNode("drv");
foreach (var s in store.GetAll()) view.Upsert(s, T0);
view.Entries.Select(e => $"{e.NodeId}:{e.Snapshot.State}").ShouldBe(["central-1:Faulted", "central-2:Healthy"]);
}
[Fact]
public void The_status_panel_renders_per_node_entries_and_primes_from_GetAll()
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "DriverStatusPanel.razor");
src.ShouldContain("new DriverStatusByNode(DriverInstanceId)");
src.ShouldContain("StatusStore.GetAll()");
src.ShouldNotContain("StatusStore.TryGet(");
src.ShouldContain("@foreach (var n in nodes)");
src.ShouldContain("@n.Label");
src.ShouldContain("@key=\"@($\"node-{n.Key}\")\"");
// The old single-snapshot field must be gone, or a half-migrated panel could still render one node.
src.ShouldNotContain("_snapshot");
}
}