using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// 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. /// [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()); ranges.ShouldBeEmpty(); } [Fact] public void Negative_gap_threshold_is_rejected() { Should.Throw(() => 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); } }