Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs
Joseph Doherty 316f820eff Auto: s7-a2 — STRING/WSTRING/CHAR/WCHAR
Closes the NotSupportedException cliff for S7 string-shaped types.

- S7DataType gains WString, Char, WChar members alongside the existing
  String entry.
- New S7StringCodec encodes/decodes the four wire formats:
    STRING  : 2-byte header (max-len + actual-len bytes) + N ASCII bytes
              -> total 2 + max_len.
    WSTRING : 4-byte header (max-len + actual-len UInt16 BE) + N×2
              UTF-16BE bytes -> total 4 + 2 × max_len.
    CHAR    : 1 ASCII byte (rejects non-ASCII on encode).
    WCHAR   : 2 UTF-16BE bytes.
  Header-bug clamp: actualLen > maxLen is silently clamped on read so
  firmware quirks don't walk past the wire buffer; rejected on write
  to avoid silent truncation.
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
  WriteBytesAsync against the parsed Area / DbNumber / ByteOffset and
  honour S7TagDefinition.StringLength (default 254 = S7 STRING max).
- MapDataType returns DriverDataType.String for the three new enum
  members so OPC UA discovery surfaces them as scalar strings.

Tests: 21 new cases on S7StringCodec covering golden-byte vectors,
encode/decode round-trips, the firmware-bug header-clamp, ASCII-only
guard on CHAR, and the StringLength default. 85/85 passing.

Closes #288
2026-04-25 16:26:05 -04:00

229 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Golden-byte unit tests for <see cref="S7StringCodec"/>: 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.
/// </summary>
[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<byte>(10); // declared max
bytes[1].ShouldBe<byte>(5); // actual length
// ASCII payload
bytes[2].ShouldBe<byte>((byte)'H');
bytes[3].ShouldBe<byte>((byte)'E');
bytes[4].ShouldBe<byte>((byte)'L');
bytes[5].ShouldBe<byte>((byte)'L');
bytes[6].ShouldBe<byte>((byte)'O');
// Padding bytes left as 0x00.
for (var i = 7; i < bytes.Length; i++) bytes[i].ShouldBe<byte>(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<ArgumentException>(() => 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<System.IO.InvalidDataException>(() => 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<byte>(0x00); // maxLen high
bytes[1].ShouldBe<byte>(0x04); // maxLen low
bytes[2].ShouldBe<byte>(0x00); // actualLen high
bytes[3].ShouldBe<byte>(0x02); // actualLen low
bytes[4].ShouldBe<byte>(0x00); // 'H' high (BE)
bytes[5].ShouldBe<byte>(0x48); // 'H' low
bytes[6].ShouldBe<byte>(0x00); // 'i' high
bytes[7].ShouldBe<byte>(0x69); // 'i' low
// Padding bytes [8..11] left as 0x00.
bytes[8].ShouldBe<byte>(0);
bytes[9].ShouldBe<byte>(0);
bytes[10].ShouldBe<byte>(0);
bytes[11].ShouldBe<byte>(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<ArgumentException>(() => S7StringCodec.EncodeWString("TOO-LONG", maxLen: 4));
}
[Fact]
public void DecodeWString_rejects_wrong_length_buffer()
{
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(0x41);
}
[Fact]
public void DecodeChar_round_trips()
{
S7StringCodec.DecodeChar(new byte[] { 0x5A }).ShouldBe('Z');
}
[Fact]
public void EncodeChar_rejects_non_ascii()
{
Should.Throw<ArgumentException>(() => S7StringCodec.EncodeChar('é'));
}
[Fact]
public void DecodeChar_rejects_wrong_length()
{
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(0x00);
b[1].ShouldBe<byte>(0x5A);
}
[Fact]
public void EncodeWChar_handles_unicode_codepoint()
{
// U+00E9 (é) -> 00 E9 BE
var b = S7StringCodec.EncodeWChar('é');
b[0].ShouldBe<byte>(0x00);
b[1].ShouldBe<byte>(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<System.IO.InvalidDataException>(() => S7StringCodec.DecodeWChar(new byte[1]));
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(254);
bytes[1].ShouldBe<byte>(100);
S7StringCodec.DecodeString(bytes, 254).ShouldBe(s);
}
[Fact]
public void EncodeString_rejects_max_length_above_254()
{
Should.Throw<ArgumentOutOfRangeException>(() => S7StringCodec.EncodeString("x", maxLen: 255));
}
}