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; // ── 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)); // ── 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 4 wire requests: ONE block read covering the eleven DB1 tags from /// byte 0 to byte 68, the Counter's own unavoidable block read, and two per-address string /// reads — the far DB1 word (past the gap budget) and the lone Memory word, each of which /// the driver deliberately leaves on the per-tag path because a single-member narrow span /// saves no round-trip. /// [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).ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length); factory.ReadBytesCalls.ShouldBe(2); // The nine narrow tags inside the big span issue NO string reads — only the two tags that // ended up alone in a span of their own do. factory.StringReadCalls.ShouldBe(2); } // ── 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); } // ── 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. 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); return new Dictionary<(S7NetDataType, int), byte[]> { [(S7NetDataType.DataBlock, 1)] = db1, [(S7NetDataType.Memory, 0)] = mem, [(S7NetDataType.Counter, 0)] = counters, }; } /// /// 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; /// When set, every read throws this instead of serving the image. public Exception? ReadFault { get; init; } /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new ImageS7Plc(this); private sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc { /// public bool IsConnected { get; private set; } /// public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; } /// public void Close() => IsConnected = false; /// public 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.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 Task.FromResult(image[startByteAdr..(startByteAdr + count)]); } /// public Task ReadAsync(string address, CancellationToken ct) { Interlocked.Increment(ref owner.StringReadCalls); 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; } } }