UnsTabDragDropE2ETests were timing out at the 'UNS Structure' nav-link locator because AdminWebAppFactory never registered AdminHubConnectionFactory / HubTokenService / DataProtection — ClusterDetail.razor's @inject threw at circuit boot, so the page never advanced past the Loading placeholder. 2 → 3 pass after the registrations land. Also documents the Modbus standard-vs- exception_injection coverage matrix in the fixture README + cross-references docs/drivers/AbServer-Test-Fixture.md from each Emulate test so a developer landing on a skipped test has a direct doc pointer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modbus integration-test fixture — pymodbus simulator
The Modbus driver's integration tests talk to a
pymodbus simulator running as a
pinned Docker container. One image, per-profile service in compose, same
port binding (5020) regardless of which profile is live. Docker is the
only supported launch path — a fresh clone needs Docker Desktop and
nothing else.
| File | Purpose |
|---|---|
Dockerfile |
python:3.12-slim-bookworm + pymodbus[simulator]==3.13.0 + every profile JSON + exception_injector.py |
docker-compose.yml |
One service per profile (standard / dl205 / mitsubishi / s7_1500 / exception_injection); all bind :5020 so only one runs at a time |
profiles/*.json |
Same seed-register definitions the native launcher uses — canonical source |
exception_injector.py |
Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the exception_injection profile |
Run
From the repo root:
# Build + start the standard profile
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up
# DL205 quirks
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile dl205 up
# Mitsubishi MELSEC quirks
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile mitsubishi up
# Siemens S7-1500 MB_SERVER quirks
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
# Exception-injection — end-to-end coverage of every Modbus exception code
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up
Detached + stop:
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
Only one profile binds :5020 at a time; switch by stopping the current
service + starting another. The integration tests discriminate by a
separate MODBUS_SIM_PROFILE env var so they skip correctly when the
wrong profile is live.
Profile coverage matrix
The two general-purpose profiles cover disjoint test sets. A full pass
of the integration suite requires running both — serially on a single
docker host (the :5020 collision), or in parallel on two hosts.
| Job | Bring up | Env to set | Expected outcome |
|---|---|---|---|
modbus-standard |
lmxopcua-fix up modbus standard |
unset MODBUS_SIM_PROFILE (or set to standard) |
Standard round-trip + AddressingGrammar suites pass; ExceptionInjectionTests (32 rows) skip with MODBUS_SIM_PROFILE != exception_injection. |
modbus-exception |
lmxopcua-fix up modbus exception_injection |
MODBUS_SIM_PROFILE=exception_injection |
ExceptionInjectionTests (32 rows) pass against the per-(fc,address) rule set; standard-profile suites (round-trip, AddressingGrammar) skip. |
The DL205 / Mitsubishi / S7-1500 profiles are similar — each gates its
own quirks suite via MODBUS_SIM_PROFILE=<profile>. Tests that don't
need a specific profile (the basic round-trip set) run under any of
the three pymodbus-based profiles. The exception_injection profile
is the only one that runs exception_injector.py instead of pymodbus.
Endpoint
- Default:
localhost:5020 - Override with
MODBUS_SIM_ENDPOINT(e.g. a real PLC on:502).
Run the integration tests
In a separate shell with one profile live:
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
ModbusSimulatorFixture probes localhost:5020 at collection init +
records a SkipReason when unreachable, so tests stay green on a fresh
clone without Docker running.
Exception injection
pymodbus's simulator naturally emits only Modbus exception codes 0x02
(Illegal Data Address, on reads outside its configured ranges) and
0x03 (Illegal Data Value, on over-length requests). The driver's
MapModbusExceptionToStatus table translates eight codes: 0x01,
0x02, 0x03, 0x04, 0x05, 0x06, 0x0A, 0x0B. Unit tests
lock the translation function; the integration side previously only
proved the wire-to-status path for 0x02.
The exception_injection profile runs
exception_injector.py — a tiny standalone
Modbus/TCP server written against the Python stdlib (zero
dependencies outside what's in the base image). It speaks the wire
protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each
incoming (fc, address) against the rules in
profiles/exception_injection.json;
a matching rule makes the server reply with
[fc | 0x80, exception_code] instead of the normal response.
Current rules (see the JSON file for the canonical list):
FC03 @1000..1007— one per exception code (0x01/0x02/0x03/0x04/0x05/0x06/0x0A/0x0B)FC06 @2000..2001—0x04Server Failure,0x06Server Busy (write-path coverage)FC16 @3000—0x04Server Failure (multi-register write path)
Adding more coverage is append-only: drop a new {fc, address, exception, description} entry into the JSON, restart the service,
add an [InlineData] row in ExceptionInjectionTests.
References
docs/drivers/Modbus-Test-Fixture.md— coverage map + gap inventorydocs/v2/dev-environment.md§Docker fixtures — full fixture inventory