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
@@ -135,9 +135,10 @@ public sealed class AbCipCommandBaseTests
var options = cmd.InvokeBuildOptions(tags);
options.Tags.Count.ShouldBe(2);
options.Tags[0].Name.ShouldBe("t1");
options.Tags[1].Name.ShouldBe("t2");
// v3: BuildOptions projects each typed tag to a RawTagEntry (RawPath = tag name).
options.RawTags.Count.ShouldBe(2);
options.RawTags[0].RawPath.ShouldBe("t1");
options.RawTags[1].RawPath.ShouldBe("t2");
}
/// <summary>
@@ -56,7 +56,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = false, // explicit; also the default
};
var drv = new AbCipDriver(opts, "drv-1", factory);
@@ -83,7 +83,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -138,7 +138,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
EnableDeclarationOnlyUdtGrouping = true,
@@ -189,7 +189,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -230,7 +230,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -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[]>();
@@ -29,10 +29,9 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -60,7 +59,7 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -86,7 +85,7 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -108,11 +107,10 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
],
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -135,11 +133,10 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
],
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -17,7 +17,7 @@ public sealed class AbCipConnectBackoffTests
private static AbCipDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = probeEnabled,
@@ -18,17 +18,18 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
/// <summary>Tests that InitializeAsync applies devices and tags from the config JSON.</summary>
/// <summary>Tests that InitializeAsync applies devices and raw tags from the config JSON.</summary>
[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.
// Constructed with NO devices/tags — the JSON is the only source of config. v3: tags arrive as
// RawTags (RawPath + TagConfig blob + DeviceName routing key).
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" } ]
"RawTags": [ { "RawPath": "line1/speed", "TagConfig": "{\"tagPath\":\"Speed\",\"dataType\":\"DInt\"}",
"WriteIdempotent": false, "DeviceName": "ab://10.0.0.9/1,0" } ]
}
""";
@@ -92,14 +93,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
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);
@@ -186,7 +185,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -228,7 +227,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -254,14 +253,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
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);
@@ -281,11 +278,9 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real), // same name
],
new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real)), // same name
}, "drv-1");
Should.Throw<InvalidOperationException>(() =>
@@ -301,14 +296,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), // collision
],
new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt)), // collision
}, "drv-1");
Should.Throw<InvalidOperationException>(() =>
@@ -336,7 +329,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -19,11 +19,9 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false),
],
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -61,12 +59,10 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt),
],
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -83,7 +79,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -418,21 +414,19 @@ public sealed class AbCipDriverDiscoveryTests
}
/// <summary>
/// A discovered member-path read goes end-to-end through the driver: with NO pre-declared
/// tag for the member, <see cref="AbCipDriver.ReadAsync"/> resolves the authored TagConfig
/// JSON blob (FullName = <c>Parent.Member</c>) via the equipment-tag resolver, materialises a
/// runtime on the device, reads it, and decodes the member's atomic value — proving discovered
/// members are readable with no extra registration. The libplctag tag name the driver builds
/// for the member equals the dotted member path.
/// A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig
/// addresses a dotted member path (<c>Motor1.Status.Code</c>) is delivered as a
/// <see cref="RawTagEntry"/>, resolves by its RawPath, materialises a runtime on the device, and
/// decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted
/// member path (the TagConfig's tagPath), independent of the RawPath identity.
/// </summary>
[Fact]
public async Task Discovered_member_path_reads_through_driver_as_member_atomic_type()
public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
{
const string device = "ab://10.0.0.5/1,0";
// The wire reference handed to ReadAsync for a discovered member is the authored TagConfig
// JSON blob (FullName = Motor1.Status.Code, atomic type Real here). No tag is pre-declared.
const string tagConfig =
"{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"ab://10.0.0.5/1,0\"}";
const string rawPath = "line1/motor1/status/code";
// The authored TagConfig addresses the dotted member path (atomic Real here).
const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}";
var tagFactory = new FakeAbCipTagFactory
{
@@ -443,16 +437,17 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
}, "drv-1", tagFactory: tagFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([tagConfig], CancellationToken.None);
var results = await drv.ReadAsync([rawPath], CancellationToken.None);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[0].Value.ShouldBe(12.5f);
// The driver built a runtime under the dotted member tag name — confirming the member-path
// read addresses the member directly (no parent-UDT registration needed).
// read addresses the member directly.
tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
}
@@ -15,7 +15,7 @@ public sealed class AbCipDriverReadTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
return (drv, factory);
@@ -42,7 +42,7 @@ public sealed class AbCipDriverReadTests
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)],
RawTags = AbCipRawTags.From(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);
@@ -19,7 +19,7 @@ public sealed class AbCipDriverWholeUdtReadTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = tags,
RawTags = AbCipRawTags.From(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,
@@ -15,7 +15,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
}, "drv-1", factory);
return (drv, factory);
}
@@ -74,7 +74,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -115,7 +115,7 @@ public sealed class AbCipDriverWriteTests
var drv2 = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)),
}, "drv-2", factory);
await drv2.InitializeAsync("{}", CancellationToken.None);
@@ -133,7 +133,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -167,12 +167,11 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
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, Writable: false),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
],
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -12,14 +12,14 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Typo_dataType_rejects_the_tag()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out _)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", "line1/eq/tag", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbCipDataType.DInt);
}
@@ -27,7 +27,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
var warnings = AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
@@ -36,13 +36,13 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured_bit_identical()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
@@ -50,7 +50,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Writable_absent_defaults_to_true()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
@@ -1,142 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipEquipmentTagParser.TryParse"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path (Driver.AbCip.Contracts-004).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipEquipmentTagParserTests
{
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
// ── Writable field (Driver.AbCip.Contracts-001) ───────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ─────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not supported in the
/// equipment-tag flow. This test documents current behaviour so a future change to reject
/// Structure is a conscious choice.
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
}
@@ -7,14 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public class AbCipEquipmentTagTests
{
private const string Raw = "line1/eq/tag";
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
public void Parses_equipment_tagconfig_into_a_definition()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("Motor1.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.5/1,0");
def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
}
@@ -23,83 +25,82 @@ public class AbCipEquipmentTagTests
public void Defaults_optional_fields_when_only_the_mandatory_tagPath_is_present()
{
var json = """{"tagPath":"PT_101"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("PT_101");
def.DeviceHostAddress.ShouldBe(""); // absent → empty, matching AbCipTagConfigModel default
def.DeviceHostAddress.ShouldBe(""); // routing key travels on the RawTagEntry, not the blob
def.DataType.ShouldBe(AbCipDataType.DInt); // AbCipTagConfigModel's default atomic type
}
[Fact]
public void Rejects_a_non_abcip_blob()
=> AbCipEquipmentTagParser.TryParse("""{"region":"x"}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"region":"x"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_a_blob_with_an_empty_tagPath()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" ","dataType":"DInt"}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" ","dataType":"DInt"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_tagPath_given_as_a_non_string()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> AbCipEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
[Fact]
public void Rejects_a_plain_authored_name_without_a_leading_brace()
=> AbCipEquipmentTagParser.TryParse("Motor1.Speed", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("not json", Raw, out _).ShouldBeFalse();
/// <summary>
/// End-to-end driver-level proof: an AbCip driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
/// definition (whose DeviceHostAddress names a configured device) and the read goes to the
/// wire instead of returning BadNodeIdUnknown.
/// End-to-end driver-level proof: an authored raw AbCip tag (RawPath + TagConfig blob delivered as a
/// <c>RawTagEntry</c>) resolves by its RawPath and the read goes to the wire instead of returning
/// BadNodeIdUnknown. This is the v3 replacement for the retired blob-parse-fallback resolver.
/// </summary>
[Fact]
public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
public async Task Driver_resolves_a_raw_tag_and_reads_instead_of_BadNodeIdUnknown()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DInt"}""";
const string device = "ab://10.0.0.5/1,0";
const string rawPath = "line1/motor/speed";
var tagConfig = """{"tagPath":"Motor1.Speed","dataType":"DInt"}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
};
var drv = new AbCipDriver(opts, "abcip-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 };
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots.Single().StatusCode.ShouldNotBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBe(4242);
// The runtime is keyed by the resolved libplctag tag path, not the RawPath identity.
factory.Tags.ShouldContainKey("Motor1.Speed");
}
/// <summary>
/// End-to-end driver-level proof of the R2-11 Phase C flip: an equipment-tag ref carrying a
/// TYPO'd <c>dataType</c> enum now REJECTS at the resolver (strict parse ⇒ <c>TryParse</c> false)
/// so the read surfaces <see cref="AbCipStatusMapper.BadNodeIdUnknown"/> instead of the old
/// lenient behaviour that silently defaulted to <c>DInt</c> and published a wrong-width
/// <c>Good</c>. Same driver + device wiring as the clean-read proof above; only the enum is
/// mistyped.
/// R2-11 Phase C strictness end-to-end: a raw tag whose TagConfig carries a TYPO'd <c>dataType</c>
/// fails to map (<c>FromTagConfig</c> ⇒ false) so it never enters the driver's RawPath table; a read
/// of that RawPath surfaces <see cref="AbCipStatusMapper.BadNodeIdUnknown"/> instead of a
/// silently-defaulted wrong-width Good.
/// </summary>
[Fact]
public async Task Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown()
public async Task Driver_read_of_a_typod_dataType_tag_surfaces_BadNodeIdUnknown()
{
var typoJson = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DIntt"}""";
const string device = "ab://10.0.0.5/1,0";
const string rawPath = "line1/motor/speed";
var typoConfig = """{"tagPath":"Motor1.Speed","dataType":"DIntt"}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, typoConfig, WriteIdempotent: false, DeviceName: device)],
};
var drv = new AbCipDriver(opts, "abcip-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 };
var snapshots = await drv.ReadAsync([typoJson], CancellationToken.None);
var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
@@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Regression coverage for Driver.AbCip-018 — the driver-config factory path
/// (<see cref="AbCipDriverFactoryExtensions.ParseOptions"/>) dropped the array shape of a
/// pre-declared tag. A <c>tags[]</c> entry with <c>"isArray": true, "elementCount": 4</c>
/// deserialised into a scalar <see cref="AbCipTagDefinition"/> (because <c>AbCipTagDto</c> /
/// <c>AbCipMemberDto</c> had no <c>ElementCount</c> / <c>IsArray</c> fields), so the tag read
/// as a single scalar despite the explicit array declaration. These tests assert the array
/// shape now survives factory deserialization for both top-level tags and UDT members, and
/// that omitting the fields keeps the legacy scalar default (additive change — no break).
/// ParseOptions timeout hardening + the array-shape round-trip through the v3 tag mapper. Under v3 the
/// factory no longer builds typed tag definitions (it carries authored <c>RawTags</c> blobs verbatim);
/// the array/member shape survives the TagConfig round-trip in
/// <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> instead. These tests assert the array shape
/// survives mapping for both top-level tags and declared UDT members, and that omitting the fields keeps
/// the scalar default.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipFactoryArrayTagTests
{
private const string Raw = "line1/eq/tag";
/// <summary>
/// A non-positive <c>TimeoutMs</c> clamps to the 2 s default rather than reaching libplctag's
/// <c>Tag.Timeout</c> setter (which throws <see cref="ArgumentOutOfRangeException"/> for a
@@ -44,103 +44,56 @@ public sealed class AbCipFactoryArrayTagTests
opts.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500));
}
/// <summary>A driver-config tag declaring isArray/elementCount produces an array definition.</summary>
/// <summary>A TagConfig declaring isArray/arrayLength maps to an array definition.</summary>
[Fact]
public void ParseOptions_threads_isArray_and_elementCount_into_tag_definition()
public void FromTagConfig_threads_isArray_and_arrayLength_into_tag_definition()
{
const string json = """
{
"Tags": [ {
"Name": "Setpoints",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Setpoints",
"DataType": "Real",
"isArray": true,
"elementCount": 4
} ]
}
""";
const string tagConfig = """{"tagPath":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
tag.IsArray.ShouldBeTrue();
tag.ElementCount.ShouldBe(4);
}
/// <summary>A driver-config UDT member declaring isArray/elementCount produces an array member.</summary>
/// <summary>A declared UDT member declaring isArray/arrayLength maps to an array member.</summary>
[Fact]
public void ParseOptions_threads_isArray_and_elementCount_into_structure_member()
public void FromTagConfig_threads_isArray_and_arrayLength_into_structure_member()
{
const string json = """
{
"Tags": [ {
"Name": "Motor",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor",
"DataType": "Structure",
"Members": [ {
"Name": "Setpoints",
"DataType": "Real",
"isArray": true,
"elementCount": 4
} ]
} ]
}
const string tagConfig = """
{"tagPath":"Motor","dataType":"Structure",
"members":[{"name":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}]}
""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var member = opts.Tags.Single().Members!.Single();
var member = tag.Members!.Single();
member.IsArray.ShouldBeTrue();
member.ElementCount.ShouldBe(4);
}
/// <summary>A driver-config tag without array fields stays scalar (additive change — no break).</summary>
/// <summary>A TagConfig without array fields stays scalar.</summary>
[Fact]
public void ParseOptions_defaults_to_scalar_when_array_fields_absent()
public void FromTagConfig_defaults_to_scalar_when_array_fields_absent()
{
const string json = """
{
"Tags": [ {
"Name": "Speed",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Speed",
"DataType": "DInt"
} ]
}
""";
const string tagConfig = """{"tagPath":"Speed","dataType":"DInt"}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
tag.IsArray.ShouldBeFalse();
tag.ElementCount.ShouldBe(1);
}
/// <summary>A non-positive elementCount falls back to the scalar count of 1 (positive-value guard).</summary>
/// <summary>A non-positive arrayLength falls back to the scalar count of 1 (canonical rule: scalar).</summary>
[Fact]
public void ParseOptions_guards_against_non_positive_elementCount()
public void FromTagConfig_guards_against_non_positive_arrayLength()
{
const string json = """
{
"Tags": [ {
"Name": "Speed",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Speed",
"DataType": "DInt",
"isArray": true,
"elementCount": 0
} ]
}
""";
const string tagConfig = """{"tagPath":"Speed","dataType":"DInt","isArray":true,"arrayLength":0}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
// elementCount <= 0 is degenerate — count clamps to 1; the explicit IsArray flag still
// carries (a 1-element array is valid), mirroring the equipment-tag parser's contract.
// arrayLength <= 0 with isArray:true is degenerate — canonical rule collapses to scalar.
tag.ElementCount.ShouldBe(1);
tag.IsArray.ShouldBeTrue();
tag.IsArray.ShouldBeFalse();
}
}
@@ -172,11 +172,10 @@ public sealed class AbCipHostProbeTests
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
],
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -216,11 +215,10 @@ public sealed class AbCipHostProbeTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -93,7 +93,7 @@ public sealed class AbCipLoggingTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -124,7 +124,7 @@ public sealed class AbCipLoggingTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -29,7 +29,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AllowPacking: false)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -69,7 +69,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(micro, AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -88,7 +88,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, ConnectionSize: 504)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -106,7 +106,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -109,7 +109,7 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -141,12 +141,11 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Writable: true, SafetyTag: true))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -167,11 +166,10 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Writable: true, SafetyTag: true))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -0,0 +1,26 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Test helper bridging the pre-v3 pre-declared-tag authoring style (a typed
/// <see cref="AbCipTagDefinition"/>) to the v3 RawPath seam the driver now consumes
/// (<see cref="RawTagEntry"/>). Each typed def is serialised to its <c>TagConfig</c> blob via
/// <see cref="AbCipTagDefinitionFactory.ToTagConfig"/>, and the def's identity (<c>Name</c> → RawPath),
/// device routing key (<c>DeviceHostAddress</c> → <see cref="RawTagEntry.DeviceName"/>) and write-idempotence
/// travel on the entry — exactly the shape the deploy artifact delivers. Lets the driver's read / write /
/// discovery suites keep expressing fixtures as typed definitions while exercising the v3
/// <c>RawTags</c> → <c>FromTagConfig</c> table-build path.
/// </summary>
internal static class AbCipRawTags
{
/// <summary>Projects typed definitions to the driver's <c>RawTags</c> option shape.</summary>
/// <param name="defs">The typed definitions to project.</param>
/// <returns>The equivalent <see cref="RawTagEntry"/> list.</returns>
public static IReadOnlyList<RawTagEntry> From(params AbCipTagDefinition[] defs) =>
[.. defs.Select(d => new RawTagEntry(
RawPath: d.Name,
TagConfig: AbCipTagDefinitionFactory.ToTagConfig(d),
WriteIdempotent: d.WriteIdempotent,
DeviceName: d.DeviceHostAddress))];
}
@@ -1,14 +1,15 @@
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>
/// CONV-4 — <see cref="AbCipDriver.ResolveHost"/> keys per-host resilience (bulkhead / circuit
/// breaker). It must resolve an equipment-tag reference (raw TagConfig JSON) to its OWN device
/// host, not fall back to the first device — otherwise a broken device B trips device A's
/// breaker. Authored tags and unknown refs keep the current fallback.
/// breaker). It must resolve an authored tag (by its RawPath) to its OWN device host, not fall back
/// to the first device — otherwise a broken device B trips device A's breaker. Unknown refs keep the
/// current first-device fallback.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipResolveHostTests
@@ -16,21 +17,27 @@ public sealed class AbCipResolveHostTests
private const string DeviceA = "ab://10.0.0.5/1,0";
private const string DeviceB = "ab://10.0.0.6/1,0";
private static AbCipDriver NewDriver() => new(
private static AbCipDriver NewDriver(params RawTagEntry[] rawTags) => new(
new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(DeviceA), new AbCipDeviceOptions(DeviceB)],
RawTags = rawTags,
Probe = new AbCipProbeOptions { Enabled = false },
},
"abcip-1", new FakeAbCipTagFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
/// <summary>An authored tag routed to device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
public async Task ResolveHost_TagRoutedToDeviceB_ReturnsItsOwnDeviceHost()
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","tagPath":"Motor1.Speed","dataType":"DInt"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
var drv = NewDriver(new RawTagEntry(
RawPath: "line1/motorB/speed",
TagConfig: """{"tagPath":"Motor1.Speed","dataType":"DInt"}""",
WriteIdempotent: false,
DeviceName: DeviceB));
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("line1/motorB/speed").ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
@@ -70,7 +70,7 @@ public sealed class AbCipRuntimeConcurrencyTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
};
return new AbCipDriver(opts, "drv-1", factory);
}
@@ -15,7 +15,7 @@ public sealed class AbCipSubscriptionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
}, "drv-1", factory);
return (drv, factory);
}
@@ -161,11 +161,10 @@ public sealed class AbCipSubscriptionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName == "Motor1.Speed"
@@ -0,0 +1,182 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipTagDefinitionFactory.FromTagConfig"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path. Under v3 the definition's
/// identity (<c>Name</c>) is the RawPath the mapper is handed, not the blob, and the mapper does
/// NOT read <c>deviceHostAddress</c> (device placement travels on the RawTagEntry).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipTagDefinitionFactoryTests
{
private const string Raw = "line1/eq/tag";
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("not json at all", Raw, out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""["tagPath","foo"]""", Raw, out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("\"Motor.Speed\"", Raw, out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"dataType":"DInt"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" "}""", Raw, out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse();
// ── Writable field ────────────────────────────────────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType ────────────────────────────────────────────────────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not authored via the
/// production equipment-tag flow (AbCipTagConfigModel emits no members key).
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
// ── ToTagConfig / FromTagConfig round-trip (v3 RawTagEntry carrier) ────────────────────
/// <summary>
/// A typed definition serialised via <see cref="AbCipTagDefinitionFactory.ToTagConfig"/> and
/// re-read via <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> reproduces every per-tag
/// field except the out-of-band device routing key + WriteIdempotent (which travel on the
/// RawTagEntry, not the blob). Covers the array + declared-member round-trip the driver-test
/// AbCipRawTags helper relies on.
/// </summary>
[Fact]
public void ToTagConfig_FromTagConfig_round_trips_arrays_and_members()
{
var def = new AbCipTagDefinition(
Name: "Motor1", DeviceHostAddress: "ab://10.0.0.5/1,0", TagPath: "Motor1",
DataType: AbCipDataType.Structure, Writable: false, SafetyTag: true,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Buf", AbCipDataType.Real, Writable: false, ElementCount: 4, IsArray: true),
]);
var blob = AbCipTagDefinitionFactory.ToTagConfig(def);
AbCipTagDefinitionFactory.FromTagConfig(blob, "Motor1", out var back).ShouldBeTrue();
back!.Name.ShouldBe("Motor1");
back.DeviceHostAddress.ShouldBe(""); // routing key not in the blob
back.TagPath.ShouldBe("Motor1");
back.DataType.ShouldBe(AbCipDataType.Structure);
back.Writable.ShouldBeFalse();
back.SafetyTag.ShouldBeTrue();
back.Members!.Count.ShouldBe(2);
back.Members[0].Name.ShouldBe("Speed");
back.Members[1].IsArray.ShouldBeTrue();
back.Members[1].ElementCount.ShouldBe(4);
back.Members[1].Writable.ShouldBeFalse();
}
}
@@ -16,8 +16,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition(
Name: "Motor1",
DeviceHostAddress: "ab://10.0.0.5/1,0",
@@ -28,8 +27,8 @@ public sealed class AbCipUdtMemberTests
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
]),
],
]))
,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -63,15 +62,14 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -91,14 +89,13 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -117,14 +114,13 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -145,7 +141,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -163,7 +159,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -181,16 +177,15 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
],
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real))
,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);