From c8ab8fc3480128aa888103bd6a19833820ae86c8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 12:40:04 -0400 Subject: [PATCH] test(abcip): Emulate-tier nested-UDT live-gate smoke + docs (backlog #6) Add AbCipEmulateNestedUdtTests (skip-gated, AB_SERVER_PROFILE=emulate) to close the live-gate gap for nested-struct UDT discovery via CIP Template Object (class 0x6C) threaded by commits 3d8ce4e8/d203f31c. Compiles + skips cleanly against ab_server (no CIP Template Object service). Update docs/drivers/AbCip.md nested-struct section to record the shipped decode path, the Emulate-only live-gate, and offline unit coverage. --- docs/drivers/AbCip.md | 24 +++- .../Emulate/AbCipEmulateNestedUdtTests.cs | 132 ++++++++++++++++++ 2 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 3724670d..0a62c406 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -132,13 +132,23 @@ Variables — in addition to the UDT container tag itself. separate Variable node under the UDT's `Discovered/` sub-folder, addressable by its member path (e.g. `MyUdt.Temperature`, `MyUdt.Flags`). Top-level atomic member discovery is **functional in production**. -- **Nested struct members** — when a UDT member is itself a struct, the driver walks into it up - to a **depth cap of 8 levels**. Nested-struct expansion is a **documented deferral in - production**: the Template Object member block carries no nested template ID, so the - sub-shape cannot be re-fetched from the controller at discovery time. Nested struct leaves are - never mis-emitted — they are simply dropped (the parent member is omitted from discovery if it - is not atomic and its sub-shape is unavailable). Use pre-declared `Members` for nested structs - that must be individually addressed. +- **Nested struct members** — **shipped** (commits 3d8ce4e8 / d203f31c). When a UDT member is + itself a struct, the Template Object member block carries the nested UDT's template instance ID + in the low 12 bits of the member `info` field (struct flag `0x8000` set). The driver captures + this as `AbCipUdtMember.NestedTemplateId` and threads it into a second `FetchUdtShapeAsync` + call (`@udt/{id}`) so the nested struct's atomic leaves are expanded into dot-joined addressable + paths (e.g. `Motor1.Status.Code`, `Motor1.Status.Running`). Recursion is bounded by a + **depth cap of 8 levels**; members at or beyond the cap are dropped rather than emitted. + Nested struct leaves that resolve to no emittable atomic leaf produce no sub-folder (lazy + materialisation). + + **Live verification is Emulate-tier only** (`AB_SERVER_PROFILE=emulate` + a Logix Emulate + instance): `ab_server` (the default Docker simulator) does not implement the CIP Template + Object service (class 0x6C), so the nested-id fetch cannot be exercised against it. + See `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs` + for the skip-gated live-gate smoke (backlog #6). Offline unit coverage lives in + `AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam` + and `CipTemplateObjectDecoderTests`. ### Bare-container reads 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 new file mode 100644 index 00000000..0fcbb9a2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs @@ -0,0 +1,132 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; + +/// +/// Live-gate smoke for backlog #6: controller-discovered nested-struct UDT expansion +/// via Template Object (CIP class 0x6C) against Rockwell Studio 5000 Logix Emulate. +/// Closes the gap left by the offline unit suite +/// (AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam +/// and CipTemplateObjectDecoderTests) — those tests proved correctness with +/// golden-box byte buffers, but the Template Object service (class 0x6C) is NOT +/// implemented by the default ab_server Docker simulator. Emulate's firmware +/// speaks production CIP responses so the nested-template-id threading (commits +/// 3d8ce4e8 / d203f31c) gets end-to-end wire-level coverage here. +/// +/// +/// Required Emulate project state: +/// +/// UDT StatusNested_UDT with members Code : DINT, +/// Running : BOOL. +/// UDT MotorNested_UDT with members Speed : DINT, +/// Status : StatusNested_UDTStatus is the nested struct member +/// whose template instance id the driver must re-fetch via @udt/{id}. +/// Controller-scope tag MotorNested1 : MotorNested_UDT. +/// +/// Runs only when AB_SERVER_PROFILE=emulate. With ab_server +/// (the default tier), skips cleanly — ab_server lacks CIP Template Object +/// (class 0x6C) emulation so the nested-id fetch would time-out regardless. +/// The Emulate tier is hardware-gated (Rockwell per-seat license, Windows-only, +/// conflicts with Docker Desktop's WSL 2 backend) so a permanent skip in CI is expected +/// — see docs/drivers/AbServer-Test-Fixture.md §"Logix Emulate golden-box tier" +/// for the full rationale + the gap matrix this test closes. +/// +[Collection("AbServerEmulate")] +[Trait("Category", "Integration")] +[Trait("Tier", "Emulate")] +public sealed class AbCipEmulateNestedUdtTests +{ + /// + /// Verifies that a controller-discovered nested-struct UDT expands into atomic + /// 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() + { + AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); + + var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT") + ?? throw new InvalidOperationException( + "AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " + + "(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate."); + + var options = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")], + EnableControllerBrowse = true, + Timeout = TimeSpan.FromSeconds(10), + }; + + await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-nested-udt-smoke"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Drive controller discovery — the driver walks @tags, encounters MotorNested1 + // (a Structure symbol), reads its template via @udt/{motorTemplateId}, finds + // the nested Status member (struct flag set, nested template id in low 12 bits), + // then re-fetches @udt/{statusTemplateId} to expand the nested leaves. + var discovered = new DiscoveryRecorder(); + await drv.DiscoverAsync(discovered, TestContext.Current.CancellationToken); + + // The MotorNested1 UDT sub-folder is materialised under Discovered/. + discovered.FolderNames.ShouldContain("MotorNested1", + "expected a MotorNested1 sub-folder — check that the Emulate project has a " + + "MotorNested1 : MotorNested_UDT controller-scope tag and EnableControllerBrowse is true"); + + var byName = discovered.Variables.ToDictionary(v => v.FullName, v => v); + + // Top-level atomic member — Speed : DINT. + byName.ShouldContainKey("MotorNested1.Speed", + "MotorNested1.Speed not discovered — top-level atomic member expansion failed"); + + // Nested struct atomic leaves — Status.Code : DINT, Status.Running : BOOL. + // These prove the nested template-id fetch actually ran (ab_server can't reach here). + byName.ShouldContainKey("MotorNested1.Status.Code", + "MotorNested1.Status.Code not discovered — nested template-id fetch + expansion did not run"); + byName.ShouldContainKey("MotorNested1.Status.Running", + "MotorNested1.Status.Running not discovered — nested template-id fetch + expansion did not run"); + + // The nested struct's sub-folder is materialised (lazy, only when a leaf is emitted). + discovered.FolderNames.ShouldContain("MotorNested1.Status", + "expected a MotorNested1.Status sub-folder for the nested StatusNested_UDT expansion"); + + // The bare MotorNested1 Structure placeholder Variable must NOT be emitted — the + // container is replaced by the member fan-out, not emitted alongside it. + byName.ShouldNotContainKey("MotorNested1", + "MotorNested1 bare Structure placeholder should NOT be emitted when member fan-out succeeds"); + } + + // ---- minimal recorder wired to IAddressSpaceBuilder ---- + + private sealed class DiscoveryRecorder : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAddressSpaceBuilder + { + public List FolderNames { get; } = new(); + public List<(string FullName, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType DataType)> Variables { get; } = new(); + + public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAddressSpaceBuilder Folder(string browseName, string displayName) + { FolderNames.Add(browseName); return this; } + + public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IVariableHandle Variable( + string browseName, string displayName, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo info) + { + Variables.Add((info.FullName, info.DriverDataType)); + return new Nop(info.FullName); + } + + public void AddProperty(string _, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType __, object? ___) { } + + private sealed class Nop(string fullRef) : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IVariableHandle + { + public string FullReference => fullRef; + public ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmConditionSink MarkAsAlarmCondition( + ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmConditionInfo info) => new NullSink(); + } + + private sealed class NullSink : ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmConditionSink + { + public void OnTransition(ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmEventArgs args) { } + } + } +}