using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; [Trait("Category", "Unit")] public sealed class TwinCATNativeNotificationTests { private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = tags, Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = true, }, "drv-1", factory); return (drv, factory); } [Fact] public async Task Native_subscribe_registers_one_notification_per_tag() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real)); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None); handle.DiagnosticId.ShouldStartWith("twincat-native-sub-"); factory.Clients[0].Notifications.Count.ShouldBe(2); factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true); } [Fact] public async Task Native_notification_fires_OnDataChange_with_pushed_value() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); factory.Clients[0].FireNotification("MAIN.Speed", 4200); factory.Clients[0].FireNotification("MAIN.Speed", 4201); events.Count.ShouldBe(2); events.Last().Snapshot.Value.ShouldBe(4201); events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol } [Fact] public async Task Native_unsubscribe_disposes_all_notifications() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None); factory.Clients[0].Notifications.Count.ShouldBe(2); await drv.UnsubscribeAsync(handle, CancellationToken.None); factory.Clients[0].Notifications.ShouldBeEmpty(); } [Fact] public async Task Native_unsubscribe_halts_future_notifications() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); factory.Clients[0].FireNotification("MAIN.X", 1); var snapshotFake = factory.Clients[0]; await drv.UnsubscribeAsync(handle, CancellationToken.None); var afterUnsub = events.Count; // After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing // to invoke. This mirrors the production contract — disposed handles no longer deliver. snapshotFake.FireNotification("MAIN.X", 999); events.Count.ShouldBe(afterUnsub); } [Fact] public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state() { // Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws. // Subscribe's catch block must tear the first one down before rethrowing so no zombie // notification lingers. var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1); var factory = new FakeTwinCATClientFactory { Customise = () => fake }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [ new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt), ], Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await Should.ThrowAsync(() => drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None)); // First registration succeeded then got torn down by the catch; second threw. fake.AddCallCount.ShouldBe(2); fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up } private sealed class AbTagParamsIrrelevant { } private sealed class FailAfterNAddsFake : FakeTwinCATClient { private readonly int _succeedBefore; public int AddCallCount { get; private set; } public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base() { _succeedBefore = succeedBefore; } public override Task AddNotificationAsync( string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, Action onChange, CancellationToken cancellationToken) { AddCallCount++; if (AddCallCount > _succeedBefore) throw new InvalidOperationException($"fake fail on call #{AddCallCount}"); return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken); } } [Fact] public async Task Native_shutdown_disposes_subscriptions() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); _ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); factory.Clients[0].Notifications.Count.ShouldBe(1); await drv.ShutdownAsync(CancellationToken.None); factory.Clients[0].Notifications.ShouldBeEmpty(); } [Fact] public async Task Poll_path_still_works_when_UseNativeNotifications_false() { var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = false, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(150), CancellationToken.None); await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2)); events.First().Snapshot.Value.ShouldBe(7); factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path await drv.UnsubscribeAsync(handle, CancellationToken.None); } [Fact] public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll() { var (drvNative, _) = NewNativeDriver( new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); await drvNative.InitializeAsync("{}", CancellationToken.None); var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); nativeHandle.DiagnosticId.ShouldContain("native"); var factoryPoll = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } }, }; var drvPoll = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = false, }, "drv-1", factoryPoll); await drvPoll.InitializeAsync("{}", CancellationToken.None); var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); pollHandle.DiagnosticId.ShouldNotContain("native"); } private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } }