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; /// /// #739 / R02:P1 — TwinCAT is an . DriverInstanceActor subscribes ONLY the /// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the /// Bad snapshots the native path publishes itself — must reach OnDataChangeBatch; the per-tag event /// keeps firing for legacy consumers (the CLI). /// [Trait("Category", "Unit")] public sealed class TwinCATBatchSubscribeTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) => new(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries( [ new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt), ]), Probe = new TwinCATProbeOptions { Enabled = false }, UseNativeNotifications = native, }, "drv-1", factory); /// The driver advertises the batch capability. [Fact] public void Driver_is_batch_subscribable() => NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo(); /// A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice. [Fact] public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag() { var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } }, }; var drv = NewDriver(factory, native: false); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var batches = new ConcurrentQueue(); var single = new ConcurrentQueue(); drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e); drv.OnDataChange += (_, e) => single.Enqueue(e); var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3)); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); var first = batches.First(); first.SubscriptionHandle.ShouldBe(handle); first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true); single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2); } /// A native push and the native path's own Bad snapshot both reach the batch event. [Fact] public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event() { var fake = new FakeTwinCATClient(); var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var batches = new ConcurrentQueue(); drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e); _ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); fake.FireNotification("MAIN.A", 42); var items = batches.SelectMany(b => b.Items).ToList(); items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); var a = items.Single(i => i.FullReference == "A"); a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); a.Snapshot.Value.ShouldBe(42); } private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } }