using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for the S7 1-D array support: the pure /// decode loop (the half of the contiguous /// block read that turns a raw S7 big-endian byte block into a typed CLR array — the /// network I/O half, Plc.ReadBytesAsync, has no in-process fake so only the /// decode is unit-proven), the IsArray/ArrayDim /// flip, and the equipment-tag resolver threading arrayLength into the transient /// tag-def's array count. /// [Trait("Category", "Unit")] public sealed class S7ArrayReadTests { // ── Helpers ────────────────────────────────────────────────────────────────────────── private static S7TagDefinition ArrTag(S7DataType dt, int count) => new("ArrTag", "DB1.DBW0", dt, ArrayCount: count); private static S7ParsedAddress Addr(S7Size size) => new(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: 0); // S7 is big-endian: most-significant byte first. private static byte[] BeWords(params ushort[] words) { var b = new byte[words.Length * 2]; for (var i = 0; i < words.Length; i++) { b[i * 2] = (byte)(words[i] >> 8); b[i * 2 + 1] = (byte)(words[i] & 0xFF); } return b; } private static byte[] BeDwords(params uint[] dwords) { var b = new byte[dwords.Length * 4]; for (var i = 0; i < dwords.Length; i++) { b[i * 4] = (byte)(dwords[i] >> 24); b[i * 4 + 1] = (byte)(dwords[i] >> 16); b[i * 4 + 2] = (byte)(dwords[i] >> 8); b[i * 4 + 3] = (byte)(dwords[i] & 0xFF); } return b; } // ── DecodeArrayBlock — element-typed CLR arrays ─────────────────────────────────────── /// Verifies an Int16 array decodes to a typed short[] with big-endian values. [Fact] public void DecodeArrayBlock_Int16_returns_short_array() { // 3 words: 1, -1 (0xFFFF), 32767 (0x7FFF). var block = BeWords(0x0001, 0xFFFF, 0x7FFF); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int16, 3), Addr(S7Size.Word), block); var arr = result.ShouldBeOfType(); arr.ShouldBe(new short[] { 1, -1, 32767 }); } /// Verifies a UInt16 array decodes to a typed ushort[]. [Fact] public void DecodeArrayBlock_UInt16_returns_ushort_array() { var block = BeWords(0, 1000, 65535); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 3), Addr(S7Size.Word), block); result.ShouldBeOfType().ShouldBe(new ushort[] { 0, 1000, 65535 }); } /// Verifies an Int32 array decodes to a typed int[] with big-endian dwords. [Fact] public void DecodeArrayBlock_Int32_returns_int_array() { var block = BeDwords(1u, 0xFFFF_FFFFu, 0x7FFF_FFFFu); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int32, 3), Addr(S7Size.DWord), block); result.ShouldBeOfType().ShouldBe(new[] { 1, -1, int.MaxValue }); } /// Verifies a UInt32 array decodes to a typed uint[]. [Fact] public void DecodeArrayBlock_UInt32_returns_uint_array() { var block = BeDwords(0u, 70_000u, 0xFFFF_FFFFu); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt32, 3), Addr(S7Size.DWord), block); result.ShouldBeOfType().ShouldBe(new uint[] { 0, 70_000, 0xFFFF_FFFF }); } /// Verifies a Float32 array decodes to a typed float[] from IEEE-754 big-endian dwords. [Fact] public void DecodeArrayBlock_Float32_returns_float_array() { var bits0 = BitConverter.SingleToUInt32Bits(1.5f); var bits1 = BitConverter.SingleToUInt32Bits(-2.25f); var bits2 = BitConverter.SingleToUInt32Bits(3.14f); var block = BeDwords(bits0, bits1, bits2); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Float32, 3), Addr(S7Size.DWord), block); var arr = result.ShouldBeOfType(); arr.Length.ShouldBe(3); arr[0].ShouldBe(1.5f, tolerance: 1e-6f); arr[1].ShouldBe(-2.25f, tolerance: 1e-6f); arr[2].ShouldBe(3.14f, tolerance: 1e-6f); } /// Verifies a Byte array decodes to a typed byte[] (one element per byte). [Fact] public void DecodeArrayBlock_Byte_returns_byte_array() { var block = new byte[] { 0, 42, 200, 255 }; var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Byte, 4), Addr(S7Size.Byte), block); result.ShouldBeOfType().ShouldBe(new byte[] { 0, 42, 200, 255 }); } /// Verifies a Bool array decodes from packed bits (one byte → low bit per element). [Fact] public void DecodeArrayBlock_Bool_returns_bool_array() { // Bit array: one byte per element, low bit carries the value (S7 contiguous bit access // is byte-granular over the wire so each element occupies its own byte slot). var block = new byte[] { 0x01, 0x00, 0x01 }; var result = S7Driver.DecodeArrayBlock( new S7TagDefinition("B", "DB1.DBX0.0", S7DataType.Bool, ArrayCount: 3), new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.Bit, 0, 0), block); result.ShouldBeOfType().ShouldBe(new[] { true, false, true }); } /// Verifies the array length matches the tag's declared count. [Fact] public void DecodeArrayBlock_length_matches_declared_count() { var block = BeWords(10, 20, 30, 40, 50); var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 5), Addr(S7Size.Word), block); result.ShouldBeOfType().Length.ShouldBe(5); } /// Verifies unsupported element types throw NotSupportedException in the array path. /// The unsupported S7 data type. [Theory] [InlineData(S7DataType.Int64)] [InlineData(S7DataType.Float64)] [InlineData(S7DataType.String)] public void DecodeArrayBlock_unsupported_element_type_throws(S7DataType dt) { Should.Throw(() => S7Driver.DecodeArrayBlock(ArrTag(dt, 2), Addr(S7Size.DWord), new byte[16])); } // ── ElementByteSize — block-read sizing ─────────────────────────────────────────────── /// Verifies element byte sizes used to size the contiguous block read. [Theory] [InlineData(S7Size.Bit, 1)] [InlineData(S7Size.Byte, 1)] [InlineData(S7Size.Word, 2)] [InlineData(S7Size.DWord, 4)] public void ElementByteSize_matches_size_width(S7Size size, int expected) => S7Driver.ElementByteSize(size).ShouldBe(expected); // ── Discovery — IsArray / ArrayDim flip ─────────────────────────────────────────────── private sealed class RecordingBuilder : IAddressSpaceBuilder { public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attr) { Variables.Add((browseName, attr)); return new Handle(); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } private sealed class Handle : IVariableHandle { public string FullReference => "stub"; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException(); } } /// Verifies an array tag is discovered with IsArray=true and ArrayDim=count. [Fact] public async Task DiscoverAsync_flips_IsArray_for_array_tag() { var opts = new S7DriverOptions { Host = "192.0.2.1", Tags = [ new("Scalar", "DB1.DBW0", S7DataType.Int16), new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8), ], }; using var drv = new S7Driver(opts, "s7-arr-disco"); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var scalar = builder.Variables.Single(v => v.Name == "Scalar").Attr; scalar.IsArray.ShouldBeFalse(); scalar.ArrayDim.ShouldBeNull(); var arr = builder.Variables.Single(v => v.Name == "Arr").Attr; arr.IsArray.ShouldBeTrue(); arr.ArrayDim.ShouldBe(8u); } /// Verifies a tag with ArrayCount of 1 is treated as a 1-element array (not scalar). /// The foundation materialises a [1] OPC UA array node when IsArray=true, so the driver must /// honour isArray:true + arrayLength:1 — a 1-element array IS a valid array. [Fact] public async Task DiscoverAsync_count_of_one_is_array() { var opts = new S7DriverOptions { Host = "192.0.2.1", Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)], }; using var drv = new S7Driver(opts, "s7-arr-one"); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var one = builder.Variables.Single().Attr; one.IsArray.ShouldBeTrue("ArrayCount=1 is a 1-element array, not scalar"); one.ArrayDim.ShouldBe(1u); } /// Verifies that a tag with a null ArrayCount (scalar) is still discovered as scalar. [Fact] public async Task DiscoverAsync_null_ArrayCount_is_scalar() { var opts = new S7DriverOptions { Host = "192.0.2.1", Tags = [new("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)], }; using var drv = new S7Driver(opts, "s7-arr-null"); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var scalar = builder.Variables.Single().Attr; scalar.IsArray.ShouldBeFalse("null ArrayCount is scalar"); scalar.ArrayDim.ShouldBeNull(); } /// Verifies a read of an isArray:true arrayLength:1 equipment tag returns a 1-element typed array. [Fact] public void DecodeArrayBlock_count_of_one_returns_single_element_array() { // A 1-element Int16 array: one big-endian word. var block = BeWords(0x0064); // 100 var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int16, 1), Addr(S7Size.Word), block); var arr = result.ShouldBeOfType(); arr.Length.ShouldBe(1); arr[0].ShouldBe((short)100); } // ── Equipment-tag resolver threads arrayLength → ArrayCount ─────────────────────────── /// Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount. [Fact] public void EquipmentTagParser_threads_array_length_into_ArrayCount() { var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}"""; S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayCount.ShouldBe(16); } /// Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation). [Fact] public void EquipmentTagParser_ignores_arrayLength_when_isArray_false() { var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}"""; S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true"); } /// Verifies a scalar equipment tag (no array keys) has a null ArrayCount. [Fact] public void EquipmentTagParser_scalar_has_null_ArrayCount() { var json = """{"address":"DB1.DBW0","dataType":"Int16"}"""; S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayCount.ShouldBeNull(); } }