using System.Collections.Concurrent; using AcDream.Core.Audio; using AcDream.Core.Content; using DatReaderWriter.DBObjs; using DatReaderWriter.Lib.IO; using System.Diagnostics.CodeAnalysis; using Xunit; namespace AcDream.Core.Tests.Audio; /// /// Covers the payload-LRU eviction added to bound DatSoundCache's /// decoded-wave residency (verified-unbounded audit, 2026-07-24): eviction /// order, byte accounting, negative-result (missing/unsupported) handling, /// and a concurrency smoke test. /// public sealed class DatSoundCacheTests { [Fact] public void GetWave_DecodesAndReturnsPcmWave() { var dats = new FakeDatObjectSource(); dats.AddWave(1, MakePcmWave(1, dataBytes: 100)); var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); WaveData? wave = cache.GetWave(1); Assert.NotNull(wave); Assert.Equal(100, wave!.PcmBytes.Length); Assert.Equal(1, cache.CachedWaveCount); Assert.Equal(100, cache.ResidentWaveBytes); } [Fact] public void GetWave_MissingWave_ReturnsNullAndMemoizesNegativeWithoutRepeatedLookup() { var dats = new FakeDatObjectSource(); // no wave registered for id 7 var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); Assert.Null(cache.GetWave(7)); Assert.Null(cache.GetWave(7)); Assert.Null(cache.GetWave(7)); // The negative-result memo short-circuits GetWave before it ever // reaches the dat lookup a second time. Assert.Equal(1, dats.GetCallCount(7)); Assert.Equal(0, cache.CachedWaveCount); Assert.Equal(0, cache.ResidentWaveBytes); } [Fact] public void GetWave_UnsupportedFormat_ReturnsNullAndDoesNotConsumeByteBudget() { var dats = new FakeDatObjectSource(); dats.AddWave(2, MakeMp3Wave(2)); // WaveDecoder.Decode returns null for MP3 var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); Assert.Null(cache.GetWave(2)); Assert.Equal(0, cache.CachedWaveCount); Assert.Equal(0, cache.ResidentWaveBytes); // Second call hits the negative memo, not a fresh decode of the dat entry. Assert.Null(cache.GetWave(2)); Assert.Equal(1, dats.GetCallCount(2)); } [Fact] public void GetWave_CacheHit_ReturnsSameInstanceWithoutRedecoding() { var dats = new FakeDatObjectSource(); dats.AddWave(1, MakePcmWave(1, dataBytes: 100)); var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); WaveData? first = cache.GetWave(1); WaveData? second = cache.GetWave(1); Assert.Same(first, second); Assert.Equal(1, dats.GetCallCount(1)); } [Fact] public void GetWave_EvictsLeastRecentlyUsedWhenOverBudget() { var dats = new FakeDatObjectSource(); dats.AddWave(1, MakePcmWave(1, dataBytes: 150)); // A dats.AddWave(2, MakePcmWave(2, dataBytes: 150)); // B dats.AddWave(3, MakePcmWave(3, dataBytes: 150)); // C var cache = new DatSoundCache(dats, maxWaveBytes: 300); WaveData? a1 = cache.GetWave(1); WaveData? b1 = cache.GetWave(2); Assert.Equal(2, cache.CachedWaveCount); Assert.Equal(300, cache.ResidentWaveBytes); // exactly at budget, no eviction yet // Touch A so B becomes the least-recently-used entry. Assert.Same(a1, cache.GetWave(1)); // Admitting C (150) would push residency to 450 > 300 — B must go. WaveData? c1 = cache.GetWave(3); Assert.Equal(2, cache.CachedWaveCount); Assert.Equal(300, cache.ResidentWaveBytes); // A and C are still resident (no re-decode); B was evicted (re-decodes on next use). Assert.Same(a1, cache.GetWave(1)); Assert.Equal(1, dats.GetCallCount(1)); Assert.Same(c1, cache.GetWave(3)); Assert.Equal(1, dats.GetCallCount(3)); WaveData? b2 = cache.GetWave(2); Assert.NotNull(b2); Assert.NotSame(b1, b2); // evicted entries decode fresh on next request Assert.Equal(2, dats.GetCallCount(2)); DatSoundCacheDiagnostics diagnostics = cache.Diagnostics; Assert.Equal(2, diagnostics.CachedWaveCount); Assert.Equal(300, diagnostics.ResidentWaveBytes); Assert.Equal(300, diagnostics.BudgetBytes); Assert.True(diagnostics.Hits >= 3); Assert.True(diagnostics.Misses >= 4); Assert.True(diagnostics.Evictions >= 2); } [Fact] public void GetWave_OversizeSingleWave_IsServedButNotRetained() { var dats = new FakeDatObjectSource(); dats.AddWave(9, MakePcmWave(9, dataBytes: 500)); var cache = new DatSoundCache(dats, maxWaveBytes: 100); // budget smaller than the wave itself WaveData? first = cache.GetWave(9); Assert.NotNull(first); Assert.Equal(0, cache.CachedWaveCount); Assert.Equal(0, cache.ResidentWaveBytes); WaveData? second = cache.GetWave(9); Assert.NotNull(second); Assert.NotSame(first, second); // not retained, so this re-decoded Assert.Equal(2, dats.GetCallCount(9)); } [Fact] public void GetWave_ConcurrentSameId_PublishesOneCanonicalWaveAndDecodesOnce() { var dats = new FakeDatObjectSource(); dats.AddWave(1, MakePcmWave(1, dataBytes: 1000)); var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); var results = new ConcurrentBag(); Parallel.For(0, 200, _ => results.Add(cache.GetWave(1))); WaveData?[] all = results.ToArray(); Assert.All(all, Assert.NotNull); WaveData canonical = all[0]!; Assert.All(all, value => Assert.Same(canonical, value)); Assert.Equal(1, dats.GetCallCount(1)); } [Fact] public void GetWave_ConcurrentMissingId_NeverThrowsAndAllReturnNull() { var dats = new FakeDatObjectSource(); // id 5 never registered var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); var results = new ConcurrentBag(); Parallel.For(0, 200, _ => results.Add(cache.GetWave(5))); Assert.All(results, Assert.Null); Assert.Equal(0, cache.CachedWaveCount); } // ── Test fixtures ──────────────────────────────────────────────────── private static byte[] MakePcmHeader(ushort channels = 1, uint rate = 22050, ushort bits = 16) { byte[] h = new byte[18]; h[0] = 0x01; h[1] = 0x00; // fmtTag PCM h[2] = (byte)channels; h[3] = (byte)(channels >> 8); h[4] = (byte)rate; h[5] = (byte)(rate >> 8); h[6] = (byte)(rate >> 16); h[7] = (byte)(rate >> 24); uint avg = rate * channels * (uint)(bits / 8); h[8] = (byte)avg; h[9] = (byte)(avg >> 8); h[10] = (byte)(avg >> 16); h[11] = (byte)(avg >> 24); ushort blockAlign = (ushort)(channels * (bits / 8)); h[12] = (byte)blockAlign; h[13] = (byte)(blockAlign >> 8); h[14] = (byte)bits; h[15] = (byte)(bits >> 8); return h; } private static Wave MakePcmWave(uint id, int dataBytes) => new() { Id = id, Header = MakePcmHeader(), Data = new byte[dataBytes], }; private static Wave MakeMp3Wave(uint id) => new() { Id = id, Header = new byte[18] { 0x55, 0x00, 0x02, 0x00, 0x44, 0xAC, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, Data = new byte[1024], }; private sealed class FakeDatObjectSource : IDatObjectSource { private readonly Dictionary _waves = new(); private readonly Dictionary _callCounts = new(); private readonly object _gate = new(); public void AddWave(uint id, Wave wave) => _waves[id] = wave; public int GetCallCount(uint id) { lock (_gate) return _callCounts.TryGetValue(id, out int count) ? count : 0; } [return: MaybeNull] public T Get(uint fileId) where T : IDBObj { if (typeof(T) == typeof(Wave)) { lock (_gate) { _callCounts.TryGetValue(fileId, out int count); _callCounts[fileId] = count + 1; } return _waves.TryGetValue(fileId, out var wave) ? (T)(object)wave : default; } return default; } public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { T? result = Get(fileId); if (result is null) { value = default!; return false; } value = result; return true; } } }