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);
}
}
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);
}
}