275 lines
14 KiB
C#
275 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Pins the driver-side half of the batched value pipeline: the Modbus driver is an
|
|
/// <see cref="IBatchSubscribable"/> and forwards the <c>PollGroupEngine</c>'s per-cycle batch
|
|
/// callback to <see cref="IBatchSubscribable.OnDataChangeBatch"/>. 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.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ModbusBatchSubscriptionTests
|
|
{
|
|
/// <summary>
|
|
/// Lightweight fake transport the subscription tests drive through — only the FC03
|
|
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
|
|
/// between polls is how each test simulates a PLC value change.
|
|
/// </summary>
|
|
private sealed class FakeTransport : IModbusTransport
|
|
{
|
|
public readonly ushort[] HoldingRegisters = new ushort[256];
|
|
/// <summary>Simulates connecting to the Modbus device.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
/// <summary>Simulates sending a Modbus PDU.</summary>
|
|
/// <param name="unitId">The Modbus unit ID.</param>
|
|
/// <param name="pdu">The protocol data unit.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
|
{
|
|
if (pdu[0] != 0x03) return Task.FromException<byte[]>(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);
|
|
}
|
|
/// <summary>Disposes the transport asynchronously.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The driver advertises the batched-subscription capability.</summary>
|
|
[Fact]
|
|
public void Driver_is_batch_subscribable()
|
|
{
|
|
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
|
(drv is IBatchSubscribable).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<DataChangeEventArgs>();
|
|
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The batch event honours the driver's own per-tag deadband filter. The
|
|
/// <c>PollGroupEngine</c> collects batch items unconditionally — it knows nothing about
|
|
/// <c>ShouldPublish</c> — 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.
|
|
/// </summary>
|
|
[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<DataChangeEventArgs>();
|
|
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A per-tag <see cref="ISubscribable.OnDataChange"/> subscriber that throws must not corrupt
|
|
/// the poll cycle. <c>PollGroupEngine.PollOnceAsync</c> invokes the driver's onChange bridge
|
|
/// UNGUARDED, so without the driver-side try/catch the throw unwinds the whole cycle before
|
|
/// <c>onBatch</c> 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.
|
|
/// </summary>
|
|
[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<DataChangeBatchEventArgs>();
|
|
// 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<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (q.Count < target && DateTime.UtcNow < deadline)
|
|
await Task.Delay(25);
|
|
}
|
|
}
|