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; /// /// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) — /// the pure plus the driver's dispatch /// ( / DecodeScalarBlock / EncodeScalarBlock) and one /// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping. /// [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(() => Encode(S7DataType.WString, "123456789", 8)); [Fact] public void WString_decode_rejects_curLen_above_maxLen() => Should.Throw(() => 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(() => 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(() => Encode(S7DataType.Char, "Ā")); // U+0100 Should.Throw(() => Encode(S7DataType.Char, "AB")); Should.Throw(() => 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(() => 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(() => Decode(S7DataType.Dtl, bad)); bad = (byte[])DtlVector.Clone(); bad[8] = 0xFF; // nanoseconds > 999,999,999 Should.Throw(() => Decode(S7DataType.Dtl, bad)); } [Theory] [InlineData(1969)] [InlineData(2263)] public void Dtl_write_outside_plc_year_range_is_refused(int year) => Should.Throw(() => 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(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31))); Should.Throw(() => 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(() => Encode(S7DataType.Tod, 86_400_000)); Should.Throw(() => Encode(S7DataType.Tod, -1)); Should.Throw(() => 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(() => 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(() => 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(() => Decode(S7DataType.S5Time, [0x01, 0x2A])); [Fact] public void S5Time_unrepresentable_writes_are_refused() { Should.Throw(() => Encode(S7DataType.S5Time, 9_990_001)); // above max Should.Throw(() => Encode(S7DataType.S5Time, -10)); Should.Throw(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms Should.Throw(() => 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(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken)); using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory()); await Should.ThrowAsync(() => 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(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); } /// /// #632 live-coverage finding: classic STRING and DT writes use S7.Net's own encoders, which throw /// / for an unrepresentable /// value. That fell into the generic catch (BadCommunicationError + Degraded health) while the /// sibling WSTRING/DATE codecs return BadOutOfRange — a client error must not look like a comms fault. /// [Fact] public async Task Classic_STRING_and_DT_unrepresentable_writes_are_BadOutOfRange_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("Str", "DB1.DBB0", S7DataType.String, StringLength: 4), new S7TagDefinition("Dt", "DB1.DBB20", S7DataType.DateTime)), }; using var drv = new S7Driver(opts, "s7-classic-range", new MemoryS7PlcFactory(plc)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var ct = TestContext.Current.CancellationToken; (await drv.WriteAsync([new WriteRequest("Str", "abcd")], ct))[0].StatusCode.ShouldBe(0u); (await drv.WriteAsync([new WriteRequest("Str", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u); (await drv.WriteAsync([new WriteRequest("Dt", new DateTime(1980, 1, 1))], ct))[0].StatusCode.ShouldBe(0x803C0000u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); (await drv.ReadAsync(["Str"], ct))[0].Value.ShouldBe("abcd"); } // ---- fakes ---- private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory { public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc; } /// Single-DB memory-backed fake: block reads/writes address one flat byte array. 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 ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException(); public Task ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) => Task.FromResult(Mem.AsSpan(start, count).ToArray()); public Task> ReadMultipleVarsAsync( List 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; } }