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; /// /// Native-subscribe isolation (#739): an unresolvable reference publishes BadNodeIdUnknown /// (U12) instead of keeping whatever status the server seeded, and one failing registration does not /// take down the rest of the subscription (H11). /// [Trait("Category", "Unit")] public sealed class TwinCATNativeIsolationTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries(tags), Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = true, }, "drv-1", factory); /// /// U12: an unknown reference and a tag routed to an unconfigured device each publish one /// BadNodeIdUnknown snapshot; the resolvable tag still registers and delivers Good (positive control). /// [Fact] public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references() { var factory = new FakeTwinCATClientFactory(); var drv = NewNativeDriver(factory, new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt), new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); var ghost = events.Single(e => e.FullReference == "Ghost"); ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); ghost.Snapshot.Value.ShouldBeNull(); events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); // Positive control: the resolvable tag registered and its push arrives Good. factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed"); factory.Clients[0].FireNotification("MAIN.Speed", 42); var good = events.Single(e => e.FullReference == "Speed"); good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); good.Snapshot.Value.ShouldBe(42); } /// /// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and /// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one /// bad tag served zero data for the whole driver. The good tags keep delivering (positive control). /// [Fact] public async Task One_failing_registration_does_not_tear_down_the_subscription() { var fake = new FakeTwinCATClient(); fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up"); var factory = new FakeTwinCATClientFactory { Customise = () => fake }; var drv = NewNativeDriver(factory, new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt), new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true); events.ShouldHaveSingleItem().FullReference.ShouldBe("B"); events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); fake.FireNotification("MAIN.C", 3); events.Last().FullReference.ShouldBe("C"); events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); // The failed tag is kept as a dead intent and unsubscribe still removes everything. drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty(); fake.Notifications.ShouldBeEmpty(); } /// /// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own /// status and leaves driver health alone; the good tag delivers. /// [Fact] public async Task A_refused_symbol_is_isolated_without_a_health_write() { var fake = new FakeTwinCATClient(); fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException( "MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown); var factory = new FakeTwinCATClientFactory { Customise = () => fake }; var drv = NewNativeDriver(factory, new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt), new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); drv.GetHealth().State.ShouldBe(DriverState.Healthy); fake.FireNotification("MAIN.Good", 1); events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); } /// /// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents, /// health Degraded); the reachable device's tags register and deliver. /// [Fact] public async Task An_unreachable_device_blacks_out_only_its_own_tags() { const string hostB = "ads://5.23.91.24.1.1:851"; var build = 0; var factory = new FakeTwinCATClientFactory { // Device A's client is created first and works; device B's connect throws. Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)], RawTags = TwinCATRawTags.Entries( [ new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt), new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt), new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt), new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt), ]), Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = true, }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true); events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true); events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError); drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2); drv.GetHealth().State.ShouldBe(DriverState.Degraded); factory.Clients[0].FireNotification("MAIN.A2", 22); events.Last().FullReference.ShouldBe("A2"); events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); } /// /// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and /// its first push then arrives Good — the subscription heals without a re-subscribe. /// [Fact] public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry() { var fake = new FakeTwinCATClient(); fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient"); var factory = new FakeTwinCATClientFactory { Customise = () => fake }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]), Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(40), Timeout = TimeSpan.FromMilliseconds(100), }, UseNativeNotifications = true, }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); fake.AddNotificationErrors.Clear(); await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3)); fake.Notifications.Count.ShouldBe(1); fake.FireNotification("MAIN.B", 5); events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); await drv.ShutdownAsync(TestContext.Current.CancellationToken); } /// A comms-class registration status stays an exception; any other status is a per-tag refusal. [Fact] public void Registration_failures_are_classified_by_status() { AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown) .ShouldBeOfType().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService) .ShouldBeOfType(); AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError) .ShouldBeOfType(); AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout) .ShouldBeOfType(); } private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } }