using System.Buffers.Binary; using System.Text; using Shouldly; using TwinCAT.TypeSystem; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// Pins the array half of the raw-bytes model: decides /// whether an authored array tag (element type + ArrayLength) may decode a PLC array and /// plans the byte layout, and turns the PLC's raw bytes into /// the element-typed CLR array both the poll read and the native notification publish. /// /// /// The raw-bytes model replaced the Beckhoff typed array read after the 2026-10-07 live gate on the /// TwinCAT 2.11 Elma cleaner Z33856, where that read failed for most real arrays: the CLR element /// type had to match exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional /// array demanded a rank-2 CLR array, and a dotted MEMBER array was sliced by the under-reported /// symbol size (P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE, ARRAY [1..20] OF REAL, symbol /// 10 B vs type 80 B → ArgumentOutOfRangeException). /// [Trait("Category", "Unit")] public sealed class TwinCATArrayCodecTests { private static TwinCATPlcSymbolShape Elem(string typeName, int byteSize, DataTypeCategory? category = DataTypeCategory.Primitive) => TwinCATPlcSymbolShape.Classify(typeName, category, byteSize); private static TwinCATPlcArrayShape Arr(TwinCATPlcSymbolShape element, params int[] dims) => new(dims, dims.Aggregate(1, (a, b) => a * b) * element.ByteSize, element); private static TwinCATPlcSymbolShape ArraySymbol(string typeName, int byteSize) => TwinCATPlcSymbolShape.Classify(typeName, DataTypeCategory.Array, byteSize); private static string? Check(TwinCATDataType declared, int declaredLength, TwinCATPlcArrayShape? array, out TwinCATArrayLayout layout, string typeName = "ARRAY") => TwinCATTypeGuard.CheckArray(declared, declaredLength, ArraySymbol(typeName, array?.ByteSize ?? 4), array, out layout); // ---- guard: plan + validate ---- /// A matching element type and length plans the whole array at element-size stride. [Fact] public void CheckArray_plans_a_matching_numeric_array_at_its_element_stride() { Check(TwinCATDataType.UInt, 13, Arr(Elem("UINT", 2), 13), out var layout).ShouldBeNull(); layout.ShouldBe(new TwinCATArrayLayout(13, 2)); layout.ByteLength.ShouldBe(26); } /// /// A multi-dimensional array (ARRAY [1..3, 1..2] OF REAL, live on Z33856) is published /// flattened in PLC memory order; its ArrayLength is the TOTAL element count. Lower bounds play /// no part — the published index is 0-based. /// [Fact] public void CheckArray_flattens_a_multi_dimensional_array_to_its_total_count() { Check(TwinCATDataType.Real, 6, Arr(Elem("REAL", 4), 3, 2), out var layout).ShouldBeNull(); layout.ShouldBe(new TwinCATArrayLayout(6, 4)); } /// /// An authored ArrayLength SHORTER than the PLC array reads the leading sub-range only: the first /// N elements in PLC memory order, at N × stride bytes from the array start — cheap (a smaller /// notification) and well-defined, and it matches the node's advertised ArrayDimensions. /// [Fact] public void CheckArray_plans_a_leading_sub_range_when_the_authored_length_is_shorter() { Check(TwinCATDataType.Real, 5, Arr(Elem("REAL", 4), 20), out var layout).ShouldBeNull(); layout.ShouldBe(new TwinCATArrayLayout(5, 4)); layout.ByteLength.ShouldBe(20); } /// An authored ArrayLength LONGER than the PLC array would read past it — refused. [Fact] public void CheckArray_refuses_an_authored_length_longer_than_the_plc_array() => Check(TwinCATDataType.UInt, 14, Arr(Elem("UINT", 2), 13), out _)!.ShouldContain("13"); /// A zero or negative authored length is not an array. [Fact] public void CheckArray_refuses_a_non_positive_authored_length() => Check(TwinCATDataType.UInt, 0, Arr(Elem("UINT", 2), 13), out _).ShouldNotBeNull(); /// /// The element is checked with the same rules as a scalar notification (TwinCAT-002/-008): width, /// float/integer kind and string kind must agree; a same-width, same-kind divergence is tolerated. /// [Fact] public void CheckArray_checks_the_element_like_a_scalar_notification() { Check(TwinCATDataType.DInt, 4, Arr(Elem("REAL", 4), 4), out _).ShouldNotBeNull(); // float kind Check(TwinCATDataType.Int, 4, Arr(Elem("DINT", 4), 4), out _).ShouldNotBeNull(); // width Check(TwinCATDataType.DInt, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull(); Check(TwinCATDataType.String, 4, Arr(Elem("INT", 2), 4), out _).ShouldNotBeNull(); Check(TwinCATDataType.WString, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull(); Check(TwinCATDataType.Int, 4, Arr(Elem("UINT", 2), 4), out _).ShouldBeNull(); // same width, same kind Check(TwinCATDataType.Time, 13, Arr(Elem("TIME", 4), 13), out _).ShouldBeNull(); Check(TwinCATDataType.USInt, 6, Arr(Elem("USINT", 1), 6), out _).ShouldBeNull(); Check(TwinCATDataType.SInt, 13, Arr(Elem("BYTE", 1), 13), out _).ShouldBeNull(); Check(TwinCATDataType.Bool, 19, Arr(Elem("BOOL", 1), 19), out _).ShouldBeNull(); Check(TwinCATDataType.LReal, 32, Arr(Elem("LREAL", 8), 32), out _).ShouldBeNull(); } /// A STRING(n) / WSTRING(n) element is laid out at the PLC's own width: n+1 / 2(n+1). [Fact] public void CheckArray_strides_a_string_array_by_the_plc_element_width() { Check(TwinCATDataType.String, 10, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 10), out var s).ShouldBeNull(); s.ShouldBe(new TwinCATArrayLayout(10, 81)); Check(TwinCATDataType.WString, 3, Arr(Elem("WSTRING(20)", 42, DataTypeCategory.String), 3), out var w).ShouldBeNull(); w.ShouldBe(new TwinCATArrayLayout(3, 42)); } /// /// Arrays of structs / UDTs / FBs, of pointers, and arrays of arrays (jagged) have no scalar element /// the driver can decode — refused with a reason that says so. /// [Fact] public void CheckArray_refuses_struct_pointer_and_nested_array_elements() { Check(TwinCATDataType.DInt, 13, Arr(Elem("ST_CARRIERDATA", 120, DataTypeCategory.Struct), 13), out _).ShouldNotBeNull(); Check(TwinCATDataType.DInt, 4, Arr(Elem("POINTER TO BYTE", 4, DataTypeCategory.Pointer), 4), out _).ShouldNotBeNull(); Check(TwinCATDataType.Int, 2, Arr(Elem("ARRAY [0..2] OF INT", 6, DataTypeCategory.Array), 2), out _).ShouldNotBeNull(); } /// /// An array tag over a PLC symbol that is not an array (a scalar, a string, an element path such /// as Arr[3]) is refused: there is no array to read. /// [Fact] public void CheckArray_refuses_a_plc_symbol_that_is_not_an_array() { TwinCATTypeGuard.CheckArray(TwinCATDataType.DInt, 4, Elem("DINT", 4), null, out _).ShouldNotBeNull(); TwinCATTypeGuard.CheckArray(TwinCATDataType.String, 4, Elem("STRING(80)", 81, DataTypeCategory.String), null, out _).ShouldNotBeNull(); } /// A VAR_IN_OUT array member is a reference, not the array — refused. [Fact] public void CheckArray_refuses_a_var_in_out_reference() { var symbol = TwinCATPlcSymbolShape.Classify("ARRAY [1..3] OF INT", DataTypeCategory.Array, 4, dataTypeByteSize: 6, symbolTypeName: "ARRAY [1..3] OF INT (VAR_IN_OUT)"); TwinCATTypeGuard.CheckArray(TwinCATDataType.Int, 3, symbol, Arr(Elem("INT", 2), 3), out _).ShouldNotBeNull(); } /// /// A layout whose element count × element size disagrees with the array's own byte size is not /// a packed array the driver understands (padding, a mis-resolved element) — refused rather than /// decoded at the wrong offsets. /// [Fact] public void CheckArray_refuses_an_array_whose_size_is_not_count_times_element_size() { var padded = new TwinCATPlcArrayShape([4], 32, Elem("INT", 2)); Check(TwinCATDataType.Int, 4, padded, out _).ShouldNotBeNull(); } // ---- guard: notification size ---- /// /// A native notification is capped at /// (128 KiB — the largest registration proven on a real target: a TC 2.11 /// ARRAY [0..65535] OF UINT). Above it the tag is refused with a reason pointing at polling. /// [Fact] public void CheckNotificationSize_caps_a_native_array_at_128_KiB() { TwinCATTypeGuard.MaxNotificationArrayBytes.ShouldBe(128 * 1024); TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65536, 2)).ShouldBeNull(); TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65537, 2))!.ShouldContain("UseNativeNotifications"); } // ---- codec: decode ---- /// Each numeric element type decodes little-endian into its CLR array, in PLC memory order. [Fact] public void Decode_numeric_elements_little_endian_into_the_declared_clr_array() { Decode(TwinCATDataType.Int, 2, [-1, 300], (b, v) => BinaryPrimitives.WriteInt16LittleEndian(b, v)); Decode(TwinCATDataType.UInt, 2, [65535, 7], (b, v) => BinaryPrimitives.WriteUInt16LittleEndian(b, v)); Decode(TwinCATDataType.DInt, 4, [-123456, 42], (b, v) => BinaryPrimitives.WriteInt32LittleEndian(b, v)); Decode(TwinCATDataType.UDInt, 4, [4_000_000_000, 1], (b, v) => BinaryPrimitives.WriteUInt32LittleEndian(b, v)); Decode(TwinCATDataType.LInt, 8, [long.MinValue, 9], (b, v) => BinaryPrimitives.WriteInt64LittleEndian(b, v)); Decode(TwinCATDataType.ULInt, 8, [ulong.MaxValue, 3], (b, v) => BinaryPrimitives.WriteUInt64LittleEndian(b, v)); Decode(TwinCATDataType.Real, 4, [949.2f, -0.5f], (b, v) => BinaryPrimitives.WriteSingleLittleEndian(b, v)); Decode(TwinCATDataType.LReal, 8, [Math.PI, -1e300], (b, v) => BinaryPrimitives.WriteDoubleLittleEndian(b, v)); Decode(TwinCATDataType.SInt, 1, [-128, 127], (b, v) => b[0] = unchecked((byte)v)); Decode(TwinCATDataType.USInt, 1, [0, 255], (b, v) => b[0] = v); } /// /// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same /// CLR shape a scalar publishes. Live: .THROTTLE_MODE_DEFAULT (ARRAY [0..12] OF TIME) /// begins 0, 10, 20 ms. /// [Theory] [InlineData(TwinCATDataType.Time)] [InlineData(TwinCATDataType.TimeOfDay)] public void Decode_duration_elements_as_double_ms(TwinCATDataType type) => TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false) .ShouldBe(new double[] { 0, 10, 20 }); /// DATE / DT elements decode to UTC DateTime from seconds since 1970. [Theory] [InlineData(TwinCATDataType.Date)] [InlineData(TwinCATDataType.DateTime)] public void Decode_date_elements_as_utc_datetime(TwinCATDataType type) => TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 60, 0, 0, 0], new TwinCATArrayLayout(2, 4), utf8: false) .ShouldBe(new[] { new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(1970, 1, 1, 0, 1, 0, DateTimeKind.Utc), }); /// A BOOL element is one byte; any non-zero byte is TRUE (live: .ARGTYPE_IS_BIT_TYPE). [Fact] public void Decode_bool_elements_one_byte_each_non_zero_is_true() => TwinCATArrayCodec.Decode(TwinCATDataType.Bool, [0, 1, 1, 0xFF, 0], new TwinCATArrayLayout(5, 1), utf8: false) .ShouldBe(new[] { false, true, true, true, false }); /// /// Each STRING(n) element sits at a fixed n+1 stride and ends at ITS first NUL — the stale tail a /// longer earlier value left (live on Z33856) never leaks into the element, nor into the next one. /// Windows-1252 unless the target reports UTF-8, exactly like a scalar string. /// [Fact] public void Decode_string_elements_at_their_stride_each_ending_at_its_first_nul() { var bytes = new byte[3 * 6]; // STRING(5) "RUN"u8.CopyTo(bytes.AsSpan(0)); bytes[4] = (byte)'T'; // stale tail after the NUL "MON_O"u8.CopyTo(bytes.AsSpan(6)); // full-length, no NUL before the stride end bytes[12] = 0xB0; // '°' in Windows-1252 TwinCATArrayCodec.Decode(TwinCATDataType.String, bytes, new TwinCATArrayLayout(3, 6), utf8: false) .ShouldBe(new[] { "RUN", "MON_O", "°" }); var utf8 = new byte[2 * 4]; "°C"u8.CopyTo(utf8.AsSpan(0)); TwinCATArrayCodec.Decode(TwinCATDataType.String, utf8, new TwinCATArrayLayout(2, 4), utf8: true) .ShouldBe(new[] { "°C", "" }); } /// Each WSTRING(n) element is UTF-16LE at a 2(n+1) stride, ending at its first aligned U+0000. [Fact] public void Decode_wstring_elements_at_their_stride() { var bytes = new byte[2 * 8]; // WSTRING(3) Encoding.Unicode.GetBytes("Ab").CopyTo(bytes, 0); Encoding.Unicode.GetBytes("Zürn").CopyTo(bytes, 8); // 4 chars fill the whole stride TwinCATArrayCodec.Decode(TwinCATDataType.WString, bytes, new TwinCATArrayLayout(2, 8), utf8: false) .ShouldBe(new[] { "Ab", "Zürn" }); } /// A sub-range layout decodes only its leading elements, even from a longer buffer. [Fact] public void Decode_honours_the_layout_count_not_the_buffer_length() => TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2, 0, 3, 0], new TwinCATArrayLayout(2, 2), utf8: false) .ShouldBe(new short[] { 1, 2 }); /// A buffer shorter than the layout is a protocol fault, never a silently-short array. [Fact] public void Decode_refuses_a_buffer_shorter_than_the_layout() => Should.Throw(() => TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2], new TwinCATArrayLayout(2, 2), utf8: false)); private static void Decode(TwinCATDataType type, int stride, T[] values, Action, T> write) { var bytes = new byte[values.Length * stride]; for (var i = 0; i < values.Length; i++) write(bytes.AsSpan(i * stride, stride), values[i]); var decoded = TwinCATArrayCodec.Decode(type, bytes, new TwinCATArrayLayout(values.Length, stride), utf8: false); decoded.ShouldBeOfType().ShouldBe(values); } }