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 + + + + + + + + + + + + + + + + + +