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

439 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 ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) —
/// the pure <see cref="S7ValueCodecs"/> plus the driver's dispatch
/// (<see cref="S7Driver.ScalarByteWidth"/> / <c>DecodeScalarBlock</c> / <c>EncodeScalarBlock</c>) and one
/// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ValueCodecTests
{
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254, string address = "DB1.DBB0") =>
new("T", address, dt, StringLength: stringLength);
private static S7ParsedAddress Addr() =>
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
private static object Decode(S7DataType dt, byte[] block, int stringLength = 254) =>
S7Driver.DecodeScalarBlock(Tag(dt, stringLength), Addr(), block);
private static byte[] Encode(S7DataType dt, object? value, int stringLength = 254) =>
S7Driver.EncodeScalarBlock(Tag(dt, stringLength), value);
// ---- widths ----
[Theory]
[InlineData(S7DataType.Char, 1)]
[InlineData(S7DataType.WChar, 2)]
[InlineData(S7DataType.Date, 2)]
[InlineData(S7DataType.S5Time, 2)]
[InlineData(S7DataType.Tod, 4)]
[InlineData(S7DataType.Time, 4)]
[InlineData(S7DataType.Dtl, 12)]
public void Fixed_widths(S7DataType dt, int width) =>
S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(width);
[Theory]
[InlineData(0, 4)]
[InlineData(1, 6)]
[InlineData(5, 14)]
[InlineData(16382, 32768)]
public void WString_width_is_header_plus_two_bytes_per_char(int maxLen, int width) =>
S7Driver.ScalarByteWidth(Tag(S7DataType.WString, maxLen), Addr()).ShouldBe(width);
// ---- WSTRING ----
[Fact]
public void WString_encode_known_vector()
{
// maxLen=5, curLen=2, "Hi" UTF-16BE, 3 zero-padded chars.
Encode(S7DataType.WString, "Hi", 5).ShouldBe(
[0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0, 0, 0, 0, 0, 0]);
}
[Fact]
public void WString_decode_ignores_padding_past_current_length()
{
byte[] block = [0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0x00, 0x58, 0x00, 0x58, 0, 0];
Decode(S7DataType.WString, block, 5).ShouldBe("Hi");
}
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("Grüße Ω")]
[InlineData("12345678")] // exactly at the declared max
public void WString_round_trips_including_boundary_lengths(string s)
{
var bytes = Encode(S7DataType.WString, s, 8);
bytes.Length.ShouldBe(4 + 16);
Decode(S7DataType.WString, bytes, 8).ShouldBe(s);
}
[Fact]
public void WString_empty_with_zero_max_is_a_bare_header()
{
var bytes = Encode(S7DataType.WString, "", 0);
bytes.ShouldBe([0, 0, 0, 0]);
Decode(S7DataType.WString, bytes, 0).ShouldBe("");
}
[Fact]
public void WString_write_over_declared_max_is_refused_not_truncated() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.WString, "123456789", 8));
[Fact]
public void WString_decode_rejects_curLen_above_maxLen() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x02, 0x00, 0x03, 0, 0x41, 0, 0x42, 0, 0x43], 2));
[Fact]
public void WString_decode_rejects_length_running_past_block() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x05, 0x00, 0x04, 0, 0x41], 5));
// ---- CHAR / WCHAR ----
[Fact]
public void Char_known_vectors()
{
Encode(S7DataType.Char, "A").ShouldBe([0x41]);
Encode(S7DataType.Char, "é").ShouldBe([0xE9]);
Decode(S7DataType.Char, [0x41]).ShouldBe("A");
Decode(S7DataType.Char, [0xE9]).ShouldBe("é");
}
[Fact]
public void Char_refuses_wide_character_and_wrong_length()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Char, "Ā")); // U+0100
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, "AB"));
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, ""));
}
[Fact]
public void WChar_known_vectors()
{
Encode(S7DataType.WChar, "Ω").ShouldBe([0x03, 0xA9]);
Encode(S7DataType.WChar, "A").ShouldBe([0x00, 0x41]);
Decode(S7DataType.WChar, [0x03, 0xA9]).ShouldBe("Ω");
}
// ---- DTL ----
// Siemens documentation example: DTL#2008-12-16-20:30:20.250 (a Tuesday → S7 weekday 3).
private static readonly byte[] DtlVector = [0x07, 0xD8, 0x0C, 0x10, 0x03, 0x14, 0x1E, 0x14, 0x0E, 0xE6, 0xB2, 0x80];
[Fact]
public void Dtl_known_vector_both_ways()
{
var expected = new DateTime(2008, 12, 16, 20, 30, 20, 250);
Decode(S7DataType.Dtl, DtlVector).ShouldBe(expected);
Encode(S7DataType.Dtl, expected).ShouldBe(DtlVector);
}
[Fact]
public void Dtl_keeps_100ns_resolution()
{
var dt = new DateTime(2030, 1, 2, 3, 4, 5).AddTicks(1234567);
Decode(S7DataType.Dtl, Encode(S7DataType.Dtl, dt)).ShouldBe(dt);
}
[Fact]
public void Dtl_uninitialised_zero_buffer_is_rejected_not_year_zero() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, new byte[12]));
[Fact]
public void Dtl_invalid_calendar_fields_are_rejected()
{
var bad = (byte[])DtlVector.Clone();
bad[2] = 13; // month 13
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
bad = (byte[])DtlVector.Clone();
bad[8] = 0xFF; // nanoseconds > 999,999,999
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
}
[Theory]
[InlineData(1969)]
[InlineData(2263)]
public void Dtl_write_outside_plc_year_range_is_refused(int year) =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Dtl, new DateTime(year, 1, 1)));
// ---- DT (existing S7DataType.DateTime — regression vector) ----
[Fact]
public void Dt_still_routes_through_the_8_byte_bcd_codec()
{
var dt = new DateTime(2008, 12, 16, 20, 30, 20, 250);
var bytes = Encode(S7DataType.DateTime, dt);
bytes.Length.ShouldBe(8);
bytes.Take(7).ToArray().ShouldBe([0x08, 0x12, 0x16, 0x20, 0x30, 0x20, 0x25]);
Decode(S7DataType.DateTime, bytes).ShouldBe(dt);
}
// ---- DATE ----
[Theory]
[InlineData(1990, 1, 1, 0x0000)]
[InlineData(2000, 2, 29, 0x0E7F)]
[InlineData(2009, 12, 1, 0x1C6A)]
[InlineData(2168, 12, 31, 0xFF62)]
public void Date_known_vectors(int y, int m, int d, int days)
{
byte[] bytes = [(byte)(days >> 8), (byte)days];
Decode(S7DataType.Date, bytes).ShouldBe(new DateTime(y, m, d));
Encode(S7DataType.Date, new DateTime(y, m, d, 17, 5, 0)).ShouldBe(bytes); // time-of-day dropped
}
[Fact]
public void Date_write_outside_uint16_range_is_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31)));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(2169, 6, 7)));
Encode(S7DataType.Date, new DateTime(2169, 6, 6)).ShouldBe([0xFF, 0xFF]);
}
// ---- TOD ----
[Theory]
[InlineData(0, new byte[] { 0, 0, 0, 0 })]
[InlineData(1000, new byte[] { 0, 0, 0x03, 0xE8 })]
[InlineData(86_399_999, new byte[] { 0x05, 0x26, 0x5B, 0xFF })] // TOD#23:59:59.999
public void Tod_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Tod, bytes).ShouldBe(ms);
Encode(S7DataType.Tod, ms).ShouldBe(bytes);
}
[Fact]
public void Tod_boundaries_are_refused_on_both_sides()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, 86_400_000));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, -1));
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Tod, [0x05, 0x26, 0x5C, 0x00]));
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
}
[Fact]
public void Fractional_millisecond_double_rounds_like_the_other_int32_writes()
{
// Pinned: matches BoxValueForWrite's Convert.ToInt32 behaviour (banker's), see docs.
Encode(S7DataType.Tod, 100.7).ShouldBe(Encode(S7DataType.Tod, 101));
Encode(S7DataType.Time, 100.7).ShouldBe(Encode(S7DataType.Time, 101));
Encode(S7DataType.S5Time, 99.7).ShouldBe(Encode(S7DataType.S5Time, 100));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 100.7)); // rounds to 101: off the grid
}
// ---- TIME ----
[Theory]
[InlineData(86_400_000, new byte[] { 0x05, 0x26, 0x5C, 0x00 })] // T#1d
[InlineData(-1000, new byte[] { 0xFF, 0xFF, 0xFC, 0x18 })] // T#-1s
[InlineData(int.MaxValue, new byte[] { 0x7F, 0xFF, 0xFF, 0xFF })] // T#24d20h31m23s647ms
[InlineData(int.MinValue, new byte[] { 0x80, 0, 0, 0 })]
public void Time_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Time, bytes).ShouldBe(ms);
Encode(S7DataType.Time, ms).ShouldBe(bytes);
}
[Fact]
public void Time_outside_int32_is_refused() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Time, (long)int.MaxValue + 1));
// ---- S5TIME ----
[Theory]
[InlineData(0x0000, 0)]
[InlineData(0x0999, 9_990)] // 999 × 10 ms
[InlineData(0x1500, 50_000)] // 500 × 100 ms
[InlineData(0x2127, 127_000)] // W#16#2127 = 127 s (Siemens docs)
[InlineData(0x3999, 9_990_000)] // 999 × 10 s, the maximum
[InlineData(0xC127, 1_270)] // bits 15-14 are ignored (base 00, 127 × 10 ms)
public void S5Time_decode_known_vectors(int word, int ms) =>
Decode(S7DataType.S5Time, [(byte)(word >> 8), (byte)word]).ShouldBe(ms);
[Theory]
[InlineData(0, 0x0000)]
[InlineData(9_990, 0x0999)] // finest base that fits
[InlineData(10_000, 0x1100)] // 100 × 100 ms
[InlineData(127_000, 0x2127)]
[InlineData(9_990_000, 0x3999)]
public void S5Time_encode_picks_the_finest_base_that_fits(int ms, int word) =>
Encode(S7DataType.S5Time, ms).ShouldBe([(byte)(word >> 8), (byte)word]);
[Fact]
public void S5Time_non_bcd_nibble_is_rejected_on_read() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.S5Time, [0x01, 0x2A]));
[Fact]
public void S5Time_unrepresentable_writes_are_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 9_990_001)); // above max
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, -10));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 1_000_500)); // no base holds it exactly
}
[Fact]
public void S5Time_round_trips_every_representable_value_in_a_sample()
{
foreach (var ms in new[] { 10, 990, 9_990, 10_000, 99_900, 100_000, 999_000, 1_000_000, 9_990_000 })
Decode(S7DataType.S5Time, Encode(S7DataType.S5Time, ms)).ShouldBe(ms);
}
// ---- type mapping ----
[Theory]
[InlineData(S7DataType.WString, DriverDataType.String)]
[InlineData(S7DataType.Char, DriverDataType.String)]
[InlineData(S7DataType.WChar, DriverDataType.String)]
[InlineData(S7DataType.Dtl, DriverDataType.DateTime)]
[InlineData(S7DataType.Date, DriverDataType.DateTime)]
[InlineData(S7DataType.Tod, DriverDataType.Int32)]
[InlineData(S7DataType.Time, DriverDataType.Int32)]
[InlineData(S7DataType.S5Time, DriverDataType.Int32)]
public async Task Discovery_maps_new_types_onto_existing_driver_types(S7DataType dt, DriverDataType expected)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Tag(dt, 10) with { Name = "X" }),
};
using var drv = new S7Driver(opts, "s7-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single().Attr.DriverDataType.ShouldBe(expected);
}
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
// ---- TagConfig ----
[Fact]
public void TagConfig_accepts_wstring_length_up_to_16382_but_string_stays_254()
{
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16382}""", "p", out var d).ShouldBeTrue();
d.DataType.ShouldBe(S7DataType.WString);
d.StringLength.ShouldBe(16382);
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16383}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"WString","stringLength":99999}""")
.ShouldHaveSingleItem().ShouldContain("16382");
}
[Theory]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void TagConfig_round_trips_every_new_type_name(string name)
{
var dt = Enum.Parse<S7DataType>(name);
var cfg = S7TagDefinitionFactory.ToTagConfig(Tag(dt));
S7TagDefinitionFactory.FromTagConfig(cfg, "p", out var back).ShouldBeTrue();
back.DataType.ShouldBe(dt);
}
// ---- over the seam: status mapping ----
[Fact]
public async Task Driver_reads_writes_and_maps_range_errors_without_degrading_health()
{
var plc = new MemoryS7Plc();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("Ws", "DB1.DBB0", S7DataType.WString, StringLength: 4),
new S7TagDefinition("Dtl", "DB1.DBB100", S7DataType.Dtl),
new S7TagDefinition("S5", "DB1.DBB120", S7DataType.S5Time)),
};
using var drv = new S7Driver(opts, "s7-codec-e2e", new MemoryS7PlcFactory(plc));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
// Good write then read-back.
(await drv.WriteAsync([new WriteRequest("Ws", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
plc.Mem.Take(12).ToArray().ShouldBe([0, 4, 0, 4, 0, 0x61, 0, 0x62, 0, 0x63, 0, 0x64]);
var r = await drv.ReadAsync(["Ws"], ct);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe("abcd");
// Over-length write → BadOutOfRange, nothing written, health unharmed.
(await drv.WriteAsync([new WriteRequest("Ws", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
plc.Mem.Skip(5).Take(1).Single().ShouldBe((byte)0x61);
// Uninitialised DTL (all zero) reads BadOutOfRange, not a comms fault; the sibling stays Good.
var reads = await drv.ReadAsync(["Dtl", "Ws"], ct);
reads[0].StatusCode.ShouldBe(0x803C0000u);
reads[1].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// S5TIME value that fits no base → BadOutOfRange; wrong-shape value → BadTypeMismatch.
(await drv.WriteAsync([new WriteRequest("S5", 15)], ct))[0].StatusCode.ShouldBe(0x803C0000u);
(await drv.WriteAsync([new WriteRequest("S5", "soon")], ct))[0].StatusCode.ShouldBe(0x80740000u);
(await drv.WriteAsync([new WriteRequest("S5", 127_000)], ct))[0].StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S5"], ct))[0].Value.ShouldBe(127_000);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- fakes ----
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc;
}
/// <summary>Single-DB memory-backed fake: block reads/writes address one flat byte array.</summary>
internal sealed class MemoryS7Plc : IS7Plc
{
public byte[] Mem { get; } = new byte[512];
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) =>
Task.FromResult(Mem.AsSpan(start, count).ToArray());
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> items, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string address, object value, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int start, byte[] value, CancellationToken ct)
{
value.CopyTo(Mem, start);
return Task.CompletedTask;
}
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}