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.
|
||||
|
||||
Reference in New Issue
Block a user