// tests/physics.test.mjs — deterministic unit tests for src/physics.js. // Run: node --test tests/physics.test.mjs // // Covers the 7 required SPEC tests plus brutallly-honest extras: // instrument completeness/ranges, 3000 m / 80% cruise envelope, rudder turn // direction consistency, gear-up vs gear-down drag, monotonic fuel, and // exact determinism across substep splits. import test from 'node:test'; import assert from 'node:assert/strict'; import { createAircraft, step, resetAircraft, getInstrumentData, getAeroSummary, } from '../src/physics.js'; import { cfg, atmoDensity } from '../src/config.js'; import { getGroundHeight, WORLD } from '../src/world.js'; const DT = 1 / 120; // caller-guaranteed fixed step (main.js FIXED_DT) const KTS = 1.94384; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Quaternion [w,x,y,z]: level-north (heading 0, wings level) then pitch up. */ export function levelNoseUpQuat(pitchRad) { const qp = Math.cos(pitchRad / 2), sp = Math.sin(pitchRad / 2); const LQ = [0.5, 0.5, 0.5, -0.5]; // level-north (validated vs three.js r160) return [ qp * LQ[0] - sp * LQ[1], qp * LQ[1] + sp * LQ[0], qp * LQ[2] + sp * LQ[3], qp * LQ[3] - sp * LQ[2], ]; } /** * Build a steady level-cruise state at `altM`: lift = weight, thrust = drag, * zero pitching moment, zero rates (analytic trim). Returns the aircraft, the * inputs snapshot that holds the trim, and the design cruise speed Vt. */ export function cruiseState(altM, throttle = 0.7) { const rho = atmoDensity(altM); const W = cfg.mass * cfg.g; const alphaT = 0.010; // rad trim alpha (small positive AOA) const CLt = cfg.CL0 + cfg.CLalpha * alphaT; const Vt = Math.sqrt((2 * W) / (rho * cfg.S * CLt)); // L = W const CDt = cfg.CD0 + cfg.kInd * CLt * CLt; const Dt = 0.5 * rho * Vt * Vt * cfg.S * CDt; const rpmNeeded = Dt / (cfg.Tmax * Math.pow(rho / 1.225, 0.7)); const rpm = Math.max(throttle, rpmNeeded); // keep the engine spooled at set point const deT = -(cfg.Cm0 + cfg.CmAlpha * alphaT) / cfg.Cmδe; // Cm = 0 at q̇=0 const elevT = -deT / cfg.dE_max; // δe = −elev·dE_max (input is the stick) assert.ok(rpm <= 1, `cruise rpm ${rpm} must not exceed 1`); const a = createAircraft(); a.pos = [0, altM, 0]; a.quat = levelNoseUpQuat(alphaT); // Level flight at pitch αt: body velocity has downward-body component +V·sinαt. a.vel = [Vt * Math.cos(alphaT), 0, Vt * Math.sin(alphaT)]; a.rpm = rpm; a.throttle = rpm; a.flaps = 0; a.gearDown = false; a.brakes = false; const inputs = { elevator: elevT, aileron: 0, rudder: 0, throttle: rpm, flaps: 0, gear: false, brakes: false, }; return { a, inputs, Vt }; } function run(a, inputs, seconds, dt = DT) { const n = Math.round(seconds / dt); for (let i = 0; i < n; i++) step(a, inputs, dt); } /** Unwrapped heading change (deg) over the run, monitoring each step. */ function headingDrift(a, inputs, seconds) { let unwrap = 0; let prev = getInstrumentData(a).headingDeg; for (let s = 0; s < Math.round(seconds); s++) { run(a, inputs, 1); const h = getInstrumentData(a).headingDeg; unwrap += (h - prev + 540) % 360 - 180; prev = h; } return unwrap; } // --------------------------------------------------------------------------- // 1. Trim / cruise: 1000 m, throttle 0.7, 20 s → airspeed within ±5% of the // analytic design-cruise speed, and inside 120–180 kts. // --------------------------------------------------------------------------- test('trim/cruise holds design airspeed at 1000 m (70% throttle)', () => { const { a, inputs, Vt } = cruiseState(1000, 0.7); assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180, `design cruise ${(Vt * KTS).toFixed(0)} kts must be in 120-180 kts`); run(a, inputs, 20); const d = getInstrumentData(a); assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05, `airspeed ${d.airspeedMps.toFixed(1)} m/s vs cruise ${Vt.toFixed(1)} ` + `(${(Math.abs(d.airspeedMps - Vt) / Vt * 100).toFixed(1)}%)`); assert.ok(d.airspeedKts >= 120 && d.airspeedKts <= 180, `airspeed ${d.airspeedKts.toFixed(0)} kts must be within 120-180 kts`); // altitude should stay near the cruise level (no uncontrolled dive/climb) assert.ok(Math.abs(d.altM - 1000) < 150, `altitude ${d.altM.toFixed(0)} m drifted`); }); // --------------------------------------------------------------------------- // 2. Stall: full up elevator, 30% throttle → α > α_crit within 25 s, stall // flag true. // --------------------------------------------------------------------------- test('full up elevator at 30% throttle stalls within 25 s', () => { const { a, inputs } = cruiseState(1000, 0.7); const stall = { elevator: 1, aileron: 0, rudder: 0, throttle: 0.3, flaps: 0, gear: false, brakes: false }; let sawStall = false; let maxAlpha = 0; for (let s = 0; s < 25; s++) { run(a, stall, 1); const d = getInstrumentData(a); maxAlpha = Math.max(maxAlpha, Math.abs(d.alphaDeg)); if (d.stall) sawStall = true; } assert.ok(maxAlpha > cfg.alphaCrit * (180 / Math.PI), `alpha never exceeded alpha_crit (max |alpha| ${maxAlpha.toFixed(1)}°)`); assert.ok(sawStall, 'stall flag never became true within 25 s'); }); // --------------------------------------------------------------------------- // 3. Inverted sign: at α = −10° the lift coefficient (and lift force) is // negative — lift points down. // --------------------------------------------------------------------------- test('inverted sign: CL < 0 at alpha = -10 deg (lift points down)', () => { const a = createAircraft(); a.pos = [0, 1000, 0]; a.flaps = 0; a.gearDown = false; const V = 60; const aN = -10 * Math.PI / 180; a.vel = [V * Math.cos(aN), 0, V * Math.sin(aN)]; // vz > 0 → alpha = −10° const s = getAeroSummary(a); assert.ok(Math.abs(s.alpha - aN) < 1e-9, `alpha ${s.alpha} != -10 deg`); assert.ok(s.cl < 0, `cl ${s.cl.toFixed(3)} must be negative at alpha=-10`); assert.ok(s.liftN < 0, `liftN ${s.liftN.toFixed(0)} must point down`); // mirror check: at +10° the sign flips a.vel = [V * Math.cos(-aN), 0, V * Math.sin(-aN)]; const s2 = getAeroSummary(a); assert.ok(s2.cl > 0 && s2.liftN > 0, 'lift must be positive at alpha=+10'); }); // --------------------------------------------------------------------------- // 4. Ground stop: spawn on runway, gear down, brakes → rest in < 400 m and // never sink below ground − 0.1. // --------------------------------------------------------------------------- test('braked ground stop from 30 m/s < 400 m, never below ground', () => { const a = createAircraft(); // spawns on the runway, gear down, brakes on a.rpm = 0; // dead-stick: brakes alone must stop the airframe a.engineOn = false; a.vel = [30, 0, 0]; // north-bound (body +X at heading 0 = world −Z) const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0, flaps: 0.25, gear: true, brakes: true }; const groundAt = getGroundHeight(a.pos[0], a.pos[2]); const start = [a.pos[0], a.pos[2]]; let minY = Infinity; for (let s = 0; s < 60; s++) { run(a, inputs, 1); minY = Math.min(minY, a.pos[1]); } const dist = Math.hypot(a.pos[0] - start[0], a.pos[2] - start[1]); assert.ok(Math.hypot(...a.vel) < 0.05, `still moving: V=${Math.hypot(...a.vel)}`); assert.ok(dist < 400, `stopping distance ${dist.toFixed(0)} m >= 400`); assert.ok(minY >= groundAt - 0.1, `CG sank to ${minY.toFixed(3)} m`); }); // --------------------------------------------------------------------------- // 5. Heavy impact: vertical sink 8 m/s with gear up → crashed. // --------------------------------------------------------------------------- test('vertical impact at 8 m/s with gear up crashes', () => { const a = createAircraft(); a.pos = [0, cfg.wheelH + 2, WORLD.spawn.z]; a.groundContact = false; a.airTime = 1; a.vel = [0, 0, 8]; // body +Z = world down at level attitude a.gearDown = false; const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0, flaps: 0.25, gear: false, brakes: true }; run(a, inputs, 1); assert.ok(a.crashed, 'aircraft must crash on a hard gear-up impact'); assert.ok(a.impactSpeed >= 7, `impactSpeed ${a.impactSpeed.toFixed(2)} should be ~ the 8 m/s sink`); // sanity: gear-down version of the same descent is survivable const b = createAircraft(); b.pos = [0, cfg.wheelH + 2, WORLD.spawn.z]; b.groundContact = false; b.airTime = 1; b.vel = [0, 0, 8]; b.gearDown = true; const insB = { elevator: 0, aileron: 0, rudder: 0, throttle: 0, flaps: 0.25, gear: true, brakes: true }; run(b, insB, 1); // 8 m/s > maxTouchVz (3) with gear down is also a crash by the SPEC rule — // this is just a hard landing, allowed only ≤ 3 m/s. assert.ok(b.crashed, '8 m/s still exceeds the gear-down 3 m/s limit'); }); // --------------------------------------------------------------------------- // 6. Quaternion unit norm after 2000 substeps. // --------------------------------------------------------------------------- test('quaternion stays unit-norm after 2000 substeps', () => { const a = createAircraft(); a.pos = [0, 1200, 0]; a.flaps = 0; a.gearDown = false; a.angVel = [1.2, -0.8, 1.5]; a.vel = [40, 5, -10]; const inputs = { elevator: 0.7, aileron: -0.9, rudder: 0.4, throttle: 0.5, flaps: 0, gear: false, brakes: false }; for (let i = 0; i < 2000; i++) step(a, inputs, 1 / 240); const n = Math.hypot(a.quat[0], a.quat[1], a.quat[2], a.quat[3]); assert.ok(Math.abs(n - 1) <= 1e-6, `|q| = ${n} drifted by ${(n - 1).toExponential(2)}`); }); // --------------------------------------------------------------------------- // 7. Lateral symmetry: 3 s full roll right vs roll left → |bank| within 2%. // (Engine off so prop torque/gyro asymmetry cannot bias the comparison.) // --------------------------------------------------------------------------- test('roll-right vs roll-left for 3 s give symmetric |bank| (≤2%)', () => { function roll(sign) { const a = createAircraft(); a.pos = [0, 1500, 0]; a.flaps = 0; a.gearDown = false; a.rpm = 0; a.throttle = 0; a.engineOn = false; a.vel = [60, 0, 0]; const inputs = { elevator: 0, aileron: sign, rudder: 0, throttle: 0, flaps: 0, gear: false, brakes: false }; run(a, inputs, 3); return Math.abs(getInstrumentData(a).bankDeg); } const bR = roll(1); const bL = roll(-1); const base = Math.min(bR, bL); assert.ok(base > 10, `roll was too weak (|bank| ${base.toFixed(1)}°)`); assert.ok(Math.abs(bR - bL) / base <= 0.02, `|bank| right ${bR.toFixed(1)}° vs left ${bL.toFixed(1)}° differ > 2%`); }); // --------------------------------------------------------------------------- // 8. getInstrumentData completeness + sane ranges (every SPEC field). // --------------------------------------------------------------------------- test('getInstrumentData returns every SPEC field with sane values', () => { const required = [ 'airspeedMps', 'airspeedKts', 'altM', 'altFt', 'vsMps', 'vsFpm', 'headingDeg', 'pitchDeg', 'bankDeg', 'rollRateDps', 'pitchRateDps', 'yawRateDps', 'alphaDeg', 'betaDeg', 'gLoad', 'stall', 'engineOn', 'rpm', 'throttle', 'flapsPos', 'gearDown', 'brakes', 'fuelKg', 'groundContact', 'landed', 'crashed', 'onGround', 'rolling', 'airTimeS', ]; // parked state const parked = getInstrumentData(createAircraft()); for (const k of required) { assert.ok(k in parked, `missing field ${k}`); assert.ok(Number.isFinite(parked[k]) || typeof parked[k] === 'boolean', `non-finite ${k}`); } // airborne cruise state const { a, inputs } = cruiseState(1000, 0.7); run(a, inputs, 5); const d = getInstrumentData(a); for (const k of required) { assert.ok(Number.isFinite(d[k]) || typeof d[k] === 'boolean', `${k} bad`); } assert.ok(d.airspeedKts > 100 && d.airspeedKts < 200, `airspeedKts ${d.airspeedKts}`); assert.ok(d.altM > 900 && d.altM < 1100, `altM ${d.altM}`); assert.ok(d.rpm >= 0 && d.rpm <= 1, `rpm ${d.rpm}`); assert.ok(d.throttle >= 0 && d.throttle <= 1, `throttle ${d.throttle}`); assert.ok(d.flapsPos >= 0 && d.flapsPos <= 1, `flapsPos ${d.flapsPos}`); assert.ok(d.fuelKg > 0 && d.fuelKg <= cfg.fuelKg, `fuelKg ${d.fuelKg}`); assert.ok(Math.abs(d.alphaDeg) < 45, `alphaDeg ${d.alphaDeg}`); assert.ok(Math.abs(d.betaDeg) < 45, `betaDeg ${d.betaDeg}`); assert.ok(d.gLoad > 0.5 && d.gLoad < 3, `gLoad ${d.gLoad}`); assert.ok(d.engineOn === true, 'engine must be running in cruise'); assert.ok(d.crashed === false && d.groundContact === false, 'must be airborne'); }); // --------------------------------------------------------------------------- // 9. Envelope: cruise at 3000 m @ 80% throttle for 30 s stays on-trim, // never stalls, fuel depletes monotonically. // --------------------------------------------------------------------------- test('3000 m / 80% cruise stays within envelope (V, alt, no stall, fuel)', () => { const { a, inputs, Vt } = cruiseState(3000, 0.8); assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180, `3000 m cruise ${Vt * KTS} kts`); let minAlt = Infinity, maxAlt = -Infinity, stallSeen = false; const fuel0 = a.fuelKg; let prevFuel = fuel0; for (let s = 0; s < 60; s++) { // 60 × 0.5 s = 30 s run(a, inputs, 0.5); const d = getInstrumentData(a); minAlt = Math.min(minAlt, d.altM); maxAlt = Math.max(maxAlt, d.altM); if (d.stall) stallSeen = true; assert.ok(d.fuelKg <= prevFuel + 1e-12, 'fuel must never increase'); prevFuel = d.fuelKg; } const d = getInstrumentData(a); assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05, `airspeed ${d.airspeedMps.toFixed(1)} m/s vs ${Vt.toFixed(1)} m/s`); assert.ok(Math.abs(maxAlt - 3000) <= 200 && Math.abs(3000 - minAlt) <= 200, `altitude drifted to [${minAlt.toFixed(0)}, ${maxAlt.toFixed(0)}] m`); assert.ok(!stallSeen, 'cruise must not stall'); const consumed = fuel0 - a.fuelKg; assert.ok(consumed > 0.8, `fuel barely burned (${consumed.toFixed(2)} kg in 30 s @ 80%)`); }); // --------------------------------------------------------------------------- // 10. Rudder: full-ish right rudder for 5 s → consistent right turn // (unwrapped heading advance, every second segment still right). // --------------------------------------------------------------------------- test('right rudder 5 s produces a consistent clockwise turn', () => { const a = createAircraft(); a.pos = [0, 1500, 0]; a.flaps = 0; a.gearDown = false; a.rpm = 0; a.throttle = 0; a.engineOn = false; a.vel = [60, 0, 0]; const inputs = { elevator: 0, aileron: 0, rudder: 0.5, throttle: 0, flaps: 0, gear: false, brakes: false }; let unwrap = 0, prev = getInstrumentData(a).headingDeg; const segments = []; for (let s = 0; s < 5; s++) { run(a, inputs, 1); const h = getInstrumentData(a).headingDeg; const dh = (h - prev + 540) % 360 - 180; segments.push(dh); unwrap += dh; prev = h; } assert.ok(unwrap > 20, `right rudder must turn right (>20°), got ${unwrap.toFixed(1)}°`); assert.ok(segments.every((d) => d >= -1), `heading reversed mid-turn: ${segments.map((x) => x.toFixed(1)).join(', ')}`); // mirror: left rudder must turn left const b = createAircraft(); b.pos = [0, 1500, 0]; b.flaps = 0; b.gearDown = false; b.rpm = 0; b.throttle = 0; b.engineOn = false; b.vel = [60, 0, 0]; const insL = { elevator: 0, aileron: 0, rudder: -0.5, throttle: 0, flaps: 0, gear: false, brakes: false }; const dLeft = headingDrift(b, insL, 5); assert.ok(dLeft < -20, `left rudder must turn left, got ${dLeft.toFixed(1)}°`); }); // --------------------------------------------------------------------------- // 11. Gear drag: at the same state, drag with gear down is measurably larger. // --------------------------------------------------------------------------- test('gear-down drag exceeds gear-up drag (>30%) at cruise speed', () => { const mk = (gear) => { const a = createAircraft(); a.pos = [0, 1500, 0]; a.flaps = 0; a.gearDown = gear; a.rpm = 0.3; a.throttle = 0.3; a.engineOn = true; a.vel = [70, 0, 0]; return a; }; const up = getAeroSummary(mk(false)); const down = getAeroSummary(mk(true)); assert.ok(down.dragN > up.dragN * 1.3, `gear drag too small: up ${up.dragN.toFixed(0)} N vs down ${down.dragN.toFixed(0)} N`); // gear drag also raises total CD measurably assert.ok(down.cd > up.cd + cfg.dCD_gear * 0.5, `CD delta too small: ${up.cd.toFixed(4)} -> ${down.cd.toFixed(4)}`); }); // --------------------------------------------------------------------------- // 12. Fuel depletes monotonically (and monotonically over a long cruise). // --------------------------------------------------------------------------- test('fuel depletes monotonically; enough burned in 120 s cruise', () => { const { a, inputs } = cruiseState(1000, 0.7); const fuel0 = a.fuelKg; let prev = fuel0; for (let i = 0; i < 240; i++) { // 2 s at 1/120 step(a, inputs, 1 / 120); assert.ok(a.fuelKg <= prev + 1e-12, `fuel increased at step ${i}`); prev = a.fuelKg; } assert.ok(fuel0 - a.fuelKg > 0.05, `only ${(fuel0 - a.fuelKg).toFixed(3)} kg burned in 2 s of cruise ` + `(burn rate ${(cfg.fuelBurn * 0.7 * 0.7).toFixed(4)} kg/s)`); }); // --------------------------------------------------------------------------- // 13. Determinism: two runs split differently (1/120 vs 1/240 steps) produce // bit-identical state; NaN/Infinity inputs cannot poison the sim. // --------------------------------------------------------------------------- test('deterministic across substep splits; NaN inputs are harmless', () => { const mk = () => { const a = createAircraft(); a.pos = [0, 1200, 0]; a.flaps = 0; a.gearDown = false; a.rpm = 0.5; a.throttle = 0.5; a.vel = [50, 2, -3]; a.angVel = [0.1, -0.2, 0.3]; return a; }; const inputs = { elevator: 0.2, aileron: 0.3, rudder: -0.1, throttle: 0.5, flaps: 0, gear: false, brakes: false }; const a1 = mk(), a2 = mk(); for (let i = 0; i < 480; i++) step(a1, inputs, 1 / 240); for (let i = 0; i < 240; i++) step(a2, inputs, 1 / 120); for (const k of ['pos', 'vel', 'angVel', 'quat']) { for (let j = 0; j < a1[k].length; j++) { assert.ok(a1[k][j] === a2[k][j], `${k}[${j}] differs: ${a1[k][j]} vs ${a2[k][j]}`); } } // NaN inputs must not poison the state const a3 = createAircraft(); step(a3, { elevator: NaN, aileron: Infinity, rudder: NaN, throttle: NaN }, 1 / 120); const d = getInstrumentData(a3); assert.ok(Number.isFinite(d.pitchDeg) && Number.isFinite(d.bankDeg) && Number.isFinite(d.airspeedMps), 'sim state must stay finite after bad inputs'); }); // --------------------------------------------------------------------------- // 14. resetAircraft: returns the aircraft to the spawn envelope (parked, // level, on the runway, fuel restored). // --------------------------------------------------------------------------- test('resetAircraft restores spawn state exactly', () => { const a = createAircraft(); a.pos = [100, 2000, -500]; a.vel = [40, 5, -3]; a.angVel = [0.5, 0.2, -0.4]; a.fuelKg = 3; a.crashed = true; a.airTime = 99; resetAircraft(a); const d = getInstrumentData(a); assert.deepEqual(a.pos, a.spawnPos); assert.deepEqual(a.quat, a.spawnQuat); assert.ok(Math.hypot(...a.vel) === 0 && Math.hypot(...a.angVel) === 0); assert.ok(a.fuelKg === cfg.fuelKg && a.crashed === false && a.airTime === 0); assert.ok(d.groundContact === true); assert.ok(Math.abs(d.bankDeg) < 1 && Math.abs(d.pitchDeg) < 1, `reset attitude off: pitch ${d.pitchDeg} bank ${d.bankDeg}`); });