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";
/// Verifies two members of the same UDT parent are grouped.
[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, enableDeclarationOnlyGrouping: true);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].ParentName.ShouldBe("Motor");
plan.Groups[0].Members.Count.ShouldBe(2);
plan.Fallbacks.Count.ShouldBe(0);
}
/// Verifies single member reference falls back to per-tag path.
[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, enableDeclarationOnlyGrouping: true);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
}
/// Verifies unknown references fall back without affecting groups.
[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, enableDeclarationOnlyGrouping: true);
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");
}
/// Verifies atomic top-level tags fall back untouched.
[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, enableDeclarationOnlyGrouping: true);
plan.Groups.Count.ShouldBe(1);
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
}
/// Verifies UDT with bool member does not group.
[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, enableDeclarationOnlyGrouping: true);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(2);
}
/// Verifies original indices are preserved for out-of-order batches.
[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, enableDeclarationOnlyGrouping: true);
// 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),
};
}
}