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; /// /// Falsification gate for span-grouped S7 reads. Every tag must decode to the SAME value, the /// same CLR box type and the same status code whether it was served by a wire request of its /// own (the pre-existing per-tag path, reachable by setting /// SpanReadMaxGapBytes = -1) or by a block read shared with its neighbours. /// /// /// /// The two drivers read the same in-memory PLC image through the same fake, so any /// difference between the two result sets is a defect in the grouping path — there is no /// other variable. Grouping is proven to have actually engaged by /// : without that /// assertion this whole file would pass vacuously if the planner ever produced only /// leftovers, because "leftover" IS the per-tag path. /// /// /// The fake serves ReadBytesAsync (block reads — the span path, plus the wide / /// array / Timer-Counter tags that always read blocks) and ReadAsync(string) (the /// narrow per-tag path) from ONE byte image per (area, DB), decoding the latter /// exactly as S7.Net does: DBX boxes a bool, DBB a byte, /// DBW a ushort, DBD a uint. /// /// [Trait("Category", "Unit")] public sealed class S7SpanReadParityTests { /// OPC UA BadDeviceFailure — what a device-layer read fault stamps. private const uint StatusBadDeviceFailure = 0x808B0000u; /// OPC UA BadNodeIdUnknown — what an unresolvable reference stamps. private const uint StatusBadNodeIdUnknown = 0x80340000u; /// /// OPC UA BadCommunicationError — what the driver's catch-all failure mapping stamps, /// which is where a malformed multi-var answer (an InvalidDataException) lands. /// private const uint StatusBadCommunicationError = 0x80050000u; // ── The mixed tag set ──────────────────────────────────────────────────────────────── /// /// A deliberately awkward batch: two Bools sharing one byte (bit 0 and bit 7), a Byte, a /// word pair, three dwords behind a small hole, a wide Int64, an S7 STRING, a UInt16 array, /// a far-away word past the gap budget, a second AREA, and a Counter (whose offset is a /// device number, so it can never join a byte span). /// private static IReadOnlyList MixedTags() => S7RawTags.Entries( new S7TagDefinition("Bit0", "DB1.DBX0.0", S7DataType.Bool, Writable: false), new S7TagDefinition("Bit7", "DB1.DBX0.7", S7DataType.Bool, Writable: false), new S7TagDefinition("Byte1", "DB1.DBB1", S7DataType.Byte, Writable: false), new S7TagDefinition("U16", "DB1.DBW2", S7DataType.UInt16, Writable: false), new S7TagDefinition("I16", "DB1.DBW4", S7DataType.Int16, Writable: false), new S7TagDefinition("U32", "DB1.DBD8", S7DataType.UInt32, Writable: false), new S7TagDefinition("I32", "DB1.DBD12", S7DataType.Int32, Writable: false), new S7TagDefinition("F32", "DB1.DBD16", S7DataType.Float32, Writable: false), new S7TagDefinition("I64", "DB1.DBB24", S7DataType.Int64, Writable: false), new S7TagDefinition("Str", "DB1.DBB36", S7DataType.String, Writable: false, StringLength: 10), new S7TagDefinition("Arr", "DB1.DBW60", S7DataType.UInt16, Writable: false, ArrayCount: 4), new S7TagDefinition("Far", "DB1.DBW200", S7DataType.UInt16, Writable: false), new S7TagDefinition("Mem", "MW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false)); /// Read references, in the order the batch presents them. private static readonly string[] MixedRefs = [ "Bit0", "Bit7", "Byte1", "U16", "I16", "U32", "I32", "F32", "Str", "I64", "Arr", "Far", "Mem", "Cnt", ]; /// Only the narrow, byte-addressed DB1 tags — every one of them lands in one span. private static IReadOnlyList NarrowTags() => S7RawTags.Entries( new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: false), new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: false)); /// /// narrow words spaced 8 bytes apart. With a gap budget below 6 /// none of them can merge, so every one becomes a single-member span the driver demotes to /// the leftover path — which is exactly the population the multi-var batch serves. /// /// How many scattered words to author. /// The raw-tag entries. private static IReadOnlyList ScatteredWords(int count) => S7RawTags.Entries( [.. Enumerable.Range(0, count).Select(i => new S7TagDefinition($"S{i}", $"DB1.DBW{i * 8}", S7DataType.UInt16, Writable: false))]); /// References for , in authoring order. /// How many scattered words the batch reads. /// The reference names. private static string[] ScatteredRefs(int count) => [.. Enumerable.Range(0, count).Select(i => $"S{i}")]; /// /// One of every narrow access width, spaced far enough apart that each is its own /// single-member span — so the whole set reaches the multi-var pass and every box type /// (bool / byte / ushort / short / uint / int / /// float) is exercised through it. /// private static IReadOnlyList ScatteredWidths() => S7RawTags.Entries( new S7TagDefinition("WBit", "DB1.DBX0.3", S7DataType.Bool, Writable: false), new S7TagDefinition("WByte", "DB1.DBB8", S7DataType.Byte, Writable: false), new S7TagDefinition("WU16", "DB1.DBW16", S7DataType.UInt16, Writable: false), new S7TagDefinition("WI16", "DB1.DBW24", S7DataType.Int16, Writable: false), new S7TagDefinition("WU32", "DB1.DBD32", S7DataType.UInt32, Writable: false), new S7TagDefinition("WI32", "DB1.DBD40", S7DataType.Int32, Writable: false), new S7TagDefinition("WF32", "DB1.DBD48", S7DataType.Float32, Writable: false), // A second AREA in the same batch — a multi-var request carries each item's own area/DB, // which is precisely what a block read cannot do. new S7TagDefinition("WMem", "MW0", S7DataType.UInt16, Writable: false)); /// References for , in authoring order. private static readonly string[] ScatteredWidthRefs = ["WBit", "WByte", "WU16", "WI16", "WU32", "WI32", "WF32", "WMem"]; // ── Parity ─────────────────────────────────────────────────────────────────────────── /// /// THE gate: span-grouped reads and per-tag reads produce identical snapshots — same /// value, same box type, same status — for every tag in the mixed batch. /// [Fact] public async Task Span_grouped_reads_match_the_per_tag_path_snapshot_for_snapshot() { var images = Images(); var ct = TestContext.Current.CancellationToken; var grouping = new ImageS7PlcFactory(images); var perTag = new ImageS7PlcFactory(images); using var groupedDriver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-grouped", grouping); using var perTagDriver = new S7Driver(Options(MixedTags(), maxGap: -1), "s7-per-tag", perTag); await groupedDriver.InitializeAsync("{}", ct); await perTagDriver.InitializeAsync("{}", ct); var grouped = await groupedDriver.ReadAsync(MixedRefs, ct); var individual = await perTagDriver.ReadAsync(MixedRefs, ct); grouped.Count.ShouldBe(MixedRefs.Length); for (var i = 0; i < MixedRefs.Length; i++) { var name = MixedRefs[i]; grouped[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'"); grouped[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good"); grouped[i].Value.ShouldNotBeNull($"'{name}' returned no value"); grouped[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'"); grouped[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'"); } // The per-tag control really did take the per-tag path (no grouping happened there): its // only block reads are the four tags that ALWAYS read blocks (Int64, String, array, Counter). perTag.ReadBytesCalls.ShouldBe(4); perTag.StringReadCalls.ShouldBe(10); } /// /// Proves grouping actually engaged. Without this the parity test above would pass /// vacuously if the planner degenerated to all-leftovers, since a leftover IS a per-tag /// read. /// /// /// 14 tags collapse to 3 wire requests: ONE block read covering the eleven DB1 tags from /// byte 0 to byte 68, the Counter's own unavoidable block read, and ONE multi-var request /// carrying the two tags a span can't help — the far DB1 word (past the gap budget) and the /// lone Memory word, which is in a different AREA entirely. Neither is worth a block read of /// its own, and batching them together is something only a multi-var request can do. /// [Fact] public async Task Grouping_issues_far_fewer_wire_requests_than_there_are_tags() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); using var driver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-wire-count", factory); await driver.InitializeAsync("{}", ct); _ = await driver.ReadAsync(MixedRefs, ct); (factory.ReadBytesCalls + factory.StringReadCalls + factory.MultiVarCalls) .ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBe(2); // NO per-address reads at all: the nine narrow tags inside the big span are served by its // block read, and the two that ended up alone in a span of their own are batched together // into a single multi-var request. factory.StringReadCalls.ShouldBe(0); factory.MultiVarCalls.ShouldBe(1); } // ── Failure parity ─────────────────────────────────────────────────────────────────── /// /// A failed span stamps EVERY one of its members with exactly the status the per-tag path /// would have produced for that exception, and degrades health identically. A span read is /// one wire request serving many tags, so a fault that used to affect one tag now affects /// all of them — they must all be told the same thing. /// [Fact] public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path() { var ct = TestContext.Current.CancellationToken; // ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine // device-layer fault, which the driver maps to BadDeviceFailure + Degraded. var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; using var groupedDriver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-grouped-fail", groupingFactory); using var perTagDriver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-per-tag-fail", perTagFactory); await groupedDriver.InitializeAsync("{}", ct); await perTagDriver.InitializeAsync("{}", ct); string[] refs = ["A", "B", "C"]; var grouped = await groupedDriver.ReadAsync(refs, ct); var individual = await perTagDriver.ReadAsync(refs, ct); // Every member of the failed span — not just the first — is stamped Bad. grouped.Count.ShouldBe(3); grouped.ShouldAllBe(s => s.StatusCode == StatusBadDeviceFailure); grouped.ShouldAllBe(s => s.Value == null); grouped.Select(s => s.StatusCode).ShouldBe(individual.Select(s => s.StatusCode)); groupedDriver.GetHealth().State.ShouldBe(DriverState.Degraded); groupedDriver.GetHealth().State.ShouldBe(perTagDriver.GetHealth().State); // One failed block read, not three — the span path fails once for the whole group. groupingFactory.ReadBytesCalls.ShouldBe(1); } /// /// Unresolved references still stamp BadNodeIdUnknown and never enter a span, and /// the resolvable tags around them still read Good. /// [Fact] public async Task An_unresolved_reference_is_still_BadNodeIdUnknown_under_grouping() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-unknown", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(["A", "NoSuchTag", "C"], ct); results[0].StatusCode.ShouldBe(0u); results[1].StatusCode.ShouldBe(StatusBadNodeIdUnknown); results[1].Value.ShouldBeNull(); results[2].StatusCode.ShouldBe(0u); } // ── Multi-var leftovers ────────────────────────────────────────────────────────────── /// /// The batchable leftovers ride ONE multi-var request per chunk of /// MultiVarReadMaxItems and issue no per-address reads at all. 13 scattered words /// that the span planner cannot merge used to cost 13 wire requests; they now cost 2. /// [Fact] public async Task Batchable_leftovers_ride_multi_var_requests_instead_of_one_read_each() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); using var driver = new S7Driver(Options(ScatteredWords(13), maxGap: 2), "s7-multivar", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(ScatteredRefs(13), ct); results.ShouldAllBe(s => s.StatusCode == 0u); results.ShouldAllBe(s => s.Value != null); // ⌈13 / 12⌉ = 2 requests, and NOT ONE per-address read — the whole point of the batch. factory.MultiVarCalls.ShouldBe(2); factory.StringReadCalls.ShouldBe(0); factory.ReadBytesCalls.ShouldBe(0); // The chunking is a real split, not one oversized request plus an empty one. factory.MultiVarItems.Select(c => c.Count).ShouldBe([12, 1]); } /// /// THE multi-var gate: a tag served by a multi-var request decodes to the same value, the /// same CLR box type and the same status as the per-tag path reading the same image. /// [Fact] public async Task Multi_var_leftovers_match_the_per_tag_path_snapshot_for_snapshot() { var images = Images(); var ct = TestContext.Current.CancellationToken; var batching = new ImageS7PlcFactory(images); var perTag = new ImageS7PlcFactory(images); using var batchingDriver = new S7Driver(Options(ScatteredWidths(), maxGap: 2), "s7-mv-parity", batching); using var perTagDriver = new S7Driver(Options(ScatteredWidths(), maxGap: -1), "s7-mv-control", perTag); await batchingDriver.InitializeAsync("{}", ct); await perTagDriver.InitializeAsync("{}", ct); var batched = await batchingDriver.ReadAsync(ScatteredWidthRefs, ct); var individual = await perTagDriver.ReadAsync(ScatteredWidthRefs, ct); for (var i = 0; i < ScatteredWidthRefs.Length; i++) { var name = ScatteredWidthRefs[i]; batched[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good"); batched[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'"); batched[i].Value.ShouldNotBeNull($"'{name}' returned no value"); batched[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'"); batched[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'"); } // Proves the batching side really took the multi-var path (otherwise this file's parity // claim would be vacuous), and the control side really did not. batching.MultiVarCalls.ShouldBe(1); batching.StringReadCalls.ShouldBe(0); perTag.MultiVarCalls.ShouldBe(0); perTag.StringReadCalls.ShouldBe(ScatteredWidthRefs.Length); } /// /// A multi-var request that faults falls back to the per-tag path for that chunk's tags and /// still serves them Good. Batching is an optimisation: it must never be able to turn a tag /// Bad that reading it one at a time would have served. /// [Fact] public async Task A_failing_multi_var_chunk_falls_back_to_per_tag_reads_and_still_serves_good() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()) { // A device-layer fault — the shape that WOULD stamp BadDeviceFailure had it come from // the per-tag path. Through the multi-var pass it must cost nothing but a retry. MultiVarFault = new PlcException(ErrorCode.ReadData, "S7 CPU rejected the multi-var request"), }; using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-fallback", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(ScatteredRefs(5), ct); results.ShouldAllBe(s => s.StatusCode == 0u); results.ShouldAllBe(s => s.Value != null); factory.MultiVarCalls.ShouldBe(1); // Every tag of the failed chunk was re-read individually. factory.StringReadCalls.ShouldBe(5); // And the failed batch attempt did NOT degrade the driver — nothing actually failed to read. driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// Tags the per-tag path owns — Timer and Counter, whose offsets are device numbers rather /// than byte addresses — are never handed to a multi-var request, even though they share the /// leftover list with the narrow scalars that are. /// [Fact] public async Task Timer_and_counter_leftovers_never_enter_a_multi_var_request() { var ct = TestContext.Current.CancellationToken; var tags = S7RawTags.Entries( new S7TagDefinition("Tmr", "T2", S7DataType.Float64, Writable: false), new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false), new S7TagDefinition("N0", "DB1.DBW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("N1", "DB1.DBW100", S7DataType.UInt16, Writable: false)); var factory = new ImageS7PlcFactory(Images()); using var driver = new S7Driver(Options(tags, maxGap: 2), "s7-mv-partition", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(["Tmr", "Cnt", "N0", "N1"], ct); results.ShouldAllBe(s => s.StatusCode == 0u); results[0].Value.ShouldBeOfType(); results[1].Value.ShouldBeOfType(); // One multi-var request carrying ONLY the two narrow words; the Timer and the Counter kept // their own block reads. factory.MultiVarCalls.ShouldBe(1); factory.MultiVarItems[0].Count.ShouldBe(2); factory.MultiVarItems[0].ShouldAllBe(d => d.DataType == S7NetDataType.DataBlock); factory.ReadBytesCalls.ShouldBe(2); } // ── Malformed multi-var answers ────────────────────────────────────────────────────── /// /// ONE undecodable item in an otherwise-good multi-var chunk spoils only its own tag: its /// chunk-mates keep the values the batch already carried, and NOBODY is re-read individually. /// A per-ITEM decode fault is not a chunk fault — the whole-chunk fallback exists for a /// failed REQUEST, and firing it here would re-read tags that already have good values. /// /// /// Health ends Degraded, NOT Healthy. This pin used to assert the opposite: /// the decode loop stamped health per item in chunk order, so the good item after the bad /// one re-stamped it and a pass with failures reported Healthy — last-writer-wins. /// That was deliberately replaced (perf follow-ups round 3) by worst-state-wins: the /// whole read pass accumulates its outcomes and writes health ONCE at the end, so a mixed /// pass can no longer report Healthy. Only the driver-level health claim changed — the /// per-tag statuses below are exactly as they were, because a per-ITEM decode fault still /// spoils only its own tag. /// [Fact] public async Task One_undecodable_multi_var_item_stamps_only_its_own_tag_Bad() { var ct = TestContext.Current.CancellationToken; var images = Images(); var factory = new ImageS7PlcFactory(images) { NullItemIndex = 1 }; using var driver = new S7Driver(Options(ScatteredWords(3), maxGap: 2), "s7-mv-item-fault", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(ScatteredRefs(3), ct); // The one item the CPU answered with nothing. results[1].StatusCode.ShouldBe(StatusBadCommunicationError); results[1].Value.ShouldBeNull(); // Its chunk-mates are untouched, values and all (S{i} lives at DB1.DBW{i * 8}). var db1 = images[(S7NetDataType.DataBlock, 1)]; static ushort Word(byte[] image, int at) => (ushort)((image[at] << 8) | image[at + 1]); results[0].StatusCode.ShouldBe(0u); results[0].Value.ShouldBe(Word(db1, 0)); results[2].StatusCode.ShouldBe(0u); results[2].Value.ShouldBe(Word(db1, 16)); // One batch, and NO per-tag fallback at all — not even for the tag that failed to decode, // which would have failed identically on the per-tag path. factory.MultiVarCalls.ShouldBe(1); factory.StringReadCalls.ShouldBe(0); factory.ReadBytesCalls.ShouldBe(0); driver.GetHealth().State.ShouldBe(DriverState.Degraded); } // ── Worst-state-wins read-pass health ──────────────────────────────────────────────── /// /// A pass with BOTH failures and successes reports Degraded — the failure is not /// erased by the later success — AND still advances LastSuccessfulRead, because /// something genuinely was read. /// /// /// Advancing the timestamp on a Degraded pass is load-bearing, not cosmetic: the /// comms-loss watchdog (CLAUDE.md §"Comms-loss detection") trips on non-Healthy state /// AND a frozen LastSuccessfulRead. Several drivers latch Degraded off one /// unreadable address, so freezing the timestamp on any non-Healthy pass would reconnect a /// perfectly reachable PLC every 90 s forever. /// [Fact] public async Task A_mixed_read_pass_reports_Degraded_and_still_advances_LastSuccessfulRead() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()) { NullItemIndex = 1 }; using var driver = new S7Driver(Options(ScatteredWords(3), maxGap: 2), "s7-mixed-pass", factory); await driver.InitializeAsync("{}", ct); // The connect stamped a LastSuccessfulRead; the pass must move it PAST that value, so a // frozen timestamp is distinguishable from a merely non-null one. var before = BaselineStamp(driver); before.ShouldNotBeNull(); var results = await driver.ReadAsync(ScatteredRefs(3), ct); results[0].StatusCode.ShouldBe(0u); results[1].StatusCode.ShouldBe(StatusBadCommunicationError); results[2].StatusCode.ShouldBe(0u); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Degraded, "a pass that had a failure must not report Healthy"); health.LastSuccessfulRead.ShouldNotBeNull(); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan( before!.Value, "two of the three tags read fine — the watchdog's evidence must advance"); } /// /// The SPAN path obeys the same rule: one member that fails to decode leaves the pass /// Degraded even though a later member of the same block decoded fine. /// /// /// The block read itself succeeds — this is the per-member decode guard, the span-path /// sibling of the multi-var per-item guard above. The poisoned member is an S7 STRING laid /// over the image's filler pattern, whose two-byte header declares a current length larger /// than its capacity; S7String.FromByteArray rejects that. It is deliberately the /// FIRST member (lower offset) so the good member decodes after it — the exact ordering /// last-writer-wins used to erase. /// [Fact] public async Task One_undecodable_span_member_leaves_the_pass_Degraded() { var ct = TestContext.Current.CancellationToken; // The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is // adjacent and lands in the SAME span. var tags = S7RawTags.Entries( new S7TagDefinition("BadStr", "DB1.DBB100", S7DataType.String, Writable: false, StringLength: 10), new S7TagDefinition("GoodWord", "DB1.DBW112", S7DataType.UInt16, Writable: false)); var factory = new ImageS7PlcFactory(Images()); // The poison is the FILLER, not a fake knob: Images() writes (i * 37 + 11) & 0xFF, which // steps +37 per byte and so runs ASCENDING except in the ~15% of positions where it wraps // past 256. Byte 100 is not one of those, so the STRING header at 100/101 reads // maxLen 127 / curLen 164 — a current length larger than the capacity, which // S7String.FromByteArray rejects. Asserted rather than assumed, so a change to the filler // pattern fails HERE, loudly, instead of silently turning this into a passing no-op test. var db1Image = factory.Images[(S7NetDataType.DataBlock, 1)]; db1Image[101].ShouldBeGreaterThan( db1Image[100], "the S7 STRING at DB1.DBB100 must declare curLen > maxLen to be undecodable"); using var driver = new S7Driver(Options(tags, maxGap: 16), "s7-span-decode-fault", factory); await driver.InitializeAsync("{}", ct); var before = BaselineStamp(driver); var results = await driver.ReadAsync(["BadStr", "GoodWord"], ct); // ONE block read served both members — this really is the span path, not two per-tag reads. factory.ReadBytesCalls.ShouldBe(1); factory.StringReadCalls.ShouldBe(0); // Only the poisoned member is Bad; its block-mate keeps its value. results[0].StatusCode.ShouldNotBe(0u); results[0].Value.ShouldBeNull(); results[1].StatusCode.ShouldBe(0u); results[1].Value.ShouldNotBeNull(); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased"); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); } /// /// Faulted outranks BOTH Degraded and Healthy: an access-denied read (the permanent PUT/GET /// configuration fault) on one tag survives a later device-fault tag AND a later tag that /// read successfully, all in the same pass. /// /// /// The ordering is Healthy < Degraded < Faulted, so the config alert — the one state /// an operator must act on — is precisely the one last-writer-wins was most likely to /// erase, since the denied tag is usually a minority of the batch. Both later outcomes are /// present deliberately: the success falsifies "any success clears the failure", the /// Degraded falsifies "the last failure wins". LastSuccessfulRead still advances, /// because one tag really was read. /// [Fact] public async Task An_access_denied_tag_keeps_the_pass_Faulted_even_after_a_later_tag_succeeds() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); // A: "not allowed" is what IsAccessDenied matches — PUT/GET disabled on an S7-1200/1500, // which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded, // observed AFTER it. C: no fault at all. factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); // maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the // faults can be aimed at individual addresses. using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory); await driver.InitializeAsync("{}", ct); var before = BaselineStamp(driver); var results = await driver.ReadAsync(["A", "B", "C"], ct); results[0].StatusCode.ShouldNotBe(0u); results[1].StatusCode.ShouldBe(StatusBadDeviceFailure); results[2].StatusCode.ShouldBe(0u); var health = driver.GetHealth(); health.State.ShouldBe( DriverState.Faulted, "a config alert must outrank a later device fault AND a later success"); health.LastError.ShouldNotBeNull(); // The FIRST failure is the causal one — the later device fault does not overwrite it. health.LastError!.ShouldContain("PUT/GET"); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan( before!.Value, "one tag read fine, so the watchdog's evidence must still advance"); } /// /// The pass's message must explain the state it reports. When a merely-Degraded fault is /// observed BEFORE the access-denied one, escalating to Faulted has to re-point /// LastError at the access-denied cause — reporting the config alert under the /// earlier device-fault message tells the operator to chase the wrong thing. /// /// /// This is the reversed ordering of /// , /// and the pair is the point: keeping the pass's chronologically-first message satisfies /// that test and fails this one, so only a message that moves WITH the severity passes both. /// [Fact] public async Task Escalating_to_Faulted_re_points_the_error_message_at_the_access_denied_cause() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); // A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert // (Faulted) after it. C: no fault at all. factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory); await driver.InitializeAsync("{}", ct); var before = BaselineStamp(driver); var results = await driver.ReadAsync(["A", "B", "C"], ct); results[0].StatusCode.ShouldBe(StatusBadDeviceFailure); results[1].StatusCode.ShouldNotBe(0u); results[2].StatusCode.ShouldBe(0u); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Faulted, "the access-denied fault must still outrank the earlier one"); health.LastError.ShouldNotBeNull(); health.LastError!.ShouldContain( "PUT/GET", customMessage: "the message must explain the FAULTED state, not the earlier Degraded one"); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); } /// /// Cancellation mid-pass abandons the batch but must NOT discard the evidence the pass had /// already gathered: a tag that read successfully before the cancellation still advances /// LastSuccessfulRead. /// /// /// Deciding health once at the end of the pass makes this a real risk, which per-item /// writes did not have. On a partially-working device a repeatedly-cancelled poll would /// freeze LastSuccessfulRead while tags were demonstrably still being read, and the /// comms-loss watchdog — which trips on non-Healthy state AND a frozen timestamp — would /// reconnect a CPU that is answering. Flushing from a finally is what prevents it. /// [Fact] public async Task A_cancelled_pass_still_flushes_the_evidence_it_had_already_gathered() { var ct = TestContext.Current.CancellationToken; // Narrow first (a per-address read, which the hang knob does NOT intercept), then a wide // Int64 (a BLOCK read, which it does) — so the pass banks one success and is then cancelled // mid-flight on the very next tag. var tags = S7RawTags.Entries( new S7TagDefinition("Narrow", "DB1.DBW0", S7DataType.UInt16, Writable: false), new S7TagDefinition("Wide", "DB1.DBB8", S7DataType.Int64, Writable: false)); var factory = new ImageS7PlcFactory(Images()) { HangReadBytes = true }; // maxGap -1 keeps both tags on the per-tag path, in reference order. using var driver = new S7Driver(Options(tags, maxGap: -1), "s7-cancel-flush", factory); await driver.InitializeAsync("{}", ct); var before = BaselineStamp(driver); using var cts = new CancellationTokenSource(); var reading = driver.ReadAsync(["Narrow", "Wide"], cts.Token); await factory.ReadBytesEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); await cts.CancelAsync(); await Should.ThrowAsync(async () => await reading); // The first tag really was served before the cancellation — one per-address read happened. factory.StringReadCalls.ShouldBe(1); var health = driver.GetHealth(); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan( before!.Value, "the banked success must survive the abandoned batch"); } /// /// A pass that observes NOTHING — every reference unresolvable, so not one tag was read and /// not one failed — reports nothing: it must leave the driver's prior Faulted config /// alert (and its LastSuccessfulRead) exactly as it found them. /// /// /// Deciding health once per pass means the pass has to answer "what did I observe?", and a /// degenerate pass observed nothing at all — writing Healthy for it would invent /// evidence and silently clear a PUT/GET config alert an operator still has to act on. The /// per-item health writes this replaced had that property for free (no item, no write); the /// end-of-pass guard is what preserves it. /// [Fact] public async Task A_pass_that_observes_nothing_leaves_a_prior_Faulted_untouched() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()); factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-observes-nothing", factory); await driver.InitializeAsync("{}", ct); // Drive the driver into the access-denied config alert first. _ = await driver.ReadAsync(["A"], ct); var faulted = driver.GetHealth(); faulted.State.ShouldBe(DriverState.Faulted); // Now a pass whose every reference is unresolvable: they are stamped BadNodeIdUnknown // before the planner ever sees them, so no span, no multi-var item and no per-tag read // happens — the pass observes neither a success nor a failure. var results = await driver.ReadAsync(["NoSuchTag", "AlsoMissing"], ct); results.ShouldAllBe(s => s.StatusCode == StatusBadNodeIdUnknown); var after = driver.GetHealth(); after.State.ShouldBe(DriverState.Faulted, "an observe-nothing pass must not clear the config alert"); after.LastError.ShouldBe(faulted.LastError); after.LastSuccessfulRead.ShouldBe(faulted.LastSuccessfulRead, "nothing was read, so nothing advances"); } /// /// An answer of the wrong SHAPE — one item short — cannot be mapped back onto the request's /// indexes, so the driver refuses to guess which tag got which value and falls the whole /// chunk back to the per-tag path, which still serves every tag Good. /// [Fact] public async Task A_short_multi_var_answer_falls_back_to_per_tag_reads_and_still_serves_good() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()) { ShortAnswer = true }; using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-shape", factory); await driver.InitializeAsync("{}", ct); var results = await driver.ReadAsync(ScatteredRefs(5), ct); results.ShouldAllBe(s => s.StatusCode == 0u); results.ShouldAllBe(s => s.Value != null); factory.MultiVarCalls.ShouldBe(1); // Every tag of the rejected chunk was re-read individually — including the four the short // answer DID carry, because a mis-shaped answer says nothing trustworthy about any of them. factory.StringReadCalls.ShouldBe(5); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } // ── Cancellation mid-read (STAB-15a) ───────────────────────────────────────────────── /// /// Cancellation observed while a SPAN's block read is in flight abandons a half-read /// ISO-on-TCP response on the single gated stream, so the handle can't be reused: the OCE /// must propagate (the caller cancelled — nobody consumes the batch) AND the next read must /// open a fresh connection. The per-tag path's sibling of this is pinned by /// S7DriverReconnectTests.Cancelled_read_marks_handle_dead_and_next_read_reopens. /// [Fact] public async Task A_cancelled_span_block_read_marks_the_handle_dead_and_the_next_read_reopens() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()) { HangReadBytes = true }; using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-span-cancel", factory); await driver.InitializeAsync("{}", ct); factory.CreatedPlcCount.ShouldBe(1); string[] refs = ["A", "B", "C"]; using var cts = new CancellationTokenSource(); var reading = driver.ReadAsync(refs, cts.Token); await factory.ReadBytesEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); await cts.CancelAsync(); await Should.ThrowAsync(async () => await reading); // The reopen is the observable half of the contract: the desynced handle was DISPOSED — not // merely leaked behind a second connection — and the next read builds a new one. factory.HangReadBytes = false; var recovered = await driver.ReadAsync(refs, ct); recovered.ShouldAllBe(s => s.StatusCode == 0u); factory.CreatedPlcCount.ShouldBe(2); factory.Plcs[0].Disposed.ShouldBeTrue(); } /// /// The same contract on the MULTI-VAR leftover path: a cancelled batch request propagates /// the OCE and marks the handle dead, so the next read reopens. Distinct from the per-chunk /// fallback — cancellation is the one request fault that must NOT be retried per tag. /// [Fact] public async Task A_cancelled_multi_var_read_marks_the_handle_dead_and_the_next_read_reopens() { var ct = TestContext.Current.CancellationToken; var factory = new ImageS7PlcFactory(Images()) { HangMultiVar = true }; using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-cancel", factory); await driver.InitializeAsync("{}", ct); factory.CreatedPlcCount.ShouldBe(1); var refs = ScatteredRefs(5); using var cts = new CancellationTokenSource(); var reading = driver.ReadAsync(refs, cts.Token); await factory.MultiVarEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); await cts.CancelAsync(); await Should.ThrowAsync(async () => await reading); factory.HangMultiVar = false; var recovered = await driver.ReadAsync(refs, ct); recovered.ShouldAllBe(s => s.StatusCode == 0u); factory.CreatedPlcCount.ShouldBe(2); factory.Plcs[0].Disposed.ShouldBeTrue(); } // ── Configurability ────────────────────────────────────────────────────────────────── /// /// The grouping knobs must be reachable from a deployed driver config. ParseOptions /// maps an explicit DTO rather than deserialising , so a knob /// that is not mapped there is silently pinned to its default forever — which would leave /// the documented "-1 disables grouping" escape hatch unreachable in production. /// [Fact] public void The_span_read_knobs_bind_from_driver_config_json() { var configured = S7DriverFactoryExtensions.ParseOptions( "s7-cfg", """{"host":"10.0.0.1","spanReadMaxGapBytes":-1,"spanReadMaxSpanBytes":64}"""); configured.SpanReadMaxGapBytes.ShouldBe(-1); configured.SpanReadMaxSpanBytes.ShouldBe(64); // Omitted keys keep the shipped defaults rather than collapsing to zero. var defaulted = S7DriverFactoryExtensions.ParseOptions("s7-cfg", """{"host":"10.0.0.1"}"""); defaulted.SpanReadMaxGapBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxGapBytes); defaulted.SpanReadMaxSpanBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxSpanBytes); } // ── Fixture ────────────────────────────────────────────────────────────────────────── /// Driver options over the given tags with the span-grouping knobs under test. /// /// The post-initialise LastSuccessfulRead stamp, taken only once the wall clock has /// moved past it. The driver stamps DateTime.UtcNow at init and again at the read /// pass; under CPU contention both can land in the same clock tick, so a strict /// "LastSuccessfulRead advanced" assertion flaked (#589). Waiting out the tick makes /// any later stamp strictly greater without a fixed delay. /// private static DateTime? BaselineStamp(S7Driver driver) { var stamp = driver.GetHealth().LastSuccessfulRead; if (stamp is { } s) SpinWait.SpinUntil(() => DateTime.UtcNow > s); return stamp; } private static S7DriverOptions Options(IReadOnlyList tags, int maxGap) => new() { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), Probe = new S7ProbeOptions { Enabled = false }, SpanReadMaxGapBytes = maxGap, SpanReadMaxSpanBytes = 220, RawTags = tags, }; /// /// The in-memory PLC images the fake serves, one byte array per (area, DB). Filled /// with a deterministic non-repeating pattern so no tag reads a vacuous all-zero value, then /// seeded with a well-formed S7 STRING field at DB1 byte 36 ([maxLen][curLen][chars…], /// which S7String.FromByteArray validates). /// private static Dictionary<(S7NetDataType Area, int Db), byte[]> Images() { var db1 = new byte[256]; for (var i = 0; i < db1.Length; i++) db1[i] = (byte)((i * 37 + 11) & 0xFF); db1[36] = 10; // declared max length db1[37] = 5; // current length var chars = "HELLO"u8.ToArray(); chars.CopyTo(db1, 38); for (var i = 38 + chars.Length; i < 36 + 12; i++) db1[i] = 0x20; // reserved padding var mem = new byte[64]; for (var i = 0; i < mem.Length; i++) mem[i] = (byte)((i * 53 + 7) & 0xFF); var counters = new byte[64]; for (var i = 0; i < counters.Length; i++) counters[i] = (byte)((i * 29 + 3) & 0xFF); var timers = new byte[64]; for (var i = 0; i < timers.Length; i++) timers[i] = (byte)((i * 17 + 5) & 0xFF); return new Dictionary<(S7NetDataType, int), byte[]> { [(S7NetDataType.DataBlock, 1)] = db1, [(S7NetDataType.Memory, 0)] = mem, [(S7NetDataType.Counter, 0)] = counters, [(S7NetDataType.Timer, 0)] = timers, }; } /// /// Fake serving both read shapes from one shared byte image per /// (area, DB), and counting each shape so a test can prove which path ran. Counters /// live on the FACTORY (not the connection) so a reconnect cannot reset them. /// private sealed class ImageS7PlcFactory(Dictionary<(S7NetDataType Area, int Db), byte[]> images) : IS7PlcFactory { /// The shared per-area byte images both read shapes decode from. public Dictionary<(S7NetDataType Area, int Db), byte[]> Images { get; } = images; /// Number of block reads issued — one per planned span, plus the always-block tags. public int ReadBytesCalls; /// Number of per-address string reads issued — the un-grouped narrow path. public int StringReadCalls; /// Number of multi-var requests issued — one per leftover chunk. public int MultiVarCalls; /// The items of every multi-var request, in issue order, so a test can assert what was batched. public List> MultiVarItems { get; } = new(); /// When set, every read throws this instead of serving the image. public Exception? ReadFault { get; init; } /// When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image. public Exception? MultiVarFault { get; init; } /// /// Per-address read faults, keyed by the address string (e.g. DB1.DBW0): only /// those addresses throw, every other one still serves the image. Lets a test put /// faults of DIFFERENT severities and successes in ONE pass, which /// (all-or-nothing) cannot express. /// public Dictionary PerTagFaults { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// When true, ReadBytesAsync hangs until its token is cancelled, so cancellation is /// the only way out of a block read (STAB-15a). Settable so a test can turn it back off /// after observing the cancellation and let the reopened handle read normally. /// public bool HangReadBytes { get; set; } /// When true, ReadMultipleVarsAsync hangs until its token is cancelled (STAB-15a). public bool HangMultiVar { get; set; } /// Completed the moment a hanging ReadBytesAsync is entered, so a test cancels a read that is genuinely in flight. public TaskCompletionSource ReadBytesEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Completed the moment a hanging ReadMultipleVarsAsync is entered. public TaskCompletionSource MultiVarEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// When set, the multi-var answer comes back the right shape but with THIS item's /// Value left null — the per-item decode fault, which must spoil only that tag. /// public int? NullItemIndex { get; init; } /// /// When true, the multi-var answer comes back ONE item short — a shape the driver cannot /// map back onto the request's indexes, so it must reject the whole chunk. /// public bool ShortAnswer { get; init; } /// How many connections this factory has been asked to build — the reopen observable. public int CreatedPlcCount; /// /// Every connection this factory built, in build order, so a test can assert what became /// of the STALE one — a driver that leaked the old handle and merely opened a second /// would satisfy alone. /// public List Plcs { get; } = new(); /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) { Interlocked.Increment(ref CreatedPlcCount); var plc = new ImageS7Plc(this); lock (Plcs) Plcs.Add(plc); return plc; } public sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc { /// public bool IsConnected { get; private set; } /// True once the driver has disposed this handle — the discard half of a reopen. public bool Disposed { get; private set; } /// public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } /// public void Close() => IsConnected = false; /// public async Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct) { // Counted BEFORE the injected fault so a test can assert how many block reads were // ATTEMPTED — a failure test that counted only successes would read zero. Interlocked.Increment(ref owner.ReadBytesCalls); if (owner.HangReadBytes) { // Signal first, then hang: the test cancels only once the read is genuinely in // flight, so it exercises cancel-mid-I/O rather than racing a timer. owner.ReadBytesEntered.TrySetResult(); await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false); } if (owner.ReadFault is { } fault) throw fault; var image = owner.Images[(area, db)]; if (startByteAdr + count > image.Length) { throw new PlcException(ErrorCode.ReadData, $"fake image overrun: {area} db{db} @{startByteAdr}+{count} exceeds {image.Length}"); } return image[startByteAdr..(startByteAdr + count)]; } /// /// /// Decodes each item from the same byte image the other two read shapes serve, boxing /// exactly as S7.Net's ParseBytes does for Count == 1: Bit a /// bool at BitAdr, Byte a byte, Word a /// ushort, DWord a uint. Like the real library it populates each /// item's Value in place and hands the same list back. /// public async Task> ReadMultipleVarsAsync( List items, CancellationToken ct) { // Counted (and captured) BEFORE the injected fault so a fallback test can assert the // batch was ATTEMPTED — counting only successes would read zero. Interlocked.Increment(ref owner.MultiVarCalls); lock (owner.MultiVarItems) owner.MultiVarItems.Add([.. items]); if (owner.HangMultiVar) { owner.MultiVarEntered.TrySetResult(); await Task.Delay(System.Threading.Timeout.Infinite, ct).ConfigureAwait(false); } if (owner.MultiVarFault is { } mvFault) throw mvFault; if (owner.ReadFault is { } fault) throw fault; foreach (var item in items) { var image = owner.Images[(item.DataType, item.DB)]; var at = item.StartByteAdr; item.Value = item.VarType switch { VarType.Bit => (object)((image[at] & (1 << item.BitAdr)) != 0), VarType.Byte => image[at], VarType.Word => (ushort)((image[at] << 8) | image[at + 1]), VarType.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16) | ((uint)image[at + 2] << 8) | image[at + 3], _ => throw new NotSupportedException($"fake cannot serve VarType {item.VarType}"), }; } // The two malformed-answer shapes, injected AFTER decoding so the answer is as good // as any other batch's apart from the one defect under test. if (owner.NullItemIndex is { } nullAt) items[nullAt].Value = null; if (owner.ShortAnswer) return items.GetRange(0, items.Count - 1); return items; } /// public Task ReadAsync(string address, CancellationToken ct) { Interlocked.Increment(ref owner.StringReadCalls); if (owner.PerTagFaults.TryGetValue(address, out var aimed)) throw aimed; if (owner.ReadFault is { } fault) throw fault; var addr = S7AddressParser.Parse(address); var image = owner.Images[(ToS7NetArea(addr.Area), addr.DbNumber)]; var at = addr.ByteOffset; // Box exactly as S7.Net's string read does — the size suffix picks the type. return Task.FromResult(addr.Size switch { S7Size.Bit => (object)((image[at] & (1 << addr.BitOffset)) != 0), S7Size.Byte => image[at], S7Size.Word => (ushort)((image[at] << 8) | image[at + 1]), S7Size.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16) | ((uint)image[at + 2] << 8) | image[at + 3], _ => throw new NotSupportedException($"fake cannot serve size {addr.Size}"), }); } /// Mirrors the driver's own area mapping so both read shapes hit the same image. private static S7NetDataType ToS7NetArea(S7Area area) => area switch { S7Area.DataBlock => S7NetDataType.DataBlock, S7Area.Memory => S7NetDataType.Memory, S7Area.Input => S7NetDataType.Input, S7Area.Output => S7NetDataType.Output, S7Area.Timer => S7NetDataType.Timer, S7Area.Counter => S7NetDataType.Counter, _ => throw new NotSupportedException($"fake cannot map area {area}"), }; /// public Task WriteAsync(string address, object value, CancellationToken ct) => Task.CompletedTask; /// public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct) => Task.CompletedTask; /// public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask; /// public void Dispose() { IsConnected = false; Disposed = true; } } } }