From 8c68694df18dff5a43c0071ed07797bbfe81da2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 17:35:31 -0400 Subject: [PATCH] test(ab): run the ab_server integration suites on both transports --- ...-10-07-native-ab-transport-gate-results.md | 41 ++++++++++++++++++- .../AbCipReadSmokeTests.cs | 20 +++++---- .../Emulate/AbCipEmulateAlmdTests.cs | 9 ++-- .../Emulate/AbCipEmulateNestedUdtTests.cs | 9 ++-- .../Emulate/AbCipEmulateUdtReadTests.cs | 9 ++-- .../TransportMatrix.cs | 37 +++++++++++++++++ .../AbLegacyReadSmokeTests.cs | 39 +++++++----------- .../TransportMatrix.cs | 35 ++++++++++++++++ 8 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md index 4f9ec0ad..3fc33956 100644 --- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -2,7 +2,46 @@ ## ab_server suites (T17) -_(pending)_ +Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration +project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time +(AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=

`; AbLegacy `~/otopcua-ablegacy`, +`AB_LEGACY_COMPOSE_PROFILE=

`); every stack was brought down afterwards. + +- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from + libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's. +- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix in the T17 `test(ab)` commit that follows them. + +Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the +6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests +(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself +on both transports when the slc500 port (`:44819`) is down. + +| Profile | Libplctag | Native | Native ⊇ Libplctag | +|---|---|---|---| +| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | +| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | +| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) | +| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | +| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes | +| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes | +| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes | + +**Native-only failures found and fixed:** + +- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that + with encapsulation status 1. Every native session failed. +- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id + (`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends + libplctag's identity. + +**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths +(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite. + +**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against +`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag +still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and +libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That +defect is pre-existing and libplctag-side; it is recorded, not fixed, here. ## Differential vs ab_server (T18) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs index 51168dfb..44d142e7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; ///

