using Microsoft.Extensions.Logging; using S7.Net; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using S7NetDataType = global::S7.Net.DataType; using S7DataItem = global::S7.Net.Types.DataItem; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// #602 / #610 S7 items: array write path (S7-016), connect backoff (STAB-21), the write-pass /// worst-state-wins accumulator (STAB-22) and the factory logger (CONV-3). All against an /// in-memory — no live PLC, no fixture. /// [Trait("Category", "Unit")] public sealed class S7ArrayWriteAndHealthTests { private const uint BadTypeMismatch = 0x80740000u; private const uint BadNotSupported = 0x803D0000u; private static S7TagDefinition Arr(S7DataType dt, string addr, int count, bool writable = true) => new("Arr", addr, dt, Writable: writable, ArrayCount: count); private static S7DriverOptions Options(params S7TagDefinition[] tags) => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false }, RawTags = S7RawTags.Entries(tags), }; // ---- pure codec ---- /// Encode is the exact inverse of for every supported element type. [Theory] [InlineData(S7DataType.Byte, "DB1.DBB0")] [InlineData(S7DataType.Int16, "DB1.DBW0")] [InlineData(S7DataType.UInt16, "DB1.DBW0")] [InlineData(S7DataType.Int32, "DB1.DBD0")] [InlineData(S7DataType.UInt32, "DB1.DBD0")] [InlineData(S7DataType.Float32, "DB1.DBD0")] public void EncodeArrayBlock_round_trips_through_DecodeArrayBlock(S7DataType dt, string addrText) { var tag = Arr(dt, addrText, 3); var addr = S7AddressParser.Parse(addrText); object source = dt switch { S7DataType.Byte => new byte[] { 1, 200, 255 }, S7DataType.Int16 => new short[] { -1, 0, 32000 }, S7DataType.UInt16 => new ushort[] { 1, 0, 65535 }, S7DataType.Int32 => new[] { -5, 0, int.MaxValue }, S7DataType.UInt32 => new uint[] { 1, 0, uint.MaxValue }, _ => new[] { -1.5f, 0f, 3.25f }, }; var block = S7Driver.EncodeArrayBlock(tag, addr, source); block.Length.ShouldBe(3 * S7Driver.ElementByteSize(addr.Size)); S7Driver.DecodeArrayBlock(tag, addr, block).ShouldBe(source); } /// Big-endian layout is pinned byte-for-byte for Int16. [Fact] public void EncodeArrayBlock_is_big_endian() { var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2); S7Driver.EncodeArrayBlock(tag, S7AddressParser.Parse("DB1.DBW0"), new short[] { 0x0102, -2 }) .ShouldBe(new byte[] { 0x01, 0x02, 0xFF, 0xFE }); } /// A non-array, a string, a wrong length and an unconvertible element are client errors, not comms faults. [Fact] public void EncodeArrayBlock_rejects_wrongly_shaped_values() { var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2); var addr = S7AddressParser.Parse("DB1.DBW0"); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, (short)1)); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, "ab")); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, new short[] { 1 })); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, new[] { 1, 2, 3 })); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, new object[] { 1, 70000 })); // overflow Should.Throw(() => S7Driver.EncodeArrayBlock(tag, addr, new object[] { 1, "x" })); // format } /// /// A null element must not silently coerce to 0 (Convert.ToXxx(null) == 0) and land on the PLC: it is a /// client error, and nothing reaches the wire. The valid write in the same batch is the positive control. /// [Fact] public async Task Write_array_with_a_null_element_is_BadTypeMismatch_and_writes_nothing() { var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2); Should.Throw(() => S7Driver.EncodeArrayBlock(tag, S7AddressParser.Parse("DB1.DBW0"), new object?[] { 1, null })); var factory = new RecordingFactory(); using var drv = new S7Driver(Options(tag), "s7-arr-null", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var res = await drv.WriteAsync( [new WriteRequest("Arr", new object?[] { 1, null }), new WriteRequest("Arr", new short[] { 7, 8 })], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadTypeMismatch); res[1].StatusCode.ShouldBe(0u); factory.Created[0].BlockWrites.ShouldHaveSingleItem().Bytes.ShouldBe(new byte[] { 0, 7, 0, 8 }); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// Bool and wide-type arrays are refused with NotSupported (BadNotSupported on the wire). [Theory] [InlineData(S7DataType.Bool, "DB1.DBX0.0")] [InlineData(S7DataType.Int64, "DB1.DBB0")] [InlineData(S7DataType.String, "DB1.DBB0")] public void EncodeArrayBlock_refuses_bool_and_wide_arrays(S7DataType dt, string addrText) { Should.Throw(() => S7Driver.EncodeArrayBlock( Arr(dt, addrText, 2), S7AddressParser.Parse(addrText), new object[] { 1, 2 })); } // ---- WriteAsync array path ---- /// /// A valid array write issues exactly ONE contiguous WriteBytes at the tag's DB/offset /// (S7-016) and reports Good. The bytes are the decode inverse, so a following read returns the value. /// [Fact] public async Task Write_array_issues_one_contiguous_block_write() { var factory = new RecordingFactory(); using var drv = new S7Driver(Options(Arr(S7DataType.Int16, "DB5.DBW10", 3)), "s7-arr-write", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var res = await drv.WriteAsync([new WriteRequest("Arr", new short[] { 1, 2, 3 })], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(0u); var w = factory.Created[0].BlockWrites.ShouldHaveSingleItem(); w.Area.ShouldBe(S7NetDataType.DataBlock); w.Db.ShouldBe(5); w.Start.ShouldBe(10); w.Bytes.ShouldBe(new byte[] { 0, 1, 0, 2, 0, 3 }); factory.Created[0].ScalarWrites.ShouldBe(0); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// A wrong-length array is BadTypeMismatch, writes nothing and leaves health untouched; the /// positive control is the valid write in the same batch landing. /// [Fact] public async Task Write_array_with_wrong_length_is_BadTypeMismatch_and_not_a_health_event() { var factory = new RecordingFactory(); using var drv = new S7Driver(Options(Arr(S7DataType.Int16, "DB1.DBW0", 3)), "s7-arr-len", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var res = await drv.WriteAsync( [new WriteRequest("Arr", new short[] { 1, 2 }), new WriteRequest("Arr", new short[] { 7, 8, 9 })], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadTypeMismatch); res[1].StatusCode.ShouldBe(0u); factory.Created[0].BlockWrites.Count.ShouldBe(1); // only the valid one reached the wire drv.GetHealth().State.ShouldBe(DriverState.Healthy); } // ---- STAB-21 connect backoff ---- /// /// A dead PLC costs ONE connect attempt per backoff window, not one per read; the window grows /// (1 s then 2 s), and a successful connect clears it so recovery is immediate. /// [Fact] public async Task Dead_plc_costs_one_connect_attempt_per_backoff_window() { var factory = new RecordingFactory(); using var drv = new S7Driver(Options(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16)), "s7-backoff", factory); var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); drv.BackoffClock = () => now; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Created.Count.ShouldBe(1); // Drop the link; every subsequent open is refused. factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped"); factory.OpenThrows = new PlcException(ErrorCode.ConnectionError, "down"); await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead // First attempt after the drop: really tries (and fails), opening a 1 s window. (await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldNotBe(0u); factory.Created.Count.ShouldBe(2); // Ten more reads inside the window: no further connect attempt, still a Bad result. for (var i = 0; i < 10; i++) (await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldNotBe(0u); factory.Created.Count.ShouldBe(2); drv.GetHealth().State.ShouldBe(DriverState.Degraded); // Window elapses -> exactly one more attempt (fails -> 2 s window). now += TimeSpan.FromSeconds(1.1); await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); factory.Created.Count.ShouldBe(3); now += TimeSpan.FromSeconds(1.1); // inside the doubled window await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); factory.Created.Count.ShouldBe(3); // PLC returns; once the window passes the next read reconnects and the window is cleared. factory.OpenThrows = null; now += TimeSpan.FromSeconds(5); (await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u); factory.Created.Count.ShouldBe(4); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // A fresh drop reconnects immediately (no residual window). factory.Created[3].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped again"); await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); (await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u); factory.Created.Count.ShouldBe(5); } // ---- STAB-22 write-pass worst-state-wins ---- private static async Task<(S7Driver Drv, RecordingFactory Factory)> TwoTagDriver() { var factory = new RecordingFactory(); var drv = new S7Driver(Options( new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: true), new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: true), new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: true)), "s7-writepass", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); return (drv, factory); } /// /// Access-denied (Faulted) followed by a generic failure (Degraded) and a success in ONE batch /// must report Faulted with the access-denied message - not whichever item came last. /// [Fact] public async Task Write_batch_reports_worst_state_not_last_item() { var (drv, factory) = await TwoTagDriver(); using var _ = drv; factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed"); factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch"); var res = await drv.WriteAsync( [new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)], TestContext.Current.CancellationToken); res[2].StatusCode.ShouldBe(0u); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Faulted); h.LastError.ShouldNotBeNull().ShouldContain("PUT/GET"); } /// /// A clean READ pass must not erase the write-side Faulted; the next write that succeeds clears /// it, after which a read pass reports Healthy again. /// [Fact] public async Task Read_pass_does_not_erase_write_fault_until_a_write_succeeds() { var (drv, factory) = await TwoTagDriver(); using var _ = drv; var plc = factory.Created[0]; plc.WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed"); await drv.WriteAsync([new WriteRequest("A", (ushort)1)], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Faulted); // Clean read of a different tag - reads work against a write-protected CPU. var read = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); read[0].StatusCode.ShouldBe(0u); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Faulted); // not erased h.LastSuccessfulRead.ShouldNotBeNull(); // but the read still advanced the watchdog evidence // A write that gets through retires the fault ... await drv.WriteAsync([new WriteRequest("B", (ushort)2)], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // ... and the next read pass stays Healthy (the sticky state is really gone). (await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// A Faulted that a READ pass raised (access denied on a read) is not the write side's to retire: a /// successful write must leave it in place until a clean read pass clears it. /// [Fact] public async Task Successful_write_does_not_clear_a_read_side_Faulted() { var (drv, factory) = await TwoTagDriver(); using var _ = drv; var plc = factory.Created[0]; plc.NextReadThrows = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Faulted); var res = await drv.WriteAsync([new WriteRequest("B", (ushort)2)], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(0u); // the write really got through drv.GetHealth().State.ShouldBe(DriverState.Faulted); // ... but it does not own this fault // Positive control: the read side retires its own fault. (await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A transient (Degraded) write failure is still erased by a clean read, as before. [Fact] public async Task Degraded_write_failure_is_still_cleared_by_a_clean_read() { var (drv, factory) = await TwoTagDriver(); using var _ = drv; factory.Created[0].WriteFaults["DB1.DBW0"] = new IOException("glitch"); await drv.WriteAsync([new WriteRequest("A", (ushort)1)], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); (await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } // ---- CONV-3 ---- /// /// The registered factory builds drivers on the host logger: a failed connect reaches the /// supplied . Before CONV-3 the factory took no logger and the driver ran on the null logger. /// [Fact] public async Task Factory_threads_the_logger_factory_into_the_driver() { const string Json = """{"host":"192.0.2.1","timeoutMs":200}"""; var withLogger = new CapturingLoggerFactory(); var registry = new DriverFactoryRegistry(); S7DriverFactoryExtensions.Register(registry, withLogger); var drv = registry.TryGet(S7DriverFactoryExtensions.DriverTypeName)!("s7-logs", Json); try { await drv.InitializeAsync(Json, TestContext.Current.CancellationToken); } catch { /* unreachable host by design */ } withLogger.Messages.ShouldContain(m => m.Contains("connect failed", StringComparison.OrdinalIgnoreCase)); } private sealed class CapturingLoggerFactory : ILoggerFactory { public List Messages { get; } = []; public void AddProvider(ILoggerProvider provider) { } public ILogger CreateLogger(string categoryName) => new Capture(this); public void Dispose() { } private sealed class Capture(CapturingLoggerFactory owner) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (owner.Messages) owner.Messages.Add(formatter(state, exception)); } } } // ---- in-memory PLC ---- private sealed class RecordingFactory : IS7PlcFactory { public List Created { get; } = []; /// When set, every connection created from now on throws this from OpenAsync. public Exception? OpenThrows { get; set; } public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) { var plc = new RecordingPlc { OpenThrowsAlways = OpenThrows }; Created.Add(plc); return plc; } } private sealed class RecordingPlc : IS7Plc { public bool IsConnected { get; private set; } public Exception? OpenThrowsAlways { get; init; } public Exception? NextReadThrows { get; set; } public Dictionary WriteFaults { get; } = new(StringComparer.Ordinal); public List<(S7NetDataType Area, int Db, int Start, byte[] Bytes)> BlockWrites { get; } = []; public int ScalarWrites { get; private set; } public Task OpenAsync(CancellationToken cancellationToken) { if (OpenThrowsAlways is { } ex) throw ex; IsConnected = true; return Task.CompletedTask; } public void Close() => IsConnected = false; public Task ReadAsync(string address, CancellationToken cancellationToken) { if (NextReadThrows is { } ex) { NextReadThrows = null; throw ex; } return Task.FromResult((ushort)42); } public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) { if (NextReadThrows is { } ex) { NextReadThrows = null; throw ex; } return Task.FromResult(new byte[count]); } public Task> ReadMultipleVarsAsync(List items, CancellationToken cancellationToken) => throw new NotSupportedException("not needed"); public Task WriteAsync(string address, object value, CancellationToken cancellationToken) { ScalarWrites++; if (WriteFaults.TryGetValue(address, out var ex)) throw ex; return Task.CompletedTask; } public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) { BlockWrites.Add((area, db, startByteAdr, value)); return Task.CompletedTask; } public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void Dispose() => IsConnected = false; } }