test(s7): live-pin the Timer/Counter multi-var exclusion against a TM/CT-seeded s7_1500 fixture

This commit is contained in:
Joseph Doherty
2026-08-17 05:39:58 -04:00
parent 68461fbfb6
commit fd202d81ce
3 changed files with 353 additions and 21 deletions
@@ -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=<cpu>: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.
@@ -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 }
]
}
]
}
@@ -23,6 +23,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// analytic model and real wire was found.
/// </para>
/// <para>
/// Re-run the same day against the same endpoint after the fixture gained its TM0 / CT0
/// areas: <b>PASSED</b> — 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
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
/// </para>
/// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
/// known, non-trivial partition rather than one that would pass by accident:
/// </para>
@@ -48,13 +55,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// </item>
/// </list>
/// <para>
/// <b>Timer / Counter are NOT covered here.</b> 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 <c>IsBufferType</c>, two
/// independent guards.
/// <b>Timer / Counter ARE covered here now.</b> The fixture profile grew TM0 + CT0 (64
/// bytes each = 32 device cells), so
/// <see cref="Timer_and_counter_tags_never_enter_a_multi_var_request"/> 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
/// <c>ReadBytes</c> 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
/// <c>IsBufferType</c>.
/// </para>
/// <para>
/// <b>⚠️ Fixture limitation — only device 0 is addressable.</b> Live-probed 2026-08-17:
/// snap7's server resolves the TM/CT request address as a BIT address (it serves byte
/// <c>addr &gt;&gt; 3</c> of the area) where S7.Net — and a real S7 CPU — put the device
/// NUMBER in that field. Measured directly: <c>ReadBytes(Timer, db 0, start 5, count 2)</c>
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
/// (<c>0 &gt;&gt; 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
/// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
/// CPU. The device-number offset arithmetic itself stays unit-covered
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para>
/// <para>
/// <b>Why a counting factory rather than logs.</b> 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);
}
/// <summary>Reads the whole layout once through a freshly-built driver and reports the wire shape.</summary>
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// The unit suite pins this against a fake (<c>S7SpanReadParityTests</c>
/// <c>Timer_and_counter_leftovers_never_enter_a_multi_var_request</c>); 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.
/// </para>
/// <para>
/// The Timer/Counter values are asserted at device <b>0</b> only — see the type remarks
/// for why this fixture cannot address any other device.
/// </para>
/// </remarks>
[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");
}
/// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
/// the assertion the shared fixture cannot serve.
/// </summary>
/// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> 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 <c>T5</c> reads TM byte 0 rather than TM byte 10
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
/// </remarks>
[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");
}
/// <summary>Reads the whole span layout once through a freshly-built driver and reports the wire shape.</summary>
/// <param name="maxGapBytes">The span budget: the shipped default, or a negative value to disable grouping.</param>
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
private async Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
private Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
ReadBatchAsync(int maxGapBytes, string driverInstanceId)
=> ReadBatchAsync(
SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), SpanLayout.TagNames, driverInstanceId);
/// <summary>Reads one batch through a freshly-built driver and reports the wire shape.</summary>
/// <param name="options">Driver options carrying the layout's raw tags and span budget.</param>
/// <param name="tagNames">The references to read, in the order the driver is asked for them.</param>
/// <param name="driverInstanceId">Instance id, distinct per call so log lines stay attributable.</param>
/// <returns>The batch's snapshots plus the wire-call counts observed for the read itself.</returns>
private static async Task<(IReadOnlyList<DataValueSnapshot> Snapshots, CountingPlcFactory Counters)>
ReadBatchAsync(S7DriverOptions options, IReadOnlyList<string> 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)
};
}
/// <summary>
/// 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
/// <c>Docker/profiles/s7_1500.json</c>.
/// </summary>
private static class TimerCounterLayout
{
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary>
/// The one device number snap7's bit-address resolution and a CPU's device-number
/// resolution both land on: <c>0 &gt;&gt; 3 == 2 × 0 == 0</c>.
/// </summary>
public const int Device0 = 0;
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d;
/// <summary>C0's seed — the raw big-endian count word.</summary>
public const int Device0CounterCount = 4711;
/// <summary>
/// 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).
/// </summary>
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),
];
/// <summary>The non-zero device numbers, reachable only from a device number-addressing CPU.</summary>
public static readonly S7TagDefinition[] DeviceNumbered =
[
new("TC_Timer5", "T5", S7DataType.Float64, Writable: false),
new("TC_Counter7", "C7", S7DataType.Int32, Writable: false),
];
/// <summary>The batch's references, in the order the driver is asked for them.</summary>
/// <param name="defs">The definition set.</param>
/// <returns>The tag names.</returns>
public static IReadOnlyList<string> Names(IReadOnlyList<S7TagDefinition> defs)
=> [.. defs.Select(d => d.Name)];
/// <summary>Builds driver options for one definition set at the shipped span budget.</summary>
/// <param name="host">The fixture hostname or IP address.</param>
/// <param name="port">The fixture port.</param>
/// <param name="defs">The definition set.</param>
/// <returns>The driver options.</returns>
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),
};
}
/// <summary>
/// <see cref="IS7PlcFactory"/> 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)
/// <param name="inner">The factory producing the real connection.</param>
private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory
{
private readonly List<(S7NetDataType Area, int Db, int Start, int Count)> _blockReadShapes = [];
private readonly List<S7NetDataType> _multiVarItemAreas = [];
/// <summary>Block reads issued — one per planned span.</summary>
public int BlockReads { get; private set; }
/// <summary>
/// The <c>(area, DB, start, count)</c> 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.
/// </summary>
public IReadOnlyList<(S7NetDataType Area, int Db, int Start, int Count)> BlockReadShapes
=> _blockReadShapes;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<S7NetDataType> MultiVarItemAreas => _multiVarItemAreas;
/// <summary>Multi-var requests issued — one per leftover chunk.</summary>
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();
}
/// <inheritdoc />
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<S7DataItem>? 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<byte[]> 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<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(
List<S7DataItem> 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);
}