using Mbproxy.Proxy.Multiplexing; using Shouldly; using Xunit; namespace Mbproxy.Tests.Proxy.Multiplexing; /// /// Unit tests for . Pure logic — no I/O. /// [Trait("Category", "Unit")] public sealed class TxIdAllocatorTests { [Fact] public void Allocate_FromEmpty_Returns_NextSequential() { var alloc = new TxIdAllocator(); alloc.TryAllocate(out ushort a).ShouldBeTrue(); alloc.TryAllocate(out ushort b).ShouldBeTrue(); alloc.TryAllocate(out ushort c).ShouldBeTrue(); a.ShouldBe((ushort)0); b.ShouldBe((ushort)1); c.ShouldBe((ushort)2); alloc.InFlightCount.ShouldBe(3); } [Fact] public void Allocate_AfterRelease_Reuses_FreedId() { var alloc = new TxIdAllocator(); alloc.TryAllocate(out ushort a).ShouldBeTrue(); alloc.TryAllocate(out ushort b).ShouldBeTrue(); alloc.TryAllocate(out ushort c).ShouldBeTrue(); // Release the middle slot and allocate again. The next allocation should advance // forward from the cursor (3) and not re-use 1 until the cursor wraps and finds it free. alloc.Release(b); alloc.InFlightCount.ShouldBe(2); alloc.TryAllocate(out ushort d).ShouldBeTrue(); d.ShouldBe((ushort)3, "allocator advances the cursor; freed slot 1 reuses only after wrap"); } [Fact] public void Allocate_AllocatesEveryUshort_BeforeWrapping() { var alloc = new TxIdAllocator(); var seen = new HashSet(); for (int i = 0; i < 65536; i++) { alloc.TryAllocate(out ushort id).ShouldBeTrue($"allocation {i} should succeed"); seen.Add(id).ShouldBeTrue($"id {id} should be unique across the full 0..65535 sweep"); } seen.Count.ShouldBe(65536); alloc.InFlightCount.ShouldBe(65536); } [Fact] public void Allocate_WrapsCorrectly_After0xFFFF() { var alloc = new TxIdAllocator(); // Allocate every slot then release slot 5. for (int i = 0; i < 65536; i++) alloc.TryAllocate(out _).ShouldBeTrue(); alloc.Release(5); // Next allocation should find slot 5 after the cursor wraps. alloc.TryAllocate(out ushort id).ShouldBeTrue(); id.ShouldBe((ushort)5); } [Fact] public void Allocate_WhenSaturated_ReturnsFalse_DoesNotThrow() { var alloc = new TxIdAllocator(); for (int i = 0; i < 65536; i++) alloc.TryAllocate(out _).ShouldBeTrue(); alloc.TryAllocate(out ushort id).ShouldBeFalse("saturated allocator must refuse cleanly"); id.ShouldBe((ushort)0); } [Fact] public void Release_OfNonAllocated_IsNoOp() { var alloc = new TxIdAllocator(); alloc.TryAllocate(out ushort a).ShouldBeTrue(); // a == 0. Release a slot that was never allocated. alloc.Release(42); alloc.InFlightCount.ShouldBe(1, "releasing a non-allocated id must not decrement the count"); } [Fact] public async Task Concurrent_AllocateRelease_NoDuplicateIds_Under_Parallel_Stress() { var alloc = new TxIdAllocator(); const int taskCount = 100; const int opsPerTask = 1000; // Each task allocates and immediately releases its id, hammering the lock. // If allocate ever hands out a duplicate, two tasks would see the same id. var observed = new System.Collections.Concurrent.ConcurrentDictionary(); await Task.WhenAll(Enumerable.Range(0, taskCount).Select(_ => Task.Run(() => { for (int i = 0; i < opsPerTask; i++) { if (!alloc.TryAllocate(out ushort id)) continue; // Add a unique tag to detect a duplicate live id. observed.TryAdd(id, 1).ShouldBeTrue(); observed.TryRemove(id, out byte _); alloc.Release(id); } }))); alloc.InFlightCount.ShouldBe(0, "every allocation was released; count must be back to 0"); } [Fact] public void WrapCount_IncrementsOnEachFullWrap() { var alloc = new TxIdAllocator(); alloc.WrapCount.ShouldBe(0); // First sweep: 65536 allocations bring the cursor from 0 back to 0 → one wrap. for (int i = 0; i < 65536; i++) alloc.TryAllocate(out _).ShouldBeTrue(); alloc.WrapCount.ShouldBe(1); // Release everything, then sweep again: should bump WrapCount to 2. for (ushort i = 0; ; i++) { alloc.Release(i); if (i == 65535) break; } for (int i = 0; i < 65536; i++) alloc.TryAllocate(out _).ShouldBeTrue(); alloc.WrapCount.ShouldBe(2); } }