using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [Trait("Category", "Unit")] public sealed class AbCipUdtReadPlannerTests { private const string Device = "ab://10.0.0.1/1,0"; [Fact] public void Groups_Two_Members_Of_The_Same_Udt_Parent() { var tags = BuildUdtTagMap(out var _); var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags); plan.Groups.Count.ShouldBe(1); plan.Groups[0].ParentName.ShouldBe("Motor"); plan.Groups[0].Members.Count.ShouldBe(2); plan.Fallbacks.Count.ShouldBe(0); } [Fact] public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path() { // Reading just one member of a UDT gains nothing from grouping — one whole-UDT read // vs one member read is equivalent cost but more client-side work. Planner demotes. var tags = BuildUdtTagMap(out var _); var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags); plan.Groups.ShouldBeEmpty(); plan.Fallbacks.Count.ShouldBe(1); plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed"); } [Fact] public void Unknown_References_Fall_Back_Without_Affecting_Groups() { var tags = BuildUdtTagMap(out var _); var plan = AbCipUdtReadPlanner.Build( new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags); plan.Groups.Count.ShouldBe(1); plan.Groups[0].Members.Count.ShouldBe(2); plan.Fallbacks.Count.ShouldBe(2); plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist"); plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember"); } [Fact] public void Atomic_Top_Level_Tag_Falls_Back_Untouched() { var tags = BuildUdtTagMap(out var _); tags = new Dictionary(tags, StringComparer.OrdinalIgnoreCase) { ["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt), }; var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags); plan.Groups.Count.ShouldBe(1); plan.Fallbacks.Count.ShouldBe(1); plan.Fallbacks[0].Reference.ShouldBe("PlainDint"); } [Fact] public void Udt_With_Bool_Member_Does_Not_Group() { // Any BOOL in the declared members disqualifies the group — offset rules for BOOL // can't be determined from declaration alone (Logix packs them into a hidden host // byte). Fallback path reads each member individually. var members = new[] { new AbCipStructureMember("Run", AbCipDataType.Bool), new AbCipStructureMember("Speed", AbCipDataType.DInt), }; var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members); var tags = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Motor"] = parent, ["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool), ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), }; var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags); plan.Groups.ShouldBeEmpty(); plan.Fallbacks.Count.ShouldBe(2); } [Fact] public void Original_Indices_Preserved_For_Out_Of_Order_Batches() { var tags = BuildUdtTagMap(out var _); var plan = AbCipUdtReadPlanner.Build( new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags); // Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so // ReadAsync can write decoded values back at the right output slot. plan.Groups.ShouldHaveSingleItem(); var group = plan.Groups[0]; group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed"); group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque"); plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other"); plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist"); } private static Dictionary BuildUdtTagMap(out AbCipTagDefinition parent) { var members = new[] { new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Torque", AbCipDataType.Real), }; parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members); return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Motor"] = parent, ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), ["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real), }; } }