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:
Joseph Doherty
2026-10-22 16:17:48 -04:00
parent cb9a48ee96
commit 44ea6595e5
12 changed files with 864 additions and 46 deletions
@@ -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&lt;int&gt;</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.
@@ -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&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"));
}
}
}
@@ -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)]
@@ -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" };
@@ -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();
}
}