using S7.Net; 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; /// /// Unit tests for the S7 reconnect path (STAB-1). The seam lets /// these exercise the lazy reconnect logic — dispose-dead-handle + /// re-open on the next data call — without a live PLC, which was previously impossible (the /// driver new-ed a concrete S7.Net.Plc inline and had no reopen path at all). /// [Trait("Category", "Unit")] public sealed class S7DriverReconnectTests { private static S7DriverOptions Options() => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), // Probe off — these tests drive reconnect through the read path deterministically; a // background probe would race the created-connection assertions. Probe = new S7ProbeOptions { Enabled = false }, Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], }; /// /// A connection-fatal fault on a read marks the handle dead; the NEXT read disposes it and /// opens a fresh connection, and the tag recovers to Good with health Degraded→Healthy. /// [Fact] public async Task Read_reopens_a_fresh_connection_after_a_connection_fatal_fault() { var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(Options(), "s7-reconnect", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Created.Count.ShouldBe(1); // Read #1 — good value, Healthy. var r1 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); r1[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // The live socket drops on its next read. IsConnected deliberately stays true (a stale // socket that hasn't noticed the drop) so the ONLY trigger for reopen is the driver's // own dead-handle flag set by the connection-fatal classification. factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "socket dropped"); // Read #2 — connection-fatal fault → Degraded, handle marked dead, but no reopen yet. var r2 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); r2[0].StatusCode.ShouldNotBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Degraded); factory.Created.Count.ShouldBe(1); // Read #3 — EnsureConnectedAsync disposes the dead handle and opens a SECOND connection, // which reads Good; health recovers to Healthy. var r3 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); factory.Created.Count.ShouldBe(2); r3[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); factory.Created[0].Disposed.ShouldBeTrue(); factory.Created[1].Disposed.ShouldBeFalse(); } /// /// A protocol / data-address error (S7.Net ) is NOT a /// connection loss — the driver keeps the same connection and does not churn a reopen. /// [Fact] public async Task Read_does_not_reopen_on_a_data_address_error() { var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(Options(), "s7-dataerr", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // A data error — not a socket drop. factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ReadData, "bad address"); var bad = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); bad[0].StatusCode.ShouldNotBe(0u); // Next read reuses the SAME connection — no reopen for a data error. var ok = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); ok[0].StatusCode.ShouldBe(0u); factory.Created.Count.ShouldBe(1); } /// /// A raw socket exception (no S7.Net wrapper) is also classified connection-fatal and /// triggers a reopen — the driver doesn't depend on every drop arriving as a /// . /// [Fact] public async Task Read_reopens_after_a_raw_socket_exception() { var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(Options(), "s7-socket", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Created[0].NextReadThrows = new System.Net.Sockets.SocketException(); await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Degraded); var recovered = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); recovered[0].StatusCode.ShouldBe(0u); factory.Created.Count.ShouldBe(2); } /// /// When the reopen itself fails (PLC still down), the batch degrades to a communication /// error rather than throwing, and a later successful reopen recovers the tag. /// [Fact] public async Task Read_degrades_when_reopen_fails_then_recovers() { var factory = new FakeS7PlcFactory(); using var drv = new S7Driver(Options(), "s7-reopen-fail", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Drop the live socket, and make the NEXT-created connection fail to open. factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped"); factory.NextOpenThrows = new PlcException(ErrorCode.ConnectionError, "still down"); await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead // Reopen attempt #1 fails — whole batch is a comm error, no throw. var down = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); down[0].StatusCode.ShouldNotBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Degraded); // PLC comes back; the next call reopens cleanly and the tag reads Good. var up = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); up[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } // ---- fakes ---- private sealed class FakeS7PlcFactory : IS7PlcFactory { /// Every connection handed out, in creation order. public List Created { get; } = new(); /// When set, the NEXT d connection throws this on OpenAsync (once). public Exception? NextOpenThrows { get; set; } /// /// Number of subsequently-created connections whose /// hangs until its token is cancelled (simulating the unreachable-but-not-refusing host /// — pulled cable / firewall DROP — that STAB-14 needs and docker restart can't /// produce). Each decrements it while positive. /// public int OpenHangsUntilCancelledCount { get; set; } /// /// When set, copied onto every created connection's persistent /// so a probe fails on every tick. Clear it /// after init to arm connection #1 only. /// public Exception? ReadStatusThrowsForNewConnections { get; set; } public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) { var plc = new FakeS7Plc(); if (NextOpenThrows is { } ex) { plc.OpenThrowsOnce = ex; NextOpenThrows = null; } if (OpenHangsUntilCancelledCount > 0) { plc.OpenHangsUntilCancelled = true; OpenHangsUntilCancelledCount--; } if (ReadStatusThrowsForNewConnections is { } rex) plc.ReadStatusThrows = rex; Created.Add(plc); return plc; } } private sealed class FakeS7Plc : IS7Plc { public bool IsConnected { get; private set; } public bool Disposed { get; private set; } /// Thrown once by , then cleared. public Exception? OpenThrowsOnce { get; set; } /// /// When true, hangs until its token is cancelled, then throws /// — the connect-timeout shape (an unreachable host /// whose SYNs are dropped) the live rig can't cheaply produce (STAB-14). /// public bool OpenHangsUntilCancelled { get; set; } /// Thrown once by the next , then cleared. public Exception? NextReadThrows { get; set; } /// When true, hangs until its token is cancelled (STAB-15 cancel-mid-I/O). public bool ReadHangsUntilCancelled { get; set; } /// When true, hangs until its token is cancelled (STAB-15 cancel-mid-I/O). public bool WriteHangsUntilCancelled { get; set; } /// Thrown by EVERY (persistent, not one-shot) — arms a probe fault (STAB-15b). public Exception? ReadStatusThrows { get; set; } /// When true, hangs until its token is cancelled (probe-timeout, STAB-15b). public bool ReadStatusHangsUntilCancelled { get; set; } /// Boxed value returned by a good read (UInt16 tag → ushort). public object? ReadValue { get; set; } = (ushort)42; public async Task OpenAsync(CancellationToken cancellationToken) { if (OpenHangsUntilCancelled) await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); if (OpenThrowsOnce is { } ex) { OpenThrowsOnce = null; throw ex; } IsConnected = true; } public void Close() => IsConnected = false; public async Task ReadAsync(string address, CancellationToken cancellationToken) { if (ReadHangsUntilCancelled) await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); if (NextReadThrows is { } ex) { NextReadThrows = null; throw ex; } return ReadValue; } public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) => throw new NotSupportedException("FakeS7Plc: block reads not needed for the reconnect tests"); public async Task WriteAsync(string address, object value, CancellationToken cancellationToken) { if (WriteHangsUntilCancelled) await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); } public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask; public async Task ReadStatusAsync(CancellationToken cancellationToken) { if (ReadStatusHangsUntilCancelled) await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); if (ReadStatusThrows is { } ex) throw ex; } public void Dispose() { Disposed = true; IsConnected = false; } } }