Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7BlockCoalescingPlannerTests.cs
2026-04-25 21:23:06 -04:00

306 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}