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.
This commit is contained in:
+17
-7
@@ -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
|
||||
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (<c>AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam</c>
|
||||
/// and <c>CipTemplateObjectDecoderTests</c>) — those tests proved correctness with
|
||||
/// golden-box byte buffers, but the Template Object service (class 0x6C) is NOT
|
||||
/// implemented by the default <c>ab_server</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>UDT <c>StatusNested_UDT</c> with members <c>Code : DINT</c>,
|
||||
/// <c>Running : BOOL</c>.</item>
|
||||
/// <item>UDT <c>MotorNested_UDT</c> with members <c>Speed : DINT</c>,
|
||||
/// <c>Status : StatusNested_UDT</c> — <c>Status</c> is the nested struct member
|
||||
/// whose template instance id the driver must re-fetch via <c>@udt/{id}</c>.</item>
|
||||
/// <item>Controller-scope tag <c>MotorNested1 : MotorNested_UDT</c>.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With <c>ab_server</c>
|
||||
/// (the default tier), skips cleanly — <c>ab_server</c> lacks CIP Template Object
|
||||
/// (class 0x6C) emulation so the nested-id fetch would time-out regardless.</para>
|
||||
/// <para>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 <c>docs/drivers/AbServer-Test-Fixture.md</c> §"Logix Emulate golden-box tier"
|
||||
/// for the full rationale + the gap matrix this test closes.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateNestedUdtTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>@udt/{id}</c> CIP fetch against Emulate's firmware.
|
||||
/// </summary>
|
||||
[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<string> 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user