v3(b1-abcip): RawPath read-path for AbCip (multi-device)

Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.

TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
This commit is contained in:
Joseph Doherty
2026-07-15 20:18:28 -04:00
parent c379e246d0
commit c0379742bc
32 changed files with 819 additions and 783 deletions
@@ -9,20 +9,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
/// <see cref="DriverAttributeInfo.IsArray"/> / <see cref="DriverAttributeInfo.ArrayDim"/>
/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array
/// boxed as <see cref="object"/>; and the equipment-tag resolver threads
/// <c>arrayLength</c> from the TagConfig into the transient definition's element count so
/// an <c>isArray</c> equipment tag reads the whole array.
/// boxed as <see cref="object"/>; and the v3 tag mapper threads <c>arrayLength</c> from the
/// TagConfig into the definition's element count so an <c>isArray</c> tag reads the whole array.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayTests
{
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("ab://10.0.0.5/1,0")],
Tags = tags,
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(tags),
};
var drv = new AbCipDriver(opts, "drv-array", factory);
return (drv, factory);
@@ -36,8 +37,8 @@ public sealed class AbCipArrayTests
{
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10),
new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt));
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 10, IsArray: true),
new AbCipTagDefinition("Single", Device, "Single", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
@@ -57,10 +58,10 @@ public sealed class AbCipArrayTests
{
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(
new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]));
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -83,7 +84,7 @@ public sealed class AbCipArrayTests
public async Task Array_DInt_read_returns_typed_int_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4));
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 });
@@ -101,7 +102,7 @@ public sealed class AbCipArrayTests
public async Task Array_Real_read_returns_typed_float_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3));
new AbCipTagDefinition("Floats", Device, "Floats", AbCipDataType.Real, ElementCount: 3, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f });
@@ -116,7 +117,7 @@ public sealed class AbCipArrayTests
public async Task Array_Bool_read_returns_typed_bool_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3));
new AbCipTagDefinition("Flags", Device, "Flags", AbCipDataType.Bool, ElementCount: 3, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true });
@@ -131,7 +132,7 @@ public sealed class AbCipArrayTests
public async Task Array_String_read_returns_typed_string_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2));
new AbCipTagDefinition("Names", Device, "Names", AbCipDataType.String, ElementCount: 2, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" });
@@ -146,7 +147,7 @@ public sealed class AbCipArrayTests
public async Task Scalar_read_path_unchanged_for_element_count_one()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
@@ -158,20 +159,18 @@ public sealed class AbCipArrayTests
/// <summary>
/// Driver.AbCip-016 — a DECLARED UDT member that is a 1-D array (<c>Setpoints : REAL[4]</c>)
/// must READ as a typed CLR array, matching the array node it discovers as. Before the fix
/// the member fan-out in <c>InitializeAsync</c> dropped the member's <c>ElementCount</c> /
/// <c>IsArray</c>, so the fanned-out runtime definition defaulted to scalar and the read
/// returned a single element (or null) instead of the array — a declared-type-vs-runtime-value
/// mismatch.
/// must READ as a typed CLR array, matching the array node it discovers as. The member fan-out
/// in <c>InitializeAsync</c> threads the member's <c>ElementCount</c> / <c>IsArray</c> into the
/// fanned-out runtime definition.
/// </summary>
[Fact]
public async Task Declared_udt_array_member_reads_as_typed_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]));
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -187,24 +186,18 @@ public sealed class AbCipArrayTests
factory.Tags["Motor.Setpoints"].CreationParams.IsArray.ShouldBeTrue();
}
// ---- Resolver: arrayLength threading ----
// ---- Mapper: arrayLength threading ----
/// <summary>The equipment-tag resolver threads arrayLength into the def's ElementCount.</summary>
/// <summary>An authored array tag reads as a typed CLR array end-to-end.</summary>
[Fact]
public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array()
public async Task Authored_array_tag_reads_as_a_typed_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-array", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 });
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
@@ -212,80 +205,75 @@ public sealed class AbCipArrayTests
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
}
/// <summary>The parser threads arrayLength into the transient definition's ElementCount and sets IsArray.</summary>
/// <summary>The mapper threads arrayLength into the definition's ElementCount and sets IsArray.</summary>
[Fact]
public void Parser_threads_arrayLength_into_ElementCount()
public void Mapper_threads_arrayLength_into_ElementCount()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.ElementCount.ShouldBe(8);
def.IsArray.ShouldBeTrue();
}
/// <summary>A non-array equipment ref defaults ElementCount to 1 (scalar) and IsArray false.</summary>
/// <summary>A non-array TagConfig defaults ElementCount to 1 (scalar) and IsArray false.</summary>
[Fact]
public void Parser_defaults_ElementCount_to_one_when_not_an_array()
public void Mapper_defaults_ElementCount_to_one_when_not_an_array()
{
var json = """{"tagPath":"Recipe","dataType":"DInt"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.ElementCount.ShouldBe(1);
def.IsArray.ShouldBeFalse();
}
/// <summary>
/// Review finding I-2 — <c>isArray:true</c> with NO <c>arrayLength</c> is a DEGENERATE input
/// that must parse as SCALAR (IsArray false, ElementCount 1), matching every other driver
/// (Modbus, S7, TwinCAT, AbLegacy). The AdminUI validator blocks authoring this combination,
/// but the parser contract must still be consistent cross-driver.
/// that must map as SCALAR (IsArray false, ElementCount 1), matching every other driver.
/// </summary>
[Fact]
public void Parser_isArray_true_without_arrayLength_parses_as_scalar()
public void Mapper_isArray_true_without_arrayLength_maps_as_scalar()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-2 — <c>isArray:true</c> with an invalid (zero) <c>arrayLength</c> also
/// parses as SCALAR, consistent with the cross-driver rule.
/// maps as SCALAR, consistent with the cross-driver rule.
/// </summary>
[Fact]
public void Parser_isArray_true_with_zero_arrayLength_parses_as_scalar()
public void Mapper_isArray_true_with_zero_arrayLength_maps_as_scalar()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-1 — a 1-element array (<c>isArray:true, arrayLength:1</c>) is a valid
/// 1-element array, NOT a scalar: the parser sets <see cref="AbCipTagDefinition.IsArray"/>
/// true and <see cref="AbCipTagDefinition.ElementCount"/> 1.
/// 1-element array, NOT a scalar: the mapper sets IsArray true and ElementCount 1.
/// </summary>
[Fact]
public void Parser_treats_isArray_with_arrayLength_one_as_a_one_element_array()
public void Mapper_treats_isArray_with_arrayLength_one_as_a_one_element_array()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-1 — <c>isArray:true, arrayLength:1</c> must DISCOVER as a [1] array node
/// (IsArray + ArrayDim 1), matching the foundation's materialisation, not as a scalar.
/// Review finding I-1 — a 1-element array tag must DISCOVER as a [1] array node (IsArray +
/// ArrayDim 1), matching the foundation's materialisation, not as a scalar.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_arrayLength_one_discovers_as_one_element_array()
public async Task Authored_one_element_array_discovers_as_one_element_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(def!);
var (drv, _) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
@@ -296,25 +284,17 @@ public sealed class AbCipArrayTests
}
/// <summary>
/// Review finding I-1 — the I-1 case: an <c>isArray:true, arrayLength:1</c> equipment tag
/// reads a 1-ELEMENT typed array, NOT a scalar. On current code (gate <c>ElementCount &gt; 1</c>)
/// this reads a scalar; the explicit IsArray flag fixes it.
/// Review finding I-1 — a 1-element array tag reads a 1-ELEMENT typed array, NOT a scalar.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_arrayLength_one_reads_as_one_element_array()
public async Task Authored_one_element_array_reads_as_one_element_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-array1", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 99 });
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
@@ -324,24 +304,18 @@ public sealed class AbCipArrayTests
}
/// <summary>
/// Regression — a genuinely scalar equipment ref (<c>isArray:false</c>) reads a boxed
/// scalar via <see cref="IAbCipTagRuntime.DecodeValue"/>, never an array.
/// Regression — a genuinely scalar tag (<c>isArray:false</c>) reads a boxed scalar via
/// <see cref="IAbCipTagRuntime.DecodeValue"/>, never an array.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_false_reads_as_scalar()
public async Task Scalar_tag_reads_as_scalar()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Speed","dataType":"DInt","isArray":false}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-scalar", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(4200);
snapshots.Single().Value.ShouldNotBeOfType<int[]>();