using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// #739 U9: array writes and STRING writes go through the same codecs the read side decodes with. /// Arrays were advertised writable but every write threw InvalidCastException (reported BadTypeMismatch); /// strings bypassed the codec (host-ANSI encoding, no length check against STRING(n)). /// [Trait("Category", "Unit")] public sealed class TwinCATArrayStringWriteTests { private const string Host = "ads://5.23.91.23.1.1:851"; // ---- string codec: encode ---- /// A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252. [Fact] public void EncodeString_is_NUL_padded_Windows1252_at_the_PLC_width() { TwinCATStringCodec.EncodeString("AB", 4, utf8: false).ShouldBe(new byte[] { 0x41, 0x42, 0, 0 }); TwinCATStringCodec.EncodeString("ä€", 3, utf8: false).ShouldBe(new byte[] { 0xE4, 0x80, 0 }); TwinCATStringCodec.EncodeString("ä", 3, utf8: true).ShouldBe(new byte[] { 0xC3, 0xA4, 0 }); TwinCATStringCodec.EncodeString("", 2, utf8: false).ShouldBe(new byte[] { 0, 0 }); } /// The encode is the decode's inverse (a STRING(n) of exactly n characters fits). [Fact] public void EncodeString_round_trips_through_DecodeString() { foreach (var utf8 in new[] { false, true }) { var bytes = TwinCATStringCodec.EncodeString("Grüße 40°", 81, utf8); TwinCATStringCodec.DecodeString(bytes, utf8).ShouldBe("Grüße 40°"); } TwinCATStringCodec.EncodeString("ABC", 4, utf8: false).Length.ShouldBe(4); } /// A value longer than STRING(n), or a character the target encoding cannot hold, is out of range. [Fact] public void EncodeString_refuses_a_value_that_does_not_fit() { Should.Throw(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false)); Should.Throw(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false)); Should.Throw(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL } /// A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips. [Fact] public void EncodeWString_is_UTF16LE_with_a_terminator() { TwinCATStringCodec.EncodeWString("Hi", 6).ShouldBe(new byte[] { 0x48, 0, 0x69, 0, 0, 0 }); TwinCATStringCodec.DecodeWString(TwinCATStringCodec.EncodeWString("中文", 22)).ShouldBe("中文"); Should.Throw(() => TwinCATStringCodec.EncodeWString("Hi", 4)); } // ---- array codec: elements + encode ---- /// /// A CLR list converts element-wise to the declared type (ScadaBridge writes an int[] to an /// Int16 node); the element type is converted, not exact-matched. /// [Fact] public void ToWriteElements_converts_each_element_to_the_declared_type() { var elements = TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, -2, 300 }, 3); elements.ShouldBe(new object[] { (short)1, (short)-2, (short)300 }); TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Real, new List { 1, 2.5 }, 2) .ShouldBe(new object[] { 1f, 2.5f }); TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2) .ShouldBe(new object[] { "a", "b" }); } /// A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch. [Fact] public void ToWriteElements_refuses_a_wrong_shape() { Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3)); Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1)); Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1)); Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3)); Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1)); } /// Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode. [Fact] public void Encode_is_the_inverse_of_Decode() { var intLayout = new TwinCATArrayLayout(2, 2); var intBytes = TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1, (short)-2], intLayout, utf8: false); intBytes.ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); RoundTrip(TwinCATDataType.Bool, [true, false, true], new TwinCATArrayLayout(3, 1)); RoundTrip(TwinCATDataType.DInt, [int.MinValue, 7], new TwinCATArrayLayout(2, 4)); RoundTrip(TwinCATDataType.ULInt, [ulong.MaxValue], new TwinCATArrayLayout(1, 8)); RoundTrip(TwinCATDataType.LReal, [1.5, -2.25], new TwinCATArrayLayout(2, 8)); RoundTrip(TwinCATDataType.String, ["ab", "Grüße"], new TwinCATArrayLayout(2, 11)); RoundTrip(TwinCATDataType.WString, ["中", "x"], new TwinCATArrayLayout(2, 8)); // TIME elements arrive as the encoded uint ms (ConvertForWrite) and decode back to double ms. var time = TwinCATArrayCodec.Encode(TwinCATDataType.Time, TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Time, new[] { 1500.0, 20.0 }, 2), new TwinCATArrayLayout(2, 4), false); TwinCATArrayCodec.Decode(TwinCATDataType.Time, time, new TwinCATArrayLayout(2, 4), false) .ShouldBe(new[] { 1500.0, 20.0 }); } /// A string element longer than the element's STRING(n) is out of range. [Fact] public void Encode_refuses_a_string_element_wider_than_its_stride() { Should.Throw(() => TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false)); Should.Throw(() => TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1], new TwinCATArrayLayout(2, 2), utf8: false)); } private static void RoundTrip(TwinCATDataType type, object[] elements, TwinCATArrayLayout layout) { var bytes = TwinCATArrayCodec.Encode(type, elements, layout, utf8: false); bytes.Length.ShouldBe(layout.ByteLength); var decoded = TwinCATArrayCodec.Decode(type, bytes, layout, utf8: false); decoded.Cast().ShouldBe(elements); } // ---- driver ---- private static (TwinCATDriver drv, FakeTwinCATClient fake) NewDriver(params TwinCATTagDefinition[] tags) { var fake = new FakeTwinCATClient(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries(tags), Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake }); return (drv, fake); } /// An array tag's write sends ArrayLength converted elements through WriteArrayAsync. [Fact] public async Task An_array_write_sends_converted_elements() { var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var result = await drv.WriteAsync([new WriteRequest("Buf", new[] { 1, 2, 3 })], TestContext.Current.CancellationToken); result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); var write = fake.ArrayWriteLog.ShouldHaveSingleItem(); write.symbol.ShouldBe("MAIN.Buf"); write.elements.ShouldBe(new object[] { (short)1, (short)2, (short)3 }); fake.WriteLog.ShouldBeEmpty(); // never the scalar path (that threw InvalidCastException) } /// /// A length mismatch is BadOutOfRange and a scalar value BadTypeMismatch — nothing reaches the wire /// and health is untouched (a refusal is not a comms fault). Positive control: the good write above. /// [Fact] public async Task A_wrong_shape_array_write_is_refused_before_the_wire() { var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var results = await drv.WriteAsync( [ new WriteRequest("Buf", new[] { 1, 2 }), new WriteRequest("Buf", 5), new WriteRequest("Buf", new[] { 1, 2, 70_000 }), ], TestContext.Current.CancellationToken); results.Select(r => r.StatusCode).ShouldBe( [ TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch, TwinCATStatusMapper.BadOutOfRange, ]); fake.ArrayWriteLog.ShouldBeEmpty(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault. [Fact] public async Task An_over_long_string_write_is_BadOutOfRange() { var (drv, fake) = NewDriver(new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); fake.ThrowOnWrite = true; fake.Exception = new OverflowException("6 bytes do not fit STRING(4)"); var result = await drv.WriteAsync([new WriteRequest("Name", "toolong")], TestContext.Current.CancellationToken); result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } }