Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs
T
Joseph Doherty 988a7a938f fix(s7): UInt64 box cast + Timer/Counter transient-write returns BadNotWritable (final review)
M1: add missing (object) cast to UInt64 arm of DecodeScalarBlock switch expression,
matching the Int64 arm style and the comment that each arm is boxed explicitly.
M2: short-circuit Timer/Counter writes in WriteAsync to BadNotWritable before
WriteOneAsync, so transient equipment-tag refs (Writable=true from parser) return
the same status code as authored tags rejected at init — documented in the docs.
Adds 6 pure unit tests pinning the area-detection precondition the guard relies on.
EncodeScalarBlock Timer/Counter throws remain as the defensive backstop.
2026-06-17 06:31:41 -04:00

541 lines
27 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 (S5TIME → seconds <c>double</c>) and Counter (word → <c>int</c> count)
/// decode is proven via <see cref="S7.Net.Types.Timer"/> / <see cref="S7.Net.Types.Counter"/>
/// and is routed by the parsed AREA (so a Timer/Float64 tag reads 2 bytes, not 8); both are
/// read-only this phase, so their encode throws <see cref="NotSupportedException"/>.
/// </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);
// Timer / Counter addresses: the parser stores the timer/counter NUMBER in ByteOffset,
// DbNumber 0, Size Word (see S7AddressParser.ParseTimerOrCounter). A Timer tag is typed
// Float64 (decoded to seconds) and a Counter tag Int32 (decoded to count) — T1 guard-c.
private static S7TagDefinition TimerTag(int number = 5, bool writable = false) =>
new("TimerTag", $"T{number}", S7DataType.Float64, Writable: writable);
private static S7ParsedAddress TimerAddr(int number = 5) =>
new(S7Area.Timer, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0);
private static S7TagDefinition CounterTag(int number = 3, bool writable = false) =>
new("CounterTag", $"C{number}", S7DataType.Int32, Writable: writable);
private static S7ParsedAddress CounterAddr(int number = 3) =>
new(S7Area.Counter, DbNumber: 0, S7Size.Word, ByteOffset: number, 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), Addr()).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), Addr()).ShouldBe(12);
/// <summary>Headline correctness assertion: a Timer tag is typed Float64 (which would otherwise
/// yield width 8), but the Timer AREA must take precedence and read exactly 2 bytes (one S5TIME
/// word). Without area-precedence the codec would read 8 bytes and mis-frame the timer.</summary>
[Fact]
public void ScalarByteWidth_Timer_is_two_despite_Float64_datatype()
=> S7Driver.ScalarByteWidth(TimerTag(), TimerAddr()).ShouldBe(2);
/// <summary>A Counter tag is typed Int32; the Counter AREA reads exactly 2 bytes (one counter word).</summary>
[Fact]
public void ScalarByteWidth_Counter_is_two()
=> S7Driver.ScalarByteWidth(CounterTag(), CounterAddr()).ShouldBe(2);
// ── 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);
}
// ── DecodeScalarBlock — Timer (S5TIME → seconds, read-only) ───────────────────────────
/// <summary>
/// Verifies a 2-byte S5TIME block decodes to a duration in SECONDS as a <c>double</c>,
/// routed by the Timer AREA (NOT the tag's Float64 DataType — that would mis-read the
/// 2-byte block as an 8-byte LReal). The fixture is a hand-built S5TIME word: bits [15:14]
/// of the 16-bit word carry the time base (0=×10 ms, 1=×100 ms, 2=×1 s, 3=×10 s), bits
/// [13:12] are unused (always 0), and bits [11:0] carry the 3-digit BCD value (hundreds in
/// byte0 low nibble, tens in byte1 high nibble, ones in byte1 low nibble). Here base=2
/// (×1 s) and BCD=100 → 100.0 s (verified against S7.Net's
/// <see cref="S7.Net.Types.Timer.FromByteArray"/> S5TIME decode).
/// </summary>
[Fact]
public void DecodeScalarBlock_Timer_decodes_s5time_to_seconds()
{
// [0x21,0x00]: byte0 high nibble 0x2 = base bits (×1 s), byte0 low nibble 0x1 = BCD hundreds,
// byte1 0x00 = BCD tens+ones → value 100 × 1 s = 100.0 s.
var block = new byte[] { 0x21, 0x00 };
var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
result.ShouldBeOfType<double>().ShouldBe(100.0, tolerance: 1e-9);
}
/// <summary>Verifies a sub-second S5TIME decodes correctly (base = 100 ms, BCD = 250 → 25.0 s).</summary>
[Fact]
public void DecodeScalarBlock_Timer_decodes_fractional_base()
{
// [0x12,0x50]: base bits 0x1 (×0.1 s), BCD 250 → 250 × 0.1 s = 25.0 s.
var block = new byte[] { 0x12, 0x50 };
var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
result.ShouldBeOfType<double>().ShouldBe(25.0, tolerance: 1e-9);
}
/// <summary>Verifies a zero S5TIME word decodes to 0.0 seconds.</summary>
[Fact]
public void DecodeScalarBlock_Timer_zero()
{
var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), new byte[] { 0x00, 0x00 });
result.ShouldBeOfType<double>().ShouldBe(0.0);
}
/// <summary>
/// Covers the two remaining S5TIME time bases to complete all four (03). S5TIME word
/// layout: bits[15:14]=base, bits[13:12]=0, bits[11:8]=BCD-hundreds, bits[7:4]=BCD-tens,
/// bits[3:0]=BCD-ones. Bases 1 and 2 are covered by the Fact tests above; base 0
/// (×0.01 s) and base 3 (×10 s) are added here.
/// <para>
/// Byte derivation:
/// <list type="bullet">
/// <item>Base 0 (×0.01 s) / BCD=015: byte0=[base=0&lt;&lt;6|0&lt;&lt;4|hundreds=0]=0x00,
/// byte1=[tens=1&lt;&lt;4|ones=5]=0x15 → 15 × 0.01 s = 0.15 s.</item>
/// <item>Base 3 (×10 s) / BCD=005: byte0=[base=3&lt;&lt;6|0&lt;&lt;4|hundreds=0]=0x30,
/// byte1=[tens=0&lt;&lt;4|ones=5]=0x05 → 5 × 10 s = 50.0 s.</item>
/// </list>
/// </para>
/// </summary>
[Theory]
[InlineData(new byte[] { 0x00, 0x15 }, 0.15)] // base 0 (×0.01 s), BCD=015 → 15×0.01=0.15 s
[InlineData(new byte[] { 0x30, 0x05 }, 50.0)] // base 3 (×10 s), BCD=005 → 5×10=50.0 s
public void DecodeScalarBlock_Timer_all_time_bases(byte[] block, double expectedSeconds)
{
var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
result.ShouldBeOfType<double>().ShouldBe(expectedSeconds, tolerance: 1e-9);
}
// ── DecodeScalarBlock — Counter (BCD/raw word → count, read-only) ──────────────────────
/// <summary>
/// Verifies a 2-byte counter block decodes to a COUNT as an <c>int</c>, routed by the
/// Counter AREA (the tag is typed Int32). S7.Net's
/// <see cref="S7.Net.Types.Counter.FromByteArray"/> reads the word big-endian; we surface
/// it as a (non-negative) <c>int</c>.
/// </summary>
[Fact]
public void DecodeScalarBlock_Counter_decodes_to_int_count()
{
// Big-endian word 0x0042 = 66.
var block = new byte[] { 0x00, 0x42 };
var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block);
result.ShouldBeOfType<int>().ShouldBe(66);
}
/// <summary>Verifies a larger counter value decodes (0x0123 = 291) — pins big-endian order.</summary>
[Fact]
public void DecodeScalarBlock_Counter_decodes_big_endian()
{
var block = new byte[] { 0x01, 0x23 };
var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block);
result.ShouldBeOfType<int>().ShouldBe(0x0123);
}
/// <summary>Verifies a zero counter decodes to 0.</summary>
[Fact]
public void DecodeScalarBlock_Counter_zero()
{
var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), new byte[] { 0x00, 0x00 });
result.ShouldBeOfType<int>().ShouldBe(0);
}
// ── EncodeScalarBlock — Timer/Counter are read-only this phase ─────────────────────────
/// <summary>Verifies a Timer write throws NotSupportedException — Timer/Counter are read-only.
/// This backstop is still exercised even though <see cref="S7Driver.WriteAsync"/> now
/// short-circuits to BadNotWritable before reaching EncodeScalarBlock — a mis-route (e.g.
/// a future refactor that bypasses the WriteAsync guard) must still fail loudly here.</summary>
[Fact]
public void EncodeScalarBlock_Timer_throws_read_only()
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(TimerTag(), 1.0));
/// <summary>Verifies a Counter write throws NotSupportedException — Timer/Counter are read-only.</summary>
[Fact]
public void EncodeScalarBlock_Counter_throws_read_only()
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(CounterTag(), 42));
// ── WriteAsync guard — Timer/Counter area precondition ────────────────────────────────
/// <summary>
/// Pins the precondition for the <see cref="S7Driver.WriteAsync"/> Timer/Counter
/// short-circuit: <see cref="S7AddressParser.TryParse"/> on a Timer address must
/// yield <see cref="S7Area.Timer"/> and on a Counter address must yield
/// <see cref="S7Area.Counter"/>. The WriteAsync guard detects the area via TryParse (for
/// transient equipment-tag refs not in _parsedByName) and returns BadNotWritable without
/// reaching EncodeScalarBlock. This test verifies the area detection that gate depends on
/// — a pure, no-PLC assertion. (<see cref="WriteAsync"/> itself requires a connected Plc
/// and is therefore integration-tested only; the EncodeScalarBlock_Timer/Counter_throws
/// tests above cover the defensive backstop if the guard is ever bypassed.)
/// </summary>
[Theory]
[InlineData("T0", S7Area.Timer)]
[InlineData("T5", S7Area.Timer)]
[InlineData("T15", S7Area.Timer)]
[InlineData("C0", S7Area.Counter)]
[InlineData("C3", S7Area.Counter)]
[InlineData("C10", S7Area.Counter)]
public void AddressParser_yields_Timer_or_Counter_area_for_TC_addresses(string address, S7Area expectedArea)
{
S7AddressParser.TryParse(address, out var parsed).ShouldBeTrue();
parsed.Area.ShouldBe(expectedArea,
$"WriteAsync BadNotWritable guard relies on TryParse('{address}').Area == {expectedArea}");
}
}