Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/AddressingGrammarTests.cs
Joseph Doherty 5ea57d2d70 Task #138 — Modbus addressing grammar docs + e2e
Closes the docs/e2e end of the Modbus addressing line shipped across
#136-#145.

Docs:

- docs/v2/modbus-addressing.md (new) — full grammar reference.
  Region+offset (Modicon 5-digit / 6-digit / mnemonic), bit suffix,
  type codes (BOOL / I / UI / DI / UDI / LI / ULI / F / D / BCD / LBCD /
  STR<n>), all four byte-order mnemonics (ABCD / CDAB / BADC / DCBA),
  array-count semantics, family-native syntax (DL205 V/Y/C/X/SP and
  MELSEC D/M/X/Y with hex-vs-octal sub-family selection), driver-instance
  options (KeepAlive / Reconnect / IdleDisconnect, MaxCoilsPerRead and
  FC15/16 forcing, Deadband + WriteOnChangeOnly, MaxReadGap +
  CoalesceProhibited, multi-unit IPerCallHostResolver). Includes a worked
  JSON DTO example mixing AddressString + structured tag forms.

- docs/Driver.Modbus.Cli.md — appended a "v2 addressing grammar" section
  pointing users at the full reference, with quick-reference examples.

- Vendor-compatibility caveat documented: type codes and byte-order
  mnemonics were synthesised from training-era vendor docs (Wonderware
  DASMBTCP, Kepware KEPServerEX, Ignition, Matrikon, OAS) and should be
  verified against current vendor manuals before locking for production.

E2E tests (4 new AddressingGrammarTests in IntegrationTests):
- Modicon 5-digit and 6-digit forms map to identical wire offsets.
- Float32 + WordSwap (CDAB) round-trips end-to-end through the
  pymodbus simulator.
- Int16[5] array round-trips as a typed short[] surface.
- Block-read coalescing produces a wire-acceptable PDU when MaxReadGap=5
  bridges three nearby tags.

All tests skip gracefully when the pymodbus simulator at localhost:5020
is unreachable (matches the existing ModbusSimulatorFixture pattern).

Final test count across the Modbus addressing surface:
- 107 ModbusAddressing.Tests (parser + family + Modicon)
- 231 Driver.Modbus.Tests (driver, byte order, array, multi-unit, coalescing,
  protocol, subscribe, connection options)
- 110 Admin.Tests (incl. ModbusOptionsViewModel defaults pinning)
- 4 new AddressingGrammar integration tests (skip when sim down)
2026-04-25 00:32:27 -04:00

109 lines
4.7 KiB
C#

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.IntegrationTests;
/// <summary>
/// #138 e2e coverage for the new addressing grammar (#136-#144) against a live pymodbus
/// simulator. Skips when the simulator is unreachable so the suite stays portable on dev
/// boxes without Docker. Coverage is breadth-first — one round-trip per major feature
/// (Modicon parse, suffix grammar, byte order, array, multi-unit) — rather than exhaustive,
/// because the unit tests already pin the per-call semantics; the integration tests prove
/// the wire glue agrees with the simulator end-to-end.
/// </summary>
[Trait("Category", "Integration")]
[Collection("ModbusSimulator")]
public sealed class AddressingGrammarTests
{
private readonly ModbusSimulatorFixture _sim;
public AddressingGrammarTests(ModbusSimulatorFixture sim) => _sim = sim;
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
{
var opts = new ModbusDriverOptions
{
Host = _sim.Host, Port = _sim.Port, Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "addressing-e2e");
await drv.InitializeAsync("{}", CancellationToken.None);
return drv;
}
[Fact]
public async Task Modicon_5_And_6_Digit_Both_Map_To_Same_Wire_Offset()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
// Both tags target HR offset 0 via different grammar forms; reads should be equivalent.
var t5 = new ModbusTagDefinition("Five", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var t6 = new ModbusTagDefinition("Six", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var drv = await NewDriverAsync(t5, t6);
var values = await drv.ReadAsync(["Five", "Six"], CancellationToken.None);
values[0].StatusCode.ShouldBe(values[1].StatusCode);
values[0].Value.ShouldBe(values[1].Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Float32_With_CDAB_Roundtrips_Through_Wire()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
var tag = new ModbusTagDefinition("Tank", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Float32,
ByteOrder: ModbusByteOrder.WordSwap);
var drv = await NewDriverAsync(tag);
await drv.WriteAsync([new WriteRequest("Tank", 12.5f)], CancellationToken.None);
var read = await drv.ReadAsync(["Tank"], CancellationToken.None);
read[0].Value.ShouldBe(12.5f);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Int16_Array_Reads_Surface_As_Typed_Array()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
var tag = new ModbusTagDefinition("Levels", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16, ArrayCount: 5);
var drv = await NewDriverAsync(tag);
await drv.WriteAsync([new WriteRequest("Levels", new short[] { 10, 20, 30, 40, 50 })], CancellationToken.None);
var read = await drv.ReadAsync(["Levels"], CancellationToken.None);
var arr = read[0].Value.ShouldBeOfType<short[]>();
arr.ShouldBe(new short[] { 10, 20, 30, 40, 50 });
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Block_Read_Coalescing_Reduces_PDU_Count_End_To_End()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
// Sanity check that the simulator accepts the larger PDU coalescing produces.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 300, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 302, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 304, ModbusDataType.Int16);
var opts = new ModbusDriverOptions
{
Host = _sim.Host, Port = _sim.Port, Tags = [t1, t2, t3], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "addressing-e2e");
await drv.InitializeAsync("{}", CancellationToken.None);
var read = await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
read.Count.ShouldBe(3);
// All three should read Good (the simulator accepts a 5-register coalesced read).
read.All(v => v.StatusCode == 0u).ShouldBeTrue("coalesced read must succeed against the simulator");
await drv.ShutdownAsync(CancellationToken.None);
}
}