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; /// /// Review R04 L1 + L5: the in-process stores evict driver instances that are no longer configured /// (so /hosts stops rendering ghost rows), and a throwing subscriber no longer aborts the /// multicast for the subscribers after it (nor escapes onto the bridge actor). /// 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 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(); 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(); var received = new List(); 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(); var raised = new List(); broadcaster.ConnectionStateChanged += _ => throw new InvalidOperationException("boom"); broadcaster.ConnectionStateChanged += raised.Add; Should.NotThrow(() => broadcaster.SetConnected(true)); raised.ShouldBe([true]); broadcaster.IsConnected.ShouldBeTrue(); } } /// /// Source pin (no bUnit): /hosts 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. /// [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 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); } }