Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/AddressingGrammarTests.cs
Joseph Doherty b1f3e09661 test(modbus, abcip): align failing integration tests with fixtures
Two real bugs uncovered by re-running with the new fixture defaults
pointing at the shared docker host. Both are test-side, not driver-side.

AbCip — Driver_reads_seeded_DInt_from_ab_server (4 parametrized rows):
  Hardcoded 'ab://127.0.0.1:{port}/1,0' in the deviceUri instead of
  the resolved fixture.Host. The new 10.100.0.35 default (and any
  AB_SERVER_ENDPOINT override) silently couldn't reach this test —
  the driver tried to connect to a non-existent localhost:44818 and
  returned BadCommunicationError on all 4 profile rows. The sibling
  Emulate tests already use the fixture's resolved endpoint; this
  smoke test was missed in the original migration.

  Fix: deviceUri = $"ab://{fixture.Host}:{fixture.Port}/1,0".

Modbus — Float32_With_CDAB_Roundtrips_Through_Wire:
  Test wrote a Float32 to HR 100 (2 consecutive registers: 100+101).
  standard.json's writable HR range declares [100,100] only — a
  single-cell auto-incrementing register, not a 2-register pair. The
  write to register 101 was rejected with Illegal Data Address
  (BadOutOfRange).

  Fix: moved the tag from HR 100 to HR 200 (in standard.json's
  '[200, 209]' scratch range — 10 consecutive writable HRs). The
  Float32+CDAB semantic the test exercises is unchanged.

Modbus — Block_Read_Coalescing_Reduces_PDU_Count_End_To_End:
  Test read HR 300, 302, 304 — outside both the writable ranges and
  the uint16 seed list. pymodbus rejects reads to unseeded HRs even
  though 'hr size' is 2048. BadOutOfRange on every read.

  Fix: moved the tags from 300/302/304 to 200/202/204 (within the
  scratch range). The non-contiguous coalescing semantic (3 tags
  inside a 5-register window with MaxReadGap=5) is preserved.

After this commit:
  - Modbus.IntegrationTests: 6/38 pass / 32 skip / 0 fail
    (was 4 pass / 32 skip / 2 fail; 32 skips are profile-gated
    ExceptionInjectionTests — they need MODBUS_SIM_PROFILE=
    exception_injection and a different container, intentional gating)
  - AbCip.IntegrationTests: 10/12 pass / 2 skip / 0 fail
    (was 6 pass / 2 skip / 4 fail; 2 skips are Emulate tests that
    need the fixture for separate scenarios)

No driver code changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:45:57 -04:00

118 lines
5.4 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);
// HR 200 lives in pymodbus's scratch range (`write: [200, 209]` per standard.json),
// which gives us two consecutive writable HRs for the Float32 round-trip. The earlier
// version used HR 100 — but standard.json declares HR 100 as a single-cell auto-
// incrementing register (`write: [100, 100]`) so the second register of the Float32
// write was rejected with Illegal Data Address.
var tag = new ModbusTagDefinition("Tank", ModbusRegion.HoldingRegisters, 200, 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.
// Using HR 200/202/204 in the scratch range (standard.json's `uint16: 200..209`),
// not 300/302/304 — pymodbus rejects reads outside the seeded uint16 list with
// Illegal Data Address (= BadOutOfRange). The coalescing semantic the test
// exercises is identical with the scratch addresses.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 202, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 204, 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);
}
}