Driver.AbCip-001 — ReinitializeAsync silently discarded its config JSON. Extracted AbCipDriverFactoryExtensions.ParseOptions; InitializeAsync now re-parses a content-bearing driverConfigJson and replaces _options (and recreates the alarm projection), so a reinitialize with a changed config (new device/tag, changed timeout) actually takes effect. A blank or empty-object JSON keeps construction-time options for the unit-test seam. Driver.AbCip-002 — libplctag status mapping used wrong integer constants. MapLibplctagStatus now switches on the libplctag.NET Status enum members (Ok/Pending/ErrorTimeout/ErrorNotFound/ErrorNotAllowed/ErrorOutOfBounds/…) instead of hand-typed natives, so timeout/not-found/not-allowed/out-of-bounds get their specific OPC UA codes instead of all collapsing to BadCommunicationError. The int overload casts to Status to stay correct against the wrapper's contiguous renumbering. Driver.AbCip-003 — whole-UDT reads decoded members at declaration-order offsets, which Studio 5000 does not guarantee. Added the opt-in AbCipDriverOptions.EnableDeclarationOnlyUdtGrouping flag (default false); AbCipUdtReadPlanner.Build forms no groups when it is off, so by default every UDT member reads per-tag rather than at possibly-wrong offsets. Driver.AbCip-008 — probe loops were fire-and-forget and ShutdownAsync raced them. Each probe Task is stored on DeviceState.ProbeTask; ShutdownAsync now cancels every CTS, awaits each probe Task (10s timeout), then disposes the CTS and handles. DeviceState.Runtimes/ParentRuntimes are ConcurrentDictionary and the Ensure*RuntimeAsync paths use TryAdd, disposing the losing concurrent creator instead of leaking a native tag handle. Adds AbCipDriverCodeReviewRegressionTests and updates existing AbCip tests to the corrected status constants + opt-in grouping flag. AbCip driver + test project build clean; all 244 AbCip tests pass. (The full-solution build has pre-existing, unrelated Driver.Galaxy protobuf-generation errors in this worktree.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
5.2 KiB
C#
124 lines
5.2 KiB
C#
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, 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);
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[Fact]
|
|
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
|
|
{
|
|
var tags = BuildUdtTagMap(out var _);
|
|
tags = new Dictionary<string, AbCipTagDefinition>(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");
|
|
}
|
|
|
|
[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<string, AbCipTagDefinition>(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);
|
|
}
|
|
|
|
[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<string, AbCipTagDefinition> 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<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["Motor"] = parent,
|
|
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
|
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
|
|
};
|
|
}
|
|
}
|