2bd3a6c3c8
The Controls panel primed its per-node view from the snapshot store with ReceivedUtc = now, so a node that stopped publishing before the dialog opened looked live for the first 30 s stale window. InMemoryDriverStatusSnapshotStore now records each snapshot's LOCAL receive time (TimeProvider-injectable) and exposes it via GetAllWithReceiveTime(); the panel primes through the pure seam DriverStatusByNode.Prime/PrimedReceiveTime, which keeps the store's receive time (clamped to now; still skew-proof). A store that does not track one (default interface impl -> null) falls back to the publish time only when that is already older than the stale window, else now. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
256 lines
12 KiB
C#
256 lines
12 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("_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);
|
|
|
|
/// <summary>A hand-driven clock — the test project has no <c>FakeTimeProvider</c> package reference.</summary>
|
|
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<DriverHealthChanged> all) : IDriverStatusSnapshotStore
|
|
{
|
|
public event Action<DriverHealthChanged>? SnapshotChanged { add { } remove { } }
|
|
public void Upsert(DriverHealthChanged snapshot) { }
|
|
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot) { snapshot = null!; return false; }
|
|
public IReadOnlyCollection<DriverHealthChanged> GetAll() => all;
|
|
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc) => 0;
|
|
}
|
|
}
|