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_UDT — Status 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) { }
}
}
}