using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake /// runtime records ReadCount + surfaces member values by byte offset so we can assert /// both "one read per parent UDT" and "each member decoded at the correct offset." /// [Trait("Category", "Unit")] public sealed class AbCipDriverWholeUdtReadTests { private const string Device = "ab://10.0.0.5/1,0"; private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = tags, }; return (new AbCipDriver(opts, "drv-1", factory), factory); } private static AbCipTagDefinition MotorUdt() => new( "Motor", Device, "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0 new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4 ]); [Fact] public async Task Two_members_of_same_udt_trigger_one_parent_read() { var (drv, factory) = NewDriver(MotorUdt()); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); snapshots.Count.ShouldBe(2); snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); // Factory should have created ONE runtime (for the parent "Motor") + issued ONE read. // Without the optimization two runtimes (one per member) + two reads would appear. factory.Tags.Count.ShouldBe(1); factory.Tags.ShouldContainKey("Motor"); factory.Tags["Motor"].ReadCount.ShouldBe(1); } [Fact] public async Task Each_member_decodes_at_its_own_offset() { var (drv, factory) = NewDriver(MotorUdt()); await drv.InitializeAsync("{}", CancellationToken.None); // Arrange the offset-keyed values before the read fires — the planner places // Speed at offset 0 (DInt) and Torque at offset 4 (Real). // The fake records CreationParams so we fetch it up front by the parent name. var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); // The factory creates the runtime inside ReadAsync; we need to set the offset map // AFTER creation. Easier path: create the runtime on demand by reading once then // re-arming. Instead: seed via a pre-read by constructing the fake in the factory's // customise hook. var snapshots = await snapshotsTask; // First run establishes the runtime + gives the fake a chance to hold its reference. factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); snapshots[0].Value.ShouldBe(1234); snapshots[1].Value.ShouldBe(9.5f); } [Fact] public async Task Parent_read_failure_stamps_every_grouped_member_Bad() { var (drv, factory) = NewDriver(MotorUdt()); await drv.InitializeAsync("{}", CancellationToken.None); // Prime runtime existence via a first (successful) read so we can flip it to error. await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); snapshots.Count.ShouldBe(2); snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good); snapshots[0].Value.ShouldBeNull(); snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good); snapshots[1].Value.ShouldBeNull(); } [Fact] public async Task Mixed_batch_groups_udt_and_falls_back_atomics() { var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt); var (drv, factory) = NewDriver(MotorUdt(), plain); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync( ["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None); snapshots.Count.ShouldBe(3); // Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total. factory.Tags.Count.ShouldBe(2); factory.Tags.ShouldContainKey("Motor"); factory.Tags.ShouldContainKey("PlainDint"); factory.Tags["Motor"].ReadCount.ShouldBe(1); factory.Tags["PlainDint"].ReadCount.ShouldBe(1); } [Fact] public async Task Single_member_of_Udt_uses_per_tag_read_path() { // One member of a UDT doesn't benefit from grouping — the planner demotes to // fallback so the member-level runtime (distinct from the parent runtime) is used, // matching pre-#194 behavior. var (drv, factory) = NewDriver(MotorUdt()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Motor.Speed"], CancellationToken.None); factory.Tags.ShouldContainKey("Motor.Speed"); factory.Tags.ShouldNotContainKey("Motor"); } }