using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.S7; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using S7DataItem = global::S7.Net.Types.DataItem; using S7NetDataType = global::S7.Net.DataType; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Lane S7 review I-1, end to end: the REAL under the REAL /// comms-loss watchdog. A CPU with PUT/GET disabled denies every /// read but is reachable and answering; the driver reports Faulted (the config alert) and must /// still advance LastSuccessfulRead, so the watchdog's "non-Healthy AND silent for the /// threshold" conjunction is never met and the actor does NOT reconnect + re-subscribe. The /// positive control is the same rig with a real comms loss, which DOES trip it. /// public sealed class S7AccessDeniedWatchdogTests : RuntimeActorTestBase { private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50); /// The watchdog threshold — an order of magnitude above the poll so the window is a decision, not a race. private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(1500); private sealed class ScriptedPlcFactory(Func readFault) : IS7PlcFactory { public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new ScriptedPlc(readFault); } private sealed class ScriptedPlc(Func readFault) : IS7Plc { public bool IsConnected { get; private set; } public Task OpenAsync(CancellationToken cancellationToken) { IsConnected = true; return Task.CompletedTask; } public void Close() => IsConnected = false; public Task ReadAsync(string address, CancellationToken cancellationToken) => Task.FromException(readFault()); public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) => Task.FromException(readFault()); public Task> ReadMultipleVarsAsync(List items, CancellationToken cancellationToken) => Task.FromException>(readFault()); public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => Task.CompletedTask; public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask; public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask; public void Dispose() => IsConnected = false; } private (S7Driver Driver, Akka.TestKit.TestProbe Parent) Connect(Func readFault) { var driver = new S7Driver(new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, SpanReadMaxGapBytes = -1, RawTags = [ new RawTagEntry("W0", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), false), new RawTagEntry("W2", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W2", "DB1.DBW2", S7DataType.UInt16, Writable: false)), false), ], }, "s7-watchdog", new ScriptedPlcFactory(readFault)); var parent = CreateTestProbe(); var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, reconnectInterval: TimeSpan.FromSeconds(30), healthPollInterval: FastPoll, commsLossTimeout: Threshold)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); parent.ExpectMsg(TimeSpan.FromSeconds(5)).Connected.ShouldBeTrue(); return (driver, parent); } /// Drives read passes (the poll the actor would schedule) until . private static async Task<(int Passes, int FaultedPasses)> PollUntilAsync(S7Driver driver, DateTime until) { var passes = 0; var faulted = 0; while (DateTime.UtcNow < until) { try { #pragma warning disable OTOPCUA0001 // the watchdog's reading of driver health is under test, not resilience wrapping await driver.ReadAsync(["W0", "W2"], CancellationToken.None); #pragma warning restore OTOPCUA0001 passes++; if (driver.GetHealth().State == DriverState.Faulted) faulted++; } catch (InvalidOperationException) { /* the actor tore the driver down (control case) */ } await Task.Delay(100); // pacing the simulated poll, not an assertion } return (passes, faulted); } /// An all-access-denied CPU is left connected across more than twice the watchdog threshold. [Fact] public async Task An_access_denied_cpu_is_not_reconnected_by_the_watchdog() { var (driver, parent) = Connect(() => new Exception("Received error from PLC: Accessing object not allowed.")); var (passes, faulted) = await PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500)); // Positive controls: the window really ran past the threshold with the driver Faulted throughout, // so "no reconnect" below cannot pass merely because nothing happened. passes.ShouldBeGreaterThanOrEqualTo(10); faulted.ShouldBe(passes, "every pass reports the PUT/GET config alert"); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } /// The control: a real comms loss (every read times out) on the same rig IS reconnected. [Fact] public async Task A_real_comms_loss_is_reconnected_by_the_watchdog() { var (driver, parent) = Connect(() => new TimeoutException("frozen peer")); var poll = PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500)); parent.ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Connected.ShouldBeFalse(); await poll; } }