diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md index 15cb25e0..9b278870 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md @@ -76,7 +76,8 @@ without Docker running. ## What's encoded in `profiles/s7_1500.json` -DB1 (1024 bytes) + MB (256 bytes) with typed seeds at known offsets: +DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at +known offsets: | Address | Type | Seed | Purpose | |---|---|---|---| @@ -116,6 +117,58 @@ inside the holes cluster A's span reads through. No tag maps to them; they are there so a mis-computed slice offset returns garbage instead of a zero that could match by luck. Every seed above is distinct and non-zero for the same reason. +### Timer / Counter (TM / CT) areas + +`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte +S5TIME word and a counter a 2-byte count word, with device number `N` at byte +offset `2 × N`. They exist so `S7_1500SpanReadTests` can pin, against real wire, +that `S7ReadSpanPlanner` excludes Timer/Counter from every span and the multi-var +pass excludes them again on `IsBufferType` — each takes its own area-typed +`ReadBytes` round-trip instead. + +| Address | Byte offset | Seed | Decodes to | +|---|---|---|---| +| `T0` | 0 | `0x2456` | `456.0` s (S5TIME base 2 = ×1 s, BCD 456) | +| `T5` | 10 | `0x1123` | `12.3` s (base 1 = ×0.1 s, BCD 123) | +| `T9` | 18 | `0x0250` | `2.5` s (base 0 = ×0.01 s, BCD 250) | +| `C0` | 0 | `4711` | `4711` (raw big-endian count word) | +| `C7` | 14 | `31337` | `31337` | +| `C11` | 22 | `1234` | `1234` | + +A timer decodes to seconds as a `double` (S7.Net `Timer.FromByteArray`) and a +counter to an `int` count (`Counter.FromByteArray`, raw word — **not** BCD). + +#### ⚠️ Only device **0** is addressable on this fixture + +Live-probed 2026-08-17: snap7's server resolves the TM/CT request address as a +**bit address** — it serves byte `addr >> 3` of the area — whereas S7.Net, and a +real S7 CPU, put the device **number** in that field. Measured directly against +`10.100.0.35:1102`: + +``` +ReadBytes(Timer,db=0,start=0,count=8) => [00 00 00 00 24 56 00 00] # bytes 0..7 (T2 seeded at byte 4) +ReadBytes(Timer,db=0,start=2,count=2) => [00 00] # 2>>3 = byte 0, NOT timer 2 at byte 4 +ReadBytes(Timer,db=0,start=4,count=2) => [00 00] # 4>>3 = byte 0 +ReadBytes(Timer,db=0,start=32,count=2) => [24 56] # 32>>3 = byte 4 ← the timer-2 cell +ReadBytes(Counter,db=0,start=48,count=2) => [12 67] # 48>>3 = byte 6 ← the counter-3 cell +``` + +No error is raised — the reads succeed, they just land on the wrong cell. Device +**0** is the single fixed point of the two conventions (`0 >> 3 == 2 × 0 == 0`), +so `T0` / `C0` are the only cells whose *value* the fixture can pin; that is what +`Timer_and_counter_tags_never_enter_a_multi_var_request` asserts, and the +wire-shape half of that test is unaffected by the quirk. The `2 × N` cells above +are seeded for a real CPU: run +`Timer_and_counter_decode_the_seeded_values_at_their_device_numbers` against one +with + +```bash +S7_SIM_ENDPOINT=:102 S7_TIMER_NUMBER_ADDRESSING=1 \ + dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests +``` + +It skips (naming this limitation) whenever that variable is unset. + ## Known limitations From the `snap7.server.Server` docstring upstream: @@ -130,6 +183,9 @@ Not exercised here — needs a lab rig: - S7-1500 Optimized-DB symbolic access - PG / OP / S7-Basic session-type differentiation - PUT/GET-disabled-by-default enforcement +- Device-number addressing of the TM / CT areas (see the ⚠️ above — the reads are + served, but resolved as bit addresses, so only device 0 lands where a CPU + would put it) See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) for the full coverage map. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json index bca0c603..37820a15 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json @@ -1,5 +1,5 @@ { - "_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.", + "_description": "S7-1500 profile — DB1 (1024 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.", "areas": [ { "area": "DB", @@ -59,6 +59,34 @@ { "_desc": "MW10 — span-planner gate: a narrow scalar in a DIFFERENT area from the DB1 leftovers. A block read can never merge across (area, DB); the multi-var pass can, so this pins that the leftover batching crosses the boundary.", "offset": 10, "type": "u16", "value": 4097 } ] + }, + { + "_desc": "TM (timers) — 64 bytes = 32 timer cells. A timer is a 2-byte S5TIME word and timer number N lives at byte offset 2*N, so the offsets below ARE the timer numbers doubled. Values are S5TIME-encoded (bits 12..13 = time base, the remaining three nibbles = a BCD 0..999 count) and each decodes to a distinct NON-ZERO duration, because a zero-valued timer reads the same as a timer that was never served at all (the #536 trap). ⚠️ Only T0 is readable through this fixture — see the CT note below and Docker/README.md §'Timer / Counter (TM / CT) areas'.", + "area": "TM", + "index": 0, + "size": 64, + "seeds": [ + { "_desc": "T0 @ byte 0 — 0x2456: base 2 (×1s), BCD 456 ⇒ 456.0 seconds. Device 0 is the one cell where snap7's address resolution and a real CPU's agree, so this is the seed S7_1500SpanReadTests asserts on.", + "offset": 0, "type": "u16", "value": 9302 }, + { "_desc": "T5 @ byte 10 — 0x1123: base 1 (×0.1s), BCD 123 ⇒ 12.3 seconds. Reachable only from a real CPU (S7_TIMER_NUMBER_ADDRESSING=1).", + "offset": 10, "type": "u16", "value": 4387 }, + { "_desc": "T9 @ byte 18 — 0x0250: base 0 (×0.01s), BCD 250 ⇒ 2.5 seconds. Real CPU only, as above.", + "offset": 18, "type": "u16", "value": 592 } + ] + }, + { + "_desc": "CT (counters) — 64 bytes = 32 counter cells, counter number N at byte offset 2*N. The driver surfaces a counter as the RAW big-endian word (S7Driver.DecodeScalarBlock via S7.Net's Counter.FromByteArray), so these seeds are plain u16 counts; distinct + non-zero for the same reason as the timers. ⚠️ snap7's server resolves the TM/CT request address as a BIT address (byte = addr>>3) instead of as a device NUMBER, while S7.Net (and a real CPU) put the device number there — so a read of C{n} lands on byte n>>3, and device 0 is the only n where the two conventions coincide. Live-probed 2026-08-17; see Docker/README.md.", + "area": "CT", + "index": 0, + "size": 64, + "seeds": [ + { "_desc": "C0 @ byte 0 — count 4711. The device-0 cell both conventions agree on; asserted by S7_1500SpanReadTests.", + "offset": 0, "type": "u16", "value": 4711 }, + { "_desc": "C7 @ byte 14 — count 31337. Reachable only from a real CPU (S7_TIMER_NUMBER_ADDRESSING=1).", + "offset": 14, "type": "u16", "value": 31337 }, + { "_desc": "C11 @ byte 22 — count 1234. Real CPU only, as above.", + "offset": 22, "type": "u16", "value": 1234 } + ] } ] } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs index d3084ca3..5ffed5d4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs @@ -23,6 +23,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; /// analytic model and real wire was found. /// /// +/// Re-run the same day against the same endpoint after the fixture gained its TM0 / CT0 +/// areas: PASSED — 4 passed, 0 failed, 1 skipped (the device number-addressing test, +/// see below). The Timer/Counter batch was served by 4 requests for 7 tags: one DB1 span, +/// one 3-item multi-var carrying only the scalars, and one 2-byte area read apiece for +/// T0 (decoded 456.0 s from S5TIME 0x2456) and C0 (decoded count 4711). +/// +/// /// What the layout buys. The ten tags are positioned so the planner's rules produce a /// known, non-trivial partition rather than one that would pass by accident: /// @@ -48,13 +55,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; /// /// /// -/// Timer / Counter are NOT covered here. The fixture profile declares only DB1 and -/// MK; python-snap7's server can register TM/CT areas but S7.Net's timer/counter read is a -/// distinct wire shape that the fixture has never served, so pinning the planner's per-tag -/// exclusion live would mean standing up and validating a new fixture area. That exclusion -/// stays unit-covered. Nothing else in this suite depends on it — Timer/Counter tags are -/// rejected by the planner on AREA and by the multi-var pass on IsBufferType, two -/// independent guards. +/// Timer / Counter ARE covered here now. The fixture profile grew TM0 + CT0 (64 +/// bytes each = 32 device cells), so +/// pins the planner's +/// per-tag exclusion against real wire rather than a fake: a Timer and a Counter batched +/// alongside three multi-var-eligible scalars each take their own area-typed +/// ReadBytes round-trip, and the one multi-var request the scalars share carries +/// neither of them. Two independent guards produce that — the planner rejects them on AREA +/// (their offsets are device numbers, not byte addresses) and the multi-var pass on +/// IsBufferType. +/// +/// +/// ⚠️ Fixture limitation — only device 0 is addressable. Live-probed 2026-08-17: +/// snap7's server resolves the TM/CT request address as a BIT address (it serves byte +/// addr >> 3 of the area) where S7.Net — and a real S7 CPU — put the device +/// NUMBER in that field. Measured directly: ReadBytes(Timer, db 0, start 5, count 2) +/// returns TM bytes 0..1, and it takes start 80 (= 10 × 8) to reach the timer seeded +/// at TM byte 10. Device 0 is the single fixed point of the two conventions +/// (0 >> 3 == 2 × 0 == 0), so it is the only device this fixture can pin a +/// VALUE for, and that is what the mixed batch uses. The spec-correct 2 × N cells +/// are seeded too and +/// asserts +/// them — skipped unless S7_TIMER_NUMBER_ADDRESSING=1 declares the endpoint a real +/// CPU. The device-number offset arithmetic itself stays unit-covered +/// (S7ScalarBlockTests, S7SpanReadParityTests). /// /// /// Why a counting factory rather than logs. The driver emits no log on a successful @@ -178,16 +202,137 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) ungrouped.Counters.TotalReadRequests.ShouldBe(SpanLayout.TagNames.Count); } - /// Reads the whole layout once through a freshly-built driver and reports the wire shape. + /// + /// Live pin for the planner's Timer/Counter exclusion: batched alongside three + /// multi-var-eligible scalars, a Timer and a Counter each take their own area-typed block + /// read and neither ever appears as an item of the multi-var request the scalars share. + /// + /// + /// + /// The unit suite pins this against a fake (S7SpanReadParityTests + /// Timer_and_counter_leftovers_never_enter_a_multi_var_request); what only a live + /// run can add is that the excluded reads are ones a PLC actually answers — a driver + /// that put a Timer into a multi-var request would fault the WHOLE chunk on real wire, + /// taking the three innocent scalars down with it into the per-tag fallback. So the + /// scalars' values and the single 3-item request are asserted here too: they are the + /// evidence the exclusion is what kept the batch intact. + /// + /// + /// The Timer/Counter values are asserted at device 0 only — see the type remarks + /// for why this fixture cannot address any other device. + /// + /// + [Fact] + public async Task Timer_and_counter_tags_never_enter_a_multi_var_request() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var (snapshots, counters) = await ReadBatchAsync( + TimerCounterLayout.BuildOptions(sim.Host, sim.Port, TimerCounterLayout.MixedBatch), + TimerCounterLayout.Names(TimerCounterLayout.MixedBatch), + "s7-timer-counter"); + + var byName = TimerCounterLayout.Names(TimerCounterLayout.MixedBatch) + .Select((n, i) => (n, snapshots[i])) + .ToDictionary(x => x.n, x => x.Item2); + + foreach (var (name, snapshot) in byName) + snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good"); + + // (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in + // seconds (double) and a Counter as the raw count (int) — two different areas, two + // different values, two different CLR types, so a read that reached the wrong area or + // decoded through the wrong arm cannot pass. + byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds); + byName["TC_Counter"].Value.ShouldBe(TimerCounterLayout.Device0CounterCount); + + // The co-batched scalars still decode to their own seeds — the exclusion protected them. + byName["TC_SpanB_U16a"].Value.ShouldBe((ushort)40201); + byName["TC_SpanB_U16b"].Value.ShouldBe((ushort)40202); + byName["TC_Lone_U16"].Value.ShouldBe((ushort)60001); + byName["TC_Lone_I16"].Value.ShouldBe((short)-32001); + byName["TC_Lone_Mk"].Value.ShouldBe((ushort)4097); + + // (b) The wire shape. This is the pin: not one multi-var item carries a Timer or Counter. + counters.MultiVarReads.ShouldBe(1, + "the three isolated narrow scalars are past MultiVarReadMinItems, so they share one request"); + counters.MultiVarItems.ShouldBe(3, + "exactly the three scalars — a Timer or Counter joining them would make this 4 or 5"); + counters.MultiVarItemAreas.ShouldNotContain(S7NetDataType.Timer, + "a Timer must never be expressed as a multi-var item: its per-tag path decodes the S5TIME " + + "word itself and does not go through S7.Net's ParseBytes at all"); + counters.MultiVarItemAreas.ShouldNotContain(S7NetDataType.Counter, + "a Counter must never be expressed as a multi-var item — same reason, plus its per-tag " + + "decode surfaces int where S7.Net's own Counter.FromByteArray returns ushort"); + + // …and they arrive instead as their own area-typed block reads, two bytes apiece (one + // S5TIME / counter word), with the device number in the start field. Asserting the shape + // rather than just the count is what distinguishes "excluded to a block read" from + // "excluded to the per-address string path", which is a different wire request entirely. + counters.BlockReadShapes.ShouldContain((S7NetDataType.Timer, 0, TimerCounterLayout.Device0, 2)); + counters.BlockReadShapes.ShouldContain((S7NetDataType.Counter, 0, TimerCounterLayout.Device0, 2)); + counters.BlockReadShapes.ShouldContain((S7NetDataType.DataBlock, 1, 400, 4), + "the two adjacent DB1 words still merge into one span, so this batch is a real three-way split"); + counters.BlockReads.ShouldBe(3, "one span + one Timer + one Counter"); + counters.AddressReads.ShouldBe(0, + "the Timer/Counter exclusion routes to the block-read seam, never to the per-address path"); + } + + /// + /// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values — + /// the assertion the shared fixture cannot serve. + /// + /// + /// Skipped unless S7_TIMER_NUMBER_ADDRESSING=1 declares the endpoint a device + /// number-addressing CPU, because python-snap7's server is not one: it resolves the TM/CT + /// request address as a bit address, so T5 reads TM byte 0 rather than TM byte 10 + /// (measured 2026-08-17 — see the type remarks). Set the variable alongside + /// S7_SIM_ENDPOINT when pointing this suite at a real S7 CPU seeded to match + /// Docker/profiles/s7_1500.json's TM/CT areas. + /// + [Fact] + public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + if (Environment.GetEnvironmentVariable(TimerCounterLayout.RealCpuEnvVar) != "1") + { + Assert.Skip( + $"python-snap7's server resolves a TM/CT request address as a BIT address (byte = addr>>3), " + + $"not as a device number, so only device 0 is reachable on the fixture. " + + $"Set {TimerCounterLayout.RealCpuEnvVar}=1 (with S7_SIM_ENDPOINT pointing at a real S7 CPU " + + $"seeded like Docker/profiles/s7_1500.json) to run this."); + } + + var (snapshots, _) = await ReadBatchAsync( + TimerCounterLayout.BuildOptions(sim.Host, sim.Port, TimerCounterLayout.DeviceNumbered), + TimerCounterLayout.Names(TimerCounterLayout.DeviceNumbered), + "s7-timer-counter-devnum"); + + snapshots[0].StatusCode.ShouldBe(0u, "'TC_Timer5' must be Good"); + snapshots[1].StatusCode.ShouldBe(0u, "'TC_Counter7' must be Good"); + snapshots[0].Value.ShouldBe(12.3d, "T5 holds S5TIME 0x1123 — base 0.1 s, BCD 123"); + snapshots[1].Value.ShouldBe(31337, "C7 holds the raw count word 31337"); + } + + /// Reads the whole span layout once through a freshly-built driver and reports the wire shape. /// The span budget: the shipped default, or a negative value to disable grouping. /// Instance id, distinct per call so log lines stay attributable. /// The batch's snapshots plus the wire-call counts observed for the read itself. - private async Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> + private Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> ReadBatchAsync(int maxGapBytes, string driverInstanceId) + => ReadBatchAsync( + SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), SpanLayout.TagNames, driverInstanceId); + + /// Reads one batch through a freshly-built driver and reports the wire shape. + /// Driver options carrying the layout's raw tags and span budget. + /// The references to read, in the order the driver is asked for them. + /// Instance id, distinct per call so log lines stay attributable. + /// The batch's snapshots plus the wire-call counts observed for the read itself. + private static async Task<(IReadOnlyList Snapshots, CountingPlcFactory Counters)> + ReadBatchAsync(S7DriverOptions options, IReadOnlyList tagNames, string driverInstanceId) { var counters = new CountingPlcFactory(new S7PlcFactory()); - await using var drv = new S7Driver( - SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), driverInstanceId, counters); + await using var drv = new S7Driver(options, driverInstanceId, counters); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); @@ -195,7 +340,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) // subject here, so the counters start from zero at the read. counters.Reset(); - var snapshots = await drv.ReadAsync(SpanLayout.TagNames, TestContext.Current.CancellationToken); + var snapshots = await drv.ReadAsync(tagNames, TestContext.Current.CancellationToken); return (snapshots, counters); } @@ -260,6 +405,84 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) }; } + /// + /// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at + /// once (block span, multi-var, per-tag Timer/Counter), and a device-numbered pair only a + /// real CPU can serve. Addresses map 1:1 to the TM0 / CT0 seeds in + /// Docker/profiles/s7_1500.json. + /// + private static class TimerCounterLayout + { + /// Declares the endpoint a real S7 CPU, which addresses TM/CT by device number. + public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING"; + + /// + /// The one device number snap7's bit-address resolution and a CPU's device-number + /// resolution both land on: 0 >> 3 == 2 × 0 == 0. + /// + public const int Device0 = 0; + + /// T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456. + public const double Device0TimerSeconds = 456.0d; + + /// C0's seed — the raw big-endian count word. + public const int Device0CounterCount = 4711; + + /// + /// A Timer and a Counter batched with scalars laid out to produce every other read shape: + /// two adjacent DB1 words (one merged span) and three isolated narrow scalars, one of + /// them in another area (one 3-item multi-var request). + /// + public static readonly S7TagDefinition[] MixedBatch = + [ + new("TC_SpanB_U16a", "DB1.DBW400", S7DataType.UInt16), + new("TC_SpanB_U16b", "DB1.DBW402", S7DataType.UInt16), + new("TC_Lone_U16", "DB1.DBW500", S7DataType.UInt16), + new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16), + new("TC_Lone_Mk", "MW10", S7DataType.UInt16), + // Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to + // a count) — the init guard rejects any other typing. Writable:false is mandatory, not + // stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a + // writable Timer/Counter outright (they are read-only this phase). + new("TC_Timer", "T0", S7DataType.Float64, Writable: false), + new("TC_Counter", "C0", S7DataType.Int32, Writable: false), + ]; + + /// The non-zero device numbers, reachable only from a device number-addressing CPU. + public static readonly S7TagDefinition[] DeviceNumbered = + [ + new("TC_Timer5", "T5", S7DataType.Float64, Writable: false), + new("TC_Counter7", "C7", S7DataType.Int32, Writable: false), + ]; + + /// The batch's references, in the order the driver is asked for them. + /// The definition set. + /// The tag names. + public static IReadOnlyList Names(IReadOnlyList defs) + => [.. defs.Select(d => d.Name)]; + + /// Builds driver options for one definition set at the shipped span budget. + /// The fixture hostname or IP address. + /// The fixture port. + /// The definition set. + /// The driver options. + public static S7DriverOptions BuildOptions(string host, int port, S7TagDefinition[] defs) => new() + { + Host = host, + Port = port, + CpuType = S7CpuType.S71500, + Rack = 0, + Slot = 0, + Timeout = TimeSpan.FromSeconds(5), + // As in SpanLayout: a background probe would race the read for the connection gate and + // pollute the wire-shape counts. + Probe = new S7ProbeOptions { Enabled = false }, + SpanReadMaxGapBytes = SpanLayout.DefaultGapBytes, + SpanReadMaxSpanBytes = S7DriverOptions.DefaultSpanReadMaxSpanBytes, + RawTags = S7RawTags.Entries(defs), + }; + } + /// /// decorator that counts the read calls the driver makes, so a /// test can assert the wire shape rather than infer it. Delegates to the real factory, so @@ -268,9 +491,25 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) /// The factory producing the real connection. private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory { + private readonly List<(S7NetDataType Area, int Db, int Start, int Count)> _blockReadShapes = []; + private readonly List _multiVarItemAreas = []; + /// Block reads issued — one per planned span. public int BlockReads { get; private set; } + /// + /// The (area, DB, start, count) of every block read, in issue order. Counting + /// alone cannot tell a Timer's own 2-byte area read from a span's — the shape can. + /// + public IReadOnlyList<(S7NetDataType Area, int Db, int Start, int Count)> BlockReadShapes + => _blockReadShapes; + + /// + /// The area of every item carried by every multi-var request. A Timer or Counter + /// appearing here is the exact defect the planner's exclusion exists to prevent. + /// + public IReadOnlyList MultiVarItemAreas => _multiVarItemAreas; + /// Multi-var requests issued — one per leftover chunk. public int MultiVarReads { get; private set; } @@ -290,18 +529,26 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) MultiVarReads = 0; MultiVarItems = 0; AddressReads = 0; + _blockReadShapes.Clear(); + _multiVarItemAreas.Clear(); } /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new CountingPlc(inner.Create(cpuType, host, port, rack, slot, timeout), this); - private void CountBlockRead() => BlockReads++; + private void CountBlockRead(S7NetDataType area, int db, int start, int count) + { + BlockReads++; + _blockReadShapes.Add((area, db, start, count)); + } - private void CountMultiVarRead(int items) + private void CountMultiVarRead(IReadOnlyList? items) { MultiVarReads++; - MultiVarItems += items; + MultiVarItems += items?.Count ?? 0; + if (items is null) return; + foreach (var item in items) _multiVarItemAreas.Add(item.DataType); } private void CountAddressRead() => AddressReads++; @@ -331,7 +578,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) public Task ReadBytesAsync( S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) { - owner.CountBlockRead(); + owner.CountBlockRead(area, db, startByteAdr, count); return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken); } @@ -339,9 +586,10 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim) public Task> ReadMultipleVarsAsync( List items, CancellationToken cancellationToken) { - // Counted before the call: the item count is the request's shape, and S7.Net hands - // the same list back, so reading it afterwards would measure the same thing later. - owner.CountMultiVarRead(items?.Count ?? 0); + // Counted before the call: the items ARE the request's shape, and S7.Net hands the + // same list back with values filled in, so reading them afterwards would measure + // the same thing later. + owner.CountMultiVarRead(items); return inner.ReadMultipleVarsAsync(items!, cancellationToken); }