8d916f2af7
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
112 lines
4.9 KiB
C#
112 lines
4.9 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>Gitea #266 — the pure planner behind PMC range coalescing.</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasPmcCoalescerTests
|
|
{
|
|
private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
|
|
new(index, letter, number, t, bit);
|
|
|
|
[Fact]
|
|
public void Adjacent_bytes_merge_into_one_range()
|
|
{
|
|
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
|
|
var p = plans.ShouldHaveSingleItem();
|
|
(p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
|
|
p.Members.Count.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
|
|
{
|
|
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
|
|
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
|
|
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
|
|
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
|
|
}
|
|
|
|
[Fact]
|
|
public void The_span_cap_is_a_hard_limit_on_range_length()
|
|
{
|
|
var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
|
|
var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
|
|
plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
|
|
plans.Sum(p => p.Members.Count).ShouldBe(10);
|
|
plans.Count.ShouldBe(3); // 4 + 4 + 2
|
|
}
|
|
|
|
[Fact]
|
|
public void Different_letters_never_merge()
|
|
{
|
|
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
|
|
plans.Count.ShouldBe(2);
|
|
plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
|
|
plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
|
|
{
|
|
var plans = FocasPmcCoalescer.Plan(
|
|
[B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
|
|
var p = plans.ShouldHaveSingleItem();
|
|
(p.Start, p.End).ShouldBe((0, 1));
|
|
p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
|
|
{
|
|
// 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
|
|
var plans = FocasPmcCoalescer.Plan(
|
|
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
|
|
plans.Count.ShouldBe(2);
|
|
var first = plans.Single(p => p.Start == 100);
|
|
(first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
|
|
first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
|
|
plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
|
|
{
|
|
// 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
|
|
var plans = FocasPmcCoalescer.Plan(
|
|
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
|
|
var p = plans.ShouldHaveSingleItem();
|
|
(p.Start, p.End).ShouldBe((100, 105));
|
|
((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Different_widths_on_one_letter_do_not_merge()
|
|
{
|
|
FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
|
|
.Count.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
|
|
{
|
|
var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
|
|
var a = FocasPmcCoalescer.Plan(items, 128, 8);
|
|
var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
|
|
a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
|
|
a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Slot_decode_matches_the_per_tag_mapping()
|
|
{
|
|
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
|
|
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
|
|
FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
|
|
FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
|
|
FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
|
|
}
|
|
}
|