diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs index 313892ce..9bbfd8f4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs @@ -41,13 +41,13 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) [MemberData(nameof(Profiles))] public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile) { - if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); // PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing // after the `/`), but libplctag's ab_server requires a non-empty path at the // CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0` // against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW. - var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], @@ -75,23 +75,25 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) [AbLegacyFact] public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register() { - if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + // Addresses the SLC500 container by its OWN port, so it no longer matters which other + // families happen to be running. The old AB_LEGACY_COMPOSE_PROFILE guard existed because every + // family shared :44818 and this test could otherwise write SLC500 semantics at whichever + // container held the port; it survives below only for real-hardware runs, where + // AB_LEGACY_ENDPOINT pins one device and the family IS a property of that device. + if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip); - // Skip when the running compose profile isn't SLC500 — ab_server's `--plc=` - // flag selects exactly one family per process, so a write against a plc5-mode - // container with SLC500 semantics always fails at the wire. var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); - if (!string.IsNullOrEmpty(only) && + if (sim.EndpointPinned && !string.IsNullOrEmpty(only) && !string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase)) { - Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'."); + Assert.Skip($"Pinned endpoint is not SLC500; AB_LEGACY_COMPOSE_PROFILE='{only}'."); } // PCCC semantics allow an empty cip-path (real SLC/PLC-5 hardware takes nothing // after the `/`), but libplctag's ab_server requires a non-empty path at the // CIP unconnected-send layer before the PCCC dispatcher runs. Default `1,0` // against the Docker fixture; set AB_LEGACY_CIP_PATH= (empty) against real HW. - var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}"; await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)], diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs index 7faa0ca8..f0a5a347 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs @@ -17,9 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// Env-var overrides: /// -/// AB_LEGACY_ENDPOINT — host:port of the PCCC-mode simulator. -/// Defaults to 10.100.0.35:44818 — the shared Docker host (EtherNet/IP port; ab_server's PCCC -/// emulation exposes PCCC-over-CIP on the same port as CIP itself). +/// AB_LEGACY_ENDPOINT — host:port of a single PCCC-mode device, pinning +/// every family to it (real-hardware mode, where the family is a property of the device +/// rather than of the port). Unset — the default — each family is reached on its own port on the +/// shared Docker host: 10.100.0.35:44819 slc500, :44820 micrologix, +/// :44821 plc5. /// AB_LEGACY_CIP_PATH — routing path appended to the ab://host:port/ /// URI. Defaults to 1,0 (port-1/slot-0 backplane), required by ab_server /// which rejects unconnected Send_RR_Data with an empty path at the CIP layer @@ -27,8 +29,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// use an empty path — set AB_LEGACY_CIP_PATH= (empty) when pointing at /// real hardware. /// -/// Distinct from AB_SERVER_ENDPOINT used by the AB CIP fixture so both -/// can point at different containers simultaneously during a combined test run. +/// Distinct from AB_SERVER_ENDPOINT used by the AB CIP fixture so both can point at different +/// containers simultaneously during a combined test run — which was the stated intent from the start +/// but was not true in practice until 2026-08-01, because every ab_server across both fixtures +/// defaulted to :44818. /// public sealed class AbLegacyServerFixture : IAsyncLifetime { @@ -56,15 +60,58 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime /// Gets the server host address. public string Host { get; } = "10.100.0.35"; - /// Gets the server TCP port. + /// + /// The port every family shares when AB_LEGACY_ENDPOINT pins one, or + /// . Prefer — against the Docker fixture each + /// family listens on its own port and this value addresses none of them. + /// public int Port { get; } = DefaultPort; + /// True when AB_LEGACY_ENDPOINT pinned a single endpoint (real-hardware mode). + public bool EndpointPinned { get; } + + /// + /// The port to reach 's simulator on: the family's own port against the + /// Docker fixture, or the pinned endpoint's port when AB_LEGACY_ENDPOINT is set. + /// + /// The family profile under test. + /// The TCP port. + public int PortFor(AbLegacyServerProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + return EndpointPinned ? Port : profile.Port; + } + + /// + /// Why 's simulator cannot be reached, or null when it can. + /// + /// + /// ⚠️ Per-family, not per-suite (2026-08-01). Every family used to share :44818, so + /// this could only ever answer "is some ab_server up" — and the AB CIP fixture's ControlLogix + /// container answers there too. The probe therefore reported reachable, the suite declined to + /// skip, and SLC500-mode PCCC ran against a ControlLogix and failed at the wire. A reachability + /// check that cannot tell one simulator from another is not a skip gate; it is a coin flip that + /// reports its result as a test failure. + /// + /// The family profile under test. + /// A skip reason, or . + public string? SkipReasonFor(AbLegacyServerProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + var port = PortFor(profile); + if (TcpProbe(Host, port)) return null; + + return EndpointPinned + ? $"AB Legacy PCCC endpoint at {Host}:{port} ({EndpointEnvVar}) not reachable within 2 s." + : $"AB Legacy {profile.ComposeProfile} simulator at {Host}:{port} not reachable within 2 s. " + + $"Start it (docker compose -f Docker/docker-compose.yml --profile {profile.ComposeProfile} " + + $"up -d), attach real hardware, or override {EndpointEnvVar}."; + } + /// CIP routing path portion of the device URI (after the / separator). /// May be empty when targeting real hardware; non-empty against ab_server. public string CipPath { get; } = DefaultCipPath; - /// Gets the skip reason if the server is unavailable, or null if available. - public string? SkipReason { get; } /// Initializes the AB Legacy server fixture by reading environment overrides. public AbLegacyServerFixture() @@ -74,6 +121,7 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime var parts = raw.Split(':', 2); Host = parts[0]; if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p; + EndpointPinned = true; } // Empty override is intentional (real hardware); treat `null` as "not set, use @@ -81,7 +129,6 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar); if (cipOverride is not null) CipPath = cipOverride; - SkipReason = ResolveSkipReason(Host, Port); } /// Initializes the fixture asynchronously. @@ -97,29 +144,19 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime /// exists. /// public static bool IsServerAvailable() - { - var (host, port) = ResolveEndpoint(); - return ResolveSkipReason(host, port) is null; - } - - private static string? ResolveSkipReason(string host, int port) - { - if (!TcpProbe(host, port)) - { - return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " + - $"Start the Docker container (docker compose -f Docker/docker-compose.yml " + - $"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}."; - } - return null; - } - - private static (string Host, int Port) ResolveEndpoint() { var raw = Environment.GetEnvironmentVariable(EndpointEnvVar); - if (raw is null) return ("10.100.0.35", DefaultPort); - var parts = raw.Split(':', 2); - var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort; - return (parts[0], port); + if (raw is { Length: > 0 }) + { + var parts = raw.Split(':', 2); + var pinned = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort; + return TcpProbe(parts[0], pinned); + } + + // ANY family's simulator being up runs the class; each test then skips itself on its own port + // via SkipReasonFor. Gating the whole class on one family would skip the other two whenever it + // happened to be down, which reads as coverage that ran. + return KnownProfiles.All.Any(profile => TcpProbe("10.100.0.35", profile.Port)); } private static bool TcpProbe(string host, int port) @@ -140,27 +177,42 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime /// for which --plc mode + tags each family seeds; this record just ties a /// family enum to its compose-profile name + operator-facing notes. /// +/// The PCCC family. +/// The compose profile name that starts this family's container. +/// +/// The family's own host port. Each family gets one so all three run together, and alongside the +/// AB CIP fixture on :44818. Overridden wholesale by AB_LEGACY_ENDPOINT (real hardware, +/// where there is one device and the family is a property of it, not of the port). +/// +/// Operator-facing description of what the family's container seeds. public sealed record AbLegacyServerProfile( AbLegacyPlcFamily Family, string ComposeProfile, + int Port, string Notes); /// Canonical profiles covering every PCCC family the driver supports. +/// +/// The Port values must match Docker/docker-compose.yml's host-side mappings. +/// public static class KnownProfiles { public static readonly AbLegacyServerProfile Slc500 = new( Family: AbLegacyPlcFamily.Slc500, ComposeProfile: "slc500", + Port: 44819, Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files."); public static readonly AbLegacyServerProfile MicroLogix = new( Family: AbLegacyPlcFamily.MicroLogix, ComposeProfile: "micrologix", + Port: 44820, Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included."); public static readonly AbLegacyServerProfile Plc5 = new( Family: AbLegacyPlcFamily.Plc5, ComposeProfile: "plc5", + Port: 44821, Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only."); /// Gets all known profiles. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md index 2245bd23..84edcf90 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md @@ -13,7 +13,7 @@ Desktop and nothing else. | File | Purpose | |---|---| -| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` | +| [`docker-compose.yml`](docker-compose.yml) | Three per-family services — `slc500` on `:44819`, `micrologix` on `:44820`, `plc5` on `:44821`. Run them together; they no longer collide with each other or with the AB CIP fixture on `:44818`. | ## Run @@ -38,12 +38,12 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down First run builds the `otopcua-ab-server:libplctag-release` image (~3-5 min — clones libplctag + compiles `ab_server`). If the AB CIP fixture already built the image locally, docker reuses the cached layers + this -runs in seconds. Only one family binds `:44818` at a time; to switch +runs in seconds. (Until 2026-08-01 every family bound `:44818`, so only one could run and the theory's other two cases always failed; each now has its own port.) To switch families stop the current service + start another. ## Endpoint -- Default: `localhost:44818` (EtherNet/IP standard) +- Default: `10.100.0.35:44819` / `:44820` / `:44821` per family (the shared Docker host) - Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC / MicroLogix / PLC-5 PLC on its native port. @@ -51,9 +51,9 @@ families stop the current service + start another. | Var | Default | Purpose | |---|---|---| -| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. | +| `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). | | `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. | -| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | When set (e.g. `slc500`), the parametric theory filters to that profile. Only one compose container binds `:44818` at a time; set this to the profile currently up so the suite doesn't try to hit e.g. the Slc500 family against the PLC-5 container. Leave unset for real-hardware runs (all 3 families parameterize). | +| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. | ## Run the integration tests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml index f0d34323..97e4df73 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml @@ -5,15 +5,20 @@ # so `docker compose build` from here produces the same image if it # doesn't already exist; if it does, docker's cache reuses the layer. # -# One service per PCCC family. All bind :44818 on the host; run one at a -# time. PCCC tag format differs from CIP: `[]` without a -# type suffix since the type is implicit in the file letter (N = INT, -# F = REAL, B = bit-packed, L = DINT). +# One service per PCCC family, each on its OWN host port — 44819 slc500, +# 44820 micrologix, 44821 plc5 — so all three run together, and alongside the +# AB CIP fixture, which owns :44818. They all bound :44818 until 2026-08-01, +# which made the suite structurally un-greenable: the theory parameterises over +# all three families, but only one container could ever exist, so at least two +# cases failed on every run. Worse, the AB CIP ControlLogix container answers +# on :44818 too, so the fixture's TCP reachability probe saw "up", declined to +# skip, and ran SLC500-mode PCCC against a ControlLogix. # -# Usage: -# docker compose --profile slc500 up -# docker compose --profile micrologix up -# docker compose --profile plc5 up +# ab_server still listens on 44818 INSIDE the container (--port=44818); only the +# host mapping differs. Keep these in step with AbLegacyServerProfile.Port. +# +# Usage (all three at once — that is the point): +# docker compose --profile slc500 --profile micrologix --profile plc5 up -d services: slc500: profiles: ["slc500"] @@ -24,7 +29,7 @@ services: container_name: otopcua-ab-server-slc500 restart: "no" ports: - - "44818:44818" + - "44819:44818" command: [ "ab_server", "--plc=SLC500", @@ -44,7 +49,7 @@ services: container_name: otopcua-ab-server-micrologix restart: "no" ports: - - "44818:44818" + - "44820:44818" command: [ "ab_server", "--plc=Micrologix", @@ -63,7 +68,7 @@ services: container_name: otopcua-ab-server-plc5 restart: "no" ports: - - "44818:44818" + - "44821:44818" command: [ "ab_server", "--plc=PLC/5",