From 32dff7f1d6f34b67af16b45a2973500524a47070 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 23:57:24 -0400 Subject: [PATCH] =?UTF-8?q?ab=5Fserver=20integration=20fixture=20=E2=80=94?= =?UTF-8?q?=20per-family=20profiles=20+=20documented=20CI-fetch=20contract?= =?UTF-8?q?.=20Closes=20task=20#180=20(AB=20CIP=20follow-up=20=E2=80=94=20?= =?UTF-8?q?ab=5Fserver=20CI=20fixture).=20Replaces=20the=20prior=20hardcod?= =?UTF-8?q?ed=20single-family=20fixture=20with=20a=20parametric=20AbServer?= =?UTF-8?q?Profile=20abstraction=20covering=20ControlLogix=20/=20CompactLo?= =?UTF-8?q?gix=20/=20Micro800=20/=20GuardLogix.=20Prebuilt-Windows-binary?= =?UTF-8?q?=20fetch=20is=20documented=20as=20a=20CI=20YAML=20step=20rather?= =?UTF-8?q?=20than=20fabricated=20C#-side,=20because=20SHA-pinned=20binary?= =?UTF-8?q?=20distribution=20is=20a=20CI=20workflow=20concern=20(libplctag?= =?UTF-8?q?=20owns=20releases,=20we=20pin=20a=20version=20+=20verify=20has?= =?UTF-8?q?h)=20not=20a=20test-framework=20concern.=20New=20AbServerProfil?= =?UTF-8?q?e=20record=20+=20KnownProfiles=20static=20class=20at=20tests/..?= =?UTF-8?q?./AbServerProfile.cs.=20Four=20profiles:=20ControlLogix=20(wide?= =?UTF-8?q?st=20coverage=20=E2=80=94=20DINT/REAL/BOOL/SINT/STRING=20atomic?= =?UTF-8?q?=20+=20DINT[16]=20array=20so=20the=20driver's=20@tags=20Symbol-?= =?UTF-8?q?Object=20decoder=20+=20array-bound=20path=20both=20get=20end-to?= =?UTF-8?q?-end=20coverage),=20CompactLogix=20(atomic=20subset=20=E2=80=94?= =?UTF-8?q?=20driver-side=20ConnectionSize=20quirk=20from=20PR=2010=20stil?= =?UTF-8?q?l=20applies=20since=20ab=5Fserver=20doesn't=20enforce=20the=20n?= =?UTF-8?q?arrower=20limit),=20Micro800=20(ab=5Fserver=20has=20no=20dedica?= =?UTF-8?q?ted=20--plc=20micro800=20mode=20=E2=80=94=20falls=20back=20to?= =?UTF-8?q?=20controllogix=20while=20driver-side=20path=20enforces=20empty?= =?UTF-8?q?=20routing=20+=20unconnected-only=20per=20PR=2011;=20real=20Mic?= =?UTF-8?q?ro800=20coverage=20requires=20a=202080=20lab=20rig),=20GuardLog?= =?UTF-8?q?ix=20(ab=5Fserver=20has=20no=20safety=20subsystem=20=E2=80=94?= =?UTF-8?q?=20profile=20emulates=20the=20=5FS-suffixed=20naming=20contract?= =?UTF-8?q?=20the=20driver's=20safety-ViewOnly=20classification=20reads=20?= =?UTF-8?q?in=20PR=2012;=20real=20safety-lock=20behavior=20requires=20a=20?= =?UTF-8?q?1756-L8xS=20physical=20rig).=20Each=20profile=20composes=20--pl?= =?UTF-8?q?c=20+=20--tag=20args=20via=20BuildCliArgs(port)=20=E2=80=94=20p?= =?UTF-8?q?ure=20string=20formatter=20so=20the=20composition=20logic=20is?= =?UTF-8?q?=20unit-testable=20without=20launching=20the=20simulator.=20AbS?= =?UTF-8?q?erverFixture=20gains=20a=20ctor=20overload=20taking=20AbServerP?= =?UTF-8?q?rofile=20+=20port=20(defaults=20back=20to=20ControlLogix=20on?= =?UTF-8?q?=20parameterless=20ctor=20so=20existing=20test=20suites=20keep?= =?UTF-8?q?=20compiling).=20Fixture's=20InitializeAsync=20hands=20the=20pr?= =?UTF-8?q?ofile's=20CLI=20args=20to=20ProcessStartInfo.Arguments.=20New?= =?UTF-8?q?=20AbServerTheoryAttribute=20mirrors=20AbServerFactAttribute=20?= =?UTF-8?q?but=20extends=20TheoryAttribute=20so=20a=20single=20test=20can?= =?UTF-8?q?=20MemberData=20over=20KnownProfiles.All=20+=20cover=20all=20fo?= =?UTF-8?q?ur=20families.=20AbCipReadSmokeTests=20converted=20from=20singl?= =?UTF-8?q?e-fact=20to=20theory=20parametrized=20over=20KnownProfiles.All?= =?UTF-8?q?=20=E2=80=94=20one=20row=20per=20family=20reads=20TestDINT=20+?= =?UTF-8?q?=20asserts=20Good=20status=20+=20Healthy=20driver=20state.=20Fi?= =?UTF-8?q?xture=20lifecycle=20is=20explicit=20try/finally=20rather=20than?= =?UTF-8?q?=20await=20using=20because=20IAsyncLifetime.DisposeAsync=20retu?= =?UTF-8?q?rns=20ValueTask=20+=20xUnit's=20concrete=20IAsyncDisposable=20s?= =?UTF-8?q?him=20depends=20on=20xunit=20version;=20explicit=20beats=20impl?= =?UTF-8?q?icit=20here.=20Eight=20new=20unit=20tests=20in=20AbServerProfil?= =?UTF-8?q?eTests.cs=20(runs=20without=20the=20simulator=20so=20CI=20green?= =?UTF-8?q?=20even=20when=20the=20binary=20is=20absent):=20BuildCliArgs=20?= =?UTF-8?q?composes=20port=20+=20plc=20+=20tag=20flags=20in=20the=20docume?= =?UTF-8?q?nted=20order;=20empty=20seed-tag=20list=20still=20emits=20port?= =?UTF-8?q?=20+=20plc;=20SeedTag.ToCliSpec=20handles=20both=202-segment=20?= =?UTF-8?q?scalar=20+=203-segment=20array;=20KnownProfiles.ForFamily=20ret?= =?UTF-8?q?urns=20expected=20--plc=20arg=20for=20every=20family=20(verifie?= =?UTF-8?q?s=20Micro800=20+=20GuardLogix=20both=20fall=20back=20to=20contr?= =?UTF-8?q?ollogix);=20KnownProfiles.All=20covers=20every=20AbCipPlcFamily?= =?UTF-8?q?=20enum=20value=20(regression=20guard=20=E2=80=94=20adding=20a?= =?UTF-8?q?=20new=20family=20without=20a=20profile=20fails=20this=20test);?= =?UTF-8?q?=20ControlLogix=20seeds=20every=20atomic=20type=20the=20driver?= =?UTF-8?q?=20supports;=20GuardLogix=20seeds=20at=20least=20one=20=5FS-suf?= =?UTF-8?q?fixed=20safety=20tag.=20Integration=20tests=20still=20skip=20cl?= =?UTF-8?q?eanly=20when=20ab=5Fserver=20isn't=20on=20PATH.=2011/11=20unit?= =?UTF-8?q?=20tests=20passing=20in=20this=20project=20(8=20new=20+=203=20p?= =?UTF-8?q?rior).=20Full=20Admin=20solution=20builds=200=20errors.=20docs/?= =?UTF-8?q?v2/test-data-sources.md=20gets=20a=20new=20"CI=20fixture"=20sub?= =?UTF-8?q?section=20under=20=C2=A72.Gotchas=20with=20the=20exact=20GitHub?= =?UTF-8?q?=20Actions=20YAML=20step=20=E2=80=94=20fetch=20the=20pinned=20l?= =?UTF-8?q?ibplctag=20release,=20SHA256-verify=20against=20a=20pinned=20ha?= =?UTF-8?q?sh=20recorded=20in=20the=20repo's=20CI=20lockfile=20(drift=20?= =?UTF-8?q?=3D=20fail=20closed),=20extract,=20append=20to=20PATH.=20The=20?= =?UTF-8?q?C#=20harness=20stays=20PATH-driven=20so=20dev-box=20installs=20?= =?UTF-8?q?(cmake=20+=20make=20from=20source)=20work=20identically=20to=20?= =?UTF-8?q?CI.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/test-data-sources.md | 29 ++++ .../AbCipReadSmokeTests.cs | 50 ++++--- .../AbServerFixture.cs | 58 ++++++-- .../AbServerProfile.cs | 134 ++++++++++++++++++ .../AbServerProfileTests.cs | 90 ++++++++++++ 5 files changed, 325 insertions(+), 36 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs diff --git a/docs/v2/test-data-sources.md b/docs/v2/test-data-sources.md index e56e9d9..af8a1ff 100644 --- a/docs/v2/test-data-sources.md +++ b/docs/v2/test-data-sources.md @@ -189,6 +189,35 @@ Modbus has no native String, DateTime, or Int64 — those rows are skipped on th - **ab_server tag-type coverage is finite** (BOOL, DINT, REAL, arrays, basic strings). UDTs and `Program:` scoping are not fully implemented. Document an "ab_server-supported tag set" in the harness and exclude the rest from default CI; UDT coverage moves to the Studio 5000 Emulate golden-box tier. - CIP has no native subscriptions, so polling behavior matches real hardware. +### CI fixture (task #180) + +The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` exposes two test-time contracts: + +- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture` wrapper per family. +- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately. + +**CI step (intended — fleet-ops to wire):** + +```yaml +# GitHub Actions step placed before `dotnet test`: +- name: Fetch ab_server + shell: pwsh + run: | + $ver = '' + $url = "https://github.com/libplctag/libplctag/releases/download/$ver/ab_server-windows-x64.zip" + Invoke-WebRequest $url -OutFile $env:RUNNER_TEMP/ab_server.zip + # SHA256 check against a pinned value recorded in this repo's CI lockfile — drift = fail closed + $expected = '' + $actual = (Get-FileHash -Algorithm SHA256 $env:RUNNER_TEMP/ab_server.zip).Hash + if ($expected -ne $actual) { throw "ab_server hash mismatch" } + Expand-Archive $env:RUNNER_TEMP/ab_server.zip -DestinationPath $env:RUNNER_TEMP/ab_server + echo "$env:RUNNER_TEMP/ab_server" >> $env:GITHUB_PATH +``` + +The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically. + +Tests without ab_server on PATH are marked `Skip` via `AbServerFactAttribute` / `AbServerTheoryAttribute`, so fresh-clone runs without the simulator still pass all unit suites in this project. + --- ## 3. Allen-Bradley Legacy (SLC 500 / MicroLogix, PCCC) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs index ebad86e..2d6457f 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs @@ -8,37 +8,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// End-to-end smoke tests that exercise the real libplctag stack 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). /// -/// -/// Intentionally minimal — per-family + per-capability coverage ships in PRs 9–12 once the -/// integration harness is CI-ready. This file exists at PR 3 time to prove the wire path -/// works end-to-end on developer boxes that have ab_server. -/// [Trait("Category", "Integration")] [Trait("Requires", "AbServer")] -public sealed class AbCipReadSmokeTests : IAsyncLifetime +public sealed class AbCipReadSmokeTests { - private readonly AbServerFixture _fixture = new(); + public static IEnumerable Profiles => + KnownProfiles.All.Select(p => new object[] { p }); - public async ValueTask InitializeAsync() => await _fixture.InitializeAsync(); - public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); - - [AbServerFact] - public async Task Driver_reads_DInt_from_ab_server() + [AbServerTheory] + [MemberData(nameof(Profiles))] + public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile) { - var drv = new AbCipDriver(new AbCipDriverOptions + var fixture = new AbServerFixture(profile); + await fixture.InitializeAsync(); + try { - Devices = [new AbCipDeviceOptions($"ab://127.0.0.1:{_fixture.Port}/1,0", AbCipPlcFamily.ControlLogix)], - Tags = [new AbCipTagDefinition("Counter", $"ab://127.0.0.1:{_fixture.Port}/1,0", "TestDINT", AbCipDataType.DInt)], - Timeout = TimeSpan.FromSeconds(5), - }, "drv-smoke"); + var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)], + Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)], + Timeout = TimeSpan.FromSeconds(5), + }, $"drv-smoke-{profile.Family}"); - await drv.InitializeAsync("{}", CancellationToken.None); - var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); + await drv.InitializeAsync("{}", CancellationToken.None); + var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); - snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); - drv.GetHealth().State.ShouldBe(DriverState.Healthy); + snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); - await drv.ShutdownAsync(CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + finally + { + await fixture.DisposeAsync(); + } } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs index 400b660..0a20229 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs @@ -6,28 +6,44 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// Shared fixture that starts libplctag's ab_server simulator in the background for -/// the duration of an integration test collection. Binary is expected on PATH; the per-test -/// JSON profile is passed via --config. +/// the duration of an integration test collection. The fixture takes an +/// (see ) so each AB family — ControlLogix, +/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right --plc +/// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step +/// that downloads the pinned Windows build from libplctag GitHub Releases before +/// dotnet test — see docs/v2/test-data-sources.md §2.CI for the exact step. /// /// -/// ab_server is a C binary shipped in the same repo as libplctag (see -/// test-data-sources.md §2 and plan decision #99). On a developer workstation it's -/// built once from source and placed on PATH; in CI we intend to publish a prebuilt Windows -/// x64 binary as a GitHub release asset in a follow-up PR so the fixture can download + -/// extract it at setup time. Until then every test in this project is skipped when -/// ab_server is not locatable. +/// ab_server is a C binary shipped in libplctag's repo (MIT). On developer +/// workstations it's built once from source and placed on PATH; on CI the workflow file +/// fetches a version-pinned prebuilt + stages it. Tests skip (via +/// ) when the binary is not on PATH so a fresh clone +/// without the simulator still gets a green unit-test run. /// -/// Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix) -/// ship under Profiles/ and drive the simulator's tag shape — this is where the -/// UDT + Program-scope coverage gap will be filled by the hand-rolled stub in PR 6. +/// Per-family profiles live in . When a test wants a +/// specific family, instantiate the fixture with that profile — either via a +/// derived type or by constructing directly in a +/// parametric test (the latter is used below for the smoke suite). /// public sealed class AbServerFixture : IAsyncLifetime { private Process? _proc; - public int Port { get; } = 44818; + /// The profile the simulator was started with. Same instance the driver-side options should use. + public AbServerProfile Profile { get; } + public int Port { get; } public bool IsAvailable { get; private set; } + public AbServerFixture() : this(KnownProfiles.ControlLogix, AbServerProfile.DefaultPort) { } + + public AbServerFixture(AbServerProfile profile) : this(profile, AbServerProfile.DefaultPort) { } + + public AbServerFixture(AbServerProfile profile, int port) + { + Profile = profile ?? throw new ArgumentNullException(nameof(profile)); + Port = port; + } + public ValueTask InitializeAsync() => InitializeAsync(default); public ValueTask DisposeAsync() => DisposeAsync(default); @@ -45,7 +61,7 @@ public sealed class AbServerFixture : IAsyncLifetime StartInfo = new ProcessStartInfo { FileName = binary, - Arguments = $"--port {Port} --plc controllogix", + Arguments = Profile.BuildCliArgs(Port), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -75,7 +91,7 @@ public sealed class AbServerFixture : IAsyncLifetime /// /// Locate ab_server on PATH. Returns null when missing — tests that - /// depend on it should use so CI runs without the binary + /// depend on it should use so CI runs without the binary /// simply skip rather than fail. /// public static string? LocateBinary() @@ -107,3 +123,17 @@ public sealed class AbServerFactAttribute : FactAttribute Skip = "ab_server not on PATH; install libplctag test binaries to run."; } } + +/// +/// [Theory]-equivalent that skips when ab_server is not on PATH. Pair with +/// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory row per +/// profile so a single test covers all four families. +/// +public sealed class AbServerTheoryAttribute : TheoryAttribute +{ + public AbServerTheoryAttribute() + { + if (AbServerFixture.LocateBinary() is null) + Skip = "ab_server not on PATH; install libplctag test binaries to run."; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs new file mode 100644 index 0000000..8d74e37 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs @@ -0,0 +1,134 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Per-family provisioning profile for the ab_server simulator. Instead of hard-coding +/// one fixture shape + one set of CLI args, each integration test picks a profile matching the +/// family it wants to exercise — ControlLogix / CompactLogix / Micro800 / GuardLogix. The +/// profile composes the CLI arg list passed to ab_server + the tag-definition set the +/// driver uses to address the simulator's pre-provisioned tags. +/// +/// OtOpcUa driver family this profile targets. Drives +/// + driver-side connection-parameter profile +/// (ConnectionSize, unconnected-only, etc.) per decision #9. +/// The value passed to ab_server --plc <arg>. Some families +/// map 1:1 (ControlLogix → "controllogix"); Micro800/GuardLogix fall back to the family whose +/// CIP behavior ab_server emulates most faithfully (see per-profile Notes). +/// Tags to preseed on the simulator via --tag <name>:<type>[:<size>] +/// flags. Each entry becomes one CLI arg; the driver-side +/// list references the same names so tests can read/write without walking the @tags surface +/// first. +/// Operator-facing description of what the profile covers + any quirks. +public sealed record AbServerProfile( + AbCipPlcFamily Family, + string AbServerPlcArg, + IReadOnlyList SeedTags, + string Notes) +{ + /// Default port — every profile uses the same so parallel-runs-of-different-families + /// would conflict (deliberately — one simulator per test collection is the model). + public const int DefaultPort = 44818; + + /// Compose the full ab_server CLI arg string for + /// . + public string BuildCliArgs(int port) + { + var parts = new List + { + "--port", port.ToString(), + "--plc", AbServerPlcArg, + }; + foreach (var tag in SeedTags) + { + parts.Add("--tag"); + parts.Add(tag.ToCliSpec()); + } + return string.Join(' ', parts); + } +} + +/// One tag the simulator pre-creates. ab_server spec format: +/// <name>:<type>[:<array_size>]. +public sealed record AbServerSeedTag(string Name, string AbServerType, int? ArraySize = null) +{ + public string ToCliSpec() => ArraySize is { } n ? $"{Name}:{AbServerType}:{n}" : $"{Name}:{AbServerType}"; +} + +/// Canonical profiles covering every AB CIP family shipped in PRs 9–12. +public static class KnownProfiles +{ + /// + /// ControlLogix — the widest-coverage family: full CIP capabilities, generous connection + /// size, @tags controller-walk supported. Tag shape covers atomic types + a Program-scoped + /// tag so the Symbol-Object decoder's scope-split path is exercised. + /// + public static readonly AbServerProfile ControlLogix = new( + Family: AbCipPlcFamily.ControlLogix, + AbServerPlcArg: "controllogix", + SeedTags: new AbServerSeedTag[] + { + new("TestDINT", "DINT"), + new("TestREAL", "REAL"), + new("TestBOOL", "BOOL"), + new("TestSINT", "SINT"), + new("TestString","STRING"), + new("TestArray", "DINT", ArraySize: 16), + }, + Notes: "Widest-coverage profile — PR 9 baseline. UDTs live in PR 6-shipped Template Object tests; ab_server lacks full UDT emulation."); + + /// + /// CompactLogix — narrower ConnectionSize quirk exercised here. ab_server doesn't + /// enforce the narrower limit itself; the driver-side profile caps it + this simulator + /// honors whatever the client asks for. Tag set is a subset of ControlLogix. + /// + public static readonly AbServerProfile CompactLogix = new( + Family: AbCipPlcFamily.CompactLogix, + AbServerPlcArg: "compactlogix", + SeedTags: new AbServerSeedTag[] + { + new("TestDINT", "DINT"), + new("TestREAL", "REAL"), + new("TestBOOL", "BOOL"), + }, + Notes: "Narrower ConnectionSize than ControlLogix — driver-side profile caps it per PR 10. Tag set mirrors the CompactLogix atomic subset."); + + /// + /// Micro800 — unconnected-only family. ab_server has no explicit micro800 plc mode so + /// we fall back to the nearest CIP-compatible emulation (controllogix) + document the + /// discrepancy. Driver-side path enforcement (empty routing path, unconnected-only + /// sessions) is exercised in the unit suite; this integration profile smoke-tests that + /// reads work end-to-end against the unconnected path. + /// + public static readonly AbServerProfile Micro800 = new( + Family: AbCipPlcFamily.Micro800, + AbServerPlcArg: "controllogix", // ab_server lacks dedicated micro800 mode — see Notes + SeedTags: new AbServerSeedTag[] + { + new("TestDINT", "DINT"), + new("TestREAL", "REAL"), + }, + Notes: "ab_server has no --plc micro800 — falls back to controllogix emulation. Driver side still enforces empty path + unconnected-only per PR 11. Real Micro800 coverage requires a 2080 on a lab rig."); + + /// + /// GuardLogix — safety-capable ControlLogix variant with ViewOnly safety tags. ab_server + /// doesn't emulate the safety subsystem; we preseed a safety-suffixed name (_S) so + /// the driver's read-only classification path is exercised against a real tag. + /// + public static readonly AbServerProfile GuardLogix = new( + Family: AbCipPlcFamily.GuardLogix, + AbServerPlcArg: "controllogix", + SeedTags: new AbServerSeedTag[] + { + new("TestDINT", "DINT"), + new("SafetyDINT_S", "DINT"), // _S-suffixed → driver classifies as safety-ViewOnly per PR 12 + }, + Notes: "ab_server has no safety subsystem — this profile emulates the tag-naming contract. Real safety-lock behavior requires a physical GuardLogix 1756-L8xS rig."); + + public static IReadOnlyList All { get; } = + new[] { ControlLogix, CompactLogix, Micro800, GuardLogix }; + + public static AbServerProfile ForFamily(AbCipPlcFamily family) => + All.FirstOrDefault(p => p.Family == family) + ?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family."); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs new file mode 100644 index 0000000..06557d4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs @@ -0,0 +1,90 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Pure-unit tests for the profile → CLI arg composition. Runs without ab_server +/// on PATH so CI without the binary still exercises these contracts + catches any +/// profile-definition drift (e.g. a typo in --plc mapping would silently make the +/// simulator boot with the wrong family). +/// +[Trait("Category", "Unit")] +public sealed class AbServerProfileTests +{ + [Fact] + public void BuildCliArgs_Emits_Port_And_Plc_And_TagFlags() + { + var profile = new AbServerProfile( + Family: AbCipPlcFamily.ControlLogix, + AbServerPlcArg: "controllogix", + SeedTags: new AbServerSeedTag[] + { + new("A", "DINT"), + new("B", "REAL"), + }, + Notes: "test"); + + profile.BuildCliArgs(44818).ShouldBe("--port 44818 --plc controllogix --tag A:DINT --tag B:REAL"); + } + + [Fact] + public void BuildCliArgs_NoSeedTags_Emits_Just_Port_And_Plc() + { + var profile = new AbServerProfile( + AbCipPlcFamily.ControlLogix, "controllogix", [], "empty"); + + profile.BuildCliArgs(5000).ShouldBe("--port 5000 --plc controllogix"); + } + + [Fact] + public void AbServerSeedTag_ArraySize_FormatsAsThirdSegment() + { + new AbServerSeedTag("TestArray", "DINT", ArraySize: 16) + .ToCliSpec().ShouldBe("TestArray:DINT:16"); + } + + [Fact] + public void AbServerSeedTag_NoArraySize_TwoSegments() + { + new AbServerSeedTag("TestScalar", "REAL") + .ToCliSpec().ShouldBe("TestScalar:REAL"); + } + + [Theory] + [InlineData(AbCipPlcFamily.ControlLogix, "controllogix")] + [InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")] + [InlineData(AbCipPlcFamily.Micro800, "controllogix")] // falls back — ab_server lacks dedicated mode + [InlineData(AbCipPlcFamily.GuardLogix, "controllogix")] // falls back — ab_server lacks safety subsystem + public void KnownProfiles_ForFamily_Returns_Expected_AbServerPlcArg(AbCipPlcFamily family, string expected) + { + KnownProfiles.ForFamily(family).AbServerPlcArg.ShouldBe(expected); + } + + [Fact] + public void KnownProfiles_All_Covers_Every_Family() + { + var covered = KnownProfiles.All.Select(p => p.Family).ToHashSet(); + foreach (var family in Enum.GetValues()) + covered.ShouldContain(family, $"Family {family} is missing a KnownProfiles entry."); + } + + [Fact] + public void KnownProfiles_ControlLogix_Includes_AllAtomicTypes() + { + var tags = KnownProfiles.ControlLogix.SeedTags.Select(t => t.AbServerType).ToHashSet(); + tags.ShouldContain("DINT"); + tags.ShouldContain("REAL"); + tags.ShouldContain("BOOL"); + tags.ShouldContain("SINT"); + tags.ShouldContain("STRING"); + } + + [Fact] + public void KnownProfiles_GuardLogix_SeedsSafetySuffixedTag() + { + KnownProfiles.GuardLogix.SeedTags + .ShouldContain(t => t.Name.EndsWith("_S"), "GuardLogix profile must seed at least one _S-suffixed tag for safety-classification coverage."); + } +}