Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs
T
Joseph Doherty b13bdc154a test(ablegacy): deploy the PCCC fixture, one port per family (#534)
The AbLegacy integration suite has never run against a simulator.
/opt/otopcua-ablegacy/ did not exist on the Docker host — the stack was written
but never deployed — and every ab_server across BOTH ab fixtures bound :44818.
So the AB CIP fixture's ControlLogix container (up for two weeks) answered this
suite's TCP reachability probe, the suite declined to skip, and it ran SLC500-mode
PCCC against a ControlLogix. Four failures that read as "fixture missing".

Two structural problems, both fixed here:

- The theory parameterises over all three PCCC families, but only one container
  could ever exist on the shared port, so at least two cases failed on every
  conceivable run. The suite was un-greenable by construction, and
  AB_LEGACY_COMPOSE_PROFILE existed to paper over it by filtering the theory
  down to whichever container happened to be up.
- A reachability probe that cannot tell one simulator from another is not a skip
  gate. It answered "is some ab_server listening", which is a different question
  from the one the caller is asking, and reported the difference as a test
  failure.

Each family now has its own host port — 44819 slc500, 44820 micrologix, 44821
plc5 — so all three run together and alongside AbCip on 44818 (ab_server still
listens on 44818 inside each container; only the host mapping differs). Skip is
per-family via SkipReasonFor(profile); the class-level gate runs if ANY family is
up so one down container cannot silently skip the other two.
AB_LEGACY_COMPOSE_PROFILE now applies only in AB_LEGACY_ENDPOINT-pinned
real-hardware mode, where the family genuinely is a property of the one device.
This finally makes true what the fixture's XML doc has claimed from the start —
that it and the AB CIP fixture can point at different containers simultaneously.

Deployed to /opt/otopcua-ablegacy/ on 10.100.0.35; all three containers running.

The 4 tests still FAIL, and are left red on purpose — see #534. Now aimed at
correct-family simulators, the PCCC exchange itself returns BadCommunicationError
for every server-path / client-cip-path permutation, reproduced outside the test
harness via the driver CLI. That is a true signal about the driver (or about
libplctag's PCCC support against ab_server) that skipping would hide. What
changed here is that the failure is now diagnostic instead of an artifact of a
port collision.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 02:18:53 -04:00

126 lines
6.3 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// End-to-end smoke tests against the <c>ab_server</c> PCCC Docker container.
/// Promotes the AB Legacy driver from unit-only coverage (<c>FakeAbLegacyTag</c>)
/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server
/// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5)
/// via <c>[AbLegacyTheory]</c> + <c>[MemberData]</c>.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[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.
/// <summary>Gets the available server profiles based on compose configuration.</summary>
public static IEnumerable<object[]> 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 });
}
}
/// <summary>Verifies that the driver reads seeded N file from the AB server via PCCC.</summary>
/// <param name="profile">The AB Legacy server profile describing the fixture endpoint.</param>
[AbLegacyTheory]
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
{
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.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition(
Name: "IntCounter",
DeviceHostAddress: deviceUri,
Address: "N7:0",
DataType: AbLegacyDataType.Int)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: $"ablegacy-smoke-{profile.Family}");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
["IntCounter"], TestContext.Current.CancellationToken);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
$"N7:0 read must succeed against the {profile.Family} compose profile");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that SLC500 write-then-read round trip succeeds on N7 scratch register.</summary>
[AbLegacyFact]
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
{
// 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);
var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE");
if (sim.EndpointPinned && !string.IsNullOrEmpty(only) &&
!string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase))
{
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.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition(
Name: "Scratch",
DeviceHostAddress: deviceUri,
Address: "N7:5",
DataType: AbLegacyDataType.Int,
Writable: true)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: "ablegacy-smoke-rw");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
const short probe = 0x1234;
var writeResults = await drv.WriteAsync(
[new WriteRequest("Scratch", probe)],
TestContext.Current.CancellationToken);
writeResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
"PCCC N7:5 write must succeed end-to-end");
var readResults = await drv.ReadAsync(
["Scratch"], TestContext.Current.CancellationToken);
readResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(readResults.Single().Value).ShouldBe(probe);
}
}