// Tests for EnvCellRenderer (Phase A8, 2026-05-28). // These cover the pure data-handling portions of EnvCellRenderer. // The GL-dependent Render() and RenderModernMDIInternal() paths require a // GL context and are visual-verified at the render frame (Task 10). using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Wb; using Xunit; namespace AcDream.App.Tests.Rendering.Wb; public class EnvCellRendererTests { [Fact] public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState() { var ranges = new List(); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4); Assert.Equal( [new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)], ranges); } [Fact] public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries() { var ranges = new List(); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2); Assert.Equal( [ new EnvCellRenderer.MdiDrawRange(2, 0, 3), new EnvCellRenderer.MdiDrawRange(6, 3, 2), new EnvCellRenderer.MdiDrawRange(2, 5, 1), new EnvCellRenderer.MdiDrawRange(2, 9, 2), ], ranges); } [Fact] public void OrderedMdiRanges_IgnoreEmptyCellRanges() { var ranges = new List(); EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0); Assert.Empty(ranges); } // ----------------------------------------------------------------------- // GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103 // ----------------------------------------------------------------------- [Fact] public void GetEnvCellGeomId_DedupBitSet() { var id = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1, 2, 3 }); // Bit 33 (0x2_0000_0000) must be set — distinguishes dedup geom from per-cell ids. Assert.NotEqual(0UL, id & 0x2_0000_0000UL); } [Fact] public void GetEnvCellGeomId_Deterministic() { var s = new List { 1, 2, 3 }; var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s); var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, s); Assert.Equal(a, b); } [Fact] public void GetEnvCellGeomId_DiffersByEnvironmentId() { var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List { 1 }); Assert.NotEqual(a, b); } [Fact] public void GetEnvCellGeomId_DiffersByCellStructure() { var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 8, new List { 1 }); Assert.NotEqual(a, b); } [Fact] public void GetEnvCellGeomId_DiffersBySurfaces() { var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1 }); var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 2 }); Assert.NotEqual(a, b); } // ----------------------------------------------------------------------- // Constructor — pure data, no GL // ----------------------------------------------------------------------- [Fact] public void NewRenderer_NeedsPrepareIsTrue() { // GL and meshManager are null — only valid for pure-data tests (no // Initialize() is called, so no GL calls are made). var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.True(r.NeedsPrepare); } [Fact] public void NewRenderer_NotDisposed() { var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.False(r.IsDisposed); } // ----------------------------------------------------------------------- // RemoveLandblock — pure data path // ----------------------------------------------------------------------- [Fact] public void RemoveLandblock_NonExistent_DoesNotThrow() { var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); // Should silently no-op. r.RemoveLandblock(0xA9B40000u); Assert.True(r.NeedsPrepare); } // ----------------------------------------------------------------------- // GetEnvCellGeomId — additional edge cases // ----------------------------------------------------------------------- [Fact] public void GetEnvCellGeomId_EmptySurfaces_Deterministic() { var a = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List()); var b = EnvCellRenderer.GetEnvCellGeomId(1, 0, new List()); Assert.Equal(a, b); Assert.NotEqual(0UL, a & 0x2_0000_0000UL); } [Fact] public void GetEnvCellGeomId_SurfaceOrderMatters() { var a = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List { 10, 20 }); var b = EnvCellRenderer.GetEnvCellGeomId(1, 1, new List { 20, 10 }); // The hash is order-sensitive (matches WB's foreach loop), so // swapped order should produce a different id. Assert.NotEqual(a, b); } // (Render() requires a GL context — visual-verified in Task 10.) [Fact] public void GpuInstanceUpload_UsesMeshModernMat4Stride() { // mesh_modern.vert declares SSBO InstanceData as exactly one mat4, // so the GPU array stride is 64 bytes. EnvCellRenderer's CPU // InstanceData also carries CellId/Flags for culling/filtering and // is 80 bytes; uploading that struct corrupts every instance after 0. Assert.Equal(64, Marshal.SizeOf()); Assert.Equal(80, Marshal.SizeOf()); var field = typeof(EnvCellRenderer).GetField("_gpuInstanceTransforms", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); Assert.NotNull(field); Assert.Equal(typeof(Matrix4x4[]), field!.FieldType); } // ----------------------------------------------------------------------- // Pool-aliasing regression tests (2026-05-28 audit findings). // // Two interconnected bugs caused the post-Wave-5 visual chaos: // 1. GetPooledList didn't clear reused lists, causing AddRange to grow // pool entries unbounded across frames. // 2. Render's pool cursor reset used `BatchedByCell.Count` (cell count, // a small int with no relation to the pool) instead of WB's // `PostPreparePoolIndex` (the pool high-water mark after Prepare), // pointing Render's GetPooledList back into snapshot-owned lists. // // These tests use reflection to verify the fixes without widening // EnvCellRenderer's public API. If either fix regresses, the // corresponding test fails fast. // ----------------------------------------------------------------------- [Fact] public void Snapshot_PostPreparePoolIndex_IsInitSettable() { // Compile-time guarantee: the field exists and is init-only. // If a future refactor renames or removes it, this test won't compile. var s = new EnvCellVisibilitySnapshot { PostPreparePoolIndex = 42 }; Assert.Equal(42, s.PostPreparePoolIndex); } [Fact] public void Snapshot_PostPreparePoolIndex_DefaultsToZero() { var s = new EnvCellVisibilitySnapshot(); Assert.Equal(0, s.PostPreparePoolIndex); } [Fact] public void GetPooledList_ReusedList_IsClearedBeforeReturn() { // The bug: WB's GetPooledList clears the list before returning so // the merge phase pattern `gfxDict[k] = list; list.AddRange(...)` // populates fresh data. The original port omitted Clear() — each // frame's lists grew unbounded with stale data layered on top. // // Reflection-based test that drives the private GetPooledList + // _poolIndex/_listPool fields. If a future refactor removes the // Clear() call, this test fails. var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); var type = typeof(EnvCellRenderer); var getPooledListMethod = type.GetMethod("GetPooledList", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); Assert.NotNull(getPooledListMethod); var poolIndexField = type.GetField("_poolIndex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); Assert.NotNull(poolIndexField); // First call — creates _listPool[0], _poolIndex 0 → 1. var first = (List)getPooledListMethod!.Invoke(r, null)!; first.Add(new InstanceData()); first.Add(new InstanceData()); Assert.Equal(2, first.Count); // Reset cursor to 0 — simulates the start of the next prepare cycle. poolIndexField!.SetValue(r, 0); // Second call — returns _listPool[0] (same as first). With the fix // it should be cleared. Without the fix the list still has 2 items. var second = (List)getPooledListMethod.Invoke(r, null)!; Assert.Same(first, second); // reuses the same instance Assert.Empty(second); // and the data is gone } [Fact] public void GetPooledList_FreshList_IsAlwaysEmpty() { // Sanity check for the fresh-list branch. _poolIndex past _listPool.Count // should produce a brand-new empty list and grow the pool. var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); var type = typeof(EnvCellRenderer); var getPooledListMethod = type.GetMethod("GetPooledList", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var a = (List)getPooledListMethod!.Invoke(r, null)!; var b = (List)getPooledListMethod.Invoke(r, null)!; Assert.NotSame(a, b); Assert.Empty(a); Assert.Empty(b); } // ----------------------------------------------------------------------- // Prepare gate (2026-07-24) — pure camera-tolerance half. // The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer // R-A2 note) but never survive real camera motion. // ----------------------------------------------------------------------- private static Matrix4x4 ViewProjectionFor(Vector3 eye, Vector3 forward) { var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView( fieldOfView: 1.2f, aspectRatio: 16f / 9f, nearPlaneDistance: 0.1f, farPlaneDistance: 2000f); return view * proj; } [Fact] public void CameraApproximatelyEqual_IdenticalCamera_True() { var eye = new Vector3(120f, 80f, 10f); var vp = ViewProjectionFor(eye, Vector3.UnitX); Assert.True(EnvCellRenderer.CameraApproximatelyEqual(vp, eye, vp, eye)); } [Fact] public void CameraApproximatelyEqual_RestJitter_True() { // The ~36 µm eye rest jitter must NOT dirty the gate. var eye = new Vector3(120.34f, 87.91f, 10.2f); var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f); var a = ViewProjectionFor(eye, Vector3.UnitX); var b = ViewProjectionFor(jittered, Vector3.UnitX); Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, jittered)); } [Fact] public void CameraApproximatelyEqual_SmallRealRotation_False() { // 0.05° of yaw — far below one frame of real mouse motion — must dirty. var eye = new Vector3(120.34f, 87.91f, 10.2f); float yaw = 0.05f * MathF.PI / 180f; var a = ViewProjectionFor(eye, Vector3.UnitX); var b = ViewProjectionFor(eye, new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f)); Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, eye)); } [Fact] public void CameraApproximatelyEqual_SmallRealTranslation_False() { // 5 cm of movement must dirty the gate. var eye = new Vector3(120.34f, 87.91f, 10.2f); var moved = eye + new Vector3(0.05f, 0f, 0f); var a = ViewProjectionFor(eye, Vector3.UnitX); var b = ViewProjectionFor(moved, Vector3.UnitX); Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved)); } [Fact] public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties() { // AC world coordinates reach ~5e4. A relative tolerance applied to the // matrix translation row would mask sub-meter motion at that scale — // the eye epsilon is absolute precisely so this case stays sharp. var eye = new Vector3(40120.34f, 45087.91f, 110.2f); var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame var a = ViewProjectionFor(eye, Vector3.UnitX); var b = ViewProjectionFor(moved, Vector3.UnitX); Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved)); var jittered = eye + new Vector3(36e-6f, 0f, 0f); var c = ViewProjectionFor(jittered, Vector3.UnitX); Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, c, jittered)); } [Fact] public void NewRenderer_SnapshotGenerationStartsAtZero() { var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.Equal(0, r.SnapshotGeneration); } }