acdream/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs
Erik 1658882439 fix(A.5 T4-T6): bootstrap guard + dead enum + test cleanups
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>
2026-05-09 22:49:35 +02:00

166 lines
7 KiB
C#
Raw Permalink 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 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}");
}
}