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;
[Trait("Category", "Unit")]
public sealed class ModbusSubscriptionTests
{
///
/// 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];
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
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);
}
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", Tags = tags };
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
fake.HoldingRegisters[1] = 200;
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
events.Select(e => e.FullReference).ShouldContain("Level");
events.Select(e => e.FullReference).ShouldContain("Temp");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task Unchanged_values_do_not_raise_after_initial_poll()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(500); // ~5 poll cycles at 100ms, value stable the whole time
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1); // only the initial-data push, no change events after
}
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
fake.HoldingRegisters[0] = 200; // simulate PLC update
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBeGreaterThanOrEqualTo(2);
events.Last().Snapshot.Value.ShouldBe((short)200);
}
[Fact]
public async Task Unsubscribe_stops_the_polling_loop()
{
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var countAfterUnsub = events.Count;
fake.HoldingRegisters[0] = 999; // would trigger a change if still polling
await Task.Delay(400);
events.Count.ShouldBe(countAfterUnsub);
}
[Fact]
public async Task SubscribeAsync_floors_intervals_below_100ms()
{
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
// 10ms requested — implementation floors to 100ms. We verify indirectly: over 300ms, a
// 10ms interval would produce many more events than a 100ms interval would on a stable
// value. Since the value is unchanged, we only expect the initial-data push (1 event).
var events = new ConcurrentQueue();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(10), CancellationToken.None);
await Task.Delay(300);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Multiple_subscriptions_fire_independently()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
var eventsA = new ConcurrentQueue();
var eventsB = new ConcurrentQueue();
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "A") eventsA.Enqueue(e);
else if (e.FullReference == "B") eventsB.Enqueue(e);
};
var ha = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
var hb = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForCountAsync(eventsA, 1, TimeSpan.FromSeconds(1));
await WaitForCountAsync(eventsB, 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(ha, CancellationToken.None);
var aCount = eventsA.Count;
fake.HoldingRegisters[1] = 77; // only B should pick this up
await WaitForCountAsync(eventsB, 2, TimeSpan.FromSeconds(2));
eventsA.Count.ShouldBe(aCount); // unchanged since unsubscribe
eventsB.Count.ShouldBeGreaterThanOrEqualTo(2);
await drv.UnsubscribeAsync(hb, CancellationToken.None);
}
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);
}
}