Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanCodecTests.cs
T
2026-08-15 21:41:46 -04:00

221 lines
13 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>
/// Unit tests for the pure narrow-scalar SPAN codec —
/// <see cref="S7Driver.DecodeNarrowScalar"/> and <see cref="S7Driver.NarrowScalarWidth"/>.
/// These decode a Bit/Byte/Word/DWord scalar out of a contiguous big-endian byte span (the
/// block a future span-planner will read once per group with a single
/// <c>Plc.ReadBytesAsync</c>) instead of issuing one string <c>Plc.ReadAsync</c> per tag.
/// The requirement is <b>byte-for-byte parity with the string-read path</b>, so every arm is
/// also asserted against <see cref="S7Driver.ReinterpretRawValue"/> fed the exact boxed value
/// S7.Net's string read would have returned for the same bytes (bool / byte / ushort / uint).
/// Wide types (Int64/UInt64/Float64/String/DateTime) and the Timer/Counter areas are NOT part
/// of the narrow path — they route through <see cref="S7Driver.DecodeScalarBlock"/> — so they
/// are only asserted to fail loudly here.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7SpanCodecTests
{
// ── Helpers ──────────────────────────────────────────────────────────────────────────
// The address string is only used in error messages; the decode routes on DataType + Size.
private static S7TagDefinition Tag(S7DataType dt) => new("SpanTag", "DB1.DBW0", dt);
private static S7ParsedAddress Addr(S7Size size, int bitOffset = 0) =>
new(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: bitOffset);
// ── NarrowScalarWidth ────────────────────────────────────────────────────────────────
/// <summary>Width per access size — Bit/Byte = 1, Word = 2, DWord = 4 (matches ElementByteSize).</summary>
[Theory]
[InlineData(S7Size.Bit, 1)]
[InlineData(S7Size.Byte, 1)]
[InlineData(S7Size.Word, 2)]
[InlineData(S7Size.DWord, 4)]
public void NarrowScalarWidth_matches_the_access_size(S7Size size, int expected)
=> S7Driver.NarrowScalarWidth(Addr(size)).ShouldBe(expected);
/// <summary>The span width agrees with the array-element width for every narrow size.</summary>
[Theory]
[InlineData(S7Size.Bit)]
[InlineData(S7Size.Byte)]
[InlineData(S7Size.Word)]
[InlineData(S7Size.DWord)]
public void NarrowScalarWidth_agrees_with_ElementByteSize(S7Size size)
=> S7Driver.NarrowScalarWidth(Addr(size)).ShouldBe(S7Driver.ElementByteSize(size));
// ── DecodeNarrowScalar: the type × bytes matrix ──────────────────────────────────────
/// <summary>
/// Known big-endian byte patterns decode to the exact CLR value AND the exact CLR box type.
/// The runtime type is asserted SEPARATELY and deliberately: Shouldly's <c>ShouldBe</c>
/// coerces across numeric types, so an <c>Int16</c> surfaced as a boxed <c>int</c> passes
/// the value assertion alone (verified by falsifying the Int16 arm) — and the box type is
/// the contract, since the publish pipeline and the OPC UA variable's DataType key off it.
/// Covers both sign edges of every signed width.
/// </summary>
[Theory]
// Bool — bit extracted at BitOffset, set and clear, at both ends of the byte.
[InlineData(new byte[] { 0x01 }, S7DataType.Bool, S7Size.Bit, 0, true)]
[InlineData(new byte[] { 0xFE }, S7DataType.Bool, S7Size.Bit, 0, false)]
[InlineData(new byte[] { 0x80 }, S7DataType.Bool, S7Size.Bit, 7, true)]
[InlineData(new byte[] { 0x7F }, S7DataType.Bool, S7Size.Bit, 7, false)]
[InlineData(new byte[] { 0x08 }, S7DataType.Bool, S7Size.Bit, 3, true)]
[InlineData(new byte[] { 0xF7 }, S7DataType.Bool, S7Size.Bit, 3, false)]
[InlineData(new byte[] { 0xFF }, S7DataType.Bool, S7Size.Bit, 5, true)]
[InlineData(new byte[] { 0x00 }, S7DataType.Bool, S7Size.Bit, 0, false)]
// Byte — the whole first byte, unsigned.
[InlineData(new byte[] { 0x00 }, S7DataType.Byte, S7Size.Byte, 0, (byte)0)]
[InlineData(new byte[] { 0xAB }, S7DataType.Byte, S7Size.Byte, 0, (byte)0xAB)]
[InlineData(new byte[] { 0xFF }, S7DataType.Byte, S7Size.Byte, 0, (byte)0xFF)]
// UInt16 / Int16 — the SAME big-endian word, two semantic types (the sign edge).
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.UInt16, S7Size.Word, 0, (ushort)32768)]
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.Int16, S7Size.Word, 0, (short)-32768)]
[InlineData(new byte[] { 0x7F, 0xFF }, S7DataType.UInt16, S7Size.Word, 0, (ushort)32767)]
[InlineData(new byte[] { 0x7F, 0xFF }, S7DataType.Int16, S7Size.Word, 0, (short)32767)]
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.UInt16, S7Size.Word, 0, (ushort)65535)]
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.Int16, S7Size.Word, 0, (short)-1)]
[InlineData(new byte[] { 0x01, 0x02 }, S7DataType.UInt16, S7Size.Word, 0, (ushort)0x0102)]
[InlineData(new byte[] { 0x01, 0x02 }, S7DataType.Int16, S7Size.Word, 0, (short)0x0102)]
// UInt32 / Int32 — the SAME big-endian dword, two semantic types (the sign edge).
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.UInt32, S7Size.DWord, 0, 2147483648u)]
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.Int32, S7Size.DWord, 0, int.MinValue)]
[InlineData(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF }, S7DataType.UInt32, S7Size.DWord, 0, 2147483647u)]
[InlineData(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF }, S7DataType.Int32, S7Size.DWord, 0, int.MaxValue)]
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, S7DataType.UInt32, S7Size.DWord, 0, 4294967295u)]
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, S7DataType.Int32, S7Size.DWord, 0, -1)]
[InlineData(new byte[] { 0x00, 0x00, 0x01, 0x02 }, S7DataType.UInt32, S7Size.DWord, 0, 258u)]
[InlineData(new byte[] { 0x00, 0x00, 0x01, 0x02 }, S7DataType.Int32, S7Size.DWord, 0, 258)]
// Float32 — IEEE-754 single from the big-endian dword.
[InlineData(new byte[] { 0x42, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 42.0f)]
[InlineData(new byte[] { 0xC2, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, -42.0f)]
[InlineData(new byte[] { 0x00, 0x00, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 0.0f)]
[InlineData(new byte[] { 0x3F, 0x80, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 1.0f)]
public void DecodeNarrowScalar_decodes_known_big_endian_patterns(
byte[] bytes, S7DataType dt, S7Size size, int bitOffset, object expected)
{
var actual = S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size, bitOffset), bytes);
actual.ShouldBe(expected);
actual.GetType().ShouldBe(expected.GetType());
}
/// <summary>
/// PARITY: the span decode returns exactly what the string-read path
/// (<see cref="S7Driver.ReinterpretRawValue"/>) returns for the same bytes, fed the boxed
/// unsigned value S7.Net's <c>ReadAsync</c> would have produced (DBX=bool, DBB=byte,
/// DBW=ushort, DBD=uint). This is the requirement the span planner rests on.
/// </summary>
[Theory]
[InlineData(new byte[] { 0x80 }, S7DataType.Bool, S7Size.Bit, 7)]
[InlineData(new byte[] { 0x7F }, S7DataType.Bool, S7Size.Bit, 7)]
[InlineData(new byte[] { 0x01 }, S7DataType.Bool, S7Size.Bit, 0)]
[InlineData(new byte[] { 0xAB }, S7DataType.Byte, S7Size.Byte, 0)]
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.UInt16, S7Size.Word, 0)]
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.Int16, S7Size.Word, 0)]
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.Int16, S7Size.Word, 0)]
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.UInt32, S7Size.DWord, 0)]
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.Int32, S7Size.DWord, 0)]
[InlineData(new byte[] { 0x42, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0)]
[InlineData(new byte[] { 0xC2, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0)]
public void DecodeNarrowScalar_matches_the_string_read_path(
byte[] bytes, S7DataType dt, S7Size size, int bitOffset)
{
var tag = Tag(dt);
var addr = Addr(size, bitOffset);
// What S7.Net's string ReadAsync would have boxed for these exact wire bytes.
object raw = size switch
{
S7Size.Bit => (bytes[0] & (1 << bitOffset)) != 0,
S7Size.Byte => bytes[0],
S7Size.Word => (ushort)((bytes[0] << 8) | bytes[1]),
_ => ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3],
};
var fromSpan = S7Driver.DecodeNarrowScalar(tag, addr, bytes);
var fromString = S7Driver.ReinterpretRawValue(tag, addr, raw);
fromSpan.ShouldBe(fromString);
// Value parity is not enough — ShouldBe coerces numerics, so the box type is asserted too
// (an Int16 tag published as a boxed int would change its OPC UA DataType).
fromSpan.GetType().ShouldBe(fromString.GetType());
}
// ── Span positioning ─────────────────────────────────────────────────────────────────
/// <summary>
/// The slice is positioned at the tag's own first byte and only the leading
/// <see cref="S7Driver.NarrowScalarWidth"/> bytes are consumed — trailing bytes belonging
/// to the NEXT tag in the block must not leak into the value.
/// </summary>
[Fact]
public void DecodeNarrowScalar_reads_only_the_leading_width_of_a_longer_slice()
{
// A 6-byte "block": [word 0x0102][dword 0x7FFFFFFF trailing garbage after it].
byte[] block = [0x01, 0x02, 0x7F, 0xFF, 0xFF, 0xFF];
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Int16), Addr(S7Size.Word), block)
.ShouldBe((short)0x0102);
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Byte), Addr(S7Size.Byte), block)
.ShouldBe((byte)0x01);
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Bool), Addr(S7Size.Bit, 0), block)
.ShouldBe(true);
}
/// <summary>
/// A tag mid-block decodes from its own offset slice — the caller positions the span, the
/// codec never re-applies <c>addr.ByteOffset</c>.
/// </summary>
[Fact]
public void DecodeNarrowScalar_decodes_from_a_mid_block_slice()
{
byte[] block = [0xDE, 0xAD, 0x42, 0x28, 0x00, 0x00, 0xBE, 0xEF];
// The Float32 lives at block byte 2 — the address's own ByteOffset is irrelevant here.
var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 1, S7Size.DWord, ByteOffset: 2, BitOffset: 0);
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Float32), addr, block.AsSpan(2))
.ShouldBeOfType<float>().ShouldBe(42.0f);
}
// ── Mismatches fail loudly ───────────────────────────────────────────────────────────
/// <summary>
/// A declared type that does not match the parsed access size is a config error, not a
/// silent mis-decode (mirrors <see cref="S7Driver.ReinterpretRawValue"/>'s default arm).
/// </summary>
[Theory]
[InlineData(S7DataType.Int16, S7Size.DWord)]
[InlineData(S7DataType.Float32, S7Size.Word)]
[InlineData(S7DataType.Bool, S7Size.Byte)]
[InlineData(S7DataType.Byte, S7Size.Word)]
public void DecodeNarrowScalar_rejects_a_type_size_mismatch(S7DataType dt, S7Size size)
{
byte[] bytes = [0x00, 0x00, 0x00, 0x00];
Should.Throw<System.IO.InvalidDataException>(
() => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size), bytes));
}
/// <summary>
/// Wide types never belong on the narrow span path (they route through
/// <see cref="S7Driver.DecodeScalarBlock"/>); a mis-routed one must fail here rather than
/// decode garbage from a truncated slice.
/// </summary>
[Theory]
[InlineData(S7DataType.Int64)]
[InlineData(S7DataType.UInt64)]
[InlineData(S7DataType.Float64)]
[InlineData(S7DataType.String)]
[InlineData(S7DataType.DateTime)]
public void DecodeNarrowScalar_rejects_wide_types(S7DataType dt)
{
byte[] bytes = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
Should.Throw<System.IO.InvalidDataException>(
() => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(S7Size.DWord), bytes));
}
}