From cb7b81a87a7774aed8ef080dffa816a50c9610c4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 15:02:39 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2030=20=E2=80=94=20Modbus=20inte?= =?UTF-8?q?gration-test=20project=20scaffold.=20New=20tests/ZB.MOM.WW.OtOp?= =?UTF-8?q?cUa.Driver.Modbus.IntegrationTests=20project=20is=20the=20harne?= =?UTF-8?q?ss=20modbus-test-plan.md=20called=20for:=20a=20skip-when-unreac?= =?UTF-8?q?hable=20fixture=20that=20TCP-probes=20a=20Modbus=20simulator=20?= =?UTF-8?q?endpoint=20(MODBUS=5FSIM=5FENDPOINT,=20default=20localhost:502)?= =?UTF-8?q?=20once=20per=20test=20session,=20a=20DL205=20device=20profile?= =?UTF-8?q?=20stub=20(single=20writable=20holding=20register=20at=20addres?= =?UTF-8?q?s=20100,=20probe=20disabled=20to=20avoid=20racing=20with=20asse?= =?UTF-8?q?rtions),=20and=20one=20happy-path=20smoke=20test=20that=20initi?= =?UTF-8?q?alizes=20the=20real=20ModbusDriver=20+=20real=20ModbusTcpTransp?= =?UTF-8?q?ort,=20writes=20a=20known=20Int16=20value,=20reads=20it=20back,?= =?UTF-8?q?=20and=20asserts=20status=3D0=20+=20value=20round-trip.=20No=20?= =?UTF-8?q?DL205=20quirk=20assertions=20yet=20=E2=80=94=20those=20land=20o?= =?UTF-8?q?ne-per-PR=20as=20the=20user=20validates=20each=20behavior=20in?= =?UTF-8?q?=20ModbusPal=20(word=20order=20for=2032-bit,=20register-zero=20?= =?UTF-8?q?access,=20coil=20addressing=20base,=20max=20registers=20per=20F?= =?UTF-8?q?C03,=20response=20framing=20under=20load,=20exception=20code=20?= =?UTF-8?q?on=20protected-bit=20coil=20write).=20ModbusSimulatorFixture=20?= =?UTF-8?q?is=20a=20collection=20fixture=20so=20the=202s=20TCP=20probe=20r?= =?UTF-8?q?uns=20once=20per=20run,=20not=20per=20test;=20SkipReason=20gets?= =?UTF-8?q?=20a=20clear=20operator-facing=20message=20('start=20ModbusPal?= =?UTF-8?q?=20or=20override=20MODBUS=5FSIM=5FENDPOINT').=20Tests=20call=20?= =?UTF-8?q?Assert.Skip(sim.SkipReason)=20rather=20than=20silently=20return?= =?UTF-8?q?ing=20=E2=80=94=20matches=20the=20test-plan=20convention=20and?= =?UTF-8?q?=20reads=20cleanly=20in=20CI=20logs.=20DL205Profile.BuildOption?= =?UTF-8?q?s=20deliberately=20disables=20the=20background=20probe=20loop?= =?UTF-8?q?=20since=20integration=20tests=20drive=20reads=20explicitly=20a?= =?UTF-8?q?nd=20the=20probe=20would=20race=20with=20assertions.=20Tag=20na?= =?UTF-8?q?ming=20uses=20the=20DL205=5F=20prefix=20so=20filter=20'DisplayN?= =?UTF-8?q?ame~DL205'=20surfaces=20device-specific=20failures=20at=20a=20g?= =?UTF-8?q?lance.=20Project=20references:=20xunit.v3=20+=20Shouldly=20+=20?= =?UTF-8?q?Microsoft.NET.Test.Sdk=20+=20xunit.runner.visualstudio=20(match?= =?UTF-8?q?es=20the=20existing=20Driver.Modbus.Tests=20unit=20project),=20?= =?UTF-8?q?project=20ref=20to=20src/Driver.Modbus.=20Registered=20in=20ZB.?= =?UTF-8?q?MOM.WW.OtOpcUa.slnx=20under=20tests/.=20ModbusPal/README.md=20d?= =?UTF-8?q?ocuments=20the=20dev=20loop=20(install=20ModbusPal=20jar,=20loa?= =?UTF-8?q?d=20profile,=20start=20simulator,=20dotnet=20test),=20explains?= =?UTF-8?q?=20MODBUS=5FSIM=5FENDPOINT=20override=20for=20real-PLC=20benchw?= =?UTF-8?q?ork,=20and=20flags=20DL205.xmpp=20as=20the=20first=20profile=20?= =?UTF-8?q?to=20add=20in=20a=20follow-up=20PR.=20dotnet=20test=20run=20aga?= =?UTF-8?q?inst=20the=20scaffold=20(no=20simulator=20running)=20skips=20cl?= =?UTF-8?q?eanly:=200=20failed,=200=20passed,=201=20skipped,=20with=20the?= =?UTF-8?q?=20SkipReason=20surfaced.=20dotnet=20build=20clean=20(0=20warni?= =?UTF-8?q?ngs,=200=20errors).=20Updated=20docs/v2/modbus-test-plan.md=20t?= =?UTF-8?q?o=20mark=20the=20scaffold=20PR=20done=20and=20renumbered=20futu?= =?UTF-8?q?re=20PRs=20from=20'PR=2027+'=20to=20'PR=2031+'=20to=20stay=20in?= =?UTF-8?q?=20sync=20with=20the=20actual=20PR=20chain.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + docs/v2/modbus-test-plan.md | 23 ++++--- .../DL205/DL205Profile.cs | 45 +++++++++++++ .../DL205/DL205SmokeTests.cs | 53 +++++++++++++++ .../ModbusPal/README.md | 30 +++++++++ .../ModbusSimulatorFixture.cs | 66 +++++++++++++++++++ ...pcUa.Driver.Modbus.IntegrationTests.csproj | 36 ++++++++++ 7 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index b022300..32d6b5a 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -24,6 +24,7 @@ + diff --git a/docs/v2/modbus-test-plan.md b/docs/v2/modbus-test-plan.md index 3301ae0..ada85f4 100644 --- a/docs/v2/modbus-test-plan.md +++ b/docs/v2/modbus-test-plan.md @@ -6,8 +6,10 @@ routing against a textbook Modbus server. That's necessary but not sufficient: r populations disagree with the spec in small, device-specific ways, and a driver that passes textbook tests can still misbehave against actual equipment. -This doc is the harness-and-quirks playbook. It's what gets wired up in the -`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate). +This doc is the harness-and-quirks playbook. The project it describes lives at +`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with +the simulator fixture, DL205 profile stub, and one write/read smoke test. Each +confirmed DL205 quirk lands in a follow-up PR as a named test in that project. ## Harness @@ -94,10 +96,13 @@ vendors get promoted into driver defaults or opt-in options: ## Next concrete PRs -- **PR 26 — Integration test project + DL205 profile scaffold**: creates - `tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or - generates it from JSON), adds the fixture with skip-when-unreachable, plus - one smoke test that reads a register. No DL205-specific assertions yet — that - waits for the user to validate each quirk. -- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any - driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. +- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**. + Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with + `ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the + endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one + writable holding register at address 100), and `DL205/DL205SmokeTests.cs` + (write-then-read round-trip). `ModbusPal/` directory holds the README + pointing at the to-be-committed `DL205.xmpp` profile. +- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any + driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop + the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs new file mode 100644 index 0000000..97b5cc3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs @@ -0,0 +1,45 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal +/// .xmpp profile in ModbusPal/DL205.xmpp exposes (or the real PLC, when +/// is pointed at one). +/// +/// +/// This is the scaffold — each tag is deliberately generic so the smoke test has stable +/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero +/// access, etc.) will land in their own test classes alongside this profile as the user +/// validates each behavior in ModbusPal; see docs/v2/modbus-test-plan.md §per-device +/// quirk catalog for the checklist. +/// +public static class DL205Profile +{ + /// Holding register the smoke test reads. Address 100 sidesteps the DL205 + /// register-zero quirk (pending confirmation) — see modbus-test-plan.md. + public const ushort SmokeHoldingRegister = 100; + + /// Expected value the ModbusPal profile seeds into register 100. When running + /// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke + /// test seeds this value first, then reads it back. + public const short SmokeHoldingValue = 1234; + + public static ModbusDriverOptions BuildOptions(string host, int port) => new() + { + Host = host, + Port = port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = + [ + new ModbusTagDefinition( + Name: "DL205_Smoke_HReg100", + Region: ModbusRegion.HoldingRegisters, + Address: SmokeHoldingRegister, + DataType: ModbusDataType.Int16, + Writable: true), + ], + // Disable the background probe loop — integration tests drive reads explicitly and + // the probe would race with assertions. + Probe = new ModbusProbeOptions { Enabled = false }, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs new file mode 100644 index 0000000..d1dd22c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when +/// MODBUS_SIM_ENDPOINT points at one). Drives the full +/// + real stack — no fake transport. Success proves the +/// driver can initialize against the simulator, write a known value, and read it back +/// with the correct status and value, which is the baseline every device-quirk test +/// builds on. +/// +/// +/// Device-specific quirk tests (word order, max-register, register-zero access, exception +/// code translation, etc.) land as separate test classes in this directory as each quirk +/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation +/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named +/// test so filtering by device class (--filter DisplayName~DL205) surfaces the +/// quirk-specific failure mode. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "DL205")] +public sealed class DL205SmokeTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task DL205_roundtrip_write_then_read_of_holding_register() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = DL205Profile.BuildOptions(sim.Host, sim.Port); + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke"); + await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken); + + // Write first so the test is self-contained — ModbusPal's default register bank is + // zeroed at simulator start, and tests must not depend on prior-test state per the + // test-plan conventions. + var writeResults = await driver.WriteAsync( + [new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)], + TestContext.Current.CancellationToken); + writeResults.Count.ShouldBe(1); + writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile"); + + var readResults = await driver.ReadAsync( + ["DL205_Smoke_HReg100"], + TestContext.Current.CancellationToken); + readResults.Count.ShouldBe(1); + readResults[0].StatusCode.ShouldBe(0u); + readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md new file mode 100644 index 0000000..c2fcdbd --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md @@ -0,0 +1,30 @@ +# ModbusPal simulator profiles + +Drop device-specific `.xmpp` profiles here. The integration tests connect to the +endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the +simulator to already be running — tests do not launch ModbusPal themselves, +because its Java GUI + JRE requirement is heavier than the harness is worth. + +## Getting started + +1. Download ModbusPal from SourceForge (`modbuspal.jar`). +2. `java -jar modbuspal.jar` to launch the GUI. +3. Load a profile from this directory (or configure one manually) and start the + simulator on TCP port 502. +4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests + auto-skip with a clear `SkipReason` if the TCP probe at the configured + endpoint fails within 2 seconds. + +## Profile files + +- `DL205.xmpp` — _to be added_ — register map reflecting the AutomationDirect + DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke + test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and + present; a minimal ModbusPal profile with a single holding-register bank at + address 100 is sufficient. + +## Environment variables + +- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`; + defaults to `localhost:502`. Useful when pointing the suite at a real PLC on + the bench. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs new file mode 100644 index 0000000..5f55e2d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs @@ -0,0 +1,66 @@ +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; + +/// +/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses +/// MODBUS_SIM_ENDPOINT (default localhost:502) and TCP-connects once at +/// fixture construction. Each test checks and calls +/// Assert.Skip when the endpoint was unreachable, so a dev box without a running +/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in +/// GalaxyRepositoryLiveSmokeTests. +/// +/// +/// +/// Do NOT keep the probe socket open for the life of the fixture. The probe is a +/// one-shot liveness check; tests open their own transports (the real +/// ) against the same endpoint. Sharing a socket +/// across tests would serialize them on a single TCP stream. +/// +/// +/// The fixture is a collection fixture so the reachability probe runs once per test +/// session, not per test — checking every test would waste several seconds against a +/// firewalled endpoint that times out each attempt. +/// +/// +public sealed class ModbusSimulatorFixture : IAsyncDisposable +{ + private const string DefaultEndpoint = "localhost:502"; + private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT"; + + public string Host { get; } + public int Port { get; } + public string? SkipReason { get; } + + public ModbusSimulatorFixture() + { + var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint; + var parts = raw.Split(':', 2); + Host = parts[0]; + Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502; + + try + { + using var client = new TcpClient(); + var task = client.ConnectAsync(Host, Port); + if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected) + { + SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + + $"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; + } + } + catch (Exception ex) + { + SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " + + $"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +[Xunit.CollectionDefinition(Name)] +public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture +{ + public const string Name = "ModbusSimulator"; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj new file mode 100644 index 0000000..0d192b5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + -- 2.49.1