using System; using System.Numerics; using AcDream.App.Rendering; using AcDream.Core.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class RetailChaseCameraTests { // ── Heading source ──────────────────────────────────────────────── [Fact] public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector() { var avgVel = Vector3.Zero; float yaw = MathF.PI / 4f; // 45° var h = RetailChaseCamera.ComputeHeading( avgVel, yaw, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, alignToSlope: true); Assert.Equal(MathF.Cos(yaw), h.X, 5); Assert.Equal(MathF.Sin(yaw), h.Y, 5); Assert.Equal(0f, h.Z, 5); } [Fact] public void Heading_MovingOnFlatGround_HeadingIsHorizontalFacing() { // Player moving forward (yaw=0 = +X), on flat ground. Heading // should be the yaw vector — the projection onto (0,0,1)-normal // plane is a no-op since the base is already horizontal. var avgVel = new Vector3(3f, 0f, 0f); var h = RetailChaseCamera.ComputeHeading( avgVel, yaw: 0f, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, alignToSlope: true); Assert.Equal(1f, h.X, 5); Assert.Equal(0f, h.Y, 5); Assert.Equal(0f, h.Z, 5); } [Fact] public void Heading_OnUphillSlope_TiltsWithSlope() { // Player facing +Y (yaw=π/2), walking up a slope rising in +Y. // Slope normal tilts back-up: (0, -0.5, 0.866) (30° rise). // Projection of (0,1,0) onto plane perpendicular to (0,-0.5,0.866): // dot = 1*(-0.5) = -0.5 // projected = (0,1,0) - (0,-0.5,0.866)*(-0.5) = (0, 0.75, 0.433) // normalized → (0, 0.866, 0.5) — slope-aligned heading with +Z tilt. var avgVel = new Vector3(0f, 3f, 1.5f); // moving up the slope var normal = new Vector3(0f, -0.5f, 0.866f); var h = RetailChaseCamera.ComputeHeading( avgVel, yaw: MathF.PI / 2f, isOnGround: true, contactPlaneNormal: normal, alignToSlope: true); Assert.True(h.Z > 0.4f, $"expected slope-aligned +Z tilt, got Z={h.Z}"); Assert.Equal(1f, h.Length(), 4); } [Fact] public void Heading_AirborneJumpingStraightUp_StaysHorizontal() { // Player standing still, then jumps straight up. avgVel.xy is // zero, the horizontal-velocity gate fires → returns the base // facing direction. The vertical-velocity component is ignored. // This is THE bug the contact-plane fix prevents: in the old // code, normalize((0,0,5)) → (0,0,1) → camera basis tilted up. var avgVel = new Vector3(0f, 0f, 5f); var h = RetailChaseCamera.ComputeHeading( avgVel, yaw: 0f, isOnGround: false, contactPlaneNormal: Vector3.Zero, alignToSlope: true); Assert.Equal(1f, h.X, 5); Assert.Equal(0f, h.Y, 5); Assert.Equal(0f, h.Z, 5); } [Fact] public void Heading_AirborneRunningJump_StaysHorizontal() { // Running jump: horizontal velocity nonzero, vertical also // nonzero. Airborne path projects onto world up — strips Z // from the (already horizontal) base heading, no-op. Camera // basis stays horizontal even though player is rising. var avgVel = new Vector3(3f, 0f, 4f); var h = RetailChaseCamera.ComputeHeading( avgVel, yaw: 0f, isOnGround: false, contactPlaneNormal: Vector3.Zero, alignToSlope: true); Assert.Equal(1f, h.X, 5); Assert.Equal(0f, h.Y, 5); Assert.Equal(0f, h.Z, 5); } [Fact] public void Heading_SlopeAlignDisabled_IgnoresVelocityAndContactPlane() { // Pure-vertical velocity + a tilted contact normal — neither // should affect the heading when alignToSlope is off. var avgVel = new Vector3(0f, 0f, 1f); var tiltedNormal = new Vector3(0f, -0.5f, 0.866f); var h = RetailChaseCamera.ComputeHeading( avgVel, yaw: 0f, isOnGround: true, contactPlaneNormal: tiltedNormal, alignToSlope: false); Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0) Assert.Equal(0f, h.Y, 5); Assert.Equal(0f, h.Z, 5); } // ── Basis from heading ──────────────────────────────────────────── [Fact] public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded() { var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f)); Assert.Equal(1f, forward.Length(), 5); Assert.Equal(1f, right.Length(), 5); Assert.Equal(1f, up.Length(), 5); // Orthogonal Assert.Equal(0f, Vector3.Dot(forward, right), 5); Assert.Equal(0f, Vector3.Dot(forward, up), 5); Assert.Equal(0f, Vector3.Dot(right, up), 5); // forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1) Assert.Equal(0f, up.X, 5); Assert.Equal(0f, up.Y, 5); Assert.True(up.Z > 0f); } [Fact] public void Basis_NearVerticalHeading_UsesXFallbackForRight() { // forward nearly straight up (rare; airborne edge case). Must not produce // a zero-length right vector from cross(forward, worldUp). var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f)); Assert.Equal(1f, right.Length(), 5); Assert.Equal(1f, up.Length(), 5); Assert.Equal(0f, Vector3.Dot(right, up), 5); } // ── Velocity ring & averaging ──────────────────────────────────── [Fact] public void VelocityRing_AveragesLastN() { var ring = new Vector3[5]; int count = 0; ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0)); Assert.Equal(5, count); var avg = RetailChaseCamera.AverageVelocity(ring, count); Assert.Equal(1.8f, avg.X, 5); Assert.Equal(0f, avg.Y, 5); Assert.Equal(0f, avg.Z, 5); } [Fact] public void VelocityRing_FifoEvictsOldest() { var ring = new Vector3[5]; int count = 0; // Push 6 entries; oldest (the first 1,0,0) should be evicted. for (int i = 0; i < 5; i++) ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0)); Assert.Equal(5, count); // still capped at 5 // Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8 var avg = RetailChaseCamera.AverageVelocity(ring, count); Assert.Equal(2.8f, avg.X, 5); } [Fact] public void VelocityRing_PartialFillUsesActualCount() { var ring = new Vector3[5]; int count = 0; ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0)); ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0)); Assert.Equal(2, count); var avg = RetailChaseCamera.AverageVelocity(ring, count); Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5 } // ── Damping alpha ──────────────────────────────────────────────── [Fact] public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent() { // stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075 float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f); Assert.Equal(0.075f, alpha, 4); } [Fact] public void DampingAlpha_LargeDtClampsToOne() { float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f); Assert.Equal(1f, alpha); } [Fact] public void DampingAlpha_NegativeOrZero_ClampsToZero() { Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f)); Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f)); } // ── Mouse low-pass ─────────────────────────────────────────────── [Fact] public void MouseFilter_BeyondWindow_OutputsRaw() { float lastDelta = 5f; float lastTime = 0f; float windowSec = 0.25f; float result = RetailChaseCamera.FilterMouseAxis( raw: 10f, weight: 0.5f, nowSec: 1.0f, ref lastDelta, ref lastTime, windowSec); // Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw. Assert.Equal(10f, result, 5); } [Fact] public void MouseFilter_WithinWindow_AveragesWithPrevious() { float lastDelta = 10f; float lastTime = 0f; float windowSec = 0.25f; float result = RetailChaseCamera.FilterMouseAxis( raw: 20f, weight: 0.5f, nowSec: 0.1f, ref lastDelta, ref lastTime, windowSec); // Within window: avg = (10 + 20)/2 = 15. // Output: 20 * 0.5 + 15 * 0.5 = 17.5 Assert.Equal(17.5f, result, 5); } [Fact] public void MouseFilter_WeightZero_OutputsRaw() { float lastDelta = 10f; float lastTime = 0f; float windowSec = 0.25f; float result = RetailChaseCamera.FilterMouseAxis( raw: 20f, weight: 0f, nowSec: 0.1f, ref lastDelta, ref lastTime, windowSec); Assert.Equal(20f, result, 5); } [Fact] public void MouseFilter_WeightOne_OutputsAveraged() { float lastDelta = 10f; float lastTime = 0f; float windowSec = 0.25f; float result = RetailChaseCamera.FilterMouseAxis( raw: 20f, weight: 1f, nowSec: 0.1f, ref lastDelta, ref lastTime, windowSec); // weight=1 → out = avg = 15 Assert.Equal(15f, result, 5); } [Fact] public void MouseFilter_UpdatesLastDeltaAndTime() { float lastDelta = 10f; float lastTime = 0f; float windowSec = 0.25f; float result = RetailChaseCamera.FilterMouseAxis( raw: 20f, weight: 0.5f, nowSec: 0.1f, ref lastDelta, ref lastTime, windowSec); Assert.Equal(result, lastDelta); // last is updated to output Assert.Equal(0.1f, lastTime, 5); // last time advances } // ── Auto-fade translucency ─────────────────────────────────────── [Fact] public void Translucency_AtFarThreshold_IsZero() { Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5); Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5); } [Fact] public void Translucency_MidwayBetweenThresholds_IsHalf() { // Midpoint between 0.20 and 0.45 = 0.325 // t = 1 - (0.20 - 0.325) / (0.20 - 0.45) // = 1 - (-0.125) / (-0.25) // = 1 - 0.5 = 0.5 Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4); } [Fact] public void Translucency_AtNearThreshold_IsOne() { Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5); Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5); Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5); } // ── Update() integration ───────────────────────────────────────── [Fact] public void FirstUpdate_SnapsToTarget() { bool savedAlign = CameraDiagnostics.AlignToSlope; try { var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec cam.Update( playerPosition: new Vector3(10f, 20f, 30f), playerYaw: 0f, // forward = +X playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, // flat dt: 1f / 60f); // Expected target eye: // pivot = (10, 20, 30+1.5=31.5) // forward (yaw=0)= (1, 0, 0) // right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0) // up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1) // viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0) // eye = pivot + right*0 + forward*-5 + up*0 // = (10 - 5, 20, 31.5) = (5, 20, 31.5) Assert.Equal(5f, cam.Position.X, 4); Assert.Equal(20f, cam.Position.Y, 4); Assert.Equal(31.5f, cam.Position.Z, 4); } finally { CameraDiagnostics.AlignToSlope = savedAlign; } } [Fact] public void SecondUpdate_LerpsTowardTarget() { bool savedAlign = CameraDiagnostics.AlignToSlope; float savedTranslation = CameraDiagnostics.TranslationStiffness; float savedRotation = CameraDiagnostics.RotationStiffness; try { var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; CameraDiagnostics.AlignToSlope = false; CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f; // First update at origin: dampedEye = (-5, 0, 1.5). cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f); var firstEye = cam.Position; // Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5). // alpha = 0.45 * (1/60) * 10 = 0.075. // New eye = firstEye + 0.075 * (target - firstEye) // = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5)) // = (-5,0,1.5) + 0.075 * (10,0,0) // = (-4.25, 0, 1.5) cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f); Assert.Equal(-4.25f, cam.Position.X, 3); Assert.Equal(0f, cam.Position.Y, 4); Assert.Equal(1.5f, cam.Position.Z, 4); } finally { CameraDiagnostics.AlignToSlope = savedAlign; CameraDiagnostics.TranslationStiffness = savedTranslation; CameraDiagnostics.RotationStiffness = savedRotation; } } [Fact] public void Translucency_PropertyReflectsCurrentDampedDistance() { bool savedAlign = CameraDiagnostics.AlignToSlope; try { var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f }; CameraDiagnostics.AlignToSlope = false; // Far from pivot — translucency should be 0. cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f); Assert.Equal(0f, cam.PlayerTranslucency, 5); } finally { CameraDiagnostics.AlignToSlope = savedAlign; } } [Fact] public void AdjustDistance_ClampsToRange() { var cam = new RetailChaseCamera { Distance = 5f }; cam.AdjustDistance(-100f); Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance); cam.AdjustDistance(+200f); Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance); } [Fact] public void AdjustPitch_ClampsToRange() { var cam = new RetailChaseCamera { Pitch = 0f }; cam.AdjustPitch(-10f); Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch); cam.AdjustPitch(+10f); Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch); } }