using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for the pure narrow-scalar SPAN codec — /// and . /// 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 /// Plc.ReadBytesAsync) instead of issuing one string Plc.ReadAsync per tag. /// The requirement is byte-for-byte parity with the string-read path, so every arm is /// also asserted against 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 — so they /// are only asserted to fail loudly here. /// [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 ──────────────────────────────────────────────────────────────── /// Width per access size — Bit/Byte = 1, Word = 2, DWord = 4 (matches ElementByteSize). [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); /// The span width agrees with the array-element width for every narrow size. [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 ────────────────────────────────────── /// /// 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 ShouldBe /// coerces across numeric types, so an Int16 surfaced as a boxed int 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. /// [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()); } /// /// PARITY: the span decode returns exactly what the string-read path /// () returns for the same bytes, fed the boxed /// unsigned value S7.Net's ReadAsync would have produced (DBX=bool, DBB=byte, /// DBW=ushort, DBD=uint). This is the requirement the span planner rests on. /// [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 ───────────────────────────────────────────────────────────────── /// /// The slice is positioned at the tag's own first byte and only the leading /// bytes are consumed — trailing bytes belonging /// to the NEXT tag in the block must not leak into the value. /// [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); } /// /// A tag mid-block decodes from its own offset slice — the caller positions the span, the /// codec never re-applies addr.ByteOffset. /// [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().ShouldBe(42.0f); } // ── Mismatches fail loudly ─────────────────────────────────────────────────────────── /// /// A declared type that does not match the parsed access size is a config error, not a /// silent mis-decode (mirrors 's default arm). /// [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( () => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size), bytes)); } /// /// Wide types never belong on the narrow span path (they route through /// ); a mis-routed one must fail here rather than /// decode garbage from a truncated slice. /// [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( () => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(S7Size.DWord), bytes)); } }