using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Golden-byte unit tests for : STRING / WSTRING / CHAR / /// WCHAR encode + decode round-trips and the firmware-bug header-clamp on read. /// These tests intentionally don't touch S7.Net — the codec operates on raw byte /// spans so reproducing the wire format here is sufficient to lock the contract. /// [Trait("Category", "Unit")] public sealed class S7StringCodecTests { // -------- STRING -------- [Fact] public void EncodeString_emits_two_byte_header_and_ascii_payload() { var bytes = S7StringCodec.EncodeString("HELLO", maxLen: 10); bytes.Length.ShouldBe(2 + 10); // 2-byte header + max-len slot bytes[0].ShouldBe(10); // declared max bytes[1].ShouldBe(5); // actual length // ASCII payload bytes[2].ShouldBe((byte)'H'); bytes[3].ShouldBe((byte)'E'); bytes[4].ShouldBe((byte)'L'); bytes[5].ShouldBe((byte)'L'); bytes[6].ShouldBe((byte)'O'); // Padding bytes left as 0x00. for (var i = 7; i < bytes.Length; i++) bytes[i].ShouldBe(0); } [Fact] public void DecodeString_round_trips_encode() { var bytes = S7StringCodec.EncodeString("ABC", maxLen: 16); var decoded = S7StringCodec.DecodeString(bytes, maxLen: 16); decoded.ShouldBe("ABC"); } [Fact] public void DecodeString_clamps_when_actualLen_exceeds_maxLen_firmware_bug() { // Hand-craft a buffer where actualLen (255) > maxLen (10). Real firmware bug // observed on legacy CP modules. Codec must clamp to maxLen rather than walk // off the end of the wire buffer. var max = 10; var buf = new byte[2 + max]; buf[0] = (byte)max; buf[1] = 255; // out-of-spec actual for (var i = 0; i < max; i++) buf[2 + i] = (byte)('A' + i); var s = S7StringCodec.DecodeString(buf, max); s.Length.ShouldBe(max); s.ShouldBe("ABCDEFGHIJ"); } [Fact] public void DecodeString_empty_actual_len_returns_empty_string() { var buf = new byte[2 + 8]; buf[0] = 8; buf[1] = 0; S7StringCodec.DecodeString(buf, 8).ShouldBe(string.Empty); } [Fact] public void EncodeString_rejects_value_longer_than_maxLen() { Should.Throw(() => S7StringCodec.EncodeString("TOO-LONG", maxLen: 4)); } [Fact] public void DecodeString_rejects_wrong_length_buffer() { // 2 + 5 expected, give 3 — must throw rather than silently read. var buf = new byte[3]; Should.Throw(() => S7StringCodec.DecodeString(buf, 5)); } // -------- WSTRING -------- [Fact] public void EncodeWString_emits_four_byte_header_and_utf16be_payload() { // "Hi" -> H = 0x0048, i = 0x0069. UTF-16 BE wire bytes 00 48 00 69. var bytes = S7StringCodec.EncodeWString("Hi", maxLen: 4); bytes.Length.ShouldBe(4 + 2 * 4); // 4-byte header + 2 × max-len bytes bytes[0].ShouldBe(0x00); // maxLen high bytes[1].ShouldBe(0x04); // maxLen low bytes[2].ShouldBe(0x00); // actualLen high bytes[3].ShouldBe(0x02); // actualLen low bytes[4].ShouldBe(0x00); // 'H' high (BE) bytes[5].ShouldBe(0x48); // 'H' low bytes[6].ShouldBe(0x00); // 'i' high bytes[7].ShouldBe(0x69); // 'i' low // Padding bytes [8..11] left as 0x00. bytes[8].ShouldBe(0); bytes[9].ShouldBe(0); bytes[10].ShouldBe(0); bytes[11].ShouldBe(0); } [Fact] public void DecodeWString_round_trips_unicode() { // U+00E9 (é) — non-ASCII, exercises the BE encoding. var input = "café"; var bytes = S7StringCodec.EncodeWString(input, maxLen: 8); var decoded = S7StringCodec.DecodeWString(bytes, maxLen: 8); decoded.ShouldBe(input); } [Fact] public void DecodeWString_clamps_when_actualLen_exceeds_maxLen_firmware_bug() { var max = 4; var buf = new byte[4 + 2 * max]; // Header: max=4, actual=0xFFFF (firmware-bug). buf[0] = 0x00; buf[1] = (byte)max; buf[2] = 0xFF; buf[3] = 0xFF; // Payload: 'A','B','C','D' (BE). buf[4] = 0x00; buf[5] = (byte)'A'; buf[6] = 0x00; buf[7] = (byte)'B'; buf[8] = 0x00; buf[9] = (byte)'C'; buf[10] = 0x00; buf[11] = (byte)'D'; var s = S7StringCodec.DecodeWString(buf, max); s.ShouldBe("ABCD"); // clamped to maxLen × 2 bytes } [Fact] public void EncodeWString_rejects_value_longer_than_maxLen() { Should.Throw(() => S7StringCodec.EncodeWString("TOO-LONG", maxLen: 4)); } [Fact] public void DecodeWString_rejects_wrong_length_buffer() { Should.Throw(() => S7StringCodec.DecodeWString(new byte[5], maxLen: 4)); } // -------- CHAR -------- [Fact] public void EncodeChar_emits_single_ascii_byte() { var b = S7StringCodec.EncodeChar('A'); b.Length.ShouldBe(1); b[0].ShouldBe(0x41); } [Fact] public void DecodeChar_round_trips() { S7StringCodec.DecodeChar(new byte[] { 0x5A }).ShouldBe('Z'); } [Fact] public void EncodeChar_rejects_non_ascii() { Should.Throw(() => S7StringCodec.EncodeChar('é')); } [Fact] public void DecodeChar_rejects_wrong_length() { Should.Throw(() => S7StringCodec.DecodeChar(new byte[2])); } // -------- WCHAR -------- [Fact] public void EncodeWChar_emits_two_bytes_big_endian() { var b = S7StringCodec.EncodeWChar('Z'); b.Length.ShouldBe(2); b[0].ShouldBe(0x00); b[1].ShouldBe(0x5A); } [Fact] public void EncodeWChar_handles_unicode_codepoint() { // U+00E9 (é) -> 00 E9 BE var b = S7StringCodec.EncodeWChar('é'); b[0].ShouldBe(0x00); b[1].ShouldBe(0xE9); } [Fact] public void DecodeWChar_round_trips() { S7StringCodec.DecodeWChar(new byte[] { 0x00, 0x5A }).ShouldBe('Z'); S7StringCodec.DecodeWChar(new byte[] { 0x00, 0xE9 }).ShouldBe('é'); } [Fact] public void DecodeWChar_rejects_wrong_length() { Should.Throw(() => S7StringCodec.DecodeWChar(new byte[1])); Should.Throw(() => S7StringCodec.DecodeWChar(new byte[3])); } // -------- StringLength default + range -------- [Fact] public void EncodeString_default_max_length_254_round_trips() { // Default S7TagDefinition.StringLength is 254; codec must accept that. var s = new string('x', 100); var bytes = S7StringCodec.EncodeString(s, 254); bytes.Length.ShouldBe(2 + 254); bytes[0].ShouldBe(254); bytes[1].ShouldBe(100); S7StringCodec.DecodeString(bytes, 254).ShouldBe(s); } [Fact] public void EncodeString_rejects_max_length_above_254() { Should.Throw(() => S7StringCodec.EncodeString("x", maxLen: 255)); } }