using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Pins the driver-side half of the batched value pipeline: the Modbus driver is an /// and forwards the PollGroupEngine's per-cycle batch /// callback to . Both events fire by design — /// the batch once per poll cycle, the per-tag one per changed tag — so a consumer subscribes to /// exactly one of the two. /// [Trait("Category", "Unit")] public sealed class ModbusBatchSubscriptionTests { /// /// Lightweight fake transport the subscription tests drive through — only the FC03 /// (Read Holding Registers) path is used. Mutating /// between polls is how each test simulates a PLC value change. /// private sealed class FakeTransport : IModbusTransport { public readonly ushort[] HoldingRegisters = new ushort[256]; /// Simulates connecting to the Modbus device. /// The cancellation token. public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; /// Simulates sending a Modbus PDU. /// The Modbus unit ID. /// The protocol data unit. /// The cancellation token. public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { if (pdu[0] != 0x03) return Task.FromException(new NotSupportedException("FC not supported")); var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); var resp = new byte[2 + qty * 2]; resp[0] = 0x03; resp[1] = (byte)(qty * 2); for (var i = 0; i < qty; i++) { resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8); resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF); } return Task.FromResult(resp); } /// Disposes the transport asynchronously. public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags) { var fake = new FakeTransport(); var opts = new ModbusDriverOptions { Host = "fake", RawTags = ModbusRawTags.Entries(tags)}; return (new ModbusDriver(opts, "modbus-1", _ => fake), fake); } /// The driver advertises the batched-subscription capability. [Fact] public void Driver_is_batch_subscribable() { var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)); (drv is IBatchSubscribable).ShouldBeTrue(); } /// /// One poll cycle that changed N tags raises the batch event ONCE carrying N items, and /// raises the per-tag event N times with identical content. The invariant asserted across /// the whole run — the concatenation of every batch's items equals the per-tag event /// sequence — is what makes the two events interchangeable for a downstream consumer. /// [Fact] public async Task Batch_event_fires_once_per_cycle_with_per_tag_events() { var (drv, fake) = NewDriver( new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16), new ModbusTagDefinition("Flow", ModbusRegion.HoldingRegisters, 2, ModbusDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[0] = 100; fake.HoldingRegisters[1] = 200; fake.HoldingRegisters[2] = 300; var perTag = new ConcurrentQueue(); var batches = new ConcurrentQueue(); drv.OnDataChange += (_, e) => perTag.Enqueue(e); ((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e); var handle = await drv.SubscribeAsync(["Level", "Temp", "Flow"], TimeSpan.FromMilliseconds(100), CancellationToken.None); // Cycle 1 — the initial-data push: all three tags raise. await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2)); batches.Count.ShouldBe(1); batches.Single().Items.Count.ShouldBe(3); perTag.Count.ShouldBe(3); // Cycle 2 — every tag changes, so the next cycle again batches all three. fake.HoldingRegisters[0] = 101; fake.HoldingRegisters[1] = 201; fake.HoldingRegisters[2] = 301; await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3)); // Unsubscribe drains the in-flight poll loop, so the queues are stable from here. await drv.UnsubscribeAsync(handle, CancellationToken.None); var allBatches = batches.ToArray(); allBatches.Length.ShouldBeGreaterThanOrEqualTo(2); allBatches[1].Items.Count.ShouldBe(3); // Every batch belongs to the subscription it was raised for, and is never empty. foreach (var b in allBatches) { b.SubscriptionHandle.ShouldBeSameAs(handle); b.Items.ShouldNotBeEmpty(); } // Identical content: flattening the batches reproduces the per-tag event stream exactly // (same references, same snapshots, same order). var flattened = allBatches.SelectMany(b => b.Items).ToArray(); var perTagArray = perTag.ToArray(); flattened.Length.ShouldBe(perTagArray.Length); for (var i = 0; i < flattened.Length; i++) { flattened[i].FullReference.ShouldBe(perTagArray[i].FullReference); flattened[i].Snapshot.ShouldBe(perTagArray[i].Snapshot); } } /// /// The batch event honours the driver's own per-tag deadband filter. The /// PollGroupEngine collects batch items unconditionally — it knows nothing about /// ShouldPublish — so a straight pass-through of the engine's list would carry /// changes the per-tag event suppressed, and a downstream consumer that PREFERS the batch /// event would see deadband silently stop working. A cycle whose only changes are all /// suppressed raises no batch at all. /// [Fact] public async Task Deadband_suppressed_changes_are_excluded_from_the_batch() { var (drv, fake) = NewDriver( new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[0] = 100; var perTag = new ConcurrentQueue(); var batches = new ConcurrentQueue(); drv.OnDataChange += (_, e) => perTag.Enqueue(e); ((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e); var handle = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(100), CancellationToken.None); try { // Cycle 1 — initial-data push. First sample seeds the deadband baseline and publishes // through both events. await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2)); batches.Count.ShouldBe(1); batches.Single().Items.Count.ShouldBe(1); batches.Single().Items[0].Snapshot.Value.ShouldBe((short)100); perTag.Count.ShouldBe(1); // Under-threshold change (delta 2 < 5): the ENGINE sees a value change and would offer // it as a batch item, but the driver's deadband suppresses the publish — so neither // event fires and no batch is raised for that cycle at all. fake.HoldingRegisters[0] = 102; await Task.Delay(600); perTag.Count.ShouldBe(1, "under-threshold change is suppressed on the per-tag event"); batches.Count.ShouldBe(1, "a cycle whose only change is deadband-suppressed raises no batch"); // Over-threshold change (delta 10 ≥ 5 against the 100 baseline): publishes on both. fake.HoldingRegisters[0] = 110; await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3)); } finally { // Unsubscribe drains the in-flight poll loop, so the queues are stable from here. await drv.UnsubscribeAsync(handle, CancellationToken.None); } var allBatches = batches.ToArray(); allBatches.Length.ShouldBe(2); allBatches[1].Items.Count.ShouldBe(1); allBatches[1].Items[0].Snapshot.Value.ShouldBe((short)110); // Content equality: flattening the batches reproduces the per-tag event stream exactly. // The suppressed 102 appears in NEITHER. var flattened = allBatches.SelectMany(b => b.Items).ToArray(); var perTagArray = perTag.ToArray(); flattened.Length.ShouldBe(perTagArray.Length); for (var i = 0; i < flattened.Length; i++) { flattened[i].FullReference.ShouldBe(perTagArray[i].FullReference); flattened[i].Snapshot.ShouldBe(perTagArray[i].Snapshot); } flattened.ShouldNotContain(item => Equals(item.Snapshot.Value, (short)102)); } /// /// A per-tag subscriber that throws must not corrupt /// the poll cycle. PollGroupEngine.PollOnceAsync invokes the driver's onChange bridge /// UNGUARDED, so without the driver-side try/catch the throw unwinds the whole cycle before /// onBatch runs — and because Modbus buffers its published items across the cycle /// (the deadband-honouring buffer the tests above pin), the undrained items would silently /// MERGE into a later cycle's batch. This failure mode is unique to Modbus: the other poll /// drivers forward the engine's own per-cycle list and hold no state between cycles. /// Asserted on both the shape (one batch per cycle, three items each) and the VALUES, so a /// merged or dropped batch fails rather than coincidentally matching on count. /// [Fact] public async Task A_throwing_per_tag_subscriber_does_not_break_or_contaminate_the_cycle_batch() { var (drv, fake) = NewDriver( new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16), new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16), new ModbusTagDefinition("Flow", ModbusRegion.HoldingRegisters, 2, ModbusDataType.Int16)); await drv.InitializeAsync("{}", CancellationToken.None); fake.HoldingRegisters[0] = 100; fake.HoldingRegisters[1] = 200; fake.HoldingRegisters[2] = 300; var thrown = 0; var batches = new ConcurrentQueue(); // The buggy external subscriber: every per-tag delivery faults. drv.OnDataChange += (_, _) => { Interlocked.Increment(ref thrown); throw new InvalidOperationException("buggy per-tag subscriber"); }; ((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e); var handle = await drv.SubscribeAsync(["Level", "Temp", "Flow"], TimeSpan.FromMilliseconds(100), CancellationToken.None); try { // Cycle 1 — the initial-data push. All three tags publish despite every per-tag // delivery faulting, and the cycle still reaches its batch. await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2)); batches.Count.ShouldBe(1, "the cycle must still reach onBatch after a subscriber throws"); // Cycle 2 — every tag changes again. fake.HoldingRegisters[0] = 101; fake.HoldingRegisters[1] = 201; fake.HoldingRegisters[2] = 301; await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3)); } finally { // Unsubscribe drains the in-flight poll loop, so the queues are stable from here. await drv.UnsubscribeAsync(handle, CancellationToken.None); } var allBatches = batches.ToArray(); allBatches.Length.ShouldBeGreaterThanOrEqualTo(2); allBatches[0].Items.Count.ShouldBe(3); allBatches[0].Items.Select(i => i.Snapshot.Value).ShouldBe(new object[] { (short)100, (short)200, (short)300 }); // The load-bearing assertion: cycle 2's batch carries EXACTLY cycle 2's items. A cycle // aborted mid-loop would have left its items in the buffer to merge in here. allBatches[1].Items.Count.ShouldBe(3, "a cycle aborted before onBatch would merge its buffered items into the next batch"); allBatches[1].Items.Select(i => i.Snapshot.Value).ShouldBe(new object[] { (short)101, (short)201, (short)301 }); // Sanity: the throwing subscriber really was invoked for every published item. thrown.ShouldBeGreaterThanOrEqualTo(6); } private static async Task WaitForCountAsync(ConcurrentQueue q, int target, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (q.Count < target && DateTime.UtcNow < deadline) await Task.Delay(25); } }