620 lines
34 KiB
C#
620 lines
34 KiB
C#
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using S7DataItem = global::S7.Net.Types.DataItem;
|
||
using S7NetDataType = global::S7.Net.DataType;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||
|
||
/// <summary>
|
||
/// Live gate for the read-batching layer: the <see cref="S7ReadSpanPlanner"/> block-read pass
|
||
/// (16-byte gap / 220-byte span defaults) and the multi-var leftover pass, both exercised against
|
||
/// the real python-snap7 S7-1500 fixture rather than a fake. The span defaults were chosen
|
||
/// analytically and the multi-var batching shipped with unit coverage only; this suite is what
|
||
/// proves they behave on real S7comm wire.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Live-gate record.</b> Run 2026-08-17 against <c>10.100.0.35:1102</c>
|
||
/// (<c>otopcua-python-snap7-s7_1500</c>, profile <c>Docker/profiles/s7_1500.json</c>):
|
||
/// <b>PASSED</b> — 3 tests, 0 failed, 0 skipped. Grouped batch collapsed 10 tags to 3 wire
|
||
/// requests (2 block reads + 1 three-item multi-var) and matched the ungrouped 10-request
|
||
/// batch value-for-value and type-for-type. No behavioural difference between the planner's
|
||
/// 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>
|
||
/// <list type="bullet">
|
||
/// <item>
|
||
/// <b>Cluster A</b> (<c>DB1.DBW300 … DB1.DBD316</c>) — five tags whose largest hole is 5
|
||
/// bytes, so all five merge into ONE 20-byte span under the 16-byte gap budget. The
|
||
/// holes are seeded with poison (<c>0xDEAD</c>, <c>0xDEADBEEF</c>) so a mis-computed
|
||
/// slice offset yields garbage rather than a zero that could coincidentally match.
|
||
/// </item>
|
||
/// <item>
|
||
/// <b>Cluster B</b> (<c>DB1.DBW400</c> + <c>DB1.DBW402</c>) — 80 bytes past cluster A, so
|
||
/// the gap budget is what forces a SECOND span instead of one merged read. Two members
|
||
/// keep it clear of the <c>PrefersPerTagRead</c> demotion, so it is a real block read.
|
||
/// </item>
|
||
/// <item>
|
||
/// <b>Leftovers</b> (<c>DB1.DBW500</c>, <c>DB1.DBW600</c>, <c>MW10</c>) — each isolated
|
||
/// enough to form its own single-member span, each therefore demoted by
|
||
/// <c>PrefersPerTagRead</c>, and three of them is past <c>MultiVarReadMinItems</c> — so
|
||
/// they ride ONE multi-var request. <c>MW10</c> is deliberately in a different area:
|
||
/// a block read can never merge across <c>(area, DB)</c>, and the multi-var pass can,
|
||
/// so its presence in the single request is what pins that property live.
|
||
/// </item>
|
||
/// </list>
|
||
/// <para>
|
||
/// <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 >> 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 >> 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
|
||
/// grouped read, so the only honest way to assert the wire shape is to observe the calls.
|
||
/// <see cref="CountingPlcFactory"/> decorates the REAL <see cref="S7PlcFactory"/>, so the
|
||
/// shipped <c>S7PlcAdapter</c> (and its <c>S7OperationDeadline</c> ceiling) stays on the
|
||
/// wire — the gate measures production's path, not a re-implementation of it.
|
||
/// </para>
|
||
/// </remarks>
|
||
[Collection(Snap7ServerCollection.Name)]
|
||
[Trait("Category", "Integration")]
|
||
[Trait("Device", "S7_1500")]
|
||
public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
|
||
{
|
||
/// <summary>
|
||
/// Verifies the grouped batch returns exactly what reading every tag individually returns —
|
||
/// same values, same boxed CLR types, all Good.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Boxed equality is the assertion on purpose: the three paths decode through different code
|
||
/// (block slicing, <c>S7.Net.ParseBytes</c> via multi-var, and the per-address read), and a
|
||
/// path that returned the right number as the wrong CLR type would be a real defect that a
|
||
/// numeric-tolerance comparison would hide.
|
||
/// </remarks>
|
||
[Fact]
|
||
public async Task Grouped_batch_matches_ungrouped_reads_value_for_value()
|
||
{
|
||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||
|
||
var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-on");
|
||
var ungrouped = await ReadBatchAsync(SpanLayout.GroupingDisabled, "s7-span-off");
|
||
|
||
grouped.Snapshots.Count.ShouldBe(SpanLayout.TagNames.Count);
|
||
ungrouped.Snapshots.Count.ShouldBe(SpanLayout.TagNames.Count);
|
||
|
||
for (var i = 0; i < SpanLayout.TagNames.Count; i++)
|
||
{
|
||
var name = SpanLayout.TagNames[i];
|
||
grouped.Snapshots[i].StatusCode.ShouldBe(0u, $"grouped read of '{name}' must be Good");
|
||
ungrouped.Snapshots[i].StatusCode.ShouldBe(0u, $"ungrouped read of '{name}' must be Good");
|
||
grouped.Snapshots[i].Value.ShouldBe(
|
||
ungrouped.Snapshots[i].Value,
|
||
$"'{name}' must decode identically whichever path served it");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verifies the grouped batch decodes the fixture's seeded values — so both paths agreeing
|
||
/// cannot mean "both wrong the same way".
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Every seed is distinct and non-zero (the #536 trap: a zero-valued source proves nothing,
|
||
/// because a read that returned nothing at all would also read as zero). Distinctness matters
|
||
/// independently: two tags sharing a value would let a swapped slice offset pass.
|
||
/// </remarks>
|
||
[Fact]
|
||
public async Task Grouped_batch_decodes_the_seeded_values()
|
||
{
|
||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||
|
||
var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-seeds");
|
||
var byName = SpanLayout.TagNames
|
||
.Select((n, i) => (n, grouped.Snapshots[i]))
|
||
.ToDictionary(x => x.n, x => x.Item2);
|
||
|
||
foreach (var (name, snapshot) in byName)
|
||
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
|
||
|
||
// Cluster A — the five members of the single merged span.
|
||
byName["SpanA_U16"].Value.ShouldBe((ushort)51001);
|
||
byName["SpanA_I16"].Value.ShouldBe((short)-21001);
|
||
byName["SpanA_I32"].Value.ShouldBe(305419896);
|
||
byName["SpanA_Bool"].Value.ShouldBe(true);
|
||
Convert.ToSingle(byName["SpanA_F32"].Value).ShouldBe(2.7182817f, tolerance: 0.000001f);
|
||
|
||
// Cluster B — the second span.
|
||
byName["SpanB_U16a"].Value.ShouldBe((ushort)40201);
|
||
byName["SpanB_U16b"].Value.ShouldBe((ushort)40202);
|
||
|
||
// The multi-var leftovers, including the one in a different area.
|
||
byName["Lone_U16"].Value.ShouldBe((ushort)60001);
|
||
byName["Lone_I16"].Value.ShouldBe((short)-32001);
|
||
byName["Lone_MkU16"].Value.ShouldBe((ushort)4097);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Verifies the wire shape the batching actually produces against a live PLC: ten tags served
|
||
/// by three requests with grouping on, ten with it off.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This is the half of the gate that a value-parity test cannot cover — a driver that quietly
|
||
/// fell back to per-tag reads for every tag would pass parity perfectly while delivering none
|
||
/// of the round-trip saving the feature exists for. The off-case is asserted too, because
|
||
/// <c>SpanReadMaxGapBytes < 0</c> is documented to restore the pre-grouping wire shape
|
||
/// EXACTLY, and "exactly" is a claim about requests, not values.
|
||
/// </remarks>
|
||
[Fact]
|
||
public async Task Grouping_collapses_ten_tags_to_three_wire_requests()
|
||
{
|
||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||
|
||
var grouped = await ReadBatchAsync(SpanLayout.DefaultGapBytes, "s7-span-shape-on");
|
||
|
||
grouped.Counters.BlockReads.ShouldBe(2,
|
||
"cluster A merges into one span and cluster B into a second; the gap budget separates them");
|
||
grouped.Counters.MultiVarReads.ShouldBe(1,
|
||
"the three demoted single-member spans are past MultiVarReadMinItems, so they share one request");
|
||
grouped.Counters.MultiVarItems.ShouldBe(3,
|
||
"all three leftovers ride that request — including MW10, which no block read could have joined");
|
||
grouped.Counters.AddressReads.ShouldBe(0,
|
||
"nothing in this batch should reach the per-tag path once both grouping passes have run");
|
||
grouped.Counters.TotalReadRequests.ShouldBe(3);
|
||
|
||
var ungrouped = await ReadBatchAsync(SpanLayout.GroupingDisabled, "s7-span-shape-off");
|
||
|
||
ungrouped.Counters.AddressReads.ShouldBe(SpanLayout.TagNames.Count,
|
||
"grouping off must restore one per-address read per tag");
|
||
ungrouped.Counters.BlockReads.ShouldBe(0);
|
||
ungrouped.Counters.MultiVarReads.ShouldBe(0,
|
||
"the multi-var pass rides the SAME switch as the span pass — disabling grouping must disable both");
|
||
ungrouped.Counters.TotalReadRequests.ShouldBe(SpanLayout.TagNames.Count);
|
||
}
|
||
|
||
/// <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 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(options, driverInstanceId, counters);
|
||
|
||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||
|
||
// Initialization may open the connection and probe; only the batch's own wire calls are the
|
||
// subject here, so the counters start from zero at the read.
|
||
counters.Reset();
|
||
|
||
var snapshots = await drv.ReadAsync(tagNames, TestContext.Current.CancellationToken);
|
||
return (snapshots, counters);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The tag layout under test, positioned against the shipped 16-byte gap / 220-byte span
|
||
/// defaults so the planner produces a known partition. Addresses map 1:1 to the seed offsets
|
||
/// in <c>Docker/profiles/s7_1500.json</c>, so a seed drift there surfaces here as a value
|
||
/// mismatch rather than a mystery failure.
|
||
/// </summary>
|
||
private static class SpanLayout
|
||
{
|
||
/// <summary>The shipped gap budget — the value this gate exists to validate.</summary>
|
||
public const int DefaultGapBytes = S7DriverOptions.DefaultSpanReadMaxGapBytes;
|
||
|
||
/// <summary>Any negative gap disables BOTH grouping passes; the documented off switch.</summary>
|
||
public const int GroupingDisabled = -1;
|
||
|
||
private static readonly S7TagDefinition[] Definitions =
|
||
[
|
||
// Cluster A: holes of 0, 0, 2 and 5 bytes — all inside the 16-byte budget, so these five
|
||
// merge into one 300..320 span (20 bytes, far under the 220-byte ceiling).
|
||
new("SpanA_U16", "DB1.DBW300", S7DataType.UInt16),
|
||
new("SpanA_I16", "DB1.DBW302", S7DataType.Int16),
|
||
new("SpanA_I32", "DB1.DBD304", S7DataType.Int32),
|
||
new("SpanA_Bool", "DB1.DBX310.5", S7DataType.Bool),
|
||
new("SpanA_F32", "DB1.DBD316", S7DataType.Float32),
|
||
|
||
// Cluster B: 80 bytes past cluster A's end, so the budget forces a second span. Two
|
||
// members, so PrefersPerTagRead cannot demote it to the per-tag path.
|
||
new("SpanB_U16a", "DB1.DBW400", S7DataType.UInt16),
|
||
new("SpanB_U16b", "DB1.DBW402", S7DataType.UInt16),
|
||
|
||
// Leftovers: each far enough from every neighbour to form a single-member span, hence
|
||
// demoted, hence batched by the multi-var pass. MW10 is in another area entirely.
|
||
new("Lone_U16", "DB1.DBW500", S7DataType.UInt16),
|
||
new("Lone_I16", "DB1.DBW600", S7DataType.Int16),
|
||
new("Lone_MkU16", "MW10", S7DataType.UInt16),
|
||
];
|
||
|
||
/// <summary>The batch's references, in the order the driver is asked for them.</summary>
|
||
public static readonly IReadOnlyList<string> TagNames = [.. Definitions.Select(d => d.Name)];
|
||
|
||
/// <summary>Builds driver options for the layout at the requested span budget.</summary>
|
||
/// <param name="host">The fixture hostname or IP address.</param>
|
||
/// <param name="port">The fixture port.</param>
|
||
/// <param name="maxGapBytes">Span gap budget; negative disables grouping.</param>
|
||
/// <returns>The driver options.</returns>
|
||
public static S7DriverOptions BuildOptions(string host, int port, int maxGapBytes) => new()
|
||
{
|
||
Host = host,
|
||
Port = port,
|
||
CpuType = S7CpuType.S71500,
|
||
Rack = 0,
|
||
Slot = 0,
|
||
Timeout = TimeSpan.FromSeconds(5),
|
||
// Same reasoning as the smoke profile: a background probe would race these reads for the
|
||
// connection gate and would also pollute the wire-shape counts this suite asserts on.
|
||
Probe = new S7ProbeOptions { Enabled = false },
|
||
SpanReadMaxGapBytes = maxGapBytes,
|
||
SpanReadMaxSpanBytes = S7DriverOptions.DefaultSpanReadMaxSpanBytes,
|
||
RawTags = S7RawTags.Entries(Definitions),
|
||
};
|
||
}
|
||
|
||
/// <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 >> 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
|
||
/// every counted call is the one production would have issued.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>Not thread-safe, by design.</b> Every <c>ReadBatchAsync</c> builds its own factory and
|
||
/// the driver serialises all wire calls on its <c>_gate</c>, so nothing here is ever touched
|
||
/// concurrently today. A future caller that shared one factory across parallel reads would
|
||
/// silently corrupt the counts and shape lists rather than fail loudly — give it a lock, or
|
||
/// a factory per batch, before doing that.
|
||
/// </remarks>
|
||
/// <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; }
|
||
|
||
/// <summary>Items carried across all multi-var requests.</summary>
|
||
public int MultiVarItems { get; private set; }
|
||
|
||
/// <summary>Per-address reads issued — the unchanged per-tag path.</summary>
|
||
public int AddressReads { get; private set; }
|
||
|
||
/// <summary>Total read round-trips, whichever shape they took.</summary>
|
||
public int TotalReadRequests => BlockReads + MultiVarReads + AddressReads;
|
||
|
||
/// <summary>Zeroes the counters, so a batch can be measured without initialization's calls.</summary>
|
||
public void Reset()
|
||
{
|
||
BlockReads = 0;
|
||
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(S7NetDataType area, int db, int start, int count)
|
||
{
|
||
BlockReads++;
|
||
_blockReadShapes.Add((area, db, start, count));
|
||
}
|
||
|
||
private void CountMultiVarRead(IReadOnlyList<S7DataItem>? items)
|
||
{
|
||
MultiVarReads++;
|
||
MultiVarItems += items?.Count ?? 0;
|
||
if (items is null) return;
|
||
foreach (var item in items) _multiVarItemAreas.Add(item.DataType);
|
||
}
|
||
|
||
private void CountAddressRead() => AddressReads++;
|
||
|
||
/// <summary>Counting pass-through over one real connection.</summary>
|
||
/// <param name="inner">The real connection.</param>
|
||
/// <param name="owner">The factory holding the counters.</param>
|
||
private sealed class CountingPlc(IS7Plc inner, CountingPlcFactory owner) : IS7Plc
|
||
{
|
||
/// <inheritdoc />
|
||
public bool IsConnected => inner.IsConnected;
|
||
|
||
/// <inheritdoc />
|
||
public Task OpenAsync(CancellationToken cancellationToken) => inner.OpenAsync(cancellationToken);
|
||
|
||
/// <inheritdoc />
|
||
public void Close() => inner.Close();
|
||
|
||
/// <inheritdoc />
|
||
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
||
{
|
||
owner.CountAddressRead();
|
||
return inner.ReadAsync(address, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<byte[]> ReadBytesAsync(
|
||
S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
||
{
|
||
owner.CountBlockRead(area, db, startByteAdr, count);
|
||
return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(
|
||
List<S7DataItem> items, CancellationToken cancellationToken)
|
||
{
|
||
// 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);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
|
||
=> inner.WriteAsync(address, value, cancellationToken);
|
||
|
||
/// <inheritdoc />
|
||
public Task WriteBytesAsync(
|
||
S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
|
||
=> inner.WriteBytesAsync(area, db, startByteAdr, value, cancellationToken);
|
||
|
||
/// <inheritdoc />
|
||
public Task ReadStatusAsync(CancellationToken cancellationToken) => inner.ReadStatusAsync(cancellationToken);
|
||
|
||
/// <inheritdoc />
|
||
public void Dispose() => inner.Dispose();
|
||
}
|
||
}
|
||
}
|