acdream/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs
Erik 326b698161 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) <noreply@anthropic.com>
2026-05-09 22:39:16 +02:00

159 lines
6.6 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 AcDream.App.Streaming;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class StreamingRegionTwoTierTests
{
[Fact]
public void Constructor_TwoRadii_ExposesNearAndFarRadii()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 4, farRadius: 12);
Assert.Equal(4, region.NearRadius);
Assert.Equal(12, region.FarRadius);
Assert.Equal(100, region.CenterX);
Assert.Equal(100, region.CenterY);
// Radius (used by existing single-radius hysteresis math) must alias to
// FarRadius — the outer ring drives "everything currently loaded" bookkeeping.
// If a future change mistakenly aliases Radius to NearRadius, hysteresis
// becomes (NearRadius+2) for the far-tier unload, which is wrong.
Assert.Equal(region.FarRadius, region.Radius);
}
[Fact]
public void RecenterTo_FirstTick_SplitsLoadIntoNearAndFar()
{
// near=1, far=3 → near window is 3×3=9, far window is 7×7-3×3=40 LBs.
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
var diff = region.ComputeFirstTickDiff();
Assert.Equal(9, diff.ToLoadNear.Count);
Assert.Equal(40, diff.ToLoadFar.Count);
Assert.Empty(diff.ToPromote);
Assert.Empty(diff.ToDemote);
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Walk one LB east — center (100,100) → (101,100). LB column at lbX=104
// (relative dx=+3 from new center) enters the far window from null.
var diff = region.RecenterTo(newCx: 101, newCy: 100);
foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 })
{
var id = StreamingRegion.EncodeLandblockIdForTest(104, y);
Assert.Contains(id, diff.ToLoadFar);
}
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}");
}
}