b40f08e187
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.
141 lines
6.0 KiB
C#
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");
|
|
}
|
|
}
|