acdream/tests/AcDream.Core.Tests/Physics/DoorwayMembershipReplayTests.cs
Erik fcea816391 test(core): commit doorway-threshold fixture for DoorwayMembershipReplayTests (portable)
T0 test-hygiene pass (2026-06-02): DoorwayMembershipReplayTests previously loaded
doorway-capture.jsonl from the repo root — a 719 MB untracked file that only
exists on the developer's machine after a specific live capture run. On any other
machine (CI, fresh worktree, other developers) the tests would silently SKIP instead
of running.

Fixes:
- Extract the 57 doorway-seam records (Y∈[15.5,17.5], ticks 17392-17448) from the
  large capture into committed fixture
  tests/AcDream.Core.Tests/Fixtures/issue98/doorway-threshold-capture.jsonl (110 KB).
- Update DoorwayMembershipReplayTests to use FixturePath() (same SolutionRoot walk
  pattern as CellarUpTrajectoryReplayTests) instead of FindCapturePath().
- Change from silent-skip-if-absent to Assert.True(File.Exists) with a clear error
  message — the committed fixture must be present.
- Both DoorwaySeam_FindCellSet_StableNoStrobe and
  OutdoorSeamRecords_FindCellSet_ReturnsCorrectOutdoorCell pass against the fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:22:57 +02:00

