using System.Buffers.Binary; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an /// int[] threw into BadTypeMismatch + Degraded, and a scalar to an /// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes /// exactly count elements at the element width, converting each element (ScadaBridge writes CLR lists /// such as int[] to Int16 nodes), and refuses a length mismatch with BadOutOfRange. /// [Trait("Category", "Unit")] public sealed class AbLegacyArrayWriteTests { private const string Host = "ab://10.0.0.5/1,0"; private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public Array? Encoded { get; private set; } public AbLegacyDataType? EncodedType { get; private set; } public override void EncodeArray(AbLegacyDataType type, Array values) { EncodedType = type; Encoded = values; } } private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync( AbLegacyDataType type, string address, int length) { var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-u10", factory); await drv.InitializeAsync("{}", CancellationToken.None); return (drv, factory); } public static TheoryData IntPayloads => ["int[]", "short[]", "List", "object[]"]; private static object Payload(string kind) => kind switch { "int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node "short[]" => new short[] { 1, -2, 3 }, "List" => new List { 1, -2, 3 }, _ => new object[] { 1L, (short)-2, 3.0 }, }; [Theory] [MemberData(nameof(IntPayloads))] public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind) { var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3); var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var tag = (RecordingTag)f.Tags["N7:0"]; tag.CreationParams.ElementCount.ShouldBe(3); tag.EncodedType.ShouldBe(AbLegacyDataType.Int); tag.Encoded.ShouldBe(new short[] { 1, -2, 3 }); tag.WriteCount.ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// Review 07 r0 I3: every data file type encodes at ITS element width (one case per file). [Theory] [InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] [InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)] [InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] [InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] [InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)] [InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)] public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth) { var (drv, f) = await NewAsync(authored, address, 2); var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var tag = (RecordingTag)f.Tags[address]; tag.EncodedType.ShouldBe(fileWidth); tag.Encoded!.GetType().ShouldBe(fileWidth switch { AbLegacyDataType.Long => typeof(int[]), AbLegacyDataType.Float => typeof(float[]), _ => typeof(short[]), }); tag.WriteCount.ShouldBe(1); } /// /// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on /// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the /// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists. /// [Theory] [InlineData("F8:0", AbLegacyDataType.Int)] [InlineData("N7:0", AbLegacyDataType.Long)] [InlineData("N7:0", AbLegacyDataType.Float)] [InlineData("L9:0", AbLegacyDataType.Float)] [InlineData("L9:0", AbLegacyDataType.Int)] public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored) { var (drv, f) = await NewAsync(authored, address, 4); var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire drv.GetHealth().State.ShouldBe(DriverState.Healthy); AbLegacyTagDefinitionFactory.Inspect( $$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""") .ShouldContain(w => w.Contains("element width")); // and the deploy gate names it } /// Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too. [Theory] [InlineData("N7:[N7:0]", "Float", true)] [InlineData("F[N7:0]:2", "Int", true)] [InlineData("N7:[N7:0]", "Int", false)] [InlineData("F[N7:0]:2", "Float", false)] public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch) { var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}"""; Enum.TryParse(dataType, out var type).ShouldBeTrue(); (AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch); AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch); } [Fact] public async Task Positive_control_a_Float_array_on_an_F_file_is_written() { var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4); (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0] .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); ((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f }); AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""") .ShouldBeEmpty(); } [Fact] public async Task Float_and_Long_arrays_encode_at_their_element_width() { var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2); (await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0] .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); ((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f }); var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2); (await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0] .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); ((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 }); } [Theory] [InlineData(2)] [InlineData(4)] public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied) { var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3); var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent() { var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2); var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); } [Theory] [InlineData(5)] [InlineData("1,2")] public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload) { var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2); var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None); var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime() { // Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read. var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16); var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); f.Tags.ShouldBeEmpty(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Native_array_encode_writes_every_element_at_its_offset() { var channel = new FakeCipChannel(); await using var scheduler = new RequestScheduler(channel, allowPacking: false); var rt = new NativeAbLegacyTagRuntime( new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3), scheduler, 0xF33D, 0x21504345); channel.Respond = req => Task.FromResult(NativeEcho(req)); rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 }); await rt.WriteAsync(CancellationToken.None); var data = channel.Sent.Single()[23..]; data.Length.ShouldBe(6); BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10); BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1); BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300); rt.Dispose(); } [Fact] public void Native_array_encode_refuses_a_length_other_than_the_runtime_count() { var channel = new FakeCipChannel(); var scheduler = new RequestScheduler(channel, allowPacking: false); var rt = new NativeAbLegacyTagRuntime( new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3), scheduler, 0xF33D, 0x21504345); Should.Throw(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2])); rt.Dispose(); } // Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data. private static byte[] NativeEcho(byte[] req) { var body = new List(req[6..13]) { 0x4F, 0x00, req[15], req[16] }; return FakeCipChannel.Reply(0x4B, 0x00, [.. body]); } }