Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs
T
Joseph Doherty 0f1f8ad8fa fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver
deleted in /raw but not yet deployed - still running, still publishing -
vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set,
olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is
older than 60 s. Tests cover unconfigured+fresh kept (positive control),
unconfigured+stale evicted, configured+stale kept, and the deploy hand-off.

M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds
default (Galaxy 30, OpcUaClient 15, ...) when the key is absent.
M2: the budget never undercuts the config's root timeoutMs (+1 s).
M5: RawBrowseModal compares the open generation captured before the await,
so a close-then-reopen mid-open closes the stale session.
M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver
node may still be starting) instead of stopping for good.
M7: /scripts source fetch failure shows in the dialog, not the error boundary.
M8: the export modal maps only its preview rows; the endpoint writes via
Results.Text (no second byte[] copy).
M9: /hosts config queries are ordered, so SequenceEqual change detection holds.
N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults,
N5 equipment-import help text now describes RFC 4180 quoting.
H3: MonacoEditor documents that themes are page-global.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 18:21:46 -04:00

179 lines
7.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs;
/// <summary>
/// Review R04 L1 + L5: the in-process stores evict driver instances that are no longer configured
/// (so <c>/hosts</c> stops rendering ghost rows), and a throwing subscriber no longer aborts the
/// multicast for the subscribers after it (nor escapes onto the bridge actor).
/// </summary>
public sealed class StoreEvictionAndIsolationTests
{
private static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Stale = Now.AddMinutes(-5); // stopped publishing
private static readonly DateTime Fresh = Now.AddSeconds(-3); // still publishing
private static readonly DateTime Cutoff = Now.AddSeconds(-60); // what /hosts passes
private static DriverHealthChanged Health(string instance, DateTime? publishedUtc = null) =>
new("MAIN", instance, "Healthy", null, null, 0, publishedUtc ?? Now);
private static DriverResilienceStatusChanged Res(string instance, string host, DateTime? publishedUtc = null) =>
new(instance, host, false, 0, 0, null, publishedUtc ?? Now, publishedUtc ?? Now);
private static HashSet<string> Configured(params string[] ids) => new(ids, StringComparer.OrdinalIgnoreCase);
[Fact]
public void HealthStore_evicts_only_unconfigured_and_stale()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("drv-deleted-stopped", Stale)); // unconfigured + stale ⇒ evicted
store.Upsert(Health("drv-deleted-running", Fresh)); // unconfigured + fresh ⇒ kept (I1: deleted in /raw, not yet deployed)
store.Upsert(Health("drv-configured-quiet", Stale)); // configured + stale ⇒ kept
store.Upsert(Health("drv-configured-live", Fresh)); // configured + fresh ⇒ kept
var removed = store.EvictUnconfiguredStale(Configured("drv-configured-quiet", "drv-configured-live"), Cutoff);
removed.ShouldBe(1);
store.TryGet("drv-deleted-stopped", out _).ShouldBeFalse();
// Positive controls: a still-publishing driver survives even though the draft no longer has it,
// and a configured driver survives whatever its age.
store.TryGet("drv-deleted-running", out _).ShouldBeTrue();
store.TryGet("drv-configured-quiet", out _).ShouldBeTrue();
store.TryGet("drv-configured-live", out _).ShouldBeTrue();
}
[Fact]
public void HealthStore_a_running_undeployed_delete_goes_once_it_stops_publishing()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("drv-x", Fresh));
store.EvictUnconfiguredStale(Configured(), Cutoff).ShouldBe(0);
// The deploy stops it: its last snapshot ages past the cutoff, and the next sweep evicts it.
store.Upsert(Health("drv-x", Stale));
store.EvictUnconfiguredStale(Configured(), Cutoff).ShouldBe(1);
store.TryGet("drv-x", out _).ShouldBeFalse();
}
[Fact]
public void HealthStore_matches_ids_case_insensitively()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("DRV-Live", Stale));
store.EvictUnconfiguredStale(Configured("drv-live"), Cutoff).ShouldBe(0);
store.TryGet("DRV-Live", out _).ShouldBeTrue();
}
[Fact]
public void ResilienceStore_evicts_only_unconfigured_and_stale_hosts()
{
var store = new InMemoryDriverResilienceStatusStore();
store.Upsert(Res("drv-live", "h1", Stale));
store.Upsert(Res("drv-gone", "h1", Stale));
store.Upsert(Res("drv-gone", "h2", Stale));
store.Upsert(Res("drv-running", "h1", Fresh));
var removed = store.EvictUnconfiguredStale(Configured("drv-live"), Cutoff);
removed.ShouldBe(2);
store.GetForInstance("drv-gone").ShouldBeEmpty();
store.GetForInstance("drv-live").Count.ShouldBe(1);
store.GetForInstance("drv-running").Count.ShouldBe(1);
}
[Fact]
public void Failure_log_throttle_warns_at_most_once_per_interval()
{
long now = 1_000;
var throttle = new FailureLogThrottle(() => now);
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Warning);
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Debug);
now += (long)FailureLogThrottle.Interval.TotalMilliseconds - 1;
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Debug);
// Positive control: once the interval has passed, the next failure is visible again.
now += 1;
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Warning);
}
[Fact]
public void HealthStore_throwing_subscriber_does_not_stop_later_subscribers()
{
var store = new InMemoryDriverStatusSnapshotStore();
var received = new List<string>();
store.SnapshotChanged += _ => throw new InvalidOperationException("renderer torn down");
store.SnapshotChanged += s => received.Add(s.DriverInstanceId);
Should.NotThrow(() => store.Upsert(Health("drv-1")));
received.ShouldBe(["drv-1"]);
store.TryGet("drv-1", out _).ShouldBeTrue();
}
[Fact]
public void ResilienceStore_throwing_subscriber_does_not_stop_later_subscribers()
{
var store = new InMemoryDriverResilienceStatusStore();
var received = 0;
store.SnapshotChanged += _ => throw new InvalidOperationException("boom");
store.SnapshotChanged += _ => received++;
Should.NotThrow(() => store.Upsert(Res("drv-1", "h1")));
received.ShouldBe(1);
}
[Fact]
public void Broadcaster_throwing_subscriber_does_not_stop_later_subscribers()
{
var broadcaster = new InProcessBroadcaster<string>();
var received = new List<string>();
broadcaster.Received += _ => throw new InvalidOperationException("boom");
broadcaster.Received += received.Add;
Should.NotThrow(() => broadcaster.Publish("evt"));
received.ShouldBe(["evt"]);
}
[Fact]
public void Broadcaster_throwing_connection_subscriber_does_not_stop_later_subscribers()
{
var broadcaster = new InProcessBroadcaster<string>();
var raised = new List<bool>();
broadcaster.ConnectionStateChanged += _ => throw new InvalidOperationException("boom");
broadcaster.ConnectionStateChanged += raised.Add;
Should.NotThrow(() => broadcaster.SetConnected(true));
raised.ShouldBe([true]);
broadcaster.IsConnected.ShouldBeTrue();
}
}
/// <summary>
/// Source pin (no bUnit): <c>/hosts</c> sweeps both stores after a successful config load, and the
/// sweep sits before the failure catch, so a failed load (which blanks the config) never evicts.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HostsEvictionWiringTests
{
[Fact]
public void Hosts_page_evicts_both_stores_inside_the_successful_load_only()
{
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
var load = src.IndexOf("private async Task<bool> LoadConfigAsync()", StringComparison.Ordinal);
load.ShouldBeGreaterThan(0);
var firstCatch = src.IndexOf("catch (Exception ex) when (ex is OperationCanceledException", load, StringComparison.Ordinal);
firstCatch.ShouldBeGreaterThan(load);
src.IndexOf("DriverStore.EvictUnconfiguredStale(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch);
src.IndexOf("ResilienceStore.EvictUnconfiguredStale(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch);
}
}