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) { } } } }