Code review on commits 7bcabab/fb6b61e/326b698 flagged 2 Important + 4 Minor issues. Apply all fixes: Important: - Two-tier RecenterTo + MarkResidentFromBootstrap now throw InvalidOperationException on misuse — calling RecenterTo before the bootstrap silently emitted the entire window as fresh loads (no demotes/unloads since _tierResidence was empty), a correctness hazard that produced no exception. Calling MarkResidentFromBootstrap twice silently dropped accumulated tier state. Both now crash loudly via a _bootstrapped flag. - Dropped TierResidence.None from the enum — never assigned, never checked; absence from the dictionary already encodes "not resident." Minor: - Renamed test: RecenterTo_FirstTick_* → ComputeFirstTickDiff_FirstTick_* (the test calls ComputeFirstTickDiff, not RecenterTo). - Strengthened RecenterTo_PlayerWalks_NullToFar_* with assertions for ToPromote.Count==3 (the x=102 column promoting Far→Near) and ToUnload.Empty (everything within hysteresis). - Replaced System.Math.Abs with Math.Abs in new code to match the file's existing `using System;` convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
7 KiB
C#
166 lines
7 KiB
C#
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 ComputeFirstTickDiff_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);
|
||
// The 3 LBs at x=102, y in {99,100,101} were Far from old center
|
||
// (distance 2) and are now Near from new center (distance ≤1).
|
||
// They should land in ToPromote.
|
||
Assert.Equal(3, diff.ToPromote.Count);
|
||
// All resident LBs from the old window are within hysteresis of
|
||
// the new center (max distance 4 ≤ FarRadius+2=5), so nothing unloads.
|
||
Assert.Empty(diff.ToUnload);
|
||
}
|
||
|
||
[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}");
|
||
}
|
||
}
|