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() { }
}
}