using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; /// /// PR abcip-3.3 — golden-box-tier MultiPacket read-strategy test against Logix Emulate. /// Exercises the sparse-UDT case the strategy is designed for: a 50-member UDT instance /// where the OPC UA client subscribed to 5 members. Asserts the driver routes the read /// through the MultiPacket planner ( /// counter increments) and returns Good StatusCodes for every member. /// /// /// Required Emulate project state (see LogixProject/README.md for /// the L5X export that seeds this; ship the project once Emulate is on the integration /// host): /// /// UDT Tank_50 with 50 DINT members M0..M49 — a deliberately /// oversized UDT so a 5-member subscription is sparse enough for the /// default of 0.25 to /// pick MultiPacket. /// Controller-scope tag Tank1 : Tank_50 with each M{i} seeded to /// i * 10 so each subscribed member returns a distinct value. /// /// Runs only when AB_SERVER_PROFILE=emulate. With the default ab_server the /// test skips cleanly — ab_server lacks UDT / Multi-Service-Packet emulation depth so a /// wire-level pass against it would be vacuous regardless. Note: the libplctag .NET /// wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling, so the /// driver's MultiPacket runtime today issues N member reads sequentially. The planner-tier /// dispatch is what's under test here — the wire-level bundling lands when the upstream /// wrapper exposes the 0x0A service primitive (see /// docs/drivers/AbCip-Performance.md §"Read strategy"). /// [Collection("AbServerEmulate")] [Trait("Category", "Integration")] [Trait("Tier", "Emulate")] public sealed class AbCipEmulateMultiPacketReadTests { [AbServerFact] public async Task Sparse_5_of_50_member_subscription_dispatches_through_MultiPacket() { 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."); // Build a 50-member declared UDT — the planner needs the full member set to compute // the subscribed-fraction in the Auto heuristic and to place MultiPacket member offsets. var members = new AbCipStructureMember[50]; for (var i = 0; i < 50; i++) members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt); var options = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions( HostAddress: $"ab://{endpoint}/1,0", PlcFamily: AbCipPlcFamily.ControlLogix, ReadStrategy: ReadStrategy.MultiPacket)], Tags = [ new AbCipTagDefinition( Name: "Tank1", DeviceHostAddress: $"ab://{endpoint}/1,0", TagPath: "Tank1", DataType: AbCipDataType.Structure, Members: members), ], Timeout = TimeSpan.FromSeconds(5), }; await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-multipacket-smoke"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Sparse pick: 5 of 50 = 0.10 < default threshold 0.25 → MultiPacket planner. Force // the strategy explicitly above so the test isn't sensitive to threshold drift. var refs = new[] { "Tank1.M0", "Tank1.M3", "Tank1.M7", "Tank1.M22", "Tank1.M49" }; var snapshots = await drv.ReadAsync(refs, TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(5); foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good); // Plan-stats counter assertion — the device-level counter increments once per parent // UDT routed through the MultiPacket path. Sibling counter for WholeUdt must stay zero. var deviceState = drv.GetDeviceState($"ab://{endpoint}/1,0"); deviceState.ShouldNotBeNull(); deviceState!.MultiPacketGroupsExecuted.ShouldBeGreaterThan(0); deviceState.WholeUdtGroupsExecuted.ShouldBe(0); // Sanity-check the seeded values land at the right indices: M{i} == i * 10 in the // emulate fixture's startup routine. Convert.ToInt32(snapshots[0].Value).ShouldBe(0); Convert.ToInt32(snapshots[1].Value).ShouldBe(30); Convert.ToInt32(snapshots[2].Value).ShouldBe(70); Convert.ToInt32(snapshots[3].Value).ShouldBe(220); Convert.ToInt32(snapshots[4].Value).ShouldBe(490); } }