-/// End-to-end smoke tests that exercise the real libplctag stack against a running +/// End-to-end smoke tests that exercise the real transport stack (libplctag and native, via ) against a running /// ab_server. Skipped when the binary isn't on PATH (). /// Parametrized over so one test file covers every family /// (ControlLogix / CompactLogix / Micro800 / GuardLogix). @@ -15,15 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; [Trait("Requires", "AbServer")] public sealed class AbCipReadSmokeTests { - /// Gets the test profiles for the theory tests. - public static IEnumerable Profiles => - KnownProfiles.All.Select(p => new object[] { p }); - /// Verifies that the driver can read a seeded DInt value from an AB server. /// The AB server profile to test against. + /// The transport stack the driver runs on. [AbServerTheory] - [MemberData(nameof(Profiles))] - public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile) + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile, AbCipTransport transport) { var fixture = new AbServerFixture(profile); await fixture.InitializeAsync(); @@ -33,13 +30,18 @@ public sealed class AbCipReadSmokeTests // (10.100.0.35) and AB_SERVER_ENDPOINT overrides reach this test too. The // sibling Emulate tests already use the fixture's resolved endpoint; this // smoke test was missed. - var deviceUri = $"ab://{fixture.Host}:{fixture.Port}/1,0"; + // Micro800 has no backplane: its cip-path is empty (the compose service runs + // ab_server --plc=Micro800 with no --path, which refuses a routed request). Every + // other family reaches slot 0 through `1,0`. + var cipPath = profile.Family == AbCipPlcFamily.Micro800 ? "" : "1,0"; + var deviceUri = $"ab://{fixture.Host}:{fixture.Port}/{cipPath}"; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)), Timeout = TimeSpan.FromSeconds(5), - }, $"drv-smoke-{profile.Family}"); + Transport = transport, + }, $"drv-smoke-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs index d33b464d..b74feaa6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs @@ -40,8 +40,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; public sealed class AbCipEmulateAlmdTests { /// Verifies that real ALMD raise fires OnAlarmEvent through the driver projection. - [AbServerFact] - public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); @@ -73,9 +75,10 @@ public sealed class AbCipEmulateAlmdTests TagPath: "SimulateAlarm", DataType: AbCipDataType.Bool, Writable: true)), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-almd-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var raised = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs index 0fcbb9a2..e5df8e7b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs @@ -43,8 +43,10 @@ public sealed class AbCipEmulateNestedUdtTests /// leaves addressable as dot-joined paths, proving the nested template-id threading /// runs the production @udt/{id} CIP fetch against Emulate's firmware. /// - [AbServerFact] - public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); @@ -58,9 +60,10 @@ public sealed class AbCipEmulateNestedUdtTests Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")], EnableControllerBrowse = true, Timeout = TimeSpan.FromSeconds(10), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-nested-udt-smoke"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-nested-udt-smoke-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Drive controller discovery — the driver walks @tags, encounters MotorNested1 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs index a6018c02..16658a70 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs @@ -38,8 +38,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; public sealed class AbCipEmulateUdtReadTests { /// Verifies that reading a whole UDT decodes each member at its template object offset. - [AbServerFact] - public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); @@ -63,9 +65,10 @@ public sealed class AbCipEmulateUdtReadTests new AbCipStructureMember("Status", AbCipDataType.DInt), ])), Timeout = TimeSpan.FromSeconds(5), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-udt-smoke-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Whole-UDT read optimization from task #194: one libplctag read on the diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs new file mode 100644 index 00000000..55b12b85 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Crosses every ab_server compose profile with both AbCip transports so each integration +/// test runs once on libplctag and once on the native managed stack. The native run must be green +/// wherever the libplctag run is. +/// +/// +/// Every AbCip compose profile binds host :44818, so only one can run at a time. When +/// AB_SERVER_COMPOSE_PROFILE is set, keeps only that profile, so the +/// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix. +/// Unset, every profile is crossed (the original behaviour). +/// +public static class TransportMatrix +{ + /// Both transports, for tests that are not parameterised by profile. + public static IReadOnlyList AllTransports { get; } = + [AbCipTransport.Libplctag, AbCipTransport.Native]; + + /// Gets (profile, transport) rows for every selected profile × both transports. + public static IEnumerable Profiles + { + get + { + var only = Environment.GetEnvironmentVariable("AB_SERVER_COMPOSE_PROFILE"); + return from p in KnownProfiles.All + where string.IsNullOrEmpty(only) || + string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + from t in AllTransports + select new object[] { p, t }; + } + } + + /// Gets one row per transport. + public static IEnumerable Transports => + AllTransports.Select(t => new object[] { t }); +} 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 9bbfd8f4..04030e32 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 @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// End-to-end smoke tests against the ab_server PCCC Docker container. /// Promotes the AB Legacy driver from unit-only coverage (FakeAbLegacyTag) -/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server +/// to wire-level: real PCCC stack (libplctag and native, via ) over real TCP against the ab_server /// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5) /// via [AbLegacyTheory] + [MemberData]. /// @@ -17,29 +17,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) { - // Only one ab_server container binds :44818 at a time and `--plc=SLC500` only - // answers SLC-mode PCCC, etc. When `AB_LEGACY_COMPOSE_PROFILE` is set, the theory - // filters to that profile alone so the suite matches the running container. Unset - // (the default for real-hardware runs) parameterises across every family the driver - // supports. - /// Gets the available server profiles based on compose configuration. - public static IEnumerable Profiles - { - get - { - var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); - var profiles = KnownProfiles.All.Where(p => - string.IsNullOrEmpty(only) || - string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)); - return profiles.Select(p => new object[] { p }); - } - } + // Rows come from TransportMatrix.Profiles, which keeps the AB_LEGACY_COMPOSE_PROFILE filter this + // class applied before the transport split: when set, only that profile is crossed with the + // transports, so the suite matches the running container. /// Verifies that the driver reads seeded N file from the AB server via PCCC. /// The AB Legacy server profile describing the fixture endpoint. + /// The transport stack the driver runs on. [AbLegacyTheory] - [MemberData(nameof(Profiles))] - public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile) + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile, AbLegacyTransport transport) { if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); @@ -59,7 +46,8 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) DataType: AbLegacyDataType.Int)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, - }, driverInstanceId: $"ablegacy-smoke-{profile.Family}"); + Transport = transport, + }, driverInstanceId: $"ablegacy-smoke-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); @@ -72,8 +60,10 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) } /// Verifies that SLC500 write-then-read round trip succeeds on N7 scratch register. - [AbLegacyFact] - public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register() + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register(AbLegacyTransport transport) { // 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 @@ -106,7 +96,8 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) Writable: true)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, - }, driverInstanceId: "ablegacy-smoke-rw"); + Transport = transport, + }, driverInstanceId: $"ablegacy-smoke-rw-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs new file mode 100644 index 00000000..fbfdc7d1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Crosses the PCCC compose profiles with both AbLegacy transports so each integration test runs +/// once on libplctag and once on the native managed stack. +/// +/// +/// Preserves the AB_LEGACY_COMPOSE_PROFILE filter the smoke theory always applied: when set, +/// is narrowed to that profile before it is crossed with the +/// transports, so the rows match the running container. +/// +public static class TransportMatrix +{ + /// Both transports, for tests that are not parameterised by profile. + public static IReadOnlyList AllTransports { get; } = + [AbLegacyTransport.Libplctag, AbLegacyTransport.Native]; + + /// Gets (profile, transport) rows for every selected profile × both transports. + public static IEnumerable Profiles + { + get + { + var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + return from p in KnownProfiles.All + where string.IsNullOrEmpty(only) || + string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + from t in AllTransports + select new object[] { p, t }; + } + } + + /// Gets one row per transport. + public static IEnumerable Transports => + AllTransports.Select(t => new object[] { t }); +}