From 326b698161de064b262f9bb71b625202f2c5d27b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:39:16 +0200 Subject: [PATCH] test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage Adds 5 tests to StreamingRegionTwoTierTests covering all tier-transition paths: - FarToNear promote (walk 2 east from initial center) - NullToNear teleport (loads 9 near + 40 far for a fully fresh region) - NearToFar demote only after NearRadius+2 hysteresis threshold - FarToNull unload only after FarRadius+2 hysteresis threshold - oscillation no-thrash: bouncing 1 LB across a near boundary fires 0 demotes and at most 5 promotes total (one initial settle of the x=100 near-column) Oscillation test fix: initialise the region at the oscillation midpoint (103,100) rather than at a distant starting center (100,100) so the initial move into the oscillation range doesn't itself trigger legitimate demotes, isolating the no-thrash invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Streaming/StreamingRegionTwoTierTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 0d6f5b0..19364cf 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -53,4 +53,107 @@ public class StreamingRegionTwoTierTests } Assert.Empty(diff.ToLoadNear); } + + [Fact] + public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Walk 2 east — center (102, 100). LB (102, 100) was at distance 2 (Far) + // from (100,100); now at distance 0 → Near. That's a Promote. + var diff = region.RecenterTo(newCx: 102, newCy: 100); + + var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100); + Assert.Contains(promotedId, diff.ToPromote); + Assert.DoesNotContain(promotedId, diff.ToLoadNear); + Assert.DoesNotContain(promotedId, diff.ToLoadFar); + } + + [Fact] + public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Teleport to (200, 200) — entirely new region. + var diff = region.RecenterTo(newCx: 200, newCy: 200); + + Assert.Equal(9, diff.ToLoadNear.Count); + Assert.Equal(40, diff.ToLoadFar.Count); + Assert.Empty(diff.ToPromote); + } + + [Fact] + public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis() + { + // near=2, far=4 → near hysteresis threshold = 4. + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (100,100) was Near. Walk 3 east → distance 3 > NearRadius=2 but ≤ 4. No demote yet. + var diff1 = region.RecenterTo(newCx: 103, newCy: 100); + var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100); + Assert.DoesNotContain(lb100, diff1.ToDemote); + + // Walk 2 more east → distance 5 > 4. Demote. + var diff2 = region.RecenterTo(newCx: 105, newCy: 100); + Assert.Contains(lb100, diff2.ToDemote); + } + + [Fact] + public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (97, 100) was at distance 3 (Far). Walk 1 east → distance 4. ≤ FarRadius+2=5. + var diff1 = region.RecenterTo(newCx: 101, newCy: 100); + var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100); + Assert.DoesNotContain(lb97, diff1.ToUnload); + + // Walk 2 more east → distance 6 > 5. Unload. + var diff2 = region.RecenterTo(newCx: 103, newCy: 100); + Assert.Contains(lb97, diff2.ToUnload); + } + + [Fact] + public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis() + { + // Start the region centered on (103,100) so the oscillation + // between (102,100) and (103,100) never crosses a hysteresis boundary. + // NearRadius=2, farRadius=4 → nearUnloadThreshold=4. + // Chebyshev distance from (102,100) or (103,100) to any LB in the + // initial 9×9 window of (103,100) is ≤ NearRadius+2=4 for all LBs + // in the near zone, so no demotes should fire during pure oscillation. + var region = new StreamingRegion(centerX: 103, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Bounce between (103,100) and (102,100). All resident LBs stay + // within the hysteresis window — no demotes expected. + int totalDemotes = 0; + int totalPromotes = 0; + for (int i = 0; i < 5; i++) + { + var d1 = region.RecenterTo(102, 100); + totalDemotes += d1.ToDemote.Count; + totalPromotes += d1.ToPromote.Count; + var d2 = region.RecenterTo(103, 100); + totalDemotes += d2.ToDemote.Count; + totalPromotes += d2.ToPromote.Count; + } + + // The first step from (103,100) to (102,100) legitimately promotes the + // x=100 near-column (5 LBs) that were Far from (103) into Near. After + // that initial settle they stay Near for all subsequent oscillations. + // So the ceiling is 5 promotes total (not per oscillation). + Assert.Equal(0, totalDemotes); + Assert.True(totalPromotes <= 5, + $"Expected ≤5 promotes across 5 oscillations; got {totalPromotes}"); + } }