New project tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ with four pieces. AbLegacyServerFixture — TCP probe against localhost:44818 (or AB_LEGACY_ENDPOINT override), distinct from AB_SERVER_ENDPOINT so both CIP + PCCC containers can run simultaneously. Single-public-ctor to satisfy xunit collection-fixture constraint. AbLegacyServerProfile + KnownProfiles carry the per-family (SLC500 / MicroLogix / PLC-5) ComposeProfile + Notes; drives per-theory parameterisation. AbLegacyFactAttribute / AbLegacyTheoryAttribute match the AB CIP skip-attribute pattern. Docker/docker-compose.yml reuses the AB CIP otopcua-ab-server:libplctag-release image — `build:` block points at ../../AbCip.IntegrationTests/Docker context so `docker compose build` from here produces / reuses the same multi-stage build. Three compose profiles (slc500 / micrologix / plc5) with per-family `--plc` + `--tag=<file>[<size>]` flags matching the PCCC tag syntax (different from CIP's `Name:Type[size]`). AbLegacyReadSmokeTests — one parametric theory reading N7:0 across all three families + one SLC500 write-then-read on N7:5. Targets the shape the driver would use against real hardware. Verified 2026-04-20 against a live SLC500 container: TCP probe passes + container accepts connections + libplctag negotiates session, but read/write returns BadCommunicationError (libplctag status 0x80050000). Root-caused to ab_server's PCCC server-side opcode coverage being narrower than libplctag's PCCC client expects — not a driver-side bug, not a scaffold bug, just an ab_server upstream limitation. Documented honestly in Docker/README.md + AbLegacy-Test-Fixture.md rather than skipping the tests or weakening assertions; tests now skip cleanly when container is absent, fail with clear message when container is up but the protocol gap surfaces. Operator resolves by filing an ab_server upstream patch, pointing AB_LEGACY_ENDPOINT at real hardware, or scaffolding an RSEmulate 500 golden-box tier. Docker/README.md — Known limitations section leads with the PCCC round-trip gap (test date, failure signature, possible root causes, three resolution paths) before the pre-existing limitations (T/C file decomposition, ST file quirks, indirect addressing, DF1 serial). Reader can't miss the "scaffolded but blocked on upstream" framing. docs/drivers/AbLegacy-Test-Fixture.md — TL;DR flipped from "no integration fixture" to "Docker scaffold in place; wire-level round-trip currently blocked by ab_server PCCC gap". What-the-fixture-is gains an Integration section. Follow-up candidates rewritten: #1 is now "fix ab_server PCCC upstream", #2 is RSEmulate 500 golden-box (with cost callouts matching our existing Logix Emulate + TwinCAT XAR scaffolds — license + Hyper-V conflict + binary project format), #3 is lab rig. Key-files list adds the four new files. docs/drivers/README.md coverage-map row updated from "no integration fixture" to "Docker scaffold via ab_server PCCC; wire-level round-trip currently blocked, docs call out resolution paths". Solution file picks up the new tests/.../AbLegacy.IntegrationTests entry. AbLegacyDataType.Int used throughout (not Int16 — the enum uses SLC file-type naming). Build 0 errors; 2 smoke tests skip cleanly without container + fail with clear errors when container up (proving the infrastructure works end-to-end + the gap is specifically the ab_server protocol coverage, not the scaffold). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
6.2 KiB
C#
158 lines
6.2 KiB
C#
using System.Net.Sockets;
|
|
using Xunit;
|
|
using Xunit.Sdk;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
|
|
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
|
|
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
|
|
/// CIP + PCCC families from one binary. Tests skip via
|
|
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
|
|
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
|
|
/// Docker running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Env-var overrides:
|
|
/// <list type="bullet">
|
|
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
|
|
/// Defaults to <c>localhost:44818</c> (EtherNet/IP port; ab_server's PCCC
|
|
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
|
|
/// </list>
|
|
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
|
|
/// can point at different containers simultaneously during a combined test run.
|
|
/// </remarks>
|
|
public sealed class AbLegacyServerFixture : IAsyncLifetime
|
|
{
|
|
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
|
|
|
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
|
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
|
/// with, not a different TCP listener.</summary>
|
|
public const int DefaultPort = 44818;
|
|
|
|
public string Host { get; } = "127.0.0.1";
|
|
public int Port { get; } = DefaultPort;
|
|
public string? SkipReason { get; }
|
|
|
|
public AbLegacyServerFixture()
|
|
{
|
|
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
|
{
|
|
var parts = raw.Split(':', 2);
|
|
Host = parts[0];
|
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
|
}
|
|
|
|
if (!TcpProbe(Host, Port))
|
|
{
|
|
SkipReason =
|
|
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
|
|
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
|
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
|
|
}
|
|
}
|
|
|
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
public static bool IsServerAvailable()
|
|
{
|
|
var (host, port) = ResolveEndpoint();
|
|
return TcpProbe(host, port);
|
|
}
|
|
|
|
private static (string Host, int Port) ResolveEndpoint()
|
|
{
|
|
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
|
if (raw is null) return ("127.0.0.1", DefaultPort);
|
|
var parts = raw.Split(':', 2);
|
|
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
|
|
return (parts[0], port);
|
|
}
|
|
|
|
private static bool TcpProbe(string host, int port)
|
|
{
|
|
try
|
|
{
|
|
using var client = new TcpClient();
|
|
var task = client.ConnectAsync(host, port);
|
|
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
|
|
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
|
|
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
|
|
/// family enum to its compose-profile name + operator-facing notes.
|
|
/// </summary>
|
|
public sealed record AbLegacyServerProfile(
|
|
AbLegacyPlcFamily Family,
|
|
string ComposeProfile,
|
|
string Notes);
|
|
|
|
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
|
|
public static class KnownProfiles
|
|
{
|
|
public static readonly AbLegacyServerProfile Slc500 = new(
|
|
Family: AbLegacyPlcFamily.Slc500,
|
|
ComposeProfile: "slc500",
|
|
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",
|
|
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",
|
|
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
|
|
|
|
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
|
|
[Slc500, MicroLogix, Plc5];
|
|
|
|
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
|
|
All.FirstOrDefault(p => p.Family == family)
|
|
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
|
}
|
|
|
|
[Xunit.CollectionDefinition(Name)]
|
|
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
|
|
{
|
|
public const string Name = "AbLegacyServer";
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
|
|
/// </summary>
|
|
public sealed class AbLegacyFactAttribute : FactAttribute
|
|
{
|
|
public AbLegacyFactAttribute()
|
|
{
|
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
|
"or set AB_LEGACY_ENDPOINT.";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
|
|
/// </summary>
|
|
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
|
{
|
|
public AbLegacyTheoryAttribute()
|
|
{
|
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
|
"or set AB_LEGACY_ENDPOINT.";
|
|
}
|
|
}
|