Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteSupportParityTests.cs
T
Joseph Doherty 44ea6595e5 feat(arrays): advertise AbCip/AbLegacy/TwinCAT array writes in DriverArrayWriteSupport
The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write
encoders (review 07 U10/U9), but the capability table still listed all three
as scalar-only, so every array node materialised read-only and a client write
was BadNotWritable before it reached the driver.

Rows added, each listing only what WriteAsync encodes end to end:
- AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared
  length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only.
- AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match),
  plain <file><n>:<word> address, <= 256 elements; not /bit, .sub, indirect,
  S:/I:/O:, nor native PLC-5.
- TwinCAT: every codec element type, declared length; not a BOOL bit-in-word
  path.
Elements convert in all three encoders, so the rows never key on an exact
node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out
only node types whose elements never convert (DateTime/String over numeric,
Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent).

Tests: driver-level parity for each driver (every element type x node type
plus shape cases, against the real write path), the AbLegacy parity tests
un-skipped, Core.Abstractions row tests, a composer test (three drivers
writable, refused shapes read-only), and the existing AbCip negative cases
moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules.

Refs #737 #738 #739

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:17:48 -04:00

134 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbCip row against the REAL write path (#737, review 07 U10), the
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
/// <see cref="AbCipDriver.WriteAsync"/> encodes a whole-array value for it (through the production
/// <see cref="AbCipArrayCodec"/>, which the fake runtime's <c>EncodeArray</c> calls), so a driver change that adds
/// or drops an array element type fails here until the table follows.
/// </summary>
/// <remarks>
/// The value written is the CLR array the server hands the driver for the node's <c>DataType</c>: the node manager
/// converts a numeric client array (ScadaBridge's <c>int[]</c>) to the node's element type first, and the codec
/// then converts each element to the controller's type. The fake controller holds exactly
/// <c>width × count</c> bytes, so only the element type and the value can make the write fail.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class AbCipArrayWriteSupportParityTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static Array ClrArray(string dataType, int n) => dataType switch
{
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
"Int32" => Enumerable.Repeat(1, n).ToArray(),
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
"Float" => Enumerable.Repeat(1f, n).ToArray(),
"Double" => Enumerable.Repeat(1d, n).ToArray(),
// Non-numeric text: a String node over a numeric element converts only when the text happens to be a
// number, which is not a write the server can promise — the table keeps it read-only.
"String" => Enumerable.Repeat("a", n).ToArray(),
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
};
private static readonly string[] NodeTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
private static async Task<bool> DriverEncodesAsync(string tagConfig, AbCipDataType elementType, string nodeType, int count)
{
// The controller holds exactly what an encodable element array of this length occupies.
var size = (AbCipArrayCodec.ElementWidth(elementType) ?? 1) * count;
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
using var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Device)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-parity", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
return r[0].StatusCode == AbCipStatusMapper.Good
&& factory.Tags.TryGetValue("Arr", out var tag)
&& tag.EncodedArrayCount == count && tag.WriteCount == 1;
}
public static TheoryData<AbCipDataType, string> TypeMatrix()
{
var data = new TheoryData<AbCipDataType, string>();
foreach (var t in Enum.GetValues<AbCipDataType>())
foreach (var n in NodeTypes)
data.Add(t, n);
return data;
}
/// <summary>Every element type × every node DataType: the table answers exactly what the write path does.</summary>
[Theory]
[MemberData(nameof(TypeMatrix))]
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbCipDataType dataType, string nodeType)
{
var json = $$"""{"tagPath":"Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
var driver = await DriverEncodesAsync(json, dataType, nodeType, 3);
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
}
/// <summary>
/// Shapes beyond the element type, both directions: a safety tag (never writable), an undeclared length (a
/// scalar to the factory, whose scalar encoder cannot take an array), a 1-element array, an element-offset path,
/// and their positive controls.
/// </summary>
[Theory]
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":3}""", 3)]
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":1}""", 1)]
[InlineData("""{"tagPath":"Arr","isArray":true,"arrayLength":3}""", 3)] // dataType ⇒ DInt
[InlineData("""{"tagPath":"Arr","dataType":"DInt","safetyTag":true,"isArray":true,"arrayLength":3}""", 3)]
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true}""", 3)]
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":0}""", 3)]
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":false,"arrayLength":3}""", 3)]
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, int nodeLength)
{
var driver = await DriverEncodesAsync(tagConfig, AbCipDataType.DInt, "Int32", nodeLength);
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int32", tagConfig).ShouldBe(driver, tagConfig);
}
/// <summary>The ScadaBridge wire shape reaches the codec: a CLR <c>int[]</c> (and <c>List&lt;int&gt;</c>) to an
/// INT array on an Int16 node the table advertises writable encodes. Positive control for the conversion rule.</summary>
[Fact]
public async Task A_ScadaBridge_int_list_to_an_INT_array_encodes()
{
const string json = """{"tagPath":"Arr","dataType":"Int","isArray":true,"arrayLength":3}""";
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int16", json).ShouldBeTrue();
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 6 } };
using var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = [new RawTagEntry("Arr", json, WriteIdempotent: false, DeviceName: Device)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-parity-sb", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest("Arr", value)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Arr"].EncodedArrayBytes.ShouldBe(Convert.FromHexString("0100FEFF2C01"));
}
}
}