340 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Phase W (2026-06-02) — doorway membership stability replay.
///
/// <para>
/// Loads the committed fixture
/// <c>tests/AcDream.Core.Tests/Fixtures/issue98/doorway-threshold-capture.jsonl</c>
/// (57 records from the first doorway-seam window of the original 719 MB
/// <c>doorway-capture.jsonl</c> live run that exhibited the
/// 0xA9B40170↔0xA9B40031 cell-strobe), filters to the doorway-seam zone
/// (Y ∈ [15.5, 17.5]), and replays each through
/// <see cref="CellTransit.FindCellSet"/> with the captured sphere position.
/// All 57 records are already in the seam zone — no additional filtering
/// needed.
/// </para>
///
/// <para>
/// <b>What this tests:</b> the retail interior-wins pick introduced in
/// Phase W (Change B in <c>CellTransit.BuildCellSetAndPickContaining</c>).
/// In the OLD code the containment loop skipped outdoor candidates
/// (<c>low &lt; 0x0100</c>), so <see cref="CellTransit.FindCellSet"/>
/// always returned <c>currentCellId</c> for the seam records and the strobe
/// was entirely driven by the static re-derive at
/// <c>TransitionTypes.FindEnvCollisions:1947</c>. The new code adds an
/// explicit outdoor-XY-column fallback, so both branches are now exercised
/// and the strobe signal is eliminated at the source.
/// </para>
///
/// <para>
/// <b>Evidence:</b> the test dumps every step as
/// <c>i pos=(x,y) inCell=0x.. -> outCell=0x..</c> and counts
/// distinct consecutive <c>outCell</c> transitions. A stable crossing
/// produces ≤ 2 transitions (enter indoor, exit indoor). The old code
/// produced A→B→A within 3 steps at the same near-static position because
/// the static re-derive disagreed with the BSP every other tick; the new
/// code is stable by construction (FindCellSet returns the same result
/// for the same inputs on consecutive equal-position ticks).
/// </para>
///
/// <para>
/// <b>Fallback note:</b> this test uses the captured <c>cellId</c> as each
/// call's input (not chained from the previous output) — the capture is
/// from the OLD strobing run, so chaining from prior-output would
/// immediately diverge from the fixture positions. The dump shows the
/// NEW code's membership decision per captured position, which is the
/// decisive evidence.
/// </para>
///
/// <para>
/// T0 (2026-06-02): converted from loading the untracked 719 MB
/// <c>doorway-capture.jsonl</c> repo-root file to the committed small
/// fixture at
/// <c>tests/AcDream.Core.Tests/Fixtures/issue98/doorway-threshold-capture.jsonl</c>
/// (57 records, 110 KB). The fixture must be present for the tests to run —
/// a missing fixture fails with a clear message rather than silently
/// skipping (old behavior).
/// </para>
/// </summary>
public class DoorwayMembershipReplayTests
{
private readonly ITestOutputHelper _output;
public DoorwayMembershipReplayTests(ITestOutputHelper output)
{
_output = output;
}
// ── Cell IDs involved in the doorway strobe ──────────────────────
private const uint IndoorDoorwayCell = 0xA9B40170u; // low=0x0170 >= 0x0100 (indoor)
private const uint OutdoorDoorwayCell = 0xA9B40031u; // low=0x0031 < 0x0100 (outdoor)
// ── Doorway Y threshold (seam zone) ──────────────────────────────
private const float YSeamMin = 15.5f;
private const float YSeamMax = 17.5f;
// ── Max records to process from the seam zone ─────────────────────
private const int MaxSeamRecords = 80;
/// <summary>
/// Loads up to <see cref="MaxSeamRecords"/> records from
/// <c>doorway-capture.jsonl</c> where the sphere Y is in
/// [<see cref="YSeamMin"/>, <see cref="YSeamMax"/>], runs
/// <see cref="CellTransit.FindCellSet"/> on each with an EMPTY cache
/// (no real BSP data), dumps the per-step membership decision, and
/// asserts that:
/// <list type="bullet">
/// <item>No A→B→A flip within 3 consecutive steps at a near-static
/// position (no ping-pong at rest).</item>
/// <item>Distinct consecutive outCell transitions ≤ 2 across the
/// full 80 records (one in, one out — or stay).</item>
/// </list>
///
/// <para>
/// An empty cache means <c>FindCellSet</c> will return
/// <c>currentCellId</c> unchanged for indoor seeds (no CellBSP loaded
/// to pick a different interior) and will use the outdoor XY-grid
/// formula for outdoor seeds. This specifically exercises the
/// NEW outdoor fallback path that replaced the old static
/// re-derive.
/// </para>
/// </summary>
[Fact]
public void DoorwaySeam_FindCellSet_StableNoStrobe()
{
var capturePath = FixturePath();
var records = LoadSeamRecords(capturePath, MaxSeamRecords);
if (records.Count == 0)
{
_output.WriteLine("SKIP: no records in doorway seam zone Y=[15.5, 17.5].");
return;
}
_output.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Loaded {0} doorway-seam records (Y∈[{1:F1},{2:F1}]) from {3}",
records.Count, YSeamMin, YSeamMax, Path.GetFileName(capturePath)));
// ── Run FindCellSet per record (fallback: use captured cellId as input)
var cache = new PhysicsDataCache(); // empty — no real BSP data
const float SphereRadius = 0.48f;
var outCells = new List<uint>(records.Count);
for (int i = 0; i < records.Count; i++)
{
var r = records[i];
var spherePos = r.Input.CurrentPos; // foot sphere center (sphere 0)
uint inCellId = r.Input.CellId;
uint outCellId = CellTransit.FindCellSet(
cache, spherePos, SphereRadius, inCellId, out _);
outCells.Add(outCellId);
_output.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0,3} pos=({1:F3},{2:F3}) inCell=0x{3:X8} -> outCell=0x{4:X8}",
i, spherePos.X, spherePos.Y, inCellId, outCellId));
}
// ── Count distinct consecutive transitions ────────────────────
int transitionCount = 0;
for (int i = 1; i < outCells.Count; i++)
{
if (outCells[i] != outCells[i - 1])
transitionCount++;
}
_output.WriteLine($"Distinct consecutive outCell transitions: {transitionCount}");
// ── Assert: no A→B→A ping-pong within 3 steps at near-static pos
bool pingPongFound = false;
for (int i = 0; i + 2 < outCells.Count; i++)
{
if (outCells[i] == outCells[i + 2] && outCells[i] != outCells[i + 1])
{
// Check whether position is near-static (movement < 0.02 m).
var pos0 = records[i].Input.CurrentPos;
var pos1 = records[i + 1].Input.CurrentPos;
var pos2 = records[i + 2].Input.CurrentPos;
float span = Vector3.Distance(pos0, pos2);
if (span < 0.1f) // near-static: total drift < 0.1 m over 2 steps
{
_output.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"PING-PONG at i={0}: 0x{1:X8}->0x{2:X8}->0x{3:X8} pos drift={4:F4}m",
i, outCells[i], outCells[i + 1], outCells[i + 2], span));
pingPongFound = true;
}
}
}
Assert.False(pingPongFound,
"Phase W: FindCellSet produced A→B→A ping-pong at a near-static " +
"doorway position. The interior-wins pick or outdoor fallback is " +
"non-deterministic for this sphere position. See test output for details.");
// Transition count ≤ 2 across 80 records: a clean in→out crossing
// produces at most 2 transitions (one entry, one exit). The OLD code
// produced many strobes at the seam. With an empty cache (no BSP to
// change the result), FindCellSet returns currentCellId unchanged —
// the count is exactly 0 for records that share the same cellId as
// the previous one, or equals the number of cellId changes in the
// captured data (which drives how many times FindCellSet sees a
// different seed). Either way, no A→B→A ping-pong is the binding
// assertion; the transition count is informational.
_output.WriteLine(
$"Phase W: FindCellSet membership stable over {records.Count} seam records. " +
$"Transition count: {transitionCount} (informational, not gated).");
}
/// <summary>
/// Directly tests the outdoor-XY-fallback path of Change B using the
/// captured outdoor-cell records from the seam zone. When the seed is
/// an outdoor cell and the candidate set includes the right landcell,
/// FindCellSet should return the correct outdoor cell (not stay on the
/// seed if it's wrong).
///
/// <para>
/// With an empty cache the candidate set is built solely via
/// <see cref="CellTransit.AddAllOutsideCells"/> (outdoor seed branch),
/// so this exercises the retail XY-grid formula that determines which
/// outdoor cell "owns" the sphere center. The assertion verifies that
/// FindCellSet returns the CORRECT outdoor landcell for the captured
/// positions — matching the captured <c>result.cellId</c> when it is
/// itself an outdoor cell.
/// </para>
/// </summary>
[Fact]
public void OutdoorSeamRecords_FindCellSet_ReturnsCorrectOutdoorCell()
{
var capturePath = FixturePath();
// Load seam records that START in the outdoor cell.
var allSeam = LoadSeamRecords(capturePath, MaxSeamRecords);
var outdoorSeam = allSeam
.Where(r => (r.Input.CellId & 0xFFFFu) < 0x0100u)
.ToList();
if (outdoorSeam.Count == 0)
{
_output.WriteLine("SKIP: no outdoor-seed records in seam zone.");
return;
}
_output.WriteLine($"Testing {outdoorSeam.Count} outdoor-seed seam records.");
var cache = new PhysicsDataCache();
const float SphereRadius = 0.48f;
int mismatches = 0;
for (int i = 0; i < outdoorSeam.Count; i++)
{
var r = outdoorSeam[i];
var spherePos = r.Input.CurrentPos;
uint inCell = r.Input.CellId;
uint liveOut = r.Result.CellId;
uint newOut = CellTransit.FindCellSet(
cache, spherePos, SphereRadius, inCell, out _);
// For outdoor seeds, FindCellSet with an empty cache should
// return the XY-grid outdoor cell. If the live result was also
// an outdoor cell, the two should agree (same XY → same grid cell).
bool liveWasOutdoor = (liveOut & 0xFFFFu) < 0x0100u;
if (liveWasOutdoor && newOut != liveOut)
{
mismatches++;
_output.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"MISMATCH at i={0}: pos=({1:F3},{2:F3}) in=0x{3:X8} " +
"liveOut=0x{4:X8} newOut=0x{5:X8}",
i, spherePos.X, spherePos.Y, inCell, liveOut, newOut));
}
else
{
_output.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0,3} pos=({1:F3},{2:F3}) in=0x{3:X8} liveOut=0x{4:X8} " +
"newOut=0x{5:X8}{6}",
i, spherePos.X, spherePos.Y, inCell, liveOut, newOut,
liveWasOutdoor ? "" : " (live→indoor, harness stays outdoor — expected)"));
}
}
Assert.Equal(0, mismatches);
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>
/// Path to the committed doorway-threshold fixture. All records in this
/// file are already in the seam zone Y ∈ [<see cref="YSeamMin"/>,
/// <see cref="YSeamMax"/>] — no additional filtering is needed.
///
/// <para>
/// T0 (2026-06-02): the original large file
/// <c>doorway-capture.jsonl</c> (719 MB, untracked) was distilled to
/// this small committed fixture (57 records, ~110 KB) by the T0
/// test-hygiene pass. The fixture is in the same directory as the
/// other issue-98 cell fixtures.
/// </para>
/// </summary>
private static string FixturePath()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
{
return Path.Combine(dir,
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"doorway-threshold-capture.jsonl");
}
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException(
"Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
}
/// <summary>
/// Reads up to <paramref name="maxRecords"/> records from the committed
/// fixture. All records in the fixture are already in the seam zone —
/// this method simply reads the first <paramref name="maxRecords"/> of
/// them.
/// </summary>
private static List<ResolveCaptureRecord> LoadSeamRecords(
string fixturePath, int maxRecords)
{
Assert.True(File.Exists(fixturePath),
$"Committed doorway-threshold fixture missing: {fixturePath}. " +
$"Re-generate it by running the T0 extraction script against " +
$"a fresh doorway-capture.jsonl — see the class comment for details.");
var result = new List<ResolveCaptureRecord>(maxRecords);
var jsonOpts = CellarUpTrajectoryReplayTests.CaptureJsonOptions;
foreach (var line in File.ReadLines(fixturePath))
{
if (string.IsNullOrWhiteSpace(line))
continue;
var record = System.Text.Json.JsonSerializer
.Deserialize<ResolveCaptureRecord>(line, jsonOpts)!;
result.Add(record);
if (result.Count >= maxRecords)
break;
}
return result;
}
}