using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// R07:P2 — a span's block is the union of its members' ranges, so ONE member that runs past the /// DB's real end (or over-declares a STRING) made the CPU refuse the whole block and every /// neighbour inside it went Bad on every poll. A refused span now re-reads its members one at a /// time for that pass and remembers which tags must not be grouped, so later passes group only the /// members that can be grouped. /// [Trait("Category", "Unit")] public sealed class S7SpanRefusalFallbackTests { private const uint BadDeviceFailure = 0x808B0000u; /// DB1 is 4 bytes long on this fake CPU: anything reaching past byte 4 is refused. private const int DbLength = 4; private static Exception OutOfRange() => new("Received error from PLC: Address out of range."); private static (S7Driver Driver, ScriptedS7PlcFactory Factory, Func BlockReads, Func TagReads) Start( params S7TagDefinition[] tags) { var factory = new ScriptedS7PlcFactory(); var blockReads = 0; var tagReads = 0; factory.ReadBytes = (_, _, start, count, _) => { Interlocked.Increment(ref blockReads); if (start + count > DbLength) throw OutOfRange(); var b = new byte[count]; for (var i = 0; i < count; i++) b[i] = (byte)(start + i + 1); return Task.FromResult(b); }; factory.Read = (addr, _) => { Interlocked.Increment(ref tagReads); var offset = int.Parse(addr[(addr.LastIndexOf('W') + 1)..], System.Globalization.CultureInfo.InvariantCulture); if (offset + 2 > DbLength) throw OutOfRange(); return Task.FromResult((ushort)(((offset + 1) << 8) | (offset + 2))); }; var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, RawTags = S7RawTags.Entries(tags), }, "s7-span-fallback", factory); return (drv, factory, () => Volatile.Read(ref blockReads), () => Volatile.Read(ref tagReads)); } private static S7TagDefinition Word(string name, int offset) => new(name, $"DB1.DBW{offset}", S7DataType.UInt16, Writable: false); /// /// The over-running member is refused on its own; its neighbours are served from the very first /// pass, and from the second pass on the neighbours are grouped again WITHOUT the bad member. /// [Fact] public async Task A_refused_span_falls_back_per_member_and_stops_grouping_the_bad_member() { var ct = TestContext.Current.CancellationToken; var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4)); using var _ = drv; await drv.InitializeAsync("{}", ct); string[] refs = ["A", "B", "C"]; // Pass 1: the 6-byte span is refused, then each member is read on its own. var r1 = await drv.ReadAsync(refs, ct); r1[0].StatusCode.ShouldBe(0u); r1[0].Value.ShouldBe((ushort)0x0102); r1[1].StatusCode.ShouldBe(0u); r1[1].Value.ShouldBe((ushort)0x0304); r1[2].StatusCode.ShouldBe(BadDeviceFailure); drv.GetHealth().State.ShouldBe(DriverState.Healthy); blockReads().ShouldBe(1); tagReads().ShouldBe(3); // Pass 2: A+B ride ONE block read again (the planner no longer sees C); C reads alone. var r2 = await drv.ReadAsync(refs, ct); r2[0].Value.ShouldBe((ushort)0x0102); r2[1].Value.ShouldBe((ushort)0x0304); r2[2].StatusCode.ShouldBe(BadDeviceFailure); blockReads().ShouldBe(2, "A+B grouped again, without the refused member"); tagReads().ShouldBe(4, "only C took the per-tag path on pass 2"); } /// /// When the block is refused but every member reads fine alone, the span itself is the problem: /// all of its members stop being grouped (and stop re-trying the refused block every pass). /// [Fact] public async Task A_refused_span_whose_members_all_read_alone_is_not_grouped_again() { var ct = TestContext.Current.CancellationToken; var factory = new ScriptedS7PlcFactory(); var blockReads = 0; factory.ReadBytes = (_, _, _, _, _) => { Interlocked.Increment(ref blockReads); throw OutOfRange(); }; factory.Read = (_, _) => Task.FromResult((ushort)7); using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)), }, "s7-span-fallback-all", factory); await drv.InitializeAsync("{}", ct); (await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u); (await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u); Volatile.Read(ref blockReads).ShouldBe(1, "the refused block is not re-issued on later passes"); } /// /// Positive control: a span failure that is NOT a refusal (a device-layer fault) does not fall /// back — re-reading N members one by one against a failing device would only multiply the fault. /// [Fact] public async Task A_non_refusal_span_failure_does_not_fall_back() { var ct = TestContext.Current.CancellationToken; var factory = new ScriptedS7PlcFactory { ReadBytes = (_, _, _, _, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.SendData, "device fault"), }; using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)), }, "s7-span-nofallback", factory); await drv.InitializeAsync("{}", ct); var before = factory.WireOps; var r = await drv.ReadAsync(["A", "B"], ct); r.ShouldAllBe(s => s.StatusCode == BadDeviceFailure); (factory.WireOps - before).ShouldBe(1); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } /// /// Review m-5, the multi-var sibling: a leftover chunk holding one refused scalar used to fall back /// per tag on EVERY poll (1 + N requests). The member refused on its own is now memoised, so the /// next pass batches the rest and reads only the bad one alone. /// [Fact] public async Task A_refused_multi_var_chunk_memoises_only_the_member_refused_alone() { var ct = TestContext.Current.CancellationToken; var factory = new ScriptedS7PlcFactory(); var multiCalls = 0; factory.ReadMulti = (items, _) => { Interlocked.Increment(ref multiCalls); if (items.Any(i => i.DB == 3)) // The live snap7 shape of a multi-var request with one refused item. throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes)); foreach (var i in items) i.Value = (ushort)5; return Task.FromResult>(items); }; factory.Read = (addr, _) => addr.StartsWith("DB3.", StringComparison.Ordinal) ? throw new Exception("Received error from PLC: Object does not exist.") : Task.FromResult((ushort)5); using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, RawTags = S7RawTags.Entries( new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("B", "DB2.DBW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("Bad", "DB3.DBW0", S7DataType.UInt16, Writable: false)), }, "s7-mv-memo", factory); await drv.InitializeAsync("{}", ct); string[] refs = ["A", "B", "Bad"]; var before = factory.WireOps; var r1 = await drv.ReadAsync(refs, ct); r1[0].StatusCode.ShouldBe(0u); r1[1].StatusCode.ShouldBe(0u); r1[2].StatusCode.ShouldBe(BadDeviceFailure); (factory.WireOps - before).ShouldBe(4, "pass 1: one refused multi-var, then one per-tag read each"); before = factory.WireOps; var r2 = await drv.ReadAsync(refs, ct); r2[0].StatusCode.ShouldBe(0u); r2[1].StatusCode.ShouldBe(0u); r2[2].StatusCode.ShouldBe(BadDeviceFailure); (factory.WireOps - before).ShouldBe(2, "pass 2: A+B ride one multi-var, Bad reads alone"); Volatile.Read(ref multiCalls).ShouldBe(2); } /// The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again. [Fact] public async Task Reinitialize_clears_the_do_not_group_memo() { var ct = TestContext.Current.CancellationToken; var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4)); using var _ = drv; await drv.InitializeAsync("{}", ct); await drv.ReadAsync(["A", "B", "C"], ct); blockReads().ShouldBe(1); tagReads().ShouldBe(3); await drv.InitializeAsync("{}", ct); await drv.ReadAsync(["A", "B", "C"], ct); // With the memo still in force pass 2 would be one A+B block read plus ONE per-tag read (C). // Cleared, the full A..C span is tried again, refused again, and all three re-read alone. blockReads().ShouldBe(2); tagReads().ShouldBe(6, "after re-init the full span is tried again (and refused again)"); } }