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>
216 lines
8.5 KiB
C#
216 lines
8.5 KiB
C#
using libplctag;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipDriverReadTests
|
|
{
|
|
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var opts = new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
Tags = tags,
|
|
};
|
|
var drv = new AbCipDriver(opts, "drv-1", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
|
{
|
|
var (drv, _) = NewDriver();
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
|
snapshots.Single().Value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var opts = new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
|
|
};
|
|
var drv = new AbCipDriver(opts, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_DInt_read_returns_Good_with_value()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Customise the fake before the first read so the tag returns 4200.
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
|
|
|
|
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
snapshots.Single().Value.ShouldBe(4200);
|
|
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
|
|
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repeat_read_reuses_runtime_without_reinitialise()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
|
|
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
|
|
|
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
|
|
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound };
|
|
|
|
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
|
snapshots.Single().Value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exception_during_read_surfaces_BadCommunicationError()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
|
|
|
|
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
|
snapshots.Single().Value.ShouldBeNull();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Batched_reads_preserve_order_and_per_tag_status()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
|
|
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => p.TagName switch
|
|
{
|
|
"A" => new FakeAbCipTag(p) { Value = 42 },
|
|
"B" => new FakeAbCipTag(p) { Value = 3.14f },
|
|
_ => new FakeAbCipTag(p) { Value = "hello" },
|
|
};
|
|
|
|
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
|
|
snapshots.Count.ShouldBe(3);
|
|
snapshots[0].Value.ShouldBe(42);
|
|
snapshots[1].Value.ShouldBe(3.14f);
|
|
snapshots[2].Value.ShouldBe("hello");
|
|
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_read_marks_health_Healthy()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
|
|
|
|
await drv.ReadAsync(["Pressure"], CancellationToken.None);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TagCreateParams_are_built_from_device_and_profile()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["Counter"], CancellationToken.None);
|
|
|
|
var p = factory.Tags["Program:P.Counter"].CreationParams;
|
|
p.Gateway.ShouldBe("10.0.0.5");
|
|
p.Port.ShouldBe(44818);
|
|
p.CipPath.ShouldBe("1,0");
|
|
p.LibplctagPlcAttribute.ShouldBe("controllogix");
|
|
p.TagName.ShouldBe("Program:P.Counter");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancellation_propagates_from_read()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p)
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new OperationCanceledException(),
|
|
};
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => drv.ReadAsync(["Slow"], cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ShutdownAsync_disposes_each_tag_runtime()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
|
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
|
|
factory.Tags["A"].Disposed.ShouldBeTrue();
|
|
factory.Tags["B"].Disposed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
|
|
|
|
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
|
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
|
}
|
|
}
|