using Mbproxy.Proxy.Cache; using Shouldly; using Xunit; namespace Mbproxy.Tests.Proxy.Cache; /// /// Phase-11 unit tests for . Cover the load-bearing /// behaviours: set/get round-trip, TTL expiry, write-range invalidation, LRU bounds, LRU /// access ordering, concurrent safety, and disposal semantics. /// [Trait("Category", "Unit")] public sealed class ResponseCacheTests { private static CacheEntry MakeEntry(int ttlMs, byte[]? bytes = null) { var now = DateTimeOffset.UtcNow; bytes ??= [0x03, 0x02, 0x04, 0xD2]; return new CacheEntry( PduBytes: bytes, CachedAtUtc: now, ExpiresAtUtc: now.AddMilliseconds(ttlMs), Length: bytes.Length, LastUsedTick: 0); } [Fact] public void SetThenGet_RoundTrips() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); var key = new CacheKey(1, 0x03, 100, 1); var entry = MakeEntry(ttlMs: 5000); cache.Set(key, entry); cache.TryGet(key, out var got).ShouldBeTrue(); got.PduBytes.ShouldBe(entry.PduBytes); cache.Count.ShouldBe(1); } [Fact] public async Task GetExpiredEntry_ReturnsFalse_AndRemoves() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); var key = new CacheKey(1, 0x03, 100, 1); // 50 ms TTL; sleep past it and read. cache.Set(key, MakeEntry(ttlMs: 50)); cache.Count.ShouldBe(1); await Task.Delay(120, TestContext.Current.CancellationToken); cache.TryGet(key, out _).ShouldBeFalse("expired entries must report miss"); cache.Count.ShouldBe(0, "TryGet on an expired entry must remove it lazily"); } [Fact] public void Invalidate_OverlappingRange_RemovesMatching() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); // Three entries: two overlap a write [105..115), one does not. var overlapA = new CacheKey(1, 0x03, 100, 10); // [100..110) — overlaps low var overlapB = new CacheKey(1, 0x03, 110, 10); // [110..120) — overlaps high var disjoint = new CacheKey(1, 0x03, 200, 10); // [200..210) — disjoint cache.Set(overlapA, MakeEntry(ttlMs: 5000)); cache.Set(overlapB, MakeEntry(ttlMs: 5000)); cache.Set(disjoint, MakeEntry(ttlMs: 5000)); cache.Count.ShouldBe(3); int invalidated = cache.Invalidate(unitId: 1, startAddress: 105, qty: 10); invalidated.ShouldBe(2, "the two overlapping entries must be invalidated"); cache.Count.ShouldBe(1, "the disjoint entry must remain"); cache.TryGet(disjoint, out _).ShouldBeTrue("the disjoint entry must still be retrievable"); } [Fact] public void Invalidate_DifferentUnitId_DoesNotTouch() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 10); cache.Set(key, MakeEntry(ttlMs: 5000)); int invalidated = cache.Invalidate(unitId: 2, startAddress: 100, qty: 10); invalidated.ShouldBe(0, "writes on a different unit ID must not invalidate this entry"); cache.Count.ShouldBe(1); } [Fact] public void Set_AtMaxEntries_EvictsLRU() { using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000); // Insert 3 entries at distinct keys. var k1 = new CacheKey(1, 0x03, 100, 1); var k2 = new CacheKey(1, 0x03, 200, 1); var k3 = new CacheKey(1, 0x03, 300, 1); cache.Set(k1, MakeEntry(5000)); cache.Set(k2, MakeEntry(5000)); cache.Set(k3, MakeEntry(5000)); cache.Count.ShouldBe(3); // 4th insert must evict the LRU — which is k1 (the earliest insert without a hit). var k4 = new CacheKey(1, 0x03, 400, 1); cache.Set(k4, MakeEntry(5000)); cache.Count.ShouldBe(3, "cap held at 3"); cache.TryGet(k1, out _).ShouldBeFalse("k1 was the LRU and must have been evicted"); cache.TryGet(k4, out _).ShouldBeTrue(); } [Fact] public void LRU_TracksAccessOrder_AcrossGetAndSet() { using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000); var k1 = new CacheKey(1, 0x03, 100, 1); var k2 = new CacheKey(1, 0x03, 200, 1); var k3 = new CacheKey(1, 0x03, 300, 1); cache.Set(k1, MakeEntry(5000)); cache.Set(k2, MakeEntry(5000)); cache.Set(k3, MakeEntry(5000)); // Touch k1 — it becomes the most-recently-used. cache.TryGet(k1, out _).ShouldBeTrue(); // The LRU should now be k2 (k3 is fresher than k2 by insertion; k1 was just touched). var k4 = new CacheKey(1, 0x03, 400, 1); cache.Set(k4, MakeEntry(5000)); cache.TryGet(k1, out _).ShouldBeTrue("k1 was just touched — must survive"); cache.TryGet(k2, out _).ShouldBeFalse("k2 was the LRU and must have been evicted"); cache.TryGet(k3, out _).ShouldBeTrue(); cache.TryGet(k4, out _).ShouldBeTrue(); } [Fact] public async Task Concurrent_GetSet_NoDataRace() { using var cache = new ResponseCache(maxEntriesPerPlc: 256, evictionIntervalMs: 5000); // 8 tasks, 500 ops each — overlapping reads and writes on the same key space. // Verifies the cache survives concurrency without exceptions and remains coherent. const int Tasks = 8; const int Ops = 500; var ct = TestContext.Current.CancellationToken; long opsCompleted = 0; await Task.WhenAll(Enumerable.Range(0, Tasks).Select(t => Task.Run(() => { for (int i = 0; i < Ops; i++) { if (ct.IsCancellationRequested) return; var key = new CacheKey(1, 0x03, (ushort)(i & 0xFF), 1); if ((i & 1) == 0) cache.Set(key, MakeEntry(2000)); else cache.TryGet(key, out _); Interlocked.Increment(ref opsCompleted); } }, ct))); opsCompleted.ShouldBe((long)(Tasks * Ops), "every concurrent op must complete without exception"); cache.Count.ShouldBeLessThanOrEqualTo(256, "cap must never be exceeded under concurrent insertion"); } [Fact] public async Task Dispose_StopsEvictionLoop_AndDoesNotThrowOnSubsequentCalls() { var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 100); cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(ttlMs: 50)); await Task.Delay(80, TestContext.Current.CancellationToken); cache.Dispose(); cache.Dispose(); // idempotent // After dispose, no exception on a synchronous probe — but operations are // best-effort; we don't promise correct results post-dispose. The contract is: // disposal must not corrupt state or leak the eviction task. } [Fact] public void Clear_DropsAllEntries_AndReturnsCount() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(5000)); cache.Set(new CacheKey(1, 0x03, 200, 1), MakeEntry(5000)); int dropped = cache.Clear(); dropped.ShouldBe(2); cache.Count.ShouldBe(0); cache.ApproximateBytes.ShouldBe(0); } [Fact] public void ApproximateBytes_TracksSetReplaceAndInvalidate() { using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000); var k1 = new CacheKey(1, 0x03, 100, 1); var k2 = new CacheKey(1, 0x03, 200, 1); cache.Set(k1, MakeEntry(5000, bytes: new byte[10])); cache.Set(k2, MakeEntry(5000, bytes: new byte[20])); cache.ApproximateBytes.ShouldBe(30L); // Replace k1 with a bigger entry. cache.Set(k1, MakeEntry(5000, bytes: new byte[15])); cache.ApproximateBytes.ShouldBe(35L); // Invalidate k1. cache.Invalidate(unitId: 1, startAddress: 100, qty: 1).ShouldBe(1); cache.ApproximateBytes.ShouldBe(20L, "approx-bytes must decrease on invalidate"); } }