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("_byNode.Prime(StatusStore.GetAllWithReceiveTime(), DateTime.UtcNow, StaleAfter)");
// Priming must not stamp the store's snapshots "received now" (#857).
src.ShouldNotContain("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");
}
// ---- lmxopcua#857: a snapshot already old when the dialog opens is stale immediately ----
private static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(30);
/// A hand-driven clock — the test project has no FakeTimeProvider package reference.
private sealed class ManualTimeProvider(DateTime startUtc) : TimeProvider
{
public DateTime UtcNow { get; set; } = startUtc;
public override DateTimeOffset GetUtcNow() => new(UtcNow, TimeSpan.Zero);
}
[Fact]
public void The_store_records_its_local_receive_time_per_node()
{
var clock = new ManualTimeProvider(T0);
var store = new InMemoryDriverStatusSnapshotStore(time: clock);
store.Upsert(Snap("drv", "Healthy", T0.AddHours(-3), "central-1")); // peer clock far behind: irrelevant
clock.UtcNow = T0.AddSeconds(7);
store.Upsert(Snap("drv", "Healthy", T0.AddHours(-3), "central-2"));
var all = store.GetAllWithReceiveTime();
all.Single(e => e.Snapshot.ReportingNodeId == "central-1").ReceivedUtc.ShouldBe(T0);
all.Single(e => e.Snapshot.ReportingNodeId == "central-2").ReceivedUtc.ShouldBe(T0.AddSeconds(7));
store.GetAll().Count.ShouldBe(2);
}
[Fact]
public void A_snapshot_the_store_received_long_before_the_dialog_opened_is_stale_at_once()
{
// The docker-dev repro: central-2 stopped 1m30s before the panel was reopened. It must dim immediately,
// while central-1 (still publishing) stays live.
var clock = new ManualTimeProvider(T0);
var store = new InMemoryDriverStatusSnapshotStore(time: clock);
store.Upsert(Snap("drv", "Healthy", T0, "central-2"));
clock.UtcNow = T0.AddSeconds(88);
store.Upsert(Snap("drv", "Healthy", T0.AddSeconds(88), "central-1"));
var openedAt = T0.AddSeconds(90);
var view = new DriverStatusByNode("drv");
view.Prime(store.GetAllWithReceiveTime(), openedAt, StaleAfter).ShouldBeTrue();
var entries = view.Entries;
entries.Single(e => e.NodeId == "central-2").IsStale(openedAt, StaleAfter).ShouldBeTrue();
entries.Single(e => e.NodeId == "central-1").IsStale(openedAt, StaleAfter).ShouldBeFalse();
}
[Fact]
public void A_fresh_snapshot_loaded_on_open_is_not_stale_even_when_the_peer_clock_is_far_behind()
{
// Positive control + skew-proofing: the age comes from the store's LOCAL clock, so a publisher whose clock
// is an hour behind does not dim a snapshot this process received a second ago.
var clock = new ManualTimeProvider(T0);
var store = new InMemoryDriverStatusSnapshotStore(time: clock);
store.Upsert(Snap("drv", "Healthy", T0.AddHours(-1), "central-1"));
var openedAt = T0.AddSeconds(1);
var view = new DriverStatusByNode("drv");
view.Prime(store.GetAllWithReceiveTime(), openedAt, StaleAfter);
var entry = view.Entries.ShouldHaveSingleItem();
entry.ReceivedUtc.ShouldBe(T0);
entry.IsStale(openedAt, StaleAfter).ShouldBeFalse();
entry.IsStale(T0.AddSeconds(31), StaleAfter).ShouldBeTrue(); // and it does age out on the normal schedule
}
[Fact]
public void A_live_update_after_priming_un_stales_the_node()
{
var view = new DriverStatusByNode("drv");
view.Prime([new StoredDriverHealth(Snap("drv", "Healthy", T0, "central-2"), T0)], T0.AddSeconds(90), StaleAfter);
view.Entries.Single().IsStale(T0.AddSeconds(90), StaleAfter).ShouldBeTrue();
view.Upsert(Snap("drv", "Healthy", T0.AddSeconds(91), "central-2"), T0.AddSeconds(91)).ShouldBeTrue();
view.Entries.Single().IsStale(T0.AddSeconds(92), StaleAfter).ShouldBeFalse();
}
[Fact]
public void A_store_receive_time_in_the_future_is_clamped_to_now()
{
var stored = new StoredDriverHealth(Snap("drv", "Healthy", T0, "central-1"), T0.AddMinutes(5));
DriverStatusByNode.PrimedReceiveTime(stored, T0, StaleAfter).ShouldBe(T0);
}
[Fact]
public void Without_a_store_receive_time_an_old_publish_seeds_the_age_and_a_recent_one_is_now()
{
var old = new StoredDriverHealth(Snap("drv", "Healthy", T0.AddSeconds(-90), "central-2"), null);
DriverStatusByNode.PrimedReceiveTime(old, T0, StaleAfter).ShouldBe(T0.AddSeconds(-90));
// Positive control: a recent publish (or a peer clock running ahead) is treated as received now.
var recent = new StoredDriverHealth(Snap("drv", "Healthy", T0.AddSeconds(-5), "central-1"), null);
DriverStatusByNode.PrimedReceiveTime(recent, T0, StaleAfter).ShouldBe(T0);
var ahead = new StoredDriverHealth(Snap("drv", "Healthy", T0.AddMinutes(10), "central-1"), null);
DriverStatusByNode.PrimedReceiveTime(ahead, T0, StaleAfter).ShouldBe(T0);
}
[Fact]
public void The_default_interface_receive_time_is_null_for_a_store_that_does_not_track_one()
{
IDriverStatusSnapshotStore store = new GetAllOnlyStore([Snap("drv", "Healthy", T0, "central-1")]);
store.GetAllWithReceiveTime().ShouldHaveSingleItem().ReceivedUtc.ShouldBeNull();
}
private sealed class GetAllOnlyStore(IReadOnlyCollection all) : IDriverStatusSnapshotStore
{
public event Action? SnapshotChanged { add { } remove { } }
public void Upsert(DriverHealthChanged snapshot) { }
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot) { snapshot = null!; return false; }
public IReadOnlyCollection GetAll() => all;
public int EvictUnconfiguredStale(IReadOnlySet configuredInstanceIds, DateTime olderThanUtc) => 0;
}
}