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
@@ -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);
}