using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry; /// /// Verifies the node-local telemetry hub (per-cluster mesh Phase 5): snapshot-replay of the two /// snapshot-style channels (Health / Resilience), live-only forwarding of the append-style logs /// (Alarm / Script), independent bounded per-subscriber fan-out, DropOldest backpressure, and /// detach-on-dispose. The hub carries ONLY this node's own telemetry — it never touches DPS. /// public sealed class TelemetryLocalHubTests { private static DriverHealthChanged Health(string instanceId, string state = "Healthy") => new("cluster-a", instanceId, state, null, null, 0, DateTime.UtcNow); private static DriverResilienceStatusChanged Resilience(string instanceId, string host) => new(instanceId, host, false, 0, 0, null, DateTime.UtcNow, DateTime.UtcNow); private static AlarmTransitionEvent Alarm(string id) => new(id, "Area/Line/Equip", "AlarmName", "Activated", 500, "msg", "system", DateTime.UtcNow); private static ScriptLogEntry Script(string id) => new(id, "Information", "log", DateTime.UtcNow, null, null, null); private static List Drain(ITelemetrySubscription sub) { var items = new List(); while (sub.Reader.TryRead(out var item)) items.Add(item); return items; } [Fact] public void New_subscriber_is_primed_with_cached_Health_snapshot() { var hub = new TelemetryLocalHub(); hub.Emit(new TelemetryItem.Health(Health("drv-1", "Faulted"))); using var sub = hub.Subscribe(boundedCapacity: 16); var items = Drain(sub); items.ShouldHaveSingleItem(); var health = items[0].ShouldBeOfType(); health.E.DriverInstanceId.ShouldBe("drv-1"); health.E.State.ShouldBe("Faulted"); } [Fact] public void New_subscriber_is_primed_with_cached_Resilience_snapshot() { var hub = new TelemetryLocalHub(); hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a"))); using var sub = hub.Subscribe(boundedCapacity: 16); var items = Drain(sub); items.ShouldHaveSingleItem(); var res = items[0].ShouldBeOfType(); res.E.DriverInstanceId.ShouldBe("drv-1"); res.E.HostName.ShouldBe("host-a"); } [Fact] public void Alarm_and_Script_are_not_replayed_to_a_new_subscriber() { var hub = new TelemetryLocalHub(); hub.Emit(new TelemetryItem.Alarm(Alarm("a-1"))); hub.Emit(new TelemetryItem.Script(Script("s-1"))); using var sub = hub.Subscribe(boundedCapacity: 16); Drain(sub).ShouldBeEmpty(); } [Fact] public void Multiple_subscribers_each_receive_their_own_copy_of_a_post_subscribe_emit() { var hub = new TelemetryLocalHub(); using var s1 = hub.Subscribe(boundedCapacity: 16); using var s2 = hub.Subscribe(boundedCapacity: 16); hub.Emit(new TelemetryItem.Alarm(Alarm("a-1"))); Drain(s1).ShouldHaveSingleItem().ShouldBeOfType().E.AlarmId.ShouldBe("a-1"); Drain(s2).ShouldHaveSingleItem().ShouldBeOfType().E.AlarmId.ShouldBe("a-1"); } [Fact] public void Health_snapshot_keeps_only_the_latest_per_instance() { var hub = new TelemetryLocalHub(); hub.Emit(new TelemetryItem.Health(Health("drv-1", "Reconnecting"))); hub.Emit(new TelemetryItem.Health(Health("drv-1", "Healthy"))); using var sub = hub.Subscribe(boundedCapacity: 16); var items = Drain(sub); items.ShouldHaveSingleItem(); items[0].ShouldBeOfType().E.State.ShouldBe("Healthy"); } [Fact] public void Health_snapshot_keeps_distinct_instances() { var hub = new TelemetryLocalHub(); hub.Emit(new TelemetryItem.Health(Health("drv-1"))); hub.Emit(new TelemetryItem.Health(Health("drv-2"))); using var sub = hub.Subscribe(boundedCapacity: 16); var ids = Drain(sub) .OfType() .Select(h => h.E.DriverInstanceId) .OrderBy(x => x) .ToArray(); ids.ShouldBe(new[] { "drv-1", "drv-2" }); } [Fact] public void Resilience_snapshot_keeps_only_latest_per_instance_and_host() { var hub = new TelemetryLocalHub(); // Same instance, two distinct hosts → two cache entries. hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a"))); hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-b"))); // Overwrite (drv-1, host-a). hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a"))); using var sub = hub.Subscribe(boundedCapacity: 16); var keys = Drain(sub) .OfType() .Select(r => (r.E.DriverInstanceId, r.E.HostName)) .OrderBy(x => x.HostName) .ToArray(); keys.ShouldBe(new[] { ("drv-1", "host-a"), ("drv-1", "host-b") }); } [Fact] public void Bounded_channel_drops_oldest_not_newest_when_full() { var hub = new TelemetryLocalHub(); using var sub = hub.Subscribe(boundedCapacity: 2); // Append-style (uncached) items so the snapshot doesn't consume capacity. hub.Emit(new TelemetryItem.Alarm(Alarm("a-1"))); hub.Emit(new TelemetryItem.Alarm(Alarm("a-2"))); hub.Emit(new TelemetryItem.Alarm(Alarm("a-3"))); var ids = Drain(sub).OfType().Select(a => a.E.AlarmId).ToArray(); // a-1 (oldest) evicted; the two newest survive in order. ids.ShouldBe(new[] { "a-2", "a-3" }); } [Fact] public void Subscribe_with_non_positive_capacity_throws() { var hub = new TelemetryLocalHub(); Should.Throw(() => hub.Subscribe(0)); Should.Throw(() => hub.Subscribe(-1)); } [Fact] public async Task Concurrent_emit_subscribe_dispose_is_safe_and_never_double_delivers_a_snapshot() { var hub = new TelemetryLocalHub(); var stop = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var exceptions = new ConcurrentQueue(); // Writers: hammer Emit with a mix of all four kinds across a small key space so the snapshot // caches churn (same instance/host reused → last-value overwrites) while subscribers attach. var writers = Enumerable.Range(0, 4).Select(w => Task.Run(() => { var rnd = new Random(w * 7919 + 1); try { var n = 0; while (!stop.IsCancellationRequested) { var instance = "drv-" + rnd.Next(0, 4); var host = "host-" + rnd.Next(0, 3); TelemetryItem item = (n++ % 4) switch { 0 => new TelemetryItem.Health(Health(instance, "s" + n)), 1 => new TelemetryItem.Resilience(Resilience(instance, host)), 2 => new TelemetryItem.Alarm(Alarm("a-" + n)), _ => new TelemetryItem.Script(Script("s-" + n)), }; hub.Emit(item); } } catch (Exception ex) { exceptions.Enqueue(ex); } })).ToArray(); // Subscriber/disposer churn: attach, drain, verify the exactly-once-across-attach invariant, dispose. var churners = Enumerable.Range(0, 6).Select(_ => Task.Run(() => { try { while (!stop.IsCancellationRequested) { using var sub = hub.Subscribe(boundedCapacity: 4096); // The snapshot prelude is fully written before Subscribe returns, so any Health/ // Resilience item read here that was NOT part of the prelude must be a live delta — // i.e. it arrived strictly after attach. The invariant we assert: for one snapshot // KEY, the hub never delivers the SAME snapshot instance both in the prelude and // again live. We approximate "same snapshot" by reference identity: a live re-emit // is always a freshly-allocated record, so a reference-equal duplicate would be a // genuine double-delivery of one cached object across the boundary. var seen = new HashSet(ReferenceEqualityComparer.Instance); while (sub.Reader.TryRead(out var item)) { // (c) every drained item is a valid, non-null TelemetryItem of a known kind. item.ShouldNotBeNull(); item.ShouldBeAssignableTo(); // (b) no cached snapshot object delivered twice to THIS subscriber. seen.Add(item).ShouldBeTrue(); } } } catch (Exception ex) { exceptions.Enqueue(ex); } })).ToArray(); await Task.WhenAll(writers.Concat(churners)); // (a) no thread threw. exceptions.ShouldBeEmpty(); } [Fact] public void Dispose_detaches_the_subscriber_and_completes_its_channel() { var hub = new TelemetryLocalHub(); var sub = hub.Subscribe(boundedCapacity: 16); sub.Dispose(); // A further Emit must neither throw nor deliver to the detached subscriber. Should.NotThrow(() => hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")))); sub.Reader.Completion.IsCompleted.ShouldBeTrue(); sub.Reader.TryRead(out _).ShouldBeFalse(); } }