test(s7): live-gate span reads + multi-var leftovers against the s7_1500 fixture
This commit is contained in:
@@ -24,6 +24,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
||||
<!-- The live span-read gate (S7_1500SpanReadTests) wraps the REAL S7PlcFactory in a counting
|
||||
decorator so it can assert the wire shape the driver actually produces against a live PLC.
|
||||
Decorating the shipped factory (rather than re-implementing IS7Plc in the test) keeps
|
||||
S7PlcAdapter + S7OperationDeadline on the wire, so the gate measures production's path. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -27,6 +27,31 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 down
|
||||
```
|
||||
|
||||
### ⚠️ Editing a profile requires `--build`
|
||||
|
||||
`Dockerfile` **COPYs `profiles/` into the image**, so the seeds are baked in at
|
||||
build time — they are not bind-mounted. A plain `up -d`, and even
|
||||
`up -d --force-recreate`, reuses the existing `otopcua-python-snap7:1.0` image
|
||||
and silently keeps serving the OLD seeds. After editing a profile JSON:
|
||||
|
||||
```bash
|
||||
docker compose --profile s7_1500 up -d --build --force-recreate
|
||||
```
|
||||
|
||||
Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints
|
||||
`seeded DB1 size=1024 seeds=<n>` at startup — check `<n>` matches the seed count
|
||||
you just wrote. This is the fastest way to tell a stale image from a bad address.
|
||||
|
||||
On the shared Docker host the deployment mirror at `/opt/otopcua-s7/` is
|
||||
**root-owned**, so a plain `rsync` fails with `status 23` and leaves the old
|
||||
files in place while still printing the file list. Push with:
|
||||
|
||||
```bash
|
||||
rsync -av --rsync-path="sudo rsync" \
|
||||
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ \
|
||||
dohertj2@10.100.0.35:/opt/otopcua-s7/
|
||||
```
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `localhost:1102` (non-privileged; sidesteps Windows Firewall
|
||||
@@ -67,6 +92,30 @@ DB1 (1024 bytes) + MB (256 bytes) with typed seeds at known offsets:
|
||||
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
|
||||
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
|
||||
|
||||
### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`)
|
||||
|
||||
These exist to give `S7ReadSpanPlanner` a layout with a **known** partition under
|
||||
the shipped 16-byte-gap / 220-byte-span defaults — the offsets are the test, so
|
||||
moving one changes what the gate proves.
|
||||
|
||||
| Address | Type | Seed | Role in the partition |
|
||||
|---|---|---|---|
|
||||
| `DB1.DBW300` | u16 | `51001` | cluster A — merges with the four below into ONE 300..320 span |
|
||||
| `DB1.DBW302` | i16 | `-21001` | cluster A (gap 0) |
|
||||
| `DB1.DBD304` | i32 | `305419896` | cluster A (gap 0) |
|
||||
| `DB1.DBX310.5` | bool | `true` | cluster A (gap 2) |
|
||||
| `DB1.DBD316` | f32 | `2.7182817` | cluster A (gap 5); seals the span at 20 bytes |
|
||||
| `DB1.DBW400` | u16 | `40201` | cluster B — 80 bytes on, so the gap budget forces a SECOND span |
|
||||
| `DB1.DBW402` | u16 | `40202` | cluster B's second member (keeps it off the `PrefersPerTagRead` demotion) |
|
||||
| `DB1.DBW500` | u16 | `60001` | isolated ⇒ demoted single-member span ⇒ multi-var leftover |
|
||||
| `DB1.DBW600` | i16 | `-32001` | isolated ⇒ multi-var leftover |
|
||||
| `MW10` | u16 | `4097` | multi-var leftover in a **different area** — a block read can't cross `(area, DB)`, multi-var can |
|
||||
|
||||
Offsets `308`, `311` and `312` carry **poison** (`0xDEAD`, `0x5A`, `0xDEADBEEF`)
|
||||
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.
|
||||
|
||||
## Known limitations
|
||||
|
||||
From the `snap7.server.Server` docstring upstream:
|
||||
|
||||
+31
-2
@@ -19,7 +19,34 @@
|
||||
{ "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
|
||||
"offset": 100, "type": "u16", "value": 0 },
|
||||
{ "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
|
||||
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }
|
||||
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 },
|
||||
|
||||
{ "_desc": "DB1.DBW300 — span-planner gate (S7_1500SpanReadTests) starts here. Cluster A spans 300..320 and merges into ONE block read under the 16-byte gap budget. Every seed from 300 on is distinct and non-zero so a mis-sliced block can never accidentally match (the #536 zero-source trap).",
|
||||
"offset": 300, "type": "u16", "value": 51001 },
|
||||
{ "_desc": "DB1.DBW302 — cluster A, adjacent to DBW300 (gap 0)",
|
||||
"offset": 302, "type": "i16", "value": -21001 },
|
||||
{ "_desc": "DB1.DBD304 — cluster A, adjacent to DBW302 (gap 0)",
|
||||
"offset": 304, "type": "i32", "value": 305419896 },
|
||||
{ "_desc": "DB1 @308 — POISON in the 308..310 hole the span reads through; no tag maps here, so a mis-slice yields 0xDEAD rather than a zero that could pass by luck",
|
||||
"offset": 308, "type": "u16", "value": 57005 },
|
||||
{ "_desc": "DB1.DBX310.5 — cluster A bool (gap 2 from the DBD304 end)",
|
||||
"offset": 310, "type": "bool", "value": true, "bit": 5 },
|
||||
{ "_desc": "DB1 @311 — POISON in the 311..316 hole",
|
||||
"offset": 311, "type": "u8", "value": 90 },
|
||||
{ "_desc": "DB1 @312 — POISON in the 311..316 hole (0xDEADBEEF)",
|
||||
"offset": 312, "type": "u32", "value": 3735928559 },
|
||||
{ "_desc": "DB1.DBD316 — cluster A f32, gap 5 from the bool byte's end; seals the span at 300..320 = 20 bytes",
|
||||
"offset": 316, "type": "f32", "value": 2.7182817 },
|
||||
|
||||
{ "_desc": "DB1.DBW400 — cluster B; 80 bytes past cluster A's end, so the 16-byte gap budget forces a SECOND span rather than one merged read",
|
||||
"offset": 400, "type": "u16", "value": 40201 },
|
||||
{ "_desc": "DB1.DBW402 — cluster B's second member; two members keep the span off the PrefersPerTagRead demotion",
|
||||
"offset": 402, "type": "u16", "value": 40202 },
|
||||
|
||||
{ "_desc": "DB1.DBW500 — isolated narrow scalar: its own single-member span, demoted by PrefersPerTagRead into a leftover that rides the multi-var pass",
|
||||
"offset": 500, "type": "u16", "value": 60001 },
|
||||
{ "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover",
|
||||
"offset": 600, "type": "i16", "value": -32001 }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -28,7 +55,9 @@
|
||||
"size": 256,
|
||||
"seeds": [
|
||||
{ "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default",
|
||||
"offset": 0, "type": "u16", "value": 1 }
|
||||
"offset": 0, "type": "u16", "value": 1 },
|
||||
{ "_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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
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>
|
||||
/// <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 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.
|
||||
/// </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>Reads the whole 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)>
|
||||
ReadBatchAsync(int maxGapBytes, string driverInstanceId)
|
||||
{
|
||||
var counters = new CountingPlcFactory(new S7PlcFactory());
|
||||
await using var drv = new S7Driver(
|
||||
SpanLayout.BuildOptions(sim.Host, sim.Port, maxGapBytes), 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(SpanLayout.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>
|
||||
/// <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>
|
||||
/// <param name="inner">The factory producing the real connection.</param>
|
||||
private sealed class CountingPlcFactory(IS7PlcFactory inner) : IS7PlcFactory
|
||||
{
|
||||
/// <summary>Block reads issued — one per planned span.</summary>
|
||||
public int BlockReads { get; private set; }
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <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 CountMultiVarRead(int items)
|
||||
{
|
||||
MultiVarReads++;
|
||||
MultiVarItems += items;
|
||||
}
|
||||
|
||||
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();
|
||||
return inner.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user