using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes /// Operate-writable, but both runtimes encoded a SCALAR: an int[] threw InvalidCastException (BadTypeMismatch /// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only /// element 0 patched. The driver now encodes count elements at the element width, refuses a length mismatch /// with BadOutOfRange, and converts element values (ScadaBridge writes int[] to INT arrays). /// [Trait("Category", "Unit")] public sealed class AbCipArrayWriteTests { private const string Device = "ab://10.0.0.5/1,0"; private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync( AbCipDataType type, int count, int size = -1) { var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)), Probe = new AbCipProbeOptions { Enabled = false }, }, "abcip-array-write", factory); await drv.InitializeAsync("{}", CancellationToken.None); return (drv, factory); } // ---- the codec ---- /// Each element is converted and laid out little-endian at the element width. [Fact] public void The_codec_converts_elements_and_lays_them_out_at_the_element_width() { AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01")); AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05")); AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000")); AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040")); AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF")); Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000])); Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true])); } // ---- the driver ---- /// ScadaBridge's wire shape: an int[] to an INT[3] encodes 3 two-byte elements and writes once, Good. [Fact] public async Task An_int_array_to_an_INT_array_encodes_every_element() { var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6); var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); var tag = factory.Tags["Arr"]; tag.EncodedArrayType.ShouldBe(AbCipDataType.Int); tag.EncodedArrayCount.ShouldBe(3); tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300")); tag.WriteCount.ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A list of a different length is BadOutOfRange; nothing is created, encoded or written. /// The client's element count. [Theory] [InlineData(2)] [InlineData(4)] public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length) { var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3); var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only. [Fact] public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch() { var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3); var r = await drv.WriteAsync( [new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); factory.Tags.ShouldNotContainKey("Arr"); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// An element that does not fit is BadOutOfRange with no health write and nothing sent. [Fact] public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent() { var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4); var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); factory.Tags["Arr"].WriteCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// BOOL / STRING arrays are refused BadNotSupported before any runtime exists. /// The array element type. [Theory] [InlineData(AbCipDataType.Bool)] [InlineData(AbCipDataType.String)] public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type) { var (drv, factory) = await BuildAsync(type, 2); var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); factory.Tags.ShouldNotContainKey("Arr"); } /// The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent. [Fact] public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent() { var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); factory.Tags["Arr"].WriteCount.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// End to end over the REAL native runtime: an int[] to an INT[3] goes out as ONE Write Tag of type /// 0xC3 with element count 3 and the three 2-byte elements. /// [Fact] public async Task A_native_array_write_is_one_write_of_count_elements() { await using var target = new FakeCipTarget(); target.ConnectedHandler = req => Task.FromResult(req[0] switch { 0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0 0x4D => [0xCD, 0x00, 0x00, 0x00], _ => target.ServiceReply(req), }); var host = $"ab://127.0.0.1:{target.Port}/1,0"; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)), Probe = new AbCipProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), }, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool())); await drv.InitializeAsync("{}", CancellationToken.None); try { var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); var write = target.ConnectedCips.Single(c => c[0] == 0x4D); var typeAt = 2 + write[1] * 2; write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01")); } finally { await drv.DisposeAsync(); } } }