using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen /// peer), the rest of that pass must not go to the wire: each further op would pay the full /// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only /// started after it. The remaining items are stamped BadCommunicationError without I/O. /// Every assertion counts wire operations; nothing is timed. /// [Trait("Category", "Unit")] public sealed class S7DeadHandleShortCircuitTests { private const uint BadCommunicationError = 0x80050000u; private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromSeconds(5), Probe = new S7ProbeOptions { Enabled = false }, SpanReadMaxGapBytes = maxGap, RawTags = S7RawTags.Entries(tags), }; /// Five isolated words — far enough apart that each is its own span. private static S7TagDefinition[] FarApartWords() => [.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))]; private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")]; private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options) { var factory = new ScriptedS7PlcFactory(); var drv = new S7Driver(options, "s7-dead-short-circuit", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); return (drv, factory); } /// Per-tag path: the first timeout ends the pass's wire traffic. [Fact] public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead() { var ct = TestContext.Current.CancellationToken; var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); using var _ = drv; factory.Read = (_, _) => throw new TimeoutException("frozen peer"); var before = factory.WireOps; var r = await drv.ReadAsync(Refs(5), ct); (factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire"); r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); // Positive control: the next pass reopens and serves every tag. factory.Read = (_, _) => Task.FromResult((ushort)1); var recovered = await drv.ReadAsync(Refs(5), ct); recovered.ShouldAllBe(s => s.StatusCode == 0u); factory.Created.Count.ShouldBe(2); } /// Span path: arrays are block reads even alone, so five arrays are five spans. [Fact] public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead() { var ct = TestContext.Current.CancellationToken; S7TagDefinition[] arrays = [.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))]; var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays)); using var _ = drv; factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer"); var before = factory.WireOps; var r = await drv.ReadAsync(Refs(5), ct); (factory.WireOps - before).ShouldBe(1); r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); } /// /// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read /// per member — each paying the timeout again. Now it marks the handle dead and the fallback is /// short-circuited. /// [Fact] public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads() { var ct = TestContext.Current.CancellationToken; var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords())); using var _ = drv; factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer"); factory.Read = (_, _) => throw new TimeoutException("frozen peer"); var before = factory.WireOps; var r = await drv.ReadAsync(Refs(5), ct); (factory.WireOps - before).ShouldBe(1); r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } /// /// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still /// falls back per tag, so the members the CPU will serve are served. /// [Fact] public async Task Multi_var_refusal_still_falls_back_per_tag() { var ct = TestContext.Current.CancellationToken; var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords())); using var _ = drv; // The live snap7 shape of a multi-var with one refused item. factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes)); factory.Read = (_, _) => Task.FromResult((ushort)9); var before = factory.WireOps; var r = await drv.ReadAsync(Refs(5), ct); (factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each"); r.ShouldAllBe(s => s.StatusCode == 0u); factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen"); } /// A refusal does not short-circuit the pass: the CPU answered, the handle is fine. [Fact] public async Task A_refusal_does_not_short_circuit_the_pass() { var ct = TestContext.Current.CancellationToken; var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); using var _ = drv; factory.Read = (addr, _) => addr == "DB1.DBW0" ? throw new Exception("Received error from PLC: Object does not exist.") : Task.FromResult((ushort)3); var before = factory.WireOps; var r = await drv.ReadAsync(Refs(5), ct); (factory.WireOps - before).ShouldBe(5); r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u); } /// Write batch: the first transport loss fails the rest of the batch without I/O. [Fact] public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead() { var ct = TestContext.Current.CancellationToken; var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); using var _ = drv; factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset"); var before = factory.WireOps; var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct); (factory.WireOps - before).ShouldBe(1); w.ShouldAllBe(x => x.StatusCode == BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); // Positive control: the next batch reopens and writes. factory.Write = (_, _, _) => Task.CompletedTask; (await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct)) .ShouldAllBe(x => x.StatusCode == 0u); } }