fix(driver-abcip): resolve High code-review findings (Driver.AbCip-001, -002, -003, -008)

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>
This commit is contained in:
Joseph Doherty
2026-05-22 06:40:54 -04:00
parent 5197b6c237
commit 8a7668c678
14 changed files with 428 additions and 63 deletions

View File

@@ -82,6 +82,9 @@ public sealed class AbCipAlarmProjectionTests
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
EnableDeclarationOnlyUdtGrouping = true,
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -122,6 +125,9 @@ public sealed class AbCipAlarmProjectionTests
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
EnableDeclarationOnlyUdtGrouping = true,
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -159,6 +165,9 @@ public sealed class AbCipAlarmProjectionTests
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
EnableDeclarationOnlyUdtGrouping = true,
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);

View File

@@ -0,0 +1,191 @@
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;
/// <summary>
/// Regression tests for the High code-review findings Driver.AbCip-001 / -003 / -008.
/// (Driver.AbCip-002 is covered by <see cref="AbCipStatusMapperTests"/>.)
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDriverCodeReviewRegressionTests
{
private const string Device = "ab://10.0.0.5/1,0";
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
[Fact]
public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json()
{
// Constructed with NO devices/tags — the JSON is the only source of config.
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
const string json = """
{
"Devices": [ { "HostAddress": "ab://10.0.0.9/1,0", "PlcFamily": "ControlLogix" } ],
"Tags": [ { "Name": "Speed", "DeviceHostAddress": "ab://10.0.0.9/1,0",
"TagPath": "Speed", "DataType": "DInt" } ]
}
""";
await drv.InitializeAsync(json, CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetDeviceState("ab://10.0.0.9/1,0").ShouldNotBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task ReinitializeAsync_with_a_changed_config_json_picks_up_the_new_device()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Device).ShouldNotBeNull();
// Reinitialize with a JSON that names a DIFFERENT device — the change must take effect
// instead of being silently discarded (Driver.AbCip-001).
const string changed = """
{ "Devices": [ { "HostAddress": "ab://10.0.0.99/1,0" } ] }
""";
await drv.ReinitializeAsync(changed, CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetDeviceState("ab://10.0.0.99/1,0").ShouldNotBeNull();
drv.GetDeviceState(Device).ShouldBeNull();
}
[Fact]
public async Task InitializeAsync_with_blank_json_keeps_construction_time_options()
{
// The test seam: a driver constructed with explicit options + handed "{}" must keep
// those options (otherwise every fake-backed unit test would lose its config).
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetDeviceState(Device).ShouldNotBeNull();
}
// ---- Driver.AbCip-003 — declaration-only whole-UDT grouping is opt-in ----
[Fact]
public async Task Whole_udt_grouping_is_off_by_default_so_members_read_per_tag()
{
// Default options — EnableDeclarationOnlyUdtGrouping is false. Reading two members of
// a UDT must NOT collapse into a single declaration-order whole-UDT read, because the
// controller may not lay members out in declaration order.
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
// Each member got its own per-tag runtime; the parent "Motor" runtime was never created.
factory.Tags.ShouldContainKey("Motor.Speed");
factory.Tags.ShouldContainKey("Motor.Torque");
factory.Tags.ShouldNotContainKey("Motor");
}
[Fact]
public void Planner_forms_no_groups_when_declaration_only_grouping_is_disabled()
{
var members = new[]
{
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
};
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Motor"] = new("Motor", Device, "Motor", AbCipDataType.Structure, Members: members),
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
};
var plan = AbCipUdtReadPlanner.Build(
["Motor.Speed", "Motor.Torque"], tags, enableDeclarationOnlyGrouping: false);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(2);
}
// ---- Driver.AbCip-008 — ShutdownAsync awaits probe loops; reads are concurrency-safe ----
[Fact]
public async Task ShutdownAsync_awaits_the_probe_loop_before_returning()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTag",
Interval = TimeSpan.FromMilliseconds(20),
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Give the probe loop a moment to actually start spinning.
await Task.Delay(60);
// Must complete cleanly — no ObjectDisposedException from a loop racing a disposed CTS.
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
drv.DeviceCount.ShouldBe(0);
}
[Fact]
public async Task ShutdownAsync_is_idempotent()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
}
[Fact]
public async Task Concurrent_first_reads_of_the_same_tag_do_not_corrupt_the_runtime_cache()
{
// Two concurrent ReadAsync calls that both miss the runtime cache must not throw and
// must not leave the device with two un-disposed runtimes for one tag (Driver.AbCip-008
// ConcurrentDictionary + TryAdd loser-disposal).
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var reads = Enumerable.Range(0, 16)
.Select(_ => drv.ReadAsync(["Speed"], CancellationToken.None))
.ToArray();
var allResults = await Task.WhenAll(reads);
foreach (var result in allResults)
result.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
}

