perf(s7): span-grouped reads — one PDU per contiguous block instead of one per tag

This commit is contained in:
Joseph Doherty
2026-08-16 04:15:37 -04:00
parent 63081d52d8
commit 39f67a0fce
6 changed files with 1188 additions and 37 deletions
@@ -0,0 +1,301 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for <see cref="S7ReadSpanPlanner"/> — the pure grouping step that turns a batch
/// of resolved S7 tags into contiguous <c>(area, DB)</c> byte spans, one wire request each.
/// The planner does no I/O, so every rule it enforces (gap ceiling, span cap, area/DB
/// separation, Timer/Counter exclusion, never-overshoot) is provable here without a PLC.
/// </summary>
/// <remarks>
/// The single most important invariant in this file is
/// <see cref="A_span_never_extends_past_its_last_members_end"/>: a span is allowed to read
/// bytes NOBODY asked for in the middle (that is the whole point of a gap budget), but it must
/// never read past the end of its last member. Reading past the end is what could make a
/// grouped read fail (area/DB overrun) where the individual per-tag reads would have
/// succeeded — the one way this optimization could lose data the old path served.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class S7ReadSpanPlannerTests
{
// ── Helpers ──────────────────────────────────────────────────────────────────────────
/// <summary>Builds one planner input row: a resolved tag paired with its parsed address.</summary>
private static (int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr) T(
int index, string address, S7DataType dataType, int? arrayCount = null, int stringLength = 254)
=> (index,
new S7TagDefinition($"Tag{index}", address, dataType,
Writable: false, StringLength: stringLength, ArrayCount: arrayCount),
S7AddressParser.Parse(address));
/// <summary>Plans with the shipped defaults (gap 16, cap 220).</summary>
private static S7ReadSpanPlanner.Plan Plan(
params (int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)[] tags)
=> S7ReadSpanPlanner.Build(tags, maxGapBytes: 16, maxSpanBytes: 220);
// ── Merging ──────────────────────────────────────────────────────────────────────────
/// <summary>Three back-to-back words collapse into one 6-byte span with correct member offsets.</summary>
[Fact]
public void Contiguous_tags_merge_into_a_single_span()
{
var plan = Plan(
T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB1.DBW2", S7DataType.Int16),
T(2, "DB1.DBW4", S7DataType.UInt16));
plan.LeftoverIndexes.ShouldBeEmpty();
var span = plan.Spans.ShouldHaveSingleItem();
span.Area.ShouldBe(S7Area.DataBlock);
span.DbNumber.ShouldBe(1);
span.StartByte.ShouldBe(0);
span.LengthBytes.ShouldBe(6);
span.Members.Select(m => m.RelativeOffset).ShouldBe([0, 2, 4]);
span.Members.Select(m => m.WidthBytes).ShouldBe([2, 2, 2]);
}
/// <summary>A hole wider than the gap budget breaks the run into two independent spans.</summary>
[Fact]
public void A_gap_wider_than_the_budget_splits_the_span()
{
// 0..2 then 100..102 — a 98-byte hole, far past the 16-byte budget.
var plan = Plan(
T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB1.DBW100", S7DataType.UInt16));
plan.Spans.Count.ShouldBe(2);
plan.Spans[0].StartByte.ShouldBe(0);
plan.Spans[0].LengthBytes.ShouldBe(2);
plan.Spans[1].StartByte.ShouldBe(100);
plan.Spans[1].LengthBytes.ShouldBe(2);
}
/// <summary>A hole exactly equal to the budget still merges — the bound is inclusive.</summary>
[Fact]
public void A_gap_exactly_equal_to_the_budget_still_merges()
{
// First tag ends at byte 2; the second starts at 18 => a gap of exactly 16.
var plan = Plan(
T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB1.DBW18", S7DataType.UInt16));
var span = plan.Spans.ShouldHaveSingleItem();
span.StartByte.ShouldBe(0);
span.LengthBytes.ShouldBe(20);
}
/// <summary>A zero gap budget merges strictly-adjacent tags but nothing with a hole in it.</summary>
[Fact]
public void A_zero_gap_budget_merges_only_strictly_adjacent_tags()
{
var plan = S7ReadSpanPlanner.Build(
[T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB1.DBW2", S7DataType.UInt16),
T(2, "DB1.DBW6", S7DataType.UInt16)],
maxGapBytes: 0, maxSpanBytes: 220);
plan.Spans.Count.ShouldBe(2);
plan.Spans[0].StartByte.ShouldBe(0);
plan.Spans[0].LengthBytes.ShouldBe(4);
plan.Spans[1].StartByte.ShouldBe(6);
}
/// <summary>The span cap breaks a contiguous run that would otherwise merge end-to-end.</summary>
[Fact]
public void The_span_cap_splits_a_run_that_would_otherwise_merge()
{
// Eight adjacent words = 16 bytes contiguous; a 6-byte cap admits 3 words per span.
var tags = Enumerable.Range(0, 8)
.Select(i => T(i, $"DB1.DBW{i * 2}", S7DataType.UInt16))
.ToArray();
var plan = S7ReadSpanPlanner.Build(tags, maxGapBytes: 16, maxSpanBytes: 6);
plan.Spans.Count.ShouldBe(3);
plan.Spans.Select(s => s.Members.Count).ShouldBe([3, 3, 2]);
plan.Spans.ShouldAllBe(s => s.LengthBytes <= 6);
// Every tag is still served — the cap splits, it never drops.
plan.Spans.SelectMany(s => s.Members).Select(m => m.OriginalIndex)
.OrderBy(i => i).ShouldBe(Enumerable.Range(0, 8));
}
/// <summary>One tag wider than the cap still forms its own span — a tag is never split.</summary>
[Fact]
public void A_single_tag_wider_than_the_cap_forms_its_own_span()
{
// A 200-char S7 STRING occupies 202 bytes; the cap is 8.
var plan = S7ReadSpanPlanner.Build(
[T(0, "DB1.DBB0", S7DataType.String, stringLength: 200)],
maxGapBytes: 16, maxSpanBytes: 8);
var span = plan.Spans.ShouldHaveSingleItem();
span.LengthBytes.ShouldBe(202);
span.Members.ShouldHaveSingleItem().WidthBytes.ShouldBe(202);
plan.LeftoverIndexes.ShouldBeEmpty();
}
/// <summary>An oversized span does not swallow the neighbour that follows it.</summary>
[Fact]
public void An_oversized_span_does_not_absorb_the_next_tag()
{
var plan = S7ReadSpanPlanner.Build(
[T(0, "DB1.DBB0", S7DataType.String, stringLength: 200),
T(1, "DB1.DBW202", S7DataType.UInt16)],
maxGapBytes: 16, maxSpanBytes: 8);
plan.Spans.Count.ShouldBe(2);
plan.Spans[0].LengthBytes.ShouldBe(202);
plan.Spans[1].StartByte.ShouldBe(202);
}
// ── Separation ───────────────────────────────────────────────────────────────────────
/// <summary>Two DBs at the same byte offsets are different address spaces and never merge.</summary>
[Fact]
public void Distinct_data_blocks_never_merge()
{
var plan = Plan(
T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB2.DBW2", S7DataType.UInt16));
plan.Spans.Count.ShouldBe(2);
plan.Spans.Select(s => s.DbNumber).OrderBy(n => n).ShouldBe([1, 2]);
}
/// <summary>Different areas (M vs I vs Q) are different address spaces and never merge.</summary>
[Fact]
public void Distinct_areas_never_merge()
{
var plan = Plan(
T(0, "MW0", S7DataType.UInt16),
T(1, "IW2", S7DataType.UInt16),
T(2, "QW4", S7DataType.UInt16));
plan.Spans.Count.ShouldBe(3);
plan.Spans.Select(s => s.Area).ShouldBe([S7Area.Memory, S7Area.Input, S7Area.Output], ignoreOrder: true);
}
/// <summary>
/// Timer and Counter tags are excluded from every span. Their <c>ByteOffset</c> holds the
/// DEVICE NUMBER, not a byte address, so grouping them with byte-addressed tags — or with
/// each other — would read from a completely unrelated place on the wire.
/// </summary>
[Fact]
public void Timer_and_Counter_tags_are_excluded_to_leftovers()
{
var plan = Plan(
T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "T1", S7DataType.Float64),
T(2, "C1", S7DataType.Int32),
T(3, "DB1.DBW2", S7DataType.UInt16));
plan.LeftoverIndexes.OrderBy(i => i).ShouldBe([1, 2]);
var span = plan.Spans.ShouldHaveSingleItem();
span.Members.Select(m => m.OriginalIndex).ShouldBe([0, 3]);
}
// ── Overlap ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Two Bools at different bit positions in the SAME byte cost one byte between them —
/// overlap is a gap of zero, and the span covers exactly that one byte.
/// </summary>
[Fact]
public void Two_bools_in_the_same_byte_merge_into_one_single_byte_span()
{
var plan = Plan(
T(0, "DB1.DBX0.0", S7DataType.Bool),
T(1, "DB1.DBX0.7", S7DataType.Bool));
var span = plan.Spans.ShouldHaveSingleItem();
span.StartByte.ShouldBe(0);
span.LengthBytes.ShouldBe(1);
span.Members.Count.ShouldBe(2);
span.Members.ShouldAllBe(m => m.RelativeOffset == 0);
span.Members.Select(m => m.Addr.BitOffset).ShouldBe([0, 7]);
}
/// <summary>A word overlapping an already-open byte does not shrink the span's end.</summary>
[Fact]
public void An_overlapping_tag_extends_the_span_to_the_furthest_end()
{
// DBD0 covers 0..4; DBW2 sits INSIDE it. The span must stay 4 bytes, not shrink to 2+2.
var plan = Plan(
T(0, "DB1.DBD0", S7DataType.UInt32),
T(1, "DB1.DBW2", S7DataType.UInt16));
var span = plan.Spans.ShouldHaveSingleItem();
span.StartByte.ShouldBe(0);
span.LengthBytes.ShouldBe(4);
span.Members.Select(m => m.RelativeOffset).ShouldBe([0, 2]);
}
// ── The never-overshoot invariant ────────────────────────────────────────────────────
/// <summary>
/// Across a deliberately messy mixed batch, every span starts at its first member's byte
/// and ends at its furthest member's end — never one byte further. A span that overshot
/// could fault (area / DB overrun) on a read the per-tag path would have served.
/// </summary>
[Fact]
public void A_span_never_extends_past_its_last_members_end()
{
var plan = Plan(
T(0, "DB1.DBX0.3", S7DataType.Bool),
T(1, "DB1.DBB1", S7DataType.Byte),
T(2, "DB1.DBW2", S7DataType.UInt16),
T(3, "DB1.DBD8", S7DataType.Float32),
T(4, "DB1.DBB24", S7DataType.Int64),
T(5, "DB1.DBB36", S7DataType.String, stringLength: 10),
T(6, "DB1.DBW60", S7DataType.UInt16, arrayCount: 4),
T(7, "DB1.DBW200", S7DataType.UInt16),
T(8, "MW0", S7DataType.UInt16));
plan.Spans.ShouldNotBeEmpty();
foreach (var span in plan.Spans)
{
span.Members.ShouldNotBeEmpty();
span.StartByte.ShouldBe(span.Members.Min(m => m.Addr.ByteOffset));
span.LengthBytes.ShouldBe(span.Members.Max(m => m.RelativeOffset + m.WidthBytes));
// Every member must be wholly inside the block the span will read.
span.Members.ShouldAllBe(m => m.RelativeOffset >= 0
&& m.RelativeOffset + m.WidthBytes <= span.LengthBytes);
// RelativeOffset is the member's own byte, expressed against the span start.
span.Members.ShouldAllBe(m => m.RelativeOffset == m.Addr.ByteOffset - span.StartByte);
}
// Nothing is lost or duplicated between spans and leftovers.
plan.Spans.SelectMany(s => s.Members).Select(m => m.OriginalIndex)
.Concat(plan.LeftoverIndexes)
.OrderBy(i => i)
.ShouldBe(Enumerable.Range(0, 9));
}
// ── The off switch ───────────────────────────────────────────────────────────────────
/// <summary>A negative gap budget disables grouping entirely — every tag becomes a leftover.</summary>
[Fact]
public void A_negative_max_gap_disables_grouping_entirely()
{
var plan = S7ReadSpanPlanner.Build(
[T(0, "DB1.DBW0", S7DataType.UInt16),
T(1, "DB1.DBW2", S7DataType.UInt16),
T(2, "T1", S7DataType.Float64)],
maxGapBytes: -1, maxSpanBytes: 220);
plan.Spans.ShouldBeEmpty();
plan.LeftoverIndexes.OrderBy(i => i).ShouldBe([0, 1, 2]);
}
/// <summary>An empty batch plans nothing rather than throwing.</summary>
[Fact]
public void An_empty_batch_produces_an_empty_plan()
{
var plan = Plan();
plan.Spans.ShouldBeEmpty();
plan.LeftoverIndexes.ShouldBeEmpty();
}
}
@@ -0,0 +1,373 @@
using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Falsification gate for span-grouped S7 reads. Every tag must decode to the SAME value, the
/// same CLR box type and the same status code whether it was served by a wire request of its
/// own (the pre-existing per-tag path, reachable by setting
/// <c>SpanReadMaxGapBytes = -1</c>) or by a block read shared with its neighbours.
/// </summary>
/// <remarks>
/// <para>
/// The two drivers read the same in-memory PLC image through the same fake, so any
/// difference between the two result sets is a defect in the grouping path — there is no
/// other variable. Grouping is proven to have actually engaged by
/// <see cref="Grouping_issues_far_fewer_wire_requests_than_there_are_tags"/>: without that
/// assertion this whole file would pass vacuously if the planner ever produced only
/// leftovers, because "leftover" IS the per-tag path.
/// </para>
/// <para>
/// The fake serves <c>ReadBytesAsync</c> (block reads — the span path, plus the wide /
/// array / Timer-Counter tags that always read blocks) and <c>ReadAsync(string)</c> (the
/// narrow per-tag path) from ONE byte image per <c>(area, DB)</c>, decoding the latter
/// exactly as S7.Net does: <c>DBX</c> boxes a <c>bool</c>, <c>DBB</c> a <c>byte</c>,
/// <c>DBW</c> a <c>ushort</c>, <c>DBD</c> a <c>uint</c>.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class S7SpanReadParityTests
{
/// <summary>OPC UA <c>BadDeviceFailure</c> — what a device-layer read fault stamps.</summary>
private const uint StatusBadDeviceFailure = 0x808B0000u;
/// <summary>OPC UA <c>BadNodeIdUnknown</c> — what an unresolvable reference stamps.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
// ── The mixed tag set ────────────────────────────────────────────────────────────────
/// <summary>
/// A deliberately awkward batch: two Bools sharing one byte (bit 0 and bit 7), a Byte, a
/// word pair, three dwords behind a small hole, a wide Int64, an S7 STRING, a UInt16 array,
/// a far-away word past the gap budget, a second AREA, and a Counter (whose offset is a
/// device number, so it can never join a byte span).
/// </summary>
private static IReadOnlyList<RawTagEntry> MixedTags() => S7RawTags.Entries(
new S7TagDefinition("Bit0", "DB1.DBX0.0", S7DataType.Bool, Writable: false),
new S7TagDefinition("Bit7", "DB1.DBX0.7", S7DataType.Bool, Writable: false),
new S7TagDefinition("Byte1", "DB1.DBB1", S7DataType.Byte, Writable: false),
new S7TagDefinition("U16", "DB1.DBW2", S7DataType.UInt16, Writable: false),
new S7TagDefinition("I16", "DB1.DBW4", S7DataType.Int16, Writable: false),
new S7TagDefinition("U32", "DB1.DBD8", S7DataType.UInt32, Writable: false),
new S7TagDefinition("I32", "DB1.DBD12", S7DataType.Int32, Writable: false),
new S7TagDefinition("F32", "DB1.DBD16", S7DataType.Float32, Writable: false),
new S7TagDefinition("I64", "DB1.DBB24", S7DataType.Int64, Writable: false),
new S7TagDefinition("Str", "DB1.DBB36", S7DataType.String, Writable: false, StringLength: 10),
new S7TagDefinition("Arr", "DB1.DBW60", S7DataType.UInt16, Writable: false, ArrayCount: 4),
new S7TagDefinition("Far", "DB1.DBW200", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Mem", "MW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false));
/// <summary>Read references, in the order the batch presents them.</summary>
private static readonly string[] MixedRefs =
[
"Bit0", "Bit7", "Byte1", "U16", "I16", "U32", "I32", "F32",
"Str", "I64", "Arr", "Far", "Mem", "Cnt",
];
/// <summary>Only the narrow, byte-addressed DB1 tags — every one of them lands in one span.</summary>
private static IReadOnlyList<RawTagEntry> NarrowTags() => S7RawTags.Entries(
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: false),
new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: false));
// ── Parity ───────────────────────────────────────────────────────────────────────────
/// <summary>
/// THE gate: span-grouped reads and per-tag reads produce identical snapshots — same
/// value, same box type, same status — for every tag in the mixed batch.
/// </summary>
[Fact]
public async Task Span_grouped_reads_match_the_per_tag_path_snapshot_for_snapshot()
{
var images = Images();
var ct = TestContext.Current.CancellationToken;
var grouping = new ImageS7PlcFactory(images);
var perTag = new ImageS7PlcFactory(images);
using var groupedDriver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-grouped", grouping);
using var perTagDriver = new S7Driver(Options(MixedTags(), maxGap: -1), "s7-per-tag", perTag);
await groupedDriver.InitializeAsync("{}", ct);
await perTagDriver.InitializeAsync("{}", ct);
var grouped = await groupedDriver.ReadAsync(MixedRefs, ct);
var individual = await perTagDriver.ReadAsync(MixedRefs, ct);
grouped.Count.ShouldBe(MixedRefs.Length);
for (var i = 0; i < MixedRefs.Length; i++)
{
var name = MixedRefs[i];
grouped[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'");
grouped[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good");
grouped[i].Value.ShouldNotBeNull($"'{name}' returned no value");
grouped[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'");
grouped[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'");
}
// The per-tag control really did take the per-tag path (no grouping happened there): its
// only block reads are the four tags that ALWAYS read blocks (Int64, String, array, Counter).
perTag.ReadBytesCalls.ShouldBe(4);
perTag.StringReadCalls.ShouldBe(10);
}
/// <summary>
/// Proves grouping actually engaged. Without this the parity test above would pass
/// vacuously if the planner degenerated to all-leftovers, since a leftover IS a per-tag
/// read.
/// </summary>
/// <remarks>
/// 14 tags collapse to 4 wire requests: ONE block read covering the eleven DB1 tags from
/// byte 0 to byte 68, the Counter's own unavoidable block read, and two per-address string
/// reads — the far DB1 word (past the gap budget) and the lone Memory word, each of which
/// the driver deliberately leaves on the per-tag path because a single-member narrow span
/// saves no round-trip.
/// </remarks>
[Fact]
public async Task Grouping_issues_far_fewer_wire_requests_than_there_are_tags()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images());
using var driver = new S7Driver(Options(MixedTags(), maxGap: 16), "s7-wire-count", factory);
await driver.InitializeAsync("{}", ct);
_ = await driver.ReadAsync(MixedRefs, ct);
(factory.ReadBytesCalls + factory.StringReadCalls).ShouldBeLessThan(MixedRefs.Length);
factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length);
factory.ReadBytesCalls.ShouldBe(2);
// The nine narrow tags inside the big span issue NO string reads — only the two tags that
// ended up alone in a span of their own do.
factory.StringReadCalls.ShouldBe(2);
}
// ── Failure parity ───────────────────────────────────────────────────────────────────
/// <summary>
/// A failed span stamps EVERY one of its members with exactly the status the per-tag path
/// would have produced for that exception, and degrades health identically. A span read is
/// one wire request serving many tags, so a fault that used to affect one tag now affects
/// all of them — they must all be told the same thing.
/// </summary>
[Fact]
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
{
var ct = TestContext.Current.CancellationToken;
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
using var groupedDriver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-grouped-fail", groupingFactory);
using var perTagDriver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-per-tag-fail", perTagFactory);
await groupedDriver.InitializeAsync("{}", ct);
await perTagDriver.InitializeAsync("{}", ct);
string[] refs = ["A", "B", "C"];
var grouped = await groupedDriver.ReadAsync(refs, ct);
var individual = await perTagDriver.ReadAsync(refs, ct);
// Every member of the failed span — not just the first — is stamped Bad.
grouped.Count.ShouldBe(3);
grouped.ShouldAllBe(s => s.StatusCode == StatusBadDeviceFailure);
grouped.ShouldAllBe(s => s.Value == null);
grouped.Select(s => s.StatusCode).ShouldBe(individual.Select(s => s.StatusCode));
groupedDriver.GetHealth().State.ShouldBe(DriverState.Degraded);
groupedDriver.GetHealth().State.ShouldBe(perTagDriver.GetHealth().State);
// One failed block read, not three — the span path fails once for the whole group.
groupingFactory.ReadBytesCalls.ShouldBe(1);
}
/// <summary>
/// Unresolved references still stamp <c>BadNodeIdUnknown</c> and never enter a span, and
/// the resolvable tags around them still read Good.
/// </summary>
[Fact]
public async Task An_unresolved_reference_is_still_BadNodeIdUnknown_under_grouping()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images());
using var driver = new S7Driver(Options(NarrowTags(), maxGap: 16), "s7-unknown", factory);
await driver.InitializeAsync("{}", ct);
var results = await driver.ReadAsync(["A", "NoSuchTag", "C"], ct);
results[0].StatusCode.ShouldBe(0u);
results[1].StatusCode.ShouldBe(StatusBadNodeIdUnknown);
results[1].Value.ShouldBeNull();
results[2].StatusCode.ShouldBe(0u);
}
// ── Configurability ──────────────────────────────────────────────────────────────────
/// <summary>
/// The grouping knobs must be reachable from a deployed driver config. <c>ParseOptions</c>
/// maps an explicit DTO rather than deserialising <see cref="S7DriverOptions"/>, so a knob
/// that is not mapped there is silently pinned to its default forever — which would leave
/// the documented "-1 disables grouping" escape hatch unreachable in production.
/// </summary>
[Fact]
public void The_span_read_knobs_bind_from_driver_config_json()
{
var configured = S7DriverFactoryExtensions.ParseOptions(
"s7-cfg",
"""{"host":"10.0.0.1","spanReadMaxGapBytes":-1,"spanReadMaxSpanBytes":64}""");
configured.SpanReadMaxGapBytes.ShouldBe(-1);
configured.SpanReadMaxSpanBytes.ShouldBe(64);
// Omitted keys keep the shipped defaults rather than collapsing to zero.
var defaulted = S7DriverFactoryExtensions.ParseOptions("s7-cfg", """{"host":"10.0.0.1"}""");
defaulted.SpanReadMaxGapBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxGapBytes);
defaulted.SpanReadMaxSpanBytes.ShouldBe(S7DriverOptions.DefaultSpanReadMaxSpanBytes);
}
// ── Fixture ──────────────────────────────────────────────────────────────────────────
/// <summary>Driver options over the given tags with the span-grouping knobs under test.</summary>
private static S7DriverOptions Options(IReadOnlyList<RawTagEntry> tags, int maxGap) => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = maxGap,
SpanReadMaxSpanBytes = 220,
RawTags = tags,
};
/// <summary>
/// The in-memory PLC images the fake serves, one byte array per <c>(area, DB)</c>. Filled
/// with a deterministic non-repeating pattern so no tag reads a vacuous all-zero value, then
/// seeded with a well-formed S7 STRING field at DB1 byte 36 (<c>[maxLen][curLen][chars…]</c>,
/// which <c>S7String.FromByteArray</c> validates).
/// </summary>
private static Dictionary<(S7NetDataType Area, int Db), byte[]> Images()
{
var db1 = new byte[256];
for (var i = 0; i < db1.Length; i++) db1[i] = (byte)((i * 37 + 11) & 0xFF);
db1[36] = 10; // declared max length
db1[37] = 5; // current length
var chars = "HELLO"u8.ToArray();
chars.CopyTo(db1, 38);
for (var i = 38 + chars.Length; i < 36 + 12; i++) db1[i] = 0x20; // reserved padding
var mem = new byte[64];
for (var i = 0; i < mem.Length; i++) mem[i] = (byte)((i * 53 + 7) & 0xFF);
var counters = new byte[64];
for (var i = 0; i < counters.Length; i++) counters[i] = (byte)((i * 29 + 3) & 0xFF);
return new Dictionary<(S7NetDataType, int), byte[]>
{
[(S7NetDataType.DataBlock, 1)] = db1,
[(S7NetDataType.Memory, 0)] = mem,
[(S7NetDataType.Counter, 0)] = counters,
};
}
/// <summary>
/// Fake <see cref="IS7PlcFactory"/> serving both read shapes from one shared byte image per
/// <c>(area, DB)</c>, and counting each shape so a test can prove which path ran. Counters
/// live on the FACTORY (not the connection) so a reconnect cannot reset them.
/// </summary>
private sealed class ImageS7PlcFactory(Dictionary<(S7NetDataType Area, int Db), byte[]> images) : IS7PlcFactory
{
/// <summary>The shared per-area byte images both read shapes decode from.</summary>
public Dictionary<(S7NetDataType Area, int Db), byte[]> Images { get; } = images;
/// <summary>Number of block reads issued — one per planned span, plus the always-block tags.</summary>
public int ReadBytesCalls;
/// <summary>Number of per-address string reads issued — the un-grouped narrow path.</summary>
public int StringReadCalls;
/// <summary>When set, every read throws this instead of serving the image.</summary>
public Exception? ReadFault { get; init; }
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ImageS7Plc(this);
private sealed class ImageS7Plc(ImageS7PlcFactory owner) : IS7Plc
{
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <inheritdoc />
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
/// <inheritdoc />
public void Close() => IsConnected = false;
/// <inheritdoc />
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
{
// Counted BEFORE the injected fault so a test can assert how many block reads were
// ATTEMPTED — a failure test that counted only successes would read zero.
Interlocked.Increment(ref owner.ReadBytesCalls);
if (owner.ReadFault is { } fault) throw fault;
var image = owner.Images[(area, db)];
if (startByteAdr + count > image.Length)
{
throw new PlcException(ErrorCode.ReadData,
$"fake image overrun: {area} db{db} @{startByteAdr}+{count} exceeds {image.Length}");
}
return Task.FromResult(image[startByteAdr..(startByteAdr + count)]);
}
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken ct)
{
Interlocked.Increment(ref owner.StringReadCalls);
if (owner.ReadFault is { } fault) throw fault;
var addr = S7AddressParser.Parse(address);
var image = owner.Images[(ToS7NetArea(addr.Area), addr.DbNumber)];
var at = addr.ByteOffset;
// Box exactly as S7.Net's string read does — the size suffix picks the type.
return Task.FromResult<object?>(addr.Size switch
{
S7Size.Bit => (object)((image[at] & (1 << addr.BitOffset)) != 0),
S7Size.Byte => image[at],
S7Size.Word => (ushort)((image[at] << 8) | image[at + 1]),
S7Size.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16)
| ((uint)image[at + 2] << 8) | image[at + 3],
_ => throw new NotSupportedException($"fake cannot serve size {addr.Size}"),
});
}
/// <summary>Mirrors the driver's own area mapping so both read shapes hit the same image.</summary>
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
{
S7Area.DataBlock => S7NetDataType.DataBlock,
S7Area.Memory => S7NetDataType.Memory,
S7Area.Input => S7NetDataType.Input,
S7Area.Output => S7NetDataType.Output,
S7Area.Timer => S7NetDataType.Timer,
S7Area.Counter => S7NetDataType.Counter,
_ => throw new NotSupportedException($"fake cannot map area {area}"),
};
/// <inheritdoc />
public Task WriteAsync(string address, object value, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct)
=> Task.CompletedTask;
/// <inheritdoc />
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public void Dispose() => IsConnected = false;
}
}
}