using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// Gitea #266 — the pure planner behind PMC range coalescing. [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); } }