Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs
T
Joseph Doherty aafb9d4929 v3(b1-modbus): RawPath identity seam — RawTagEntry-driven Modbus driver
Wave-B EXEMPLAR. Retire the pre-declared/blob-parse tag model; the driver now
builds its RawPath -> definition table from the artifact's RawTagEntry set via a
pure mapper.

- Contracts: rename ModbusEquipmentTagParser -> ModbusTagDefinitionFactory;
  TryParse(reference) -> FromTagConfig(tagConfig, rawPath) (def.Name = rawPath,
  no leading-brace heuristic); add inverse ToTagConfig(def) serializer; keep all
  strict-enum reads + guards; also read stringByteOrder/deadband/coalesceProhibited
  so a RawTagEntry round-trips the full authored def. Inspect() unchanged.
- Options: Tags(IReadOnlyList<ModbusTagDefinition>) -> RawTags(IReadOnlyList<RawTagEntry>).
- Driver: _tagsByName -> _tagsByRawPath (Ordinal); resolver byRawPath-only; build
  the table from RawTags at Initialize threading entry.WriteIdempotent onto each def;
  Discover/Teardown updated.
- Factory: remove ModbusTagDto/BuildTag/ValidateStringLength + ConfigDto.Tags; bind
  RawTags from driver-config JSON.
- CLI ModbusCommandBase.BuildOptions: serialise typed defs -> RawTagEntry via ToTagConfig.
- Tests: migrate Tags= -> RawTags via ModbusRawTags.Entries helper; parser tests ->
  FromTagConfig(rawPath); factory String-length/enum tests re-seated on the mapper seam.
- Propagated rename to ControlPlane EquipmentTagConfigInspector (outside Modbus projects).

Modbus.Tests 315/315, Addressing.Tests 161/161, Cli.Tests 72/72 green.
Docker-gated Driver.Modbus.IntegrationTests left untouched (won't compile against
the new API; expected).
2026-07-15 19:50:10 -04:00

232 lines
13 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #148 — block-coalescing auto-recovery from protected register holes. When a coalesced
/// FC03 fails with a Modbus exception, the planner records the failed range and stops
/// re-coalescing across it on subsequent scans. Healthy tags around the protected hole
/// keep working without operator intervention.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingAutoRecoveryTests
{
/// <summary>
/// Programmable transport that returns IllegalDataAddress (Modbus exception code 0x02)
/// when a read covers a configured "protected" register address. Otherwise responds
/// normally with zero-filled data of the requested size.
/// </summary>
private sealed class ProtectedHoleTransport : IModbusTransport
{
/// <summary>Gets or sets the register address at which reads should fail with an exception.</summary>
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
/// <summary>Gets the list of all read requests made to the transport.</summary>
public readonly List<(byte Fc, ushort Address, ushort Quantity)> Reads = new();
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04) Reads.Add((pdu[0], addr, qty));
// If the protected address falls within the request span, return a Modbus exception
// PDU. The driver's transport layer detects exceptions by the high bit on the FC.
if (pdu[0] is 0x03 or 0x04 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that the first failure falls back to per-tag reads in the same scan.</summary>
[Fact]
public async Task First_Failure_Falls_Back_To_PerTag_Same_Scan()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
// Three tags: 100, 102 (protected), 104. With MaxReadGap=5, the coalesced block is
// 100..104 — covers the protected register, so FC03 quantity=5 fails. Pre-#148 marked
// ALL three Bad. Post-#148, the failure auto-falls back to per-tag in the same scan
// so 100 and 104 still surface Good values.
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t100, t102, t104]), MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
// T100 + T104 should fall through per-tag and succeed; T102 is the protected register
// and surfaces the exception status code at single-tag granularity.
values[0].StatusCode.ShouldBe(0u, "T100 should succeed via per-tag fallback");
values[2].StatusCode.ShouldBe(0u, "T104 should succeed via per-tag fallback");
values[1].StatusCode.ShouldNotBe(0u, "T102 is the protected address — single-tag read still surfaces the exception");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the second scan skips coalesced reads of prohibited ranges.</summary>
[Fact]
public async Task Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t100, t102, t104]), MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1: planner forms 100..104 block, fails, records the prohibition.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
var scan1Reads = fake.Reads.Count;
// Scan 2: planner sees the prohibition, doesn't form the 100..104 block, falls back to
// per-tag for everyone. Total scan-2 PDUs: 3 (one per tag) — vs 1 failed coalesced
// read + 3 per-tag fallbacks if we re-tried the merge.
fake.Reads.Clear();
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var coalescedAttemptedAgain = fake.Reads.Any(r => r.Address == 100 && r.Quantity > 1);
coalescedAttemptedAgain.ShouldBeFalse("planner must NOT re-attempt the prohibited block");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that reprobe clears prohibition when the range becomes healthy.</summary>
[Fact]
public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy()
{
// #151 — when AutoProhibitReprobeInterval is set, the background loop retries each
// prohibition periodically. We exercise that via the test-only RunReprobeOnceForTestAsync
// helper rather than waiting for the timer (which would slow the suite).
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t100, t102, t104]), MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1: coalesced read fails, prohibition recorded.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Operator unlocks the protected register at the PLC (firmware update etc.). The
// re-probe should now succeed and clear the prohibition.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(0, "re-probe must clear the prohibition once the range is healthy");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that reprobe leaves prohibition in place when the range is still bad.</summary>
[Fact]
public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t100, t102, t104]), MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe with the protected register still bad — prohibition stays.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "re-probe failure must keep the prohibition in place");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that GetAutoProhibitedRanges surfaces an operator-visible snapshot.</summary>
[Fact]
public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot()
{
// #152 — diagnostic accessor returns the live prohibition map as a snapshot of public
// ModbusAutoProhibition records. Consumers (Admin UI, dashboards) project this list
// into whatever shape they need.
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", UnitId = 7, RawTags = ModbusRawTags.Entries([t100, t102, t104]), MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Pre-failure: nothing prohibited.
drv.GetAutoProhibitedRanges().ShouldBeEmpty();
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var snapshot = drv.GetAutoProhibitedRanges();
snapshot.Count.ShouldBe(1);
snapshot[0].UnitId.ShouldBe((byte)7);
snapshot[0].Region.ShouldBe(ModbusRegion.HoldingRegisters);
snapshot[0].StartAddress.ShouldBe((ushort)100);
snapshot[0].EndAddress.ShouldBe((ushort)104);
snapshot[0].BisectionPending.ShouldBeTrue("multi-register prohibition starts split-pending");
snapshot[0].LastProbedUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that tags outside prohibited ranges still coalesce.</summary>
[Fact]
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
// Tags split across the protected boundary: cluster 100..104 (will fail) and cluster
// 200..204 (well clear of the protected register). The 200-cluster should keep
// coalescing on subsequent scans even after the 100-cluster is prohibited.
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var t200 = new ModbusTagDefinition("T200", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var t202 = new ModbusTagDefinition("T202", ModbusRegion.HoldingRegisters, 202, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t100, t102, t104, t200, t202]), MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None);
fake.Reads.Clear();
await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None);
// The 200..202 block should still coalesce — its range doesn't overlap the
// 100..104 prohibition.
var coalesced200Block = fake.Reads.Any(r => r.Address == 200 && r.Quantity == 3);
coalesced200Block.ShouldBeTrue("the 200..202 block must keep coalescing — it's outside the prohibited range");
await drv.ShutdownAsync(CancellationToken.None);
}
}