using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; /// /// The adapter half of review 2026-10-21 02/C1 + 02/L1. The adapter instance is reused across /// reconnects, but every reconnect opens a NEW gateway session whose item handles are plain ints /// that restart (collide) per session. A subscription id handed out by a dead session must /// therefore never reach the gateway again — it would unadvise whatever the live session gave that /// number — and the routing maps must not keep dead-session entries alive. /// [Collection("DataConnectionManagerActor")] public class MxGatewaySessionScopedHandleTests { private static Dictionary Details() => new() { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "key", ["ReadTimeoutMs"] = "2000", }; private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) => new(fake, NullLogger.Instance); /// /// A batch release of a dead-session id never reaches the gateway, even when the new session /// has handed the same handle number to a live tag — and that live tag keeps routing. /// [Fact] public async Task DeadSessionId_IsNeverReleasedAtTheGateway_EvenWhenTheHandleWasReused() { var fake = new FakeMxGatewayClient { RestartHandlesOnConnect = true }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var dead = (await adapter.SubscribeBatchAsync(["Old.Tag"], (_, _) => { }))[0].SubscriptionId!; await adapter.ConnectAsync(Details()); // reconnect: new session, handles restart at 1 var liveHits = 0; var live = (await adapter.SubscribeBatchAsync(["Live.Tag"], (_, _) => liveHits++))[0].SubscriptionId!; await adapter.UnsubscribeBatchAsync([dead]); await adapter.UnsubscribeAsync(dead); Assert.Empty(fake.Unsubscribed); Assert.NotEqual(dead, live); await WaitUntil(() => fake.OnUpdate is not null); fake.OnUpdate!(new MxValueUpdate("Live.Tag", 2, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.True(liveHits >= 1); // A current-session id still releases normally. await adapter.UnsubscribeBatchAsync([live]); Assert.Single(fake.Unsubscribed); } /// /// A reconnect drops the previous session's routing entries (L1): an update for a tag the /// new session has not (re-)subscribed is not routed to the dead session's callback. /// [Fact] public async Task Reconnect_ClearsTheDeadSessionsRoutingEntries() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var hits = 0; await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++); await adapter.ConnectAsync(Details()); await WaitUntil(() => fake.OnUpdate is not null); fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.Equal(0, hits); } /// /// A disconnect drops the routing entries too, so a later update is not delivered to a /// subscription that no longer exists at the gateway. /// [Fact] public async Task Disconnect_ClearsRoutingEntries() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var hits = 0; await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++); await WaitUntil(() => fake.OnUpdate is not null); await adapter.DisconnectAsync(); fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.Equal(0, hits); } /// /// Releasing an alarm subscription id the adapter does not know (a dead session's) must not /// decrement the live feed's reference count — at zero the shared stream is torn down while /// live subscribers still depend on it. /// [Fact] public async Task UnknownAlarmSubscriptionId_DoesNotTearDownTheLiveStream() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var delivered = 0; await adapter.SubscribeAlarmsAsync("Tank01", null, t => { if (t.Kind == AlarmTransitionKind.SnapshotComplete) Interlocked.Increment(ref delivered); }); await WaitUntil(() => fake.AlarmCallback is not null); await adapter.UnsubscribeAlarmsAsync(Guid.NewGuid().ToString()); fake.AlarmCallback!(MxGatewayAlarmMapper.SnapshotComplete()); Assert.Equal(1, Volatile.Read(ref delivered)); } /// /// Review 2026-10-21 02/P3: a burst of alarm subscribes (a staggered deploy batch of N /// alarm-bound instances) is coalesced into ONE stream reopen on the union prefix instead of N /// close/reopen cycles, each with its own connection-wide snapshot replay. Every source in the /// burst is still covered by that one fresh snapshot (DC-5's standing-state guarantee). /// [Fact] public async Task AlarmSubscribeBurst_IsCoalescedIntoOneStreamReopen() { var fake = new FakeMxGatewayClient(); var adapter = new MxGatewayDataConnection( fake, NullLogger.Instance, alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(300)); await adapter.ConnectAsync(Details()); // Spaced like actor-delivered subscribes, so an un-coalesced reopen really reaches the // gateway each time (a reopen superseded before its task runs never would). foreach (var source in new[] { "Area1.Tank1", "Area1.Tank2", "Area1.Tank3", "Area1.Tank4", "Area1.Tank5" }) { await adapter.SubscribeAlarmsAsync(source, null, _ => { }); await Task.Delay(20); } await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count >= 1; }); await Task.Delay(400); // well past the window: no trailing reopens lock (fake.AlarmStreamPrefixes) { Assert.Single(fake.AlarmStreamPrefixes); Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[0]); } } /// /// A subscribe arriving after the coalesced reopen has fired still gets its own fresh /// snapshot — coalescing never swallows a source. /// [Fact] public async Task AlarmSubscribe_AfterTheWindow_StillReopens() { var fake = new FakeMxGatewayClient(); var adapter = new MxGatewayDataConnection( fake, NullLogger.Instance, alarmStreamReopenDebounce: TimeSpan.FromMilliseconds(50)); await adapter.ConnectAsync(Details()); await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { }); await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 1; }); await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { }); await WaitUntil(() => { lock (fake.AlarmStreamPrefixes) return fake.AlarmStreamPrefixes.Count == 2; }); lock (fake.AlarmStreamPrefixes) Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]); } private static async Task WaitUntil(Func condition, int timeoutMs = 30_000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException("Condition not met in time."); await Task.Delay(10); } } }