using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Tests.Rendering; using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using AcDream.Content; using AcDream.Core.Items; using DatReaderWriter; using DatReaderWriter.Lib.IO; using DatReaderWriter.Options; using Xunit; namespace AcDream.App.Tests.UI; /// /// Hermetic companions to , /// split into their own untagged (no Lane=InstalledDat) class so CI's /// portable filter (.gitea/workflows/ci.yml, Lane!=InstalledDat) /// actually runs them: unlike the rest of that class, these two never touch a /// real DAT — the property under test is DAT-probe call COUNT via a /// bare-bones fake /, /// not decode correctness, so no installed retail DAT directory is required. /// public sealed class RetailMarkupIconResolverMemoizationTests { /// /// A Decal-habit "add the block prefix again" mistake applied to an /// already-full DID: 0x06000165 (Melee Defense's real installed /// icon, SampleData.cs:69) plus another 0x06000000 lands at /// 0x0C000165 — a value almost certainly absent from both Portal /// and HighRes. /// private const uint DecalHabitDoubleNormalizedId = 0x0C000165u; /// /// Residual round finding N4 (perf): /// used to probe the DAT (two cache misses + two B-tree lookups under /// DatDatabaseWrapper's database lock) on EVERY call for an /// unresolvable id — including once per frame from a draw-time icon /// source, forever. These two tests use a bare-bones fake /// / that counts /// TryGet calls directly, rather than the InstalledDat lane /// on — the /// property under test is call COUNT, not decode correctness, so no real /// DAT files are needed here. /// [Fact] public void ResolveDid_RepeatedUnresolvableId_ProbesTheDatExactlyOnce() { var dats = new CountingDatReaderWriter(); var device = new RecordingGpuDevice(); using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); var resolver = new RetailMarkupIconResolver(dats, icons, objects); (uint tex1, int w1, int h1) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); (uint tex2, int w2, int h2) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); (uint tex3, int w3, int h3) = resolver.ResolveDid(DecalHabitDoubleNormalizedId); Assert.Equal((0u, 0, 0), (tex1, w1, h1)); Assert.Equal((0u, 0, 0), (tex2, w2, h2)); Assert.Equal((0u, 0, 0), (tex3, w3, h3)); // Without memoization this would be 3 (one probe pair per call); // with it, the miss is cached after the first resolve. Assert.Equal(1, dats.Portal.TryGetCallCount); Assert.Equal(1, dats.HighRes.TryGetCallCount); } [Fact] public void ResolveDid_DifferentIds_ProbeIndependently() { // A per-id cache must not collapse distinct ids into one entry. var dats = new CountingDatReaderWriter(); var device = new RecordingGpuDevice(); using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); var resolver = new RetailMarkupIconResolver(dats, icons, objects); resolver.ResolveDid(0x06000001u); resolver.ResolveDid(0x06000002u); resolver.ResolveDid(0x06000001u); Assert.Equal(2, dats.Portal.TryGetCallCount); Assert.Equal(2, dats.HighRes.TryGetCallCount); } /// /// 's MISS cache is bounded /// (MaxCachedMisses = 256, FIFO eviction) so a plugin markup /// binding a different bogus/unresolvable DID every frame cannot grow it /// without bound — unlike HIT entries, which are left uncapped because /// that population is bounded by the DAT's own real surface count. Fills /// the cache with 256 distinct misses, proves the oldest is STILL served /// from cache (no re-probe) right up to the cap, then proves a 257th /// distinct miss evicts it — a repeat of the evicted id must re-probe /// the DAT (the call count rises again) rather than serve a stale hit. /// [Fact] public void ResolveDid_TwoHundredFiftySeventhDistinctMiss_EvictsTheFirst() { var dats = new CountingDatReaderWriter(); var device = new RecordingGpuDevice(); using var cache = new TextureCache(device, dats); var icons = new IconComposer(dats, cache); var objects = new ClientObjectTable(); var resolver = new RetailMarkupIconResolver(dats, icons, objects); // Fill the 256-entry miss cache with distinct ids (starting at 1; // did == 0 short-circuits before ever touching the DAT). for (uint id = 1; id <= 256; id++) resolver.ResolveDid(id); Assert.Equal(256, dats.Portal.TryGetCallCount); Assert.Equal(256, dats.HighRes.TryGetCallCount); // Still cached at the cap: a repeat of the OLDEST id must not re-probe. resolver.ResolveDid(1u); Assert.Equal(256, dats.Portal.TryGetCallCount); Assert.Equal(256, dats.HighRes.TryGetCallCount); // A 257th DISTINCT miss pushes the cache over its cap, evicting the // oldest entry (id 1). resolver.ResolveDid(257u); Assert.Equal(257, dats.Portal.TryGetCallCount); Assert.Equal(257, dats.HighRes.TryGetCallCount); // id 1 was evicted: asking again must re-probe the DAT (the call // count rises again), rather than serve the now-gone cache entry. resolver.ResolveDid(1u); Assert.Equal(258, dats.Portal.TryGetCallCount); Assert.Equal(258, dats.HighRes.TryGetCallCount); } /// Always misses (TryGet returns ), /// counting how many times it was asked. private sealed class CountingDatDatabase : IDatDatabase { public int TryGetCallCount { get; private set; } public DatDatabase Db => throw new NotImplementedException(); public int Iteration => 0; public IEnumerable GetAllIdsOfType() where T : IDBObj => throw new NotImplementedException(); public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { TryGetCallCount++; value = default; return false; } public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) => throw new NotImplementedException(); public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) => throw new NotImplementedException(); public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotImplementedException(); public void Dispose() { } } /// /// Only / are exercised by /// 's miss path — every /// other member throws if a future change starts touching it, so this /// fake fails loudly rather than silently returning nonsense. /// private sealed class CountingDatReaderWriter : IDatReaderWriter { public CountingDatDatabase Portal { get; } = new(); public CountingDatDatabase HighRes { get; } = new(); IDatDatabase IDatReaderWriter.Portal => Portal; IDatDatabase IDatReaderWriter.HighRes => HighRes; public string SourceDirectory => string.Empty; public IDatDatabase Cell => throw new NotImplementedException(); public ReadOnlyDictionary CellRegions => throw new NotImplementedException(); public IDatDatabase Language => throw new NotImplementedException(); public IDatDatabase Local => throw new NotImplementedException(); public ReadOnlyDictionary RegionFileMap => throw new NotImplementedException(); public int PortalIteration => 0; public int CellIteration => 0; public int HighResIteration => 0; public int LanguageIteration => 0; public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) => throw new NotImplementedException(); public IEnumerable GetAllIdsOfType() where T : IDBObj => throw new NotImplementedException(); public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotImplementedException(); public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => throw new NotImplementedException(); public IEnumerable ResolveId(uint id) => throw new NotImplementedException(); [return: MaybeNull] public T Get(uint fileId) where T : IDBObj => throw new NotImplementedException(); public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj => throw new NotImplementedException(); public void Dispose() { } } }