Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs
T

372 lines
18 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;
using S7.Net.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure
/// <see cref="S7Driver.DecodeScalarBlock"/> / <see cref="S7Driver.EncodeScalarBlock"/>
/// helpers and <see cref="S7Driver.ScalarByteWidth"/>. These decode/encode an
/// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the
/// network I/O half (<c>Plc.ReadBytesAsync</c>/<c>WriteBytesAsync</c>) has no in-process
/// fake so only the codec is unit-proven (mirrors <see cref="S7ArrayReadTests"/>).
/// String (S7 classic STRING) decode/encode is proven here via
/// <see cref="S7.Net.Types.S7String"/>; DateTime (S7 classic DATE_AND_TIME / DT, 8-byte BCD)
/// decode/encode is proven via <see cref="S7.Net.Types.DateTime"/> (the 8-byte DT helper —
/// NOT DTL). Timer/Counter decode/encode are still deferred stubs (T5) and this file pins the
/// NotSupportedException contract they land against.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ScalarBlockTests
{
// ── Helpers ──────────────────────────────────────────────────────────────────────────
// Wide scalars are byte-anchored: DB{n}.DBB{offset}, parser yields S7Size.Byte.
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254) =>
new("WideTag", "DB1.DBB0", dt, StringLength: stringLength);
private static S7ParsedAddress Addr() =>
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
// S7 is big-endian: most-significant byte first.
private static byte[] BeUInt64(ulong v)
{
var b = new byte[8];
for (var i = 0; i < 8; i++)
b[i] = (byte)(v >> (56 - i * 8));
return b;
}
// ── ScalarByteWidth ───────────────────────────────────────────────────────────────────
/// <summary>Verifies the 8-byte numeric widths and the String/DateTime widths.</summary>
[Theory]
[InlineData(S7DataType.Int64, 8)]
[InlineData(S7DataType.UInt64, 8)]
[InlineData(S7DataType.Float64, 8)]
[InlineData(S7DataType.DateTime, 8)]
public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected)
=> S7Driver.ScalarByteWidth(Tag(dt)).ShouldBe(expected);
/// <summary>Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len).</summary>
[Fact]
public void ScalarByteWidth_String_is_length_plus_two()
=> S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10)).ShouldBe(12);
// ── DecodeScalarBlock — Int64 ─────────────────────────────────────────────────────────
/// <summary>Verifies an Int64 block decodes from big-endian bytes.</summary>
[Fact]
public void DecodeScalarBlock_Int64_reads_big_endian()
{
var block = BeUInt64(0x0123456789ABCDEFUL);
// First byte is the most-significant byte (0x01) — proves big-endian, not little-endian.
block[0].ShouldBe((byte)0x01);
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
result.ShouldBeOfType<long>().ShouldBe(0x0123456789ABCDEFL);
}
/// <summary>Verifies a negative Int64 decodes correctly (two's complement, high bit set).</summary>
[Fact]
public void DecodeScalarBlock_Int64_negative()
{
var block = BeUInt64(unchecked((ulong)-2L)); // 0xFFFF_FFFF_FFFF_FFFE
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
result.ShouldBeOfType<long>().ShouldBe(-2L);
}
// ── DecodeScalarBlock — UInt64 ────────────────────────────────────────────────────────
/// <summary>Verifies a UInt64 block decodes a value larger than long.MaxValue.</summary>
[Fact]
public void DecodeScalarBlock_UInt64_reads_value_above_long_max()
{
var block = BeUInt64(ulong.MaxValue); // 0xFFFF_FFFF_FFFF_FFFF
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.UInt64), Addr(), block);
result.ShouldBeOfType<ulong>().ShouldBe(ulong.MaxValue);
}
// ── DecodeScalarBlock — Float64 (LReal) ───────────────────────────────────────────────
/// <summary>Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian.</summary>
[Fact]
public void DecodeScalarBlock_Float64_reads_ieee754_big_endian()
{
var bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI));
var block = BeUInt64(bits);
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Float64), Addr(), block);
result.ShouldBeOfType<double>().ShouldBe(Math.PI, tolerance: 1e-12);
}
// ── EncodeScalarBlock — big-endian byte production ────────────────────────────────────
/// <summary>Verifies Int64 encodes to big-endian bytes (MSB first).</summary>
[Fact]
public void EncodeScalarBlock_Int64_writes_big_endian()
{
var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Int64), 0x0123456789ABCDEFL);
bytes.Length.ShouldBe(8);
bytes.ShouldBe(BeUInt64(0x0123456789ABCDEFUL));
bytes[0].ShouldBe((byte)0x01); // MSB first — little-endian regression guard.
}
/// <summary>Verifies UInt64 encodes to big-endian bytes.</summary>
[Fact]
public void EncodeScalarBlock_UInt64_writes_big_endian()
{
var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue);
bytes.ShouldBe(BeUInt64(ulong.MaxValue));
}
/// <summary>Verifies Float64 encodes to IEEE-754 big-endian bytes.</summary>
[Fact]
public void EncodeScalarBlock_Float64_writes_ieee754_big_endian()
{
var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Float64), Math.PI);
bytes.ShouldBe(BeUInt64(unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI))));
}
// ── Round-trip identity (encode → decode) ─────────────────────────────────────────────
/// <summary>Verifies Int64 round-trips through encode→decode for positive, negative and edge values.</summary>
[Theory]
[InlineData(0L)]
[InlineData(1L)]
[InlineData(-1L)]
[InlineData(long.MaxValue)]
[InlineData(long.MinValue)]
[InlineData(0x0123456789ABCDEFL)]
public void Int64_round_trips(long value)
{
var tag = Tag(S7DataType.Int64);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
decoded.ShouldBeOfType<long>().ShouldBe(value);
}
/// <summary>Verifies UInt64 round-trips, including a large value above long.MaxValue.</summary>
[Theory]
[InlineData(0UL)]
[InlineData(70_000UL)]
[InlineData(ulong.MaxValue)]
[InlineData(0x8000_0000_0000_0001UL)]
public void UInt64_round_trips(ulong value)
{
var tag = Tag(S7DataType.UInt64);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
decoded.ShouldBeOfType<ulong>().ShouldBe(value);
}
/// <summary>Verifies Float64 (LReal) round-trips for representative doubles, including
/// the IEEE-754 specials (NaN / ±Infinity) — these pass through BinaryPrimitives unchanged.</summary>
[Theory]
[InlineData(0.0)]
[InlineData(3.141592653589793)]
[InlineData(-2.5e-300)]
[InlineData(1.7976931348623157e308)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void Float64_round_trips(double value)
{
var tag = Tag(S7DataType.Float64);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
// double.NaN != double.NaN, so compare via Shouldly's IsNaN for that case.
if (double.IsNaN(value))
decoded.ShouldBeOfType<double>().ShouldBe(double.NaN);
else
decoded.ShouldBeOfType<double>().ShouldBe(value);
}
// ── DecodeScalarBlock — String (S7 classic STRING) ────────────────────────────────────
/// <summary>Verifies an S7 STRING block decodes to its C# string, ignoring the
/// two-byte <c>[maxLen][curLen]</c> header and any reserved padding past curLen.</summary>
[Fact]
public void DecodeScalarBlock_String_reads_header_and_chars()
{
// S7 classic STRING layout: [maxLen=10][curLen=5]['H']['E']['L']['L']['O'][pad…].
var block = S7String.ToByteArray("HELLO", 10);
block.Length.ShouldBe(12); // StringLength(10) + 2-byte header.
block[0].ShouldBe((byte)10); // maxLen.
block[1].ShouldBe((byte)5); // curLen.
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
result.ShouldBeOfType<string>().ShouldBe("HELLO");
}
/// <summary>Verifies a hand-built STRING block (not produced by S7.Net) still decodes,
/// pinning the on-the-wire layout we depend on.</summary>
[Fact]
public void DecodeScalarBlock_String_decodes_hand_built_block()
{
var block = new byte[12];
block[0] = 10; // maxLen.
block[1] = 5; // curLen.
block[2] = (byte)'H'; block[3] = (byte)'E'; block[4] = (byte)'L';
block[5] = (byte)'L'; block[6] = (byte)'O'; // bytes 7..11 stay zero (reserved padding).
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
result.ShouldBeOfType<string>().ShouldBe("HELLO");
}
/// <summary>Verifies an empty S7 STRING decodes to the empty string.</summary>
[Fact]
public void DecodeScalarBlock_String_empty()
{
var block = S7String.ToByteArray("", 10);
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
result.ShouldBeOfType<string>().ShouldBe("");
}
// ── EncodeScalarBlock — String (S7 classic STRING) ────────────────────────────────────
/// <summary>Verifies a C# string encodes to a STRING block sized to the reserved field
/// (StringLength + 2), with header <c>[maxLen=StringLength][curLen=value.Length]</c> and
/// the chars laid out after it.</summary>
[Fact]
public void EncodeScalarBlock_String_writes_header_chars_and_pads_to_reserved()
{
var tag = Tag(S7DataType.String, stringLength: 10);
var block = S7Driver.EncodeScalarBlock(tag, "HELLO");
block.Length.ShouldBe(12); // StringLength(10) + 2 — full reserved field, not curLen-sized.
block[0].ShouldBe((byte)10); // maxLen == declared StringLength.
block[1].ShouldBe((byte)5); // curLen == value.Length.
block[2].ShouldBe((byte)'H');
block[6].ShouldBe((byte)'O');
block[7].ShouldBe((byte)0); // reserved padding is zeroed.
block[11].ShouldBe((byte)0);
}
/// <summary>Verifies a null value encodes as an empty STRING (curLen 0), not a throw.</summary>
[Fact]
public void EncodeScalarBlock_String_null_value_is_empty_string()
{
var tag = Tag(S7DataType.String, stringLength: 10);
var block = S7Driver.EncodeScalarBlock(tag, value: null);
block.Length.ShouldBe(12);
block[0].ShouldBe((byte)10); // maxLen.
block[1].ShouldBe((byte)0); // curLen 0.
}
/// <summary>Verifies a non-string value coerces via <c>Convert.ToString</c> before encoding.</summary>
[Fact]
public void EncodeScalarBlock_String_coerces_non_string_value()
{
var tag = Tag(S7DataType.String, stringLength: 10);
var block = S7Driver.EncodeScalarBlock(tag, 1234);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block);
decoded.ShouldBeOfType<string>().ShouldBe("1234");
}
/// <summary>Verifies a string longer than the reserved length throws (S7.Net rejects it;
/// we do NOT silently truncate). Pins the overflow behaviour.</summary>
[Fact]
public void EncodeScalarBlock_String_overflow_throws()
{
var tag = Tag(S7DataType.String, stringLength: 5);
// 6 chars into a 5-char reserved field — S7.Net throws ArgumentException.
Should.Throw<ArgumentException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
}
// ── String round-trip identity (encode → decode) ──────────────────────────────────────
/// <summary>Verifies String round-trips through encode→decode, incl. empty and max-length.</summary>
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("Hello, S7!")] // 10 chars == StringLength (at max).
[InlineData("Tag_Value 42")]
public void String_round_trips(string value)
{
// StringLength sized to fit the longest sample (12) so none overflow.
var tag = Tag(S7DataType.String, stringLength: 12);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
decoded.ShouldBeOfType<string>().ShouldBe(value);
}
/// <summary>Verifies a string exactly at the reserved length round-trips (boundary, no overflow).</summary>
[Fact]
public void String_at_max_length_round_trips()
{
var tag = Tag(S7DataType.String, stringLength: 5);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, "ABCDE"));
decoded.ShouldBeOfType<string>().ShouldBe("ABCDE");
}
// ── DecodeScalarBlock — DateTime (S7 classic DATE_AND_TIME / DT) ───────────────────────
/// <summary>Verifies an 8-byte S7 DT block decodes to its <see cref="System.DateTime"/> value
/// via <see cref="S7.Net.Types.DateTime"/> (the 8-byte BCD DT helper, not the 12-byte DTL).</summary>
[Fact]
public void DecodeScalarBlock_DateTime_reads_dt_bcd_block()
{
var expected = new System.DateTime(2026, 6, 17, 12, 34, 56);
// Fixture built by S7.Net's own DT encoder so the block matches the on-the-wire DT layout.
var block = global::S7.Net.Types.DateTime.ToByteArray(expected);
block.Length.ShouldBe(8);
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.DateTime), Addr(), block);
result.ShouldBeOfType<System.DateTime>().ShouldBe(expected);
}
// ── EncodeScalarBlock — DateTime ───────────────────────────────────────────────────────
/// <summary>Verifies a <see cref="System.DateTime"/> encodes to an 8-byte DT block.</summary>
[Fact]
public void EncodeScalarBlock_DateTime_writes_eight_byte_block()
{
var value = new System.DateTime(2026, 6, 17, 12, 34, 56);
var block = S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), value);
block.Length.ShouldBe(8);
// Matches S7.Net's own DT encoder exactly (same on-the-wire bytes).
block.ShouldBe(global::S7.Net.Types.DateTime.ToByteArray(value));
}
/// <summary>Verifies a string/ISO timestamp coerces via <c>Convert.ToDateTime</c> before encoding.</summary>
[Fact]
public void EncodeScalarBlock_DateTime_coerces_string_value()
{
var tag = Tag(S7DataType.DateTime);
var block = S7Driver.EncodeScalarBlock(tag, "2026-06-17T12:34:56");
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block);
decoded.ShouldBeOfType<System.DateTime>().ShouldBe(new System.DateTime(2026, 6, 17, 12, 34, 56));
}
/// <summary>Verifies a year outside the S7 DT range (19902089) throws — S7.Net's DT encoder
/// validates the range and raises <see cref="ArgumentOutOfRangeException"/>; we surface it.</summary>
[Theory]
[InlineData(1980)]
[InlineData(2100)]
public void EncodeScalarBlock_DateTime_out_of_range_year_throws(int year)
{
var tag = Tag(S7DataType.DateTime);
var value = new System.DateTime(year, 1, 1, 0, 0, 0);
Should.Throw<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
}
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
/// <summary>Verifies DateTime round-trips through encode→decode. S7 DT preserves full
/// millisecond precision (the 8-byte BCD packs ms-tens/hundreds), so the identity holds to
/// the millisecond — no precision loss to document below the second.</summary>
[Theory]
[InlineData(1990, 1, 1, 0, 0, 0, 0)] // DT minimum.
[InlineData(2026, 6, 17, 12, 34, 56, 0)] // no sub-second.
[InlineData(2026, 6, 17, 12, 34, 56, 789)] // milliseconds preserved.
[InlineData(2089, 12, 31, 23, 59, 59, 999)] // DT maximum, ms boundary.
public void DateTime_round_trips_to_the_millisecond(
int y, int mo, int d, int h, int mi, int s, int ms)
{
var value = new System.DateTime(y, mo, d, h, mi, s, ms);
var tag = Tag(S7DataType.DateTime);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
decoded.ShouldBeOfType<System.DateTime>().ShouldBe(value);
}
}