302 lines
13 KiB
C#
302 lines
13 KiB
C#
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();
|
|
}
|
|
}
|