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>
134 lines
5.7 KiB
C#
134 lines
5.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// 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."
|
|
/// </summary>
|
|
[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,
|
|
// Whole-UDT grouping is opt-in (Driver.AbCip-003) — these tests exercise the
|
|
// grouping fast path, so they switch it on explicitly.
|
|
EnableDeclarationOnlyUdtGrouping = true,
|
|
};
|
|
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");
|
|
}
|
|
}
|