ModernUO/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
Kamron Batman a706ef1449
fix(ci): run test projects on CI; remove brittle OPL attribute tests (#2513)
## Problem

Three coupled issues, each hiding the next:

1. **CI passed despite failing tests, with no test logs.** ([example run](https://github.com/modernuo/ModernUO/actions/runs/28639143286/job/84931544255) — the `Test` step produced zero output and the job went green.)
2. **Two `EmitsLowerStatReqWhenPassed` tests** fail with `KeyNotFoundException: '1060435'`.
3. Once CI actually ran the tests, **~337 UOContent tests failed** with `FileNotFoundException: tiledata.mul was not found` — the test bootstrap force-loaded copyrighted client data that CI doesn't have.

## Root causes & fixes

### 1. CI ran zero tests (`fix(ci)`)
The `Test` step ran `dotnet test --no-restore`, but the `Build` step only restores/builds `Application` — never the test projects. Without a restore, the test projects have no `project.assets.json`, so `Microsoft.NET.Test.Sdk`'s targets aren't imported, they aren't recognized as test projects, and `dotnet test` runs the `VSTest` target against **zero** projects → no output, **exit 0**.

- Both jobs now run `dotnet test --logger trx --results-directory ./TestResults` (test projects restore and run) **plus a guard** that fails the job if no `.trx` is produced — a permanent backstop against silent zero-test passes.

### 2. Impossible OPL tests (`fix(ci)` + `test(opl)`)
#2501 deliberately emits `LowerStatReq` (`1060435`) **inline in each item**, not in `GetProperties`. A follow-up "fix" dropped the `lowerStatReq:` argument to make the tests compile but left the assertions expecting `1060435`.

- Removed the two impossible tests, then removed the **entire `Tests/PropertyList/` OPL attribute set** from #2501: these assert exact cliloc/value/order of OPL emission per item base — a one-time proof of the #2501 rewire, now a permanent tax on modding (any admin reorder/value change/added line reddens the build). The one non-trivial case (LowerStatReq) is what just broke, because the test was wrong. Inline emission stays covered by the `BaseArmor`/`BaseClothing` tests.

### 3. Tile-data-dependent tests crashed CI (`test(uocontent)`)
UOContent.Tests' collection-fixture constructor force-loaded `tiledata.mul` unconditionally. On CI (no client files) it threw, and xUnit failed **every test in the collection** with the same error — mostly collateral (packet/scheduler/spawner tests that don't need tile data).

- Mirror Server.Tests' graceful pattern: `TestServerInitializer` probes for `tiledata.mul` and only loads tile/multi data (and runs the tile-dependent configure steps) when present, exposing `TileDataLoaded` so the fixture no longer throws.
- Add a shared `TileDataRequirement.SkipIfMissing()` guard and apply it to exactly the **31** pathfinding/multi/AI tests that genuinely need real tile data (`[SkippableFact]`/`[SkippableTheory]`).

## Verification (all local)

| Scenario | Server.Tests | UOContent.Tests |
|---|---|---|
| **Client data absent (CI)** | 726 pass, 17 skip, **0 fail** | 469 pass, 32 skip, **0 fail** |
| **Client data present (dev)** | 726 pass, 0 skip, **0 fail** | 501 pass, 0 skip, **0 fail** |

- Full `dotnet test` exits **0**; TRX files produced; the no-test guard trips (exit 1) only when zero `.trx` are produced.
2026-07-02 22:35:37 -07:00

550 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Reflection;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StepCacheLifecycleTests
{
[Fact]
public void Singleton_IsAvailable()
{
var cache = StepCache.Instance;
Assert.NotNull(cache);
}
[Fact]
public void Clear_OnEmptyCache_LeavesStatsZero()
{
var cache = StepCache.Instance;
cache.Clear();
var stats = cache.GetStats();
Assert.Equal(0, stats.ResidentChunks);
Assert.Equal(0L, stats.Hits);
Assert.Equal(0L, stats.BuildsTotal);
}
[Fact]
public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
Assert.NotNull(map);
// First touch on a chunk that has no resident copy and no lazy reader behind it
// must NOT eagerly build. Caller (BitmapAStarAlgorithm) interprets IsHit=false as
// "use slow path" — pets/hireables passing briefly through a chunk avoid the
// ~700µs BuildChunk cost they'd never amortize.
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
var stats = cache.GetStats();
Assert.Equal(0, stats.ResidentChunks);
Assert.Equal(0L, stats.BuildsTotal);
Assert.Equal(0L, stats.MissesNotBuilt);
Assert.Equal(1L, stats.FallthroughNotBuilt);
}
[SkippableFact]
public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
// First touch defers; second touch inside the promotion window builds + serves.
// Pinned cell (1500, 1600, z=10): mask=0xC1
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(first.IsHit);
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.True(second.IsHit);
Assert.Equal(CacheHitKind.Miss_NotBuilt, second.HitKind);
Assert.Equal((byte)0xC1, second.WalkMask);
Assert.Equal((sbyte)10, second.WalkZ_N);
var stats = cache.GetStats();
Assert.Equal(1, stats.ResidentChunks);
Assert.Equal(1L, stats.MissesNotBuilt);
Assert.Equal(1L, stats.BuildsTotal);
Assert.Equal(1L, stats.FallthroughNotBuilt);
// Third query of same cell → Hit (chunk now resident).
var third = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.True(third.IsHit);
Assert.Equal(CacheHitKind.Hit, third.HitKind);
Assert.Equal((byte)0xC1, third.WalkMask);
}
[Fact]
public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
cache.MissPromotionWindowMs = 1; // 1ms window for testability
var map = Map.Maps[1];
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(first.IsHit);
System.Threading.Thread.Sleep(20); // exceed the window
// Second touch lands outside the window: tracker resets the count to 1, returns
// Fallthrough_NotBuilt again — chunks the player just glanced through don't get
// promoted just because they get re-touched minutes later by an unrelated NPC.
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(second.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
Assert.Equal(0, cache.GetStats().ResidentChunks);
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
}
[SkippableFact]
public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
// Open a pathfind. Multiple TryGetMask calls inside this Find target the same chunk
// (different cells). The promotion gate counts distinct Finds, not raw probes — these
// calls must NOT increment the per-chunk counter, even though there are many of them.
// Without this, A* expansion would trip the gate on the second cell expansion in any
// visited chunk, defeating the whole point of deferred promotion.
cache.BeginFindGeneration();
for (var i = 0; i < 8; i++)
{
// All cells are inside chunk (1500>>4, 1600>>4) = (93, 100).
var lookup = cache.TryGetMask(map, 1500 + i, 1600, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
}
Assert.Equal(0, cache.GetStats().ResidentChunks);
Assert.Equal(0L, cache.GetStats().BuildsTotal);
Assert.Equal(8L, cache.GetStats().FallthroughNotBuilt);
// Begin a NEW Find — this is the second distinct touch under the per-Find gate.
// The chunk now crosses the threshold and promotes.
cache.BeginFindGeneration();
var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.True(promoted.IsHit);
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
Assert.Equal(1, cache.GetStats().ResidentChunks);
Assert.Equal(1L, cache.GetStats().BuildsTotal);
}
[Fact]
public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
cache.MissPromotionWindowMs = 1; // 1ms window for testability
var map = Map.Maps[1];
cache.BeginFindGeneration();
Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit);
System.Threading.Thread.Sleep(20); // exceed window
// Second Find lands outside the window. Even though it's a distinct generation,
// the elapsed-time check resets the counter to 1, so no promotion.
cache.BeginFindGeneration();
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(second.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
Assert.Equal(0, cache.GetStats().ResidentChunks);
}
[Fact]
public void TryGetMask_DistinctChunks_TrackedIndependently()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
// Two different chunks, one touch each — both must defer (each has its own counter).
var chunkA = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
var chunkB = cache.TryGetMask(map, 1600, 1700, sourceZ: 10); // different chunk
Assert.False(chunkA.IsHit);
Assert.False(chunkB.IsHit);
Assert.Equal(0, cache.GetStats().ResidentChunks);
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
}
[Fact]
public void TryGetMask_OffMap_ReturnsFalseFallthrough()
{
var cache = StepCache.Instance;
cache.Clear();
var map = Map.Maps[1];
var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
Assert.Equal((byte)0, lookup.WalkMask);
}
[SkippableFact]
public void MultiCoveredCell_AndHalo_RouteToFallthrough()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately
var map = Map.Maps[1];
// A cell far from any multi serves from the static cache.
Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit);
// Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a
// single-entry list is enough to mark the sector as multi-bearing — the fallthrough
// decision never dereferences the multi, so no real BaseMulti instance is needed.
const int mx = 2000;
const int my = 2000;
var sx = mx >> 4;
var sy = my >> 4;
var sector = map.GetRealSector(sx, sy);
var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(multisField);
var original = multisField.GetValue(sector);
try
{
multisField.SetValue(sector, new List<BaseMulti> { null });
// Cell inside the multi sector → routed to the live path.
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind);
// Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo
// (its mask would otherwise propose an edge into the multi sector).
var boundaryX = sx * 16 - 1; // last tile of sector sx-1; halo (x+1) reaches into sx
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, boundaryX, my, 0).HitKind);
// Two tiles out → interior of the multi-free sector, unaffected.
Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind);
Assert.True(cache.GetStats().FallthroughMulti >= 2);
}
finally
{
multisField.SetValue(sector, original);
}
}
[Fact]
public void MultiZCell_RoutesToFallthrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests
var map = Map.Maps[1];
// Build a chunk first so it exists.
cache.TryGetMask(map, 1500, 1600, 10);
// Snapshot current FallthroughMultiZ in case (1500, 1600) is naturally multi-Z
// in real tile data; we only assert the synthetic injection produces a delta of 1.
var preInjectionFallthroughMultiZ = cache.GetStats().FallthroughMultiZ;
// Inject a multi-Z bit via reflection on the resident chunk.
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
Assert.NotNull(chunksField);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
Assert.True(chunks.ContainsKey(key));
var chunk = chunks[key];
// Inject "this cell has strata but none match the query Z" — proves the cache
// still falls through to slow path when no stratum can answer.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0; // points to a 0-stratum-count entry → no match
var data = new byte[] { 0 };
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
var stats = cache.GetStats();
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
}
[Fact]
public void Tier4Strata_MatchingZ_ReturnsHitFromStratum()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, 10);
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
var chunk = chunks[key];
// Inject one stratum at zCenter=42, walkMask=0b00000011 (N + NE).
// Query at sourceZ=42 must hit and return that stratum's data.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1; // count
data[1] = 42; // zCenter
data[2] = 0b0000_0011; // walkMask (N | NE)
data[3] = 0; // wetMask
data[4] = 42; data[5] = 42; data[6] = 0; data[7] = 0;
data[8] = 0; data[9] = 0; data[10] = 0; data[11] = 0;
data[12] = 0; data[13] = 0; data[14] = 0; data[15] = 0;
data[16] = 0; data[17] = 0; data[18] = 0; data[19] = 0;
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 42);
Assert.True(lookup.IsHit);
Assert.Equal((byte)0b0000_0011, lookup.WalkMask);
Assert.Equal((sbyte)42, lookup.WalkZ_N);
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
}
[Fact]
public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch()
{
// Sanity check: a chunk WITHOUT a swim layer falls through on source-Z mismatch
// exactly like before. Validates we didn't accidentally serve garbage when the
// chunk has no shore cells.
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
// Same cell but query Z far from baked Z → source-Z guard fires.
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 100);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
}
[Fact]
public void SwimLayer_InjectedMatchingZ_ReturnsHitFromSwimLayer()
{
// Inject a synthetic swim layer onto a resident chunk and verify a query at the
// swim source Z routes through the swim-layer fallback, returning the swim mask.
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
var chunk = chunks[key];
chunk.AllocateSwimLayer();
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
chunk.SwimSourceZ[cellIndex] = -5;
chunk.SwimMask[cellIndex] = 0b0000_0011;
chunk.SwimZN_Layer[cellIndex] = -5;
chunk.SwimZNE_Layer[cellIndex] = -5;
// Other directions stay 0 — Mask bits 0 and 1 cover N and NE.
// Query at the chunk's primary SourceZ — primary path serves walk-layer data,
// swim layer not consulted.
var bakedSourceZ = chunk.SourceZ[cellIndex];
var walkLookup = cache.TryGetMask(map, 1500, 1600, bakedSourceZ);
Assert.True(walkLookup.IsHit);
Assert.Equal(CacheHitKind.Hit, walkLookup.HitKind);
// Walk-layer query produces walk-layer walkMask (whatever the bake found), NOT
// the synthetic swim mask we injected.
// Query at the swim source Z — primary source-Z guard fails (|5 bakedZ| > 2
// assuming baked Z is land surface), swim-layer fallback serves with our mask.
if (System.Math.Abs(-5 - bakedSourceZ) <= 2)
{
// Bake landed near water Z — adjust the test to a clearer swim Z.
chunk.SwimSourceZ[cellIndex] = (sbyte)(bakedSourceZ - 20);
}
var swimLookup = cache.TryGetMask(map, 1500, 1600, chunk.SwimSourceZ[cellIndex]);
Assert.True(swimLookup.IsHit);
Assert.Equal(CacheHitKind.Hit, swimLookup.HitKind);
Assert.Equal((byte)0, swimLookup.WalkMask); // walk = 0 at swim Z
Assert.Equal(chunk.SwimMask[cellIndex], swimLookup.WetMask);
Assert.Equal(chunk.SwimZN_Layer[cellIndex], swimLookup.SwimZ_N);
Assert.Equal(chunk.SwimZNE_Layer[cellIndex], swimLookup.SwimZ_NE);
}
[Fact]
public void SwimLayer_InjectedButCellHasNoSentinel_FallsThrough()
{
// Chunk has the swim layer (some other cell is shore), but THIS cell is inland
// (SwimSourceZ = NoSwimLayerCell). Query at non-matching walk Z must fall through,
// not erroneously match -128 against the query.
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
var chunk = chunks[key];
// Allocate layer but leave THIS cell at the sentinel.
chunk.AllocateSwimLayer();
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cellIndex]);
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
// Query at -128 (the sentinel value) — must NOT match. The guard short-circuits
// on the sentinel before computing |sourceZ - SwimSourceZ|.
var lookup = cache.TryGetMask(map, 1500, 1600, sbyte.MinValue);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
}
[Fact]
public void Tier4Strata_NonMatchingZ_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, 10);
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
var chunk = chunks[key];
// Stratum at zCenter=42; query at sourceZ=10 (delta > StepHeight=2). Must fallthrough.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1; data[1] = 42; // zCenter=42, all other bytes 0
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
}
[Fact]
public void LruCap_OverflowEvictsToCap()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MaxResidentChunks = 4;
cache.MissPromotionThreshold = 1;
try
{
var map = Map.Maps[1];
// Build 5 distinct chunks by querying different sectors.
for (var i = 0; i < 5; i++)
{
var x = 1500 + i * 16;
var y = 1600;
cache.TryGetMask(map, x, y, 10);
System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
}
cache.EnforceLruCap();
Assert.Equal(4, cache.GetStats().ResidentChunks);
Assert.True(cache.GetStats().EvictionsByLruCap >= 1L);
// _keysList must stay in lockstep with _chunks. A desync would silently
// break sampled eviction (KeyNotFoundException on stale keys, or a stuck
// resident set on missing keys).
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var keysListField = typeof(StepCache).GetField(
"_keysList",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var keysList = (System.Collections.Generic.List<long>)keysListField.GetValue(cache);
Assert.Equal(chunks.Count, keysList.Count);
foreach (var k in keysList)
{
Assert.True(chunks.ContainsKey(k), $"keysList holds key {k} not in _chunks");
}
}
finally
{
cache.MaxResidentChunks = 8192;
}
}
}