@@ -0,0 +1,131 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end verification of the block-read coalescing planner (PR-S7-B2)
|
||||
/// against the python-snap7 S7-1500 simulator. The headline assertion: 50
|
||||
/// contiguous DBW reads (DB1.DBW0..DB1.DBW98) coalesce into exactly ONE
|
||||
/// <c>Plc.ReadBytesAsync</c> call instead of 50 single-tag round-trips —
|
||||
/// a 50:1 wire-level reduction.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500BlockCoalescingTests(Snap7ServerFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Driver_coalesces_contiguous_DBWs_into_single_byte_range_read()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Build a 50-tag config covering DB1.DBW0, DBW2, DBW4, ..., DBW98.
|
||||
// Every offset is exactly 2 bytes apart, so the planner sees 50
|
||||
// adjacent ranges with gap = 0 and folds them into one 100-byte
|
||||
// ReadBytesAsync. With the multi-var packer (PR-S7-B1) alone the
|
||||
// baseline would be ⌈50/19⌉ = 3 multi-var batches; the block coalescer
|
||||
// beats that by an order of magnitude.
|
||||
var tags = new List<S7TagDefinition>(50);
|
||||
for (var i = 0; i < 50; i++)
|
||||
tags.Add(new S7TagDefinition($"BulkDBW{i:D2}", $"DB1.DBW{i * 2}", S7DataType.UInt16));
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
CpuType = S7NetCpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = tags,
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var blockReadsBefore = drv.TotalBlockReads;
|
||||
var multiVarBefore = drv.TotalMultiVarBatches;
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
tags.Select(t => t.Name).ToList(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(50);
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == 0u, "every coalesced read must surface a Good status");
|
||||
|
||||
// Headline assertion: exactly one byte-range PDU was issued for the
|
||||
// entire 50-tag fan-in. If the merge regressed we'd see 3 multi-var
|
||||
// batches (and zero block reads) or 50 single reads in the worst case.
|
||||
var blockReadsDelta = drv.TotalBlockReads - blockReadsBefore;
|
||||
var multiVarDelta = drv.TotalMultiVarBatches - multiVarBefore;
|
||||
|
||||
blockReadsDelta.ShouldBe(1L,
|
||||
$"50 contiguous DBWs must coalesce into exactly 1 ReadBytesAsync; saw {blockReadsDelta} block reads and {multiVarDelta} multi-var batches");
|
||||
multiVarDelta.ShouldBe(0L,
|
||||
"no singletons should fall through to the multi-var packer when every tag merged");
|
||||
|
||||
// Every tag in DB1 was zero-initialised by the snap7 simulator except
|
||||
// the offsets the seed file declares; DBW0 reads back the probe value
|
||||
// 4242 and DBW10 reads back -12345 (re-interpreted as ushort 53191).
|
||||
// Spot-check the probe + a couple of post-seed offsets to confirm the
|
||||
// slice math is correct.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(4242, "DB1.DBW0 carries the seeded 4242 probe value");
|
||||
Convert.ToInt32(snapshots[5].Value).ShouldBe(unchecked((ushort)(short)-12345),
|
||||
"DB1.DBW10 carries the seeded -12345 (read as UInt16 wire pattern)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_skips_coalescing_when_gap_threshold_is_zero_and_layout_is_sparse()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Sparse layout: 3 DBWs with a 100-byte gap between each. Default
|
||||
// threshold (16) keeps them apart; explicit 0 also keeps them apart;
|
||||
// either way we expect 3 standalone byte-range reads, not one giant
|
||||
// over-fetched range. Verifies that the planner actually honours the
|
||||
// gap-merge cutoff and doesn't blindly span the whole DB.
|
||||
var tags = new[]
|
||||
{
|
||||
new S7TagDefinition("Sparse_0", "DB1.DBW0", S7DataType.UInt16),
|
||||
new S7TagDefinition("Sparse_100", "DB1.DBW100", S7DataType.UInt16),
|
||||
new S7TagDefinition("Sparse_200", "DB1.DBW200", S7DataType.UInt16),
|
||||
};
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
CpuType = S7NetCpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
BlockCoalescingGapBytes = 0, // strict: only adjacent ranges merge
|
||||
Tags = tags,
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce-sparse");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var blockReadsBefore = drv.TotalBlockReads;
|
||||
var multiVarBefore = drv.TotalMultiVarBatches;
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
tags.Select(t => t.Name).ToList(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
|
||||
// Each tag is a singleton range — the planner emits 3 single-tag
|
||||
// ranges and the driver routes them through the multi-var packer
|
||||
// rather than one ReadBytesAsync per tag. Result: 0 block reads, 1
|
||||
// multi-var batch covering all 3 tags.
|
||||
(drv.TotalBlockReads - blockReadsBefore).ShouldBe(0L,
|
||||
"singletons must not pay for a one-tag ReadBytesAsync round-trip");
|
||||
(drv.TotalMultiVarBatches - multiVarBefore).ShouldBe(1L,
|
||||
"3 singleton tags should pack into a single multi-var batch");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the block-read coalescing planner (PR-S7-B2). Pins the
|
||||
/// merge math so a regression in the gap-merge logic surfaces here instead
|
||||
/// of as a flaky 50:1 wire-reduction integration test against the simulator.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7BlockCoalescingPlannerTests
|
||||
{
|
||||
private static S7BlockCoalescingPlanner.TagSpec Db(int caller, int dbNumber, int byteOffset, int byteCount, bool opaque = false)
|
||||
=> new(caller, S7Area.DataBlock, dbNumber, byteOffset, byteCount, opaque);
|
||||
|
||||
private static S7BlockCoalescingPlanner.TagSpec M(int caller, int byteOffset, int byteCount)
|
||||
=> new(caller, S7Area.Memory, DbNumber: 0, byteOffset, byteCount, OpaqueSize: false);
|
||||
|
||||
[Fact]
|
||||
public void Three_contiguous_DBWs_coalesce_into_one_six_byte_range()
|
||||
{
|
||||
// DB1.DBW0 (2 B) + DB1.DBW2 (2 B) + DB1.DBW4 (2 B) → one 6-byte range
|
||||
// covering offsets 0..6 within DB1. The headline coalescing claim.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(caller: 0, dbNumber: 1, byteOffset: 0, byteCount: 2),
|
||||
Db(caller: 1, dbNumber: 1, byteOffset: 2, byteCount: 2),
|
||||
Db(caller: 2, dbNumber: 1, byteOffset: 4, byteCount: 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].Area.ShouldBe(S7Area.DataBlock);
|
||||
ranges[0].DbNumber.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(6);
|
||||
ranges[0].Tags.Count.ShouldBe(3);
|
||||
ranges[0].Tags.Select(t => t.CallerIndex).ShouldBe(new[] { 0, 1, 2 });
|
||||
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 2, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Far_apart_tags_do_not_merge_when_gap_exceeds_threshold()
|
||||
{
|
||||
// DB1.DBW0 + DB1.DBW100 → gap of 98 bytes, way above the default 16-byte
|
||||
// threshold. Two standalone ranges so neither over-fetches into dead space.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 100, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.OrderBy(r => r.StartByte).Select(r => r.StartByte).ShouldBe(new[] { 0, 100 });
|
||||
ranges.ShouldAllBe(r => r.ByteCount == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_within_default_gap_threshold_merge_into_one_range()
|
||||
{
|
||||
// DBW0 + DBW10 → gap = 8 bytes (DBW0 ends at 2, DBW10 starts at 10).
|
||||
// 8 ≤ 16 default threshold → merge into one 12-byte range starting at 0.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 10, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(12);
|
||||
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 10 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_areas_never_merge_even_when_offsets_align()
|
||||
{
|
||||
// DB1.DBW0 and MW0 share a byte offset of 0 but live in different
|
||||
// address spaces — coalescing across areas is a wire-protocol error.
|
||||
var specs = new S7BlockCoalescingPlanner.TagSpec[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
M(1, 0, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Any(r => r.Area == S7Area.DataBlock && r.DbNumber == 1).ShouldBeTrue();
|
||||
ranges.Any(r => r.Area == S7Area.Memory).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_DB_numbers_never_merge()
|
||||
{
|
||||
// DB1.DBW0 and DB2.DBW0 share area type but live in different DBs —
|
||||
// S7 read requests carry the DB number, can't cover two DBs in one read.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 2, 0, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Select(r => r.DbNumber).OrderBy(n => n).ShouldBe(new[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opaque_tag_in_middle_of_run_splits_into_three_ranges()
|
||||
{
|
||||
// Sequence: DBW0, STRING@DB1.4 (opaque), DBW10. The opaque row emits
|
||||
// its own standalone range; the planner sees the remaining mergeable
|
||||
// candidates as DBW0 + DBW10 with gap 8 ≤ 16, so they merge into one
|
||||
// 12-byte range. Total 2 ranges (DBW0/DBW10 merged + opaque STRING).
|
||||
// Setting the test to 3 ranges deliberately — verify that the opaque
|
||||
// entry never participates in or crosses the neighbour-merge path.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 4, 256, opaque: true), // STRING-shaped, variable header
|
||||
Db(2, 1, 270, 2), // far enough to not merge with DBW0
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(3);
|
||||
ranges.Count(r => r.Tags.Count == 1).ShouldBe(3);
|
||||
ranges.Single(r => r.StartByte == 4 && r.Tags[0].CallerIndex == 1).ByteCount.ShouldBe(256);
|
||||
ranges.Single(r => r.StartByte == 0).Tags[0].CallerIndex.ShouldBe(0);
|
||||
ranges.Single(r => r.StartByte == 270).Tags[0].CallerIndex.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opaque_tag_does_not_extend_a_neighbour_block()
|
||||
{
|
||||
// DBW0, DBW2, then opaque STRING at byte 4 — without the opaque opt-out
|
||||
// the planner would happily fold them all into one read. The opaque
|
||||
// marker must keep the STRING out of the merged range.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 2, 2),
|
||||
Db(2, 1, 4, 256, opaque: true),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
var merged = ranges.Single(r => r.Tags.Count == 2);
|
||||
merged.ByteCount.ShouldBe(4); // DBW0 + DBW2 only — STRING is its own range
|
||||
var opaque = ranges.Single(r => r.Tags.Count == 1);
|
||||
opaque.StartByte.ShouldBe(4);
|
||||
opaque.ByteCount.ShouldBe(256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configurable_gap_threshold_can_merge_a_wider_gap()
|
||||
{
|
||||
// gap = 20 bytes between DBW0 (ends @2) and DBD22 (starts @22).
|
||||
// Default threshold (16) keeps them apart; threshold = 32 merges them
|
||||
// into one 26-byte range. Operator-tunable knob.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 22, 4),
|
||||
};
|
||||
|
||||
var defaultPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 16);
|
||||
defaultPlan.Count.ShouldBe(2);
|
||||
|
||||
var widenedPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 32);
|
||||
widenedPlan.Count.ShouldBe(1);
|
||||
widenedPlan[0].StartByte.ShouldBe(0);
|
||||
widenedPlan[0].ByteCount.ShouldBe(26);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_gap_threshold_only_merges_strictly_adjacent_ranges()
|
||||
{
|
||||
// DBW0 (0..2) + DBW2 (2..4) are adjacent (gap = 0); DBW6 has gap = 2.
|
||||
// Threshold 0 → DBW0+DBW2 merge but DBW6 stays standalone.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 2, 2),
|
||||
Db(2, 1, 6, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 0);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Single(r => r.Tags.Count == 2).ByteCount.ShouldBe(4);
|
||||
ranges.Single(r => r.Tags.Count == 1).StartByte.ShouldBe(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_returns_empty_plan()
|
||||
{
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(System.Array.Empty<S7BlockCoalescingPlanner.TagSpec>());
|
||||
ranges.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_gap_threshold_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7BlockCoalescingPlanner.Plan([Db(0, 1, 0, 2)], gapMergeBytes: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_with_overlapping_ranges_still_coalesce_correctly()
|
||||
{
|
||||
// DBD0 (0..4) + DBW2 (2..4): the second tag is entirely inside the
|
||||
// first's footprint. Treat as zero-gap merge (overlap == negative gap)
|
||||
// — block end stays at 4, byte count stays at 4, both tags slice from
|
||||
// the same buffer.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 4),
|
||||
Db(1, 1, 2, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(4);
|
||||
ranges[0].Tags.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fifty_contiguous_DBWs_coalesce_into_one_hundred_byte_range()
|
||||
{
|
||||
// The integration-test workload at the unit level: 50 DBW reads at
|
||||
// offsets 0,2,4,...,98 must coalesce into one read covering 100 bytes.
|
||||
var specs = Enumerable.Range(0, 50)
|
||||
.Select(i => Db(caller: i, dbNumber: 1, byteOffset: i * 2, byteCount: 2))
|
||||
.ToArray();
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(100);
|
||||
ranges[0].Tags.Count.ShouldBe(50);
|
||||
}
|
||||
|
||||
// ---- IsOpaqueSize classifier ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.String, true)]
|
||||
[InlineData(S7DataType.WString, true)]
|
||||
[InlineData(S7DataType.Char, true)]
|
||||
[InlineData(S7DataType.WChar, true)]
|
||||
[InlineData(S7DataType.Dtl, true)]
|
||||
[InlineData(S7DataType.DateAndTime, true)]
|
||||
[InlineData(S7DataType.S5Time, true)]
|
||||
[InlineData(S7DataType.Time, true)]
|
||||
[InlineData(S7DataType.TimeOfDay, true)]
|
||||
[InlineData(S7DataType.Date, true)]
|
||||
[InlineData(S7DataType.Bool, false)]
|
||||
[InlineData(S7DataType.Byte, false)]
|
||||
[InlineData(S7DataType.Int16, false)]
|
||||
[InlineData(S7DataType.UInt16, false)]
|
||||
[InlineData(S7DataType.Int32, false)]
|
||||
[InlineData(S7DataType.UInt32, false)]
|
||||
[InlineData(S7DataType.Float32, false)]
|
||||
[InlineData(S7DataType.Float64, false)]
|
||||
public void IsOpaqueSize_flags_string_and_structured_timestamp_types(S7DataType type, bool expected)
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBW0", type);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(tag).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpaqueSize_flags_arrays_regardless_of_element_type()
|
||||
{
|
||||
// Even Int16 — which is otherwise mergeable as a scalar — turns opaque
|
||||
// when ElementCount > 1 because the per-tag width is N × 2 bytes.
|
||||
var arrayTag = new S7TagDefinition("a", "DB1.DBW0", S7DataType.Int16, ElementCount: 4);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(arrayTag).ShouldBeTrue();
|
||||
|
||||
var scalarTag = new S7TagDefinition("s", "DB1.DBW0", S7DataType.Int16);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(scalarTag).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7Size.Bit, 1)]
|
||||
[InlineData(S7Size.Byte, 1)]
|
||||
[InlineData(S7Size.Word, 2)]
|
||||
[InlineData(S7Size.DWord, 4)]
|
||||
[InlineData(S7Size.LWord, 8)]
|
||||
public void ScalarByteCount_returns_wire_width_per_size_suffix(S7Size size, int expected)
|
||||
{
|
||||
S7BlockCoalescingPlanner.ScalarByteCount(size).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user