@@ -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