test(render): Phase W Stage 4/5 — assembler OutsideView AABB + PView BFS + entity-clip tests

ClipFrameAssemblerTests (3 new):
- Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds
- Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid
- Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero

PortalVisibilityBuilderTests (3 new):
- Build_ExitPortalVisible_OutsideViewNonEmpty
- Build_NoExitPortal_OutsideViewEmpty
- Build_RootCellAlwaysFirstInOrderedVisibleCells

EntityClipTests (new file, 5 tests):
- EntityClip_ParentInVisibleSet_Included
- EntityClip_ParentNotInVisibleSet_Excluded
- EntityClip_NullVisibleSet_IncludesAll
- EntityClip_NullParentCell_NullVisibleSet_Included
- EntityClip_NullParentCell_NonNullVisibleSet_Included

WbDrawDispatcher.EntityPassesVisibleCellGate changed private → internal static
(AcDream.App already has InternalsVisibleTo AcDream.App.Tests; no new seam needed).

160 → 171 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 16:21:08 +02:00
parent ce2edad66a
commit a8b831c23b
4 changed files with 231 additions and 1 deletions

View file

@ -378,6 +378,61 @@ public class PortalVisibilityBuilderTests
// see docs/research/2026-05-31-u4c-flap-characterization.md.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees
// -----------------------------------------------------------------------
[Fact]
public void Build_ExitPortalVisible_OutsideViewNonEmpty()
{
// A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon
// projects in front of the camera. The BFS must union the clipped region
// into OutsideView, making it non-empty.
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
var frame = Build(cam, all);
Assert.True(frame.OutsideView.Polygons.Count > 0,
"An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView");
}
[Fact]
public void Build_NoExitPortal_OutsideViewEmpty()
{
// A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF).
// With no exit portal in any reachable cell the OutsideView must remain empty.
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
var inner = Cell(0x0002); // no portals — no exit
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
var frame = Build(cam, all);
Assert.True(frame.OutsideView.Polygons.Count == 0,
"No exit portal in any reachable cell must leave OutsideView empty");
}
[Fact]
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
{
// The camera cell seeds the BFS at distance 0 so it must always pop
// first → OrderedVisibleCells[0] == cameraCell.CellId.
// Use the three-cell chain: A→B→C with exit window on C.
var (cells, lookup) = SyntheticChain();
var f = PortalVisibilityBuilder.Build(
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
Assert.NotEmpty(f.OrderedVisibleCells);
Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]);
}
// Note: Build_CyclicPortalGraph_Terminates is already covered by
// Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds.
// The dedup invariant (each cell appears at most once) is validated by
// Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count().
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
private static float CellViewArea(CellView view)
{