View File

@@ -1,3 +1,4 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -88,7 +89,7 @@ public sealed class AbCipDriverReadTests
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 = -14 /* PLCTAG_ERR_NOT_FOUND */ };
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound };
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);

View File

@@ -20,6 +20,9 @@ public sealed class AbCipDriverWholeUdtReadTests
{
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);
}

View File

@@ -1,3 +1,4 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -85,7 +86,7 @@ public sealed class AbCipDriverWriteTests
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorTimeout };
var results = await drv.WriteAsync(
[new WriteRequest("Broken", 1)], CancellationToken.None);

View File

@@ -1,3 +1,4 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -25,17 +26,37 @@ public sealed class AbCipStatusMapperTests
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
}
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
[Theory]
[InlineData(0, AbCipStatusMapper.Good)]
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
[InlineData(Status.ErrorTimeout, AbCipStatusMapper.BadTimeout)]
[InlineData(Status.ErrorNotFound, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(Status.ErrorNotAllowed, AbCipStatusMapper.BadNotWritable)]
[InlineData(Status.ErrorOutOfBounds, AbCipStatusMapper.BadOutOfRange)]
[InlineData(Status.ErrorBadConnection, AbCipStatusMapper.BadCommunicationError)]
[InlineData(Status.ErrorBadGateway, AbCipStatusMapper.BadCommunicationError)]
[InlineData(Status.ErrorUnsupported, AbCipStatusMapper.BadNotSupported)]
[InlineData(Status.ErrorNoMem, AbCipStatusMapper.BadCommunicationError)] // unmapped → generic comms
public void MapLibplctagStatus_maps_real_enum_members(Status status, uint expected)
{
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
// The int overload must agree — it is the seam IAbCipTagRuntime.GetStatus() drives.
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
}
[Fact]
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
{
// Regression for Driver.AbCip-002: a real timeout used to fall through to
// BadCommunicationError because the code matched the wrong integer (-5).
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout)
.ShouldBe(AbCipStatusMapper.BadTimeout);
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
.ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
}
}

View File

@@ -12,7 +12,7 @@ public sealed class AbCipUdtReadPlannerTests
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);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags, enableDeclarationOnlyGrouping: true);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].ParentName.ShouldBe("Motor");
@@ -26,7 +26,7 @@ public sealed class AbCipUdtReadPlannerTests
// 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);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(1);
@@ -38,7 +38,7 @@ public sealed class AbCipUdtReadPlannerTests
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags, enableDeclarationOnlyGrouping: true);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].Members.Count.ShouldBe(2);
@@ -55,7 +55,7 @@ public sealed class AbCipUdtReadPlannerTests
{
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags, enableDeclarationOnlyGrouping: true);
plan.Groups.Count.ShouldBe(1);
plan.Fallbacks.Count.ShouldBe(1);
@@ -82,7 +82,7 @@ public sealed class AbCipUdtReadPlannerTests
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(2);
@@ -93,7 +93,7 @@ public sealed class AbCipUdtReadPlannerTests
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
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.