using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// Pins '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 /// converts a whole-array value (TwinCATArrayCodec.ToWriteElements) /// and hands it to WriteArrayAsync, AND the production lays the /// converted elements out. A driver change that adds or drops an array element type fails here until the table /// follows. /// /// /// The value written is the CLR array the server hands the driver for the node's DataType (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 (CheckArray: 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. /// [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 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 TypeMatrix() { var data = new TheoryData(); foreach (var t in Enum.GetValues()) foreach (var n in NodeTypes) data.Add(t, n); return data; } /// Every element type × every node DataType: the table answers exactly what the write path does. [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"); } /// /// 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. /// [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); } }