372 lines
18 KiB
C#
372 lines
18 KiB
C#
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 (1990–2089) 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);
|
||
}
|
||
}
|