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);
}
}