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
This commit is contained in:
+107
-1
@@ -138,11 +138,117 @@ public sealed class DriverArrayWriteSupportTests
|
||||
public void OpcUaClient_forwards_every_array() =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
|
||||
|
||||
// ---- AbCip / AbLegacy / TwinCAT (review 07 U10 / U9: their array encoders landed in #737 / #738 / #739) ----
|
||||
|
||||
/// <summary>AbCip: the atomic element types <c>AbCipArrayCodec</c> encodes. BOOL (bit-packed DWORDs), STRING and
|
||||
/// structure arrays stay read-only, as do a safety tag, an undeclared length and a node type no element converts
|
||||
/// from.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"int\"" + Arr2 + "}", "Int16", true)] // case-insensitive, like the factory
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"LReal\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Dt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInnt\"" + Arr2 + "}", "Int32", false)] // typo ⇒ the factory refuses the tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"safetyTag\":true" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"isArray\":true}", "Int32", false)] // no length ⇒ scalar
|
||||
[InlineData("{\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", false)] // no tagPath ⇒ not an AbCip tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "DateTime", false)] // DateTime elements never convert
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "String", false)] // text converts only when numeric
|
||||
public void AbCip_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the
|
||||
/// write path refuses stays read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\"" + Arr2 + "}", "Int16", true)] // dataType ⇒ Int
|
||||
[InlineData("{\"address\":\" n7:10 \",\"dataType\":\"analogint\"" + Arr2 + "}", "Int16", true)] // trimmed, case-insensitive
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"A9:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"L9:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Float\"" + Arr2 + "}", "Float", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":256}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":257}", "Int16", false)] // the driver clamps to 256
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Bit\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"address\":\"ST9:0\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"T4:0\",\"dataType\":\"TimerElement\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0/3\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"T4:0.ACC\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:[N7:0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // type / file mismatch
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"address\":\"S:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // libplctag refuses a translated name
|
||||
[InlineData("{\"address\":\"O:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Innt\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Boolean", false)] // bool elements are refused
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
public void AbLegacy_rows_follow_the_element_type_and_data_file(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>The native transport refuses every PLC-5 write before the wire, so a native PLC-5 device keeps its
|
||||
/// arrays read-only, wherever the transport and the family are configured. libplctag PLC-5 is the control.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"transport\":\"Native\",\"devices\":[{\"hostAddress\":\"ab://h/1,0\",\"plcFamily\":\"Plc5\"}]}", "{}", false)]
|
||||
[InlineData("{\"Transport\":\"native\"}", "{\"PlcFamily\":\"plc5\"}", false)]
|
||||
[InlineData("{\"transport\":\"Native\"}", "{\"plcFamily\":\"Slc500\"}", true)]
|
||||
[InlineData("{\"transport\":\"Libplctag\"}", "{\"plcFamily\":\"Plc5\"}", true)]
|
||||
[InlineData("{}", "{\"plcFamily\":\"Plc5\"}", true)] // absent ⇒ libplctag
|
||||
public void AbLegacy_native_PLC5_stays_read_only(string driverConfig, string deviceConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16",
|
||||
"{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", driverConfig, deviceConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>TwinCAT: every element type <c>TwinCATArrayCodec</c> encodes (the factory refuses Structure tags). A
|
||||
/// BOOL bit-in-word path writes a scalar bit and is refused for an array value.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"DateTime\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Grid[0,0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)] // bit-in-word
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "DateTime", false)] // a duration, not a date
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "DateTime", false)] // culture-dependent text vs strict Windows-1252
|
||||
public void TwinCAT_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>ScadaBridge writes CLR <c>int[]</c> / <c>List<int></c> to Int16 nodes, and an unknown DataType
|
||||
/// name (served as BaseDataType) hands the driver whatever the client sent. The element CONVERTS in all three
|
||||
/// encoders, so neither case may make the row read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.AbCip, "{\"tagPath\":\"Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.AbLegacy, "{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
public void Converting_drivers_do_not_key_on_an_exact_node_type(string driverType, string tagConfig)
|
||||
{
|
||||
foreach (var dataType in new[] { "Int16", "Int32", "Int64", "UInt16", "Double", "Float32", "Reference" })
|
||||
DriverArrayWriteSupport.Supports(driverType, dataType, tagConfig).ShouldBeTrue($"{driverType} {dataType}");
|
||||
DriverArrayWriteSupport.Supports(driverType, null, tagConfig).ShouldBeTrue($"{driverType} <null>");
|
||||
}
|
||||
|
||||
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
|
||||
[Fact]
|
||||
public void Every_other_driver_type_is_not_array_writable()
|
||||
{
|
||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||
var listed = new[]
|
||||
{
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy,
|
||||
DriverTypeNames.AbCip, DriverTypeNames.AbLegacy, DriverTypeNames.TwinCAT,
|
||||
};
|
||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||
others.ShouldNotBeEmpty();
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-18
@@ -1,24 +1,48 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL write path, the same way the Modbus,
|
||||
/// S7 and Galaxy parity tests do: the server must advertise an array node writable exactly when
|
||||
/// <see cref="AbLegacyDriver.WriteAsync"/> encodes a whole-array value for it, so a driver change that adds or drops
|
||||
/// an array shape fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// The driver is driven end to end — <c>FromTagConfig</c>, the transport's own <c>WriteRefusal</c>, the data-file
|
||||
/// width check, the element conversion — against a fake runtime that records <c>EncodeArray</c>. 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 to the node's element type first), sized to the node's declared length.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
// Tag.DataType name → the CLR array the server delivers for a 1-D write of that type.
|
||||
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(),
|
||||
"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 string AddressFor(AbLegacyDataType t) => t switch
|
||||
{
|
||||
@@ -32,24 +56,131 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public Array? Encoded { get; private set; }
|
||||
|
||||
public override void EncodeArray(AbLegacyDataType type, Array values) => Encoded = values;
|
||||
}
|
||||
|
||||
// The fake runtime, but the PRODUCTION transport's pre-wire write refusal (libplctag: translated names; native:
|
||||
// PLC-5 and status-bit sub-elements).
|
||||
private sealed class ParityFactory(IAbLegacyTagFactory transport) : IAbLegacyTagFactory
|
||||
{
|
||||
public List<RecordingTag> Created { get; } = [];
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var tag = new RecordingTag(p);
|
||||
lock (Created) Created.Add(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public string? WriteRefusal(AbLegacyTagCreateParams p) => transport.WriteRefusal(p);
|
||||
}
|
||||
|
||||
// Whether the driver encodes a whole-array write of the node's declared length for this tag.
|
||||
private static async Task<bool> DriverEncodesAsync(
|
||||
string tagConfig, string nodeType, int nodeLength, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var factory = new ParityFactory(native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());
|
||||
using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, family)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, nodeLength))], CancellationToken.None);
|
||||
|
||||
bool encoded;
|
||||
lock (factory.Created) encoded = factory.Created.Any(t => t.Encoded is { } a && a.Length == nodeLength && t.WriteCount == 1);
|
||||
return r[0].StatusCode == AbLegacyStatusMapper.Good && encoded;
|
||||
}
|
||||
|
||||
public static TheoryData<AbLegacyDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbLegacyDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbLegacyDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType, on the matching data file: 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(AbLegacyDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 2, AbLegacyPlcFamily.Slc500, native: false);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: the <c>/bit</c>, <c>.sub</c>, type-versus-file-width and
|
||||
/// over-long cases the write path refuses, a native PLC-5 device (refused before the wire), and their positive
|
||||
/// controls. Each row carries the address, element type, length, family and transport.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.MicroLogix, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, false)] // libplctag writes PLC-5
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, true)] // native refuses every PLC-5 write
|
||||
[InlineData("B3:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("A9:0", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:10", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Long", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Float", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 256, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 300, AbLegacyPlcFamily.Slc500, false)] // clamped to 256 by the factory: a 300-element write is refused
|
||||
[InlineData("N7:0/3", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0/3", "Bit", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("T4:0.ACC", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Long", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Float", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("S:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] // libplctag refuses a translated name
|
||||
[InlineData("O:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(
|
||||
string address, string dataType, int length, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":{{length}}}""";
|
||||
var driverConfig = native ? """{"transport":"Native"}""" : "{}";
|
||||
var deviceConfig = $$"""{"hostAddress":"{{Host}}","plcFamily":"{{family}}"}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, "Int16", length, family, native);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json, driverConfig, deviceConfig)
|
||||
.ShouldBe(driver, $"{address} {dataType}[{length}] {family} native={native}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes the table keeps read-only although the write path may encode them — the deliberately conservative
|
||||
/// direction, pinned so it is a decision and not drift: an INDIRECT address (its element moves with the index
|
||||
/// word, and the index read is not atomic with the write), and the file-number-less <c>S:</c> / <c>I:</c> /
|
||||
/// <c>O:</c> files on the native transport (libplctag refuses them; the table does not read the transport
|
||||
/// closely enough to widen output-image and status-file writes on native alone).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"S:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
[InlineData("""{"address":"O:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
public void Table_keeps_the_conservative_shapes_read_only(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||||
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// a native PLC-5 device. Keyed on <c>dataType</c> alone the row would advertise them writable.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
@@ -60,7 +191,7 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
/// <summary>What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s TwinCAT row against the REAL write path (#739, review 07 U9), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="TwinCATDriver.WriteAsync"/> converts a whole-array value (<c>TwinCATArrayCodec.ToWriteElements</c>)
|
||||
/// and hands it to <c>WriteArrayAsync</c>, AND the production <see cref="TwinCATArrayCodec.Encode"/> lays the
|
||||
/// converted elements out. 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 to the node's element type first). Every array node is materialised 1-D; a
|
||||
/// multi-dimensional PLC array is written row-major from that flat value, so nothing here considers more than one
|
||||
/// rank. The PLC-side shape check (<c>CheckArray</c>: declared length ≤ the PLC's element count, element width)
|
||||
/// runs against the resolved symbol at write time and is outside what a compose-time table can know.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
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 non-string element converts only when the text happens to parse,
|
||||
// which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc), 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"];
|
||||
|
||||
// A PLC element width the codec can lay each type out at (STRING(80) / WSTRING(80) for the string types).
|
||||
private static int StrideOf(TwinCATDataType t) => t switch
|
||||
{
|
||||
TwinCATDataType.String => 81,
|
||||
TwinCATDataType.WString => 162,
|
||||
_ => TwinCATTypeGuard.DeclaredByteSize(t),
|
||||
};
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, string nodeType, int count)
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
using var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "twincat-parity", new FakeTwinCATClientFactory { Customise = () => fake });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
if (r[0].StatusCode != TwinCATStatusMapper.Good || fake.ArrayWriteLog.Count != 1) return false;
|
||||
|
||||
var (_, type, elements) = fake.ArrayWriteLog[0];
|
||||
if (elements.Count != count) return false;
|
||||
try
|
||||
{
|
||||
TwinCATArrayCodec.Encode(type, elements, new TwinCATArrayLayout(count, StrideOf(type)), utf8: false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<TwinCATDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<TwinCATDataType, string>();
|
||||
foreach (var t in Enum.GetValues<TwinCATDataType>())
|
||||
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(TwinCATDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"symbolPath":"MAIN.Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
if (dataType == TwinCATDataType.String && nodeType == "DateTime")
|
||||
{
|
||||
// The one CULTURE-dependent pair: a DateTime element reaches a STRING as Convert.ToString(DateTime), and
|
||||
// whether its strict Windows-1252 encode accepts that text depends on the host culture (ICU's en-US puts a
|
||||
// U+202F before "PM"; NLS a plain space). The table keeps it read-only — the conservative direction.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBeFalse();
|
||||
return;
|
||||
}
|
||||
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a BOOL bit-in-word path (a scalar bit write, which an array
|
||||
/// value cannot feed), an undeclared length (a scalar tag), an element-offset path into a multi-dimensional
|
||||
/// array, a TwinCAT 2 global, and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"symbolPath":"MAIN.Flags","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Grid[1,0]","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":".GlobalArr","dataType":"Real","isArray":true,"arrayLength":3}""", "Float")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true,"arrayLength":0}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","isArray":true,"arrayLength":3}""", "Int32")] // dataType ⇒ DInt
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, string nodeType)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// <list type="bullet">
|
||||
/// <item>An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and
|
||||
/// its UNS mirror, and a whole-array write round-trips.</item>
|
||||
/// <item>An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is
|
||||
/// authored <c>ReadWrite</c>: the write is <c>BadNotWritable</c> at the node and never reaches the
|
||||
/// gateway — the "never advertise a write the driver will refuse" rule.</item>
|
||||
/// <item>An AbCip BOOL array (bit-packed DWORDs: AbCip's array encoder refuses it, though it encodes the atomic
|
||||
/// numeric arrays since #737) stays read-only even though it is authored <c>ReadWrite</c>: the write is
|
||||
/// <c>BadNotWritable</c> at the node and never reaches the gateway — the "never advertise a write the
|
||||
/// driver will refuse" rule.</item>
|
||||
/// <item>A device-refused array write is reverted on whichever NodeId it was written through.</item>
|
||||
/// <item>A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and
|
||||
/// leave the node holding its prior array.</item>
|
||||
@@ -110,7 +111,8 @@ public sealed class ArrayWriteWireTests
|
||||
{
|
||||
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
|
||||
// A BOOL array: the AbCip array encoder has no encoding for it (a DInt array is writable since #737).
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
|
||||
+71
-4
@@ -271,8 +271,8 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
|
||||
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; an AbCip BOOL array (bit-packed
|
||||
/// DWORDs, which AbCip's array encoder refuses) is not; a scalar is never flagged.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
||||
{
|
||||
@@ -286,7 +286,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
{
|
||||
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
||||
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
@@ -303,7 +303,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
|
||||
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no BOOL-array encoder
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
|
||||
@@ -347,4 +347,71 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
||||
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an
|
||||
/// authored-<c>ReadWrite</c> array tag on each now materialises WRITABLE — raw node and UNS mirror alike — with
|
||||
/// the ScadaBridge-shaped Int16 node type. An array already deployed read-only turns writable on the next deploy
|
||||
/// through <c>ArrayWritable</c>, which rides the surgical whitelist (pinned in the classifier tests). A shape
|
||||
/// each encoder refuses (an AbCip BOOL array, an AbLegacy Bit array, an AbLegacy Int array on an F file, a
|
||||
/// TwinCAT BOOL bit-in-word path) stays read-only on the SAME drivers — the negative control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AbCip_AbLegacy_and_TwinCAT_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab", DriverType = "AbCip", DriverConfig = "{}" };
|
||||
var slc = new DriverInstance { DriverInstanceId = "drv-slc", ClusterId = "c1", RawFolderId = "f", Name = "Slc", DriverType = "AbLegacy", DriverConfig = "{}" };
|
||||
var tc = new DriverInstance { DriverInstanceId = "drv-tc", ClusterId = "c1", RawFolderId = "f", Name = "Tc", DriverType = "TwinCAT", DriverConfig = "{}" };
|
||||
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-ab", Name = "plc", DeviceConfig = "{}" };
|
||||
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-slc", Name = "slc", DeviceConfig = "{\"plcFamily\":\"Slc500\"}" };
|
||||
var d3 = new Device { DeviceId = "d3", DriverInstanceId = "drv-tc", Name = "ads", DeviceConfig = "{}" };
|
||||
static Tag Arr(string id, string device, string dataType, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, DeviceId = device, Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
var tags = new[]
|
||||
{
|
||||
Arr("abInts", "d1", "Int16", "{\"tagPath\":\"Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("abBools", "d1", "Boolean", "{\"tagPath\":\"Bools\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcInts", "d2", "Int16", "{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcBits", "d2", "Boolean", "{\"address\":\"B3:0\",\"dataType\":\"Bit\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcWrongFile", "d2", "Int16", "{\"address\":\"F8:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcInts", "d3", "Int16", "{\"symbolPath\":\"MAIN.Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcBit", "d3", "Boolean", "{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r-ab", EquipmentId = "e", TagId = "abInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-slc", EquipmentId = "e", TagId = "slcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-tc", EquipmentId = "e", TagId = "tcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-bits", EquipmentId = "e", TagId = "slcBits" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { ab, slc, tc }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2, d3 });
|
||||
|
||||
foreach (var writable in new[] { "abInts", "slcInts", "tcInts" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == writable);
|
||||
plan.ArrayWritable.ShouldBeTrue(writable);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
||||
}
|
||||
foreach (var refused in new[] { "abBools", "slcBits", "slcWrongFile", "tcBit" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == refused);
|
||||
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
||||
plan.ArrayWritable.ShouldBeFalse(refused);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
||||
}
|
||||
|
||||
foreach (var id in new[] { "r-ab", "r-slc", "r-tc" })
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user