using S7.Net; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// R07:H4 — a deterministic PLC refusal (S7.Net ReadData / WriteData carrying an S7 /// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU /// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances /// LastSuccessfulRead, so a driver whose every tag is refused (wrong DB number) no longer /// trips the 90 s comms-loss watchdog against a reachable PLC. /// [Trait("Category", "Unit")] public sealed class S7RefusalHealthTests { private const uint BadDeviceFailure = 0x808B0000u; /// /// The shape S7.Net's READ paths really raise for a CPU item error — a BARE /// , live-probed against the snap7 fixture (not a PlcException). /// private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist."); private static PlcException WriteRefusal() => new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range.")); private static S7DriverOptions Options() => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromSeconds(5), Probe = new S7ProbeOptions { Enabled = false }, // Grouping off: one wire request per tag keeps each tag's outcome its own. SpanReadMaxGapBytes = -1, RawTags = S7RawTags.Entries( new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16), new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)), }; /// A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY. private sealed class PassClock { public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); /// Moves the clock one second on and returns the new time. public DateTime Step() => Now = Now.AddSeconds(1); } private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync() { var factory = new ScriptedS7PlcFactory(); var log = new CapturingS7Logger(); var clock = new PassClock(); var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now }; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock"); return (drv, factory, log, clock); } /// A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once. [Fact] public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning() { var ct = TestContext.Current.CancellationToken; var (drv, factory, log, clock) = await StartAsync(); using var _ = drv; factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); for (var pass = 0; pass < 3; pass++) { var at = clock.Step(); var r = await drv.ReadAsync(["Missing"], ct); r[0].StatusCode.ShouldBe(BadDeviceFailure); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault"); drv.GetHealth().LastSuccessfulRead.ShouldBe(at, "the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time"); } log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll"); // Positive control: the good tag still reads, and nothing reopened the connection. (await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u); factory.Created.Count.ShouldBe(1); } /// /// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the /// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the /// watchdog's trip condition. /// [Fact] public async Task A_refusal_after_a_comms_fault_restores_healthy() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; // Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen. var initStamp = clock.Now; clock.Step(); factory.Read = (_, _) => throw new TimeoutException("frozen peer"); await drv.ReadAsync(["Missing"], ct); var degraded = drv.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); degraded.LastSuccessfulRead.ShouldBe(initStamp); var at = clock.Step(); factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); var r = await drv.ReadAsync(["Missing"], ct); r[0].StatusCode.ShouldBe(BadDeviceFailure); var after = drv.GetHealth(); after.State.ShouldBe(DriverState.Healthy); after.LastSuccessfulRead.ShouldBe(at); } /// The ReadData-wrapped shape of a refusal is classified the same way. [Fact] public async Task A_read_data_wrapped_refusal_is_also_a_refusal() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal()); (await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert. /// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read /// refusal fell through to BadCommunicationError + Degraded. /// [Fact] public async Task A_bare_access_denied_read_is_the_faulted_config_alert() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed."); (await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported drv.GetHealth().State.ShouldBe(DriverState.Faulted); drv.GetHealth().LastError!.ShouldContain("PUT/GET"); } /// /// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is /// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted. /// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip /// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix /// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen. /// [Fact] public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed."); DateTime last = default; for (var pass = 0; pass < 2; pass++) { last = clock.Step(); await drv.ReadAsync(["Good", "Missing"], ct); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Faulted, "the config alert is kept"); h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass"); } // Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it). clock.Step(); factory.Read = (_, _) => throw new TimeoutException("frozen peer"); await drv.ReadAsync(["Good"], ct); drv.GetHealth().LastSuccessfulRead.ShouldBe(last); } /// The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success. [Fact] public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData, new Exception("Received error from PLC: Accessing object not allowed.")); var at = clock.Step(); (await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u); drv.GetHealth().State.ShouldBe(DriverState.Faulted); drv.GetHealth().LastSuccessfulRead.ShouldBe(at); // A clean read leaves the write-side Faulted in place (sticky) … clock.Step(); await drv.ReadAsync(["Good"], ct); drv.GetHealth().State.ShouldBe(DriverState.Faulted); // … and a real write success clears it. factory.Write = (_, _, _) => Task.CompletedTask; clock.Step(); await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded), /// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert. /// [Fact] public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; clock.Step(); factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData, new System.IO.IOException("Accessing object not allowed: socket closed by peer")); (await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode .ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported"); drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert"); S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse(); S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue(); } /// A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal. [Fact] public async Task A_read_data_wrapping_a_socket_fault_still_degrades() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset")); await drv.ReadAsync(["Good"], ct); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } /// A write refusal is per-tag Bad, counts as answered and leaves health alone. [Fact] public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered() { var ct = TestContext.Current.CancellationToken; var (drv, factory, log, clock) = await StartAsync(); using var _ = drv; factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask; // Put the driver into a comms-fault Degraded first; the refusal must lift it. factory.Read = (_, _) => throw new TimeoutException("frozen peer"); await drv.ReadAsync(["Good"], ct); var degraded = drv.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); for (var i = 0; i < 2; i++) { var at = clock.Step(); var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct); w[0].StatusCode.ShouldBe(BadDeviceFailure); var h = drv.GetHealth(); h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly"); // The Degraded was written by the READ side (its own message), so it is the read pass's to clear. h.State.ShouldBe(DriverState.Degraded); } var after = drv.GetHealth(); after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1); } /// /// A good write clears a Degraded the write side wrote. Before the fix a success-only batch /// never wrote health, so a transient write fault latched Degraded until some read pass ran. /// [Fact] public async Task A_good_write_clears_a_write_side_degraded() { var ct = TestContext.Current.CancellationToken; var (drv, factory, _, clock) = await StartAsync(); using var _ = drv; factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset"); var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct); bad[0].StatusCode.ShouldNotBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades"); factory.Write = (_, _, _) => Task.CompletedTask; var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct); good[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } }