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 ADS notifications for ARRAY tags. Before this, the notification path registered only the /// declared scalar, so an array tag published its first element at Good; the declared-type guard /// then refused every array tag with BadTypeMismatch. The driver now hands the authored /// ArrayLength to the client, which registers the whole array as raw bytes and decodes it /// () — these pin the driver's half of that contract. /// [Trait("Category", "Unit")] public sealed class TwinCATNativeArraySubscribeTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries(tags), Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = true, }, "drv-1", factory); return (drv, factory); } /// /// An array tag registers with its authored ArrayLength, and the element-typed array the client /// decodes reaches OnDataChange whole, at Good — the same shape the poll read publishes. /// [Fact] public async Task Native_subscribe_registers_an_array_tag_with_its_authored_length_and_publishes_the_array() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("Samples", Host, "P_FILTER.SAMPLE", TwinCATDataType.Real, ArrayLength: 20)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["Samples"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); var reg = factory.Clients[0].Notifications.ShouldHaveSingleItem(); reg.Type.ShouldBe(TwinCATDataType.Real); reg.ArrayCount.ShouldBe(20); var samples = Enumerable.Range(1, 20).Select(i => i * 1.5f).ToArray(); factory.Clients[0].FireNotification("P_FILTER.SAMPLE", samples); var e = events.ShouldHaveSingleItem(); e.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); e.Snapshot.Value.ShouldBeSameAs(samples); } /// /// A scalar tag, an element-indexed path (Arr[3]) and a bit-indexed BOOL stay scalars: no /// array count reaches the client. /// [Fact] public async Task Scalar_element_indexed_and_bit_indexed_tags_register_without_an_array_count() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt), new TwinCATTagDefinition("Fault3", Host, "MAIN.FaultCodes[3]", TwinCATDataType.UInt), new TwinCATTagDefinition("Run0", Host, "MAIN.State.0", TwinCATDataType.Bool)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); _ = await drv.SubscribeAsync(["Count", "Fault3", "Run0"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); var regs = factory.Clients[0].Notifications; regs.Count.ShouldBe(3); regs.ShouldAllBe(n => n.ArrayCount == null); regs.Single(n => n.SymbolPath == "MAIN.State.0").BitIndex.ShouldBe(0); } /// A reconnect replays an array registration with the same array count. [Fact] public async Task Reconnect_replays_an_array_registration_with_its_array_count() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("Faults", Host, "MAIN.FaultCodes", TwinCATDataType.UInt, ArrayLength: 13)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); _ = await drv.SubscribeAsync(["Faults"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); factory.Clients[0].SimulateWireDrop(); _ = await drv.ReadAsync(["Faults"], TestContext.Current.CancellationToken); factory.Clients.Count.ShouldBe(2); factory.Clients[1].Notifications.ShouldHaveSingleItem().ArrayCount.ShouldBe(13); } /// /// A notification the client refuses for a reason other than a type mismatch (an array above the /// native-notification size cap → BadOutOfRange) is isolated the same way: that one tag /// publishes the refusal's own status and the rest of the subscription registers. /// [Fact] public async Task A_refused_oversized_array_is_isolated_with_the_refusals_own_status() { var (drv, factory) = NewNativeDriver( new TwinCATTagDefinition("Slaves", Host, "P_EC.SLAVEADDRESS", TwinCATDataType.UInt, ArrayLength: 70000), new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt)); factory.Customise = () => { var c = new FakeTwinCATClient(); c.AddNotificationErrors["P_EC.SLAVEADDRESS"] = new TwinCATNotificationRefusedException( "P_EC.SLAVEADDRESS", "140000 B exceeds the 131072 B native-notification cap", TwinCATStatusMapper.BadOutOfRange); return c; }; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); _ = await drv.SubscribeAsync(["Slaves", "Count"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); factory.Clients[0].Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Count"); var refused = events.ShouldHaveSingleItem(); refused.FullReference.ShouldBe("Slaves"); refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); refused.Snapshot.Value.ShouldBeNull(); } /// A type-mismatch refusal is a notification refusal carrying BadTypeMismatch. [Fact] public void A_type_mismatch_is_a_notification_refusal_carrying_BadTypeMismatch() { TwinCATNotificationRefusedException ex = new TwinCATTypeMismatchException("MAIN.X", "declared DInt over REAL"); ex.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch); ex.SymbolPath.ShouldBe("MAIN.X"); } }