acdream/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs
Erik 1853a57c12 feat(diagnostics): complete residency pressure ledger
Complete Slice D4 by adding aggregate lifecycle occupancy and traffic facts, validating physical source reports, and including decoded audio under the typed startup budget. Exercise every domain under forced pressure and retain the real cache/fence convergence gates.
2026-07-24 16:40:17 +02:00

245 lines
8.7 KiB
C#

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;
/// <summary>
/// Covers the payload-LRU eviction added to bound <c>DatSoundCache</c>'s
/// decoded-wave residency (verified-unbounded audit, 2026-07-24): eviction
/// order, byte accounting, negative-result (missing/unsupported) handling,
/// and a concurrency smoke test.
/// </summary>
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<WaveData?>();
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<WaveData?>();
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<uint, Wave> _waves = new();
private readonly Dictionary<uint, int> _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<T>(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<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
T? result = Get<T>(fileId);
if (result is null)
{
value = default!;
return false;
}
value = result;
return true;
}
}
}