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; /// /// Covers — the per-node view behind the /hosts Controls dialog's /// DriverStatusPanel (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. /// [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"); } }