44ea6595e5
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
134 lines
7.3 KiB
C#
134 lines
7.3 KiB
C#
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<int></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"));
|
||
}
|
||
}
|
||
}
|