import assert from "node:assert/strict"; import { describe, it } from "node:test"; import * as THREE from "three"; import { AircraftController, ELECTRIC_AIRCRAFT_METRICS, aircraftChaseCameraPose, advanceAircraftFans, buildElectricAircraft, createElectricAircraftMaterials, disposeElectricAircraft, normalizeAircraftActions, replayAircraftInputs, setAircraftControlSurfaces, setAircraftFanRotation, } from "../aircraft/index.ts"; const ROUTE = [ { id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 }, { id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 }, ] as const; describe("procedural electric aircraft", () => { it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => { const rig = buildElectricAircraft(); assert.equal(rig.root.name, "electric-aircraft"); assert.equal(rig.root.userData.forwardAxis, "-Z"); assert.equal(rig.root.userData.aircraftModel, "electric-vtail"); assert.equal(rig.fans.length, 6); assert.equal(rig.ownsMaterials, true); assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length); const bounds = new THREE.Box3().setFromObject(rig.root); const size = bounds.getSize(new THREE.Vector3()); assert.ok(size.x > 10); assert.ok(size.z > 7); assert.ok(size.y > 1.5); assert.ok(rig.leftAileron.position.z <= 0.6, "left aileron remains attached to tapered trailing edge"); assert.ok(rig.rightAileron.position.z <= 0.6, "right aileron remains attached to tapered trailing edge"); const wing = rig.root.getObjectByName("electric-aircraft:wing"); assert.ok(wing instanceof THREE.Mesh); const normals = wing.geometry.getAttribute("normal"); assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y > 0.5), "wing has a readable upper airfoil surface"); assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y < -0.5), "wing is a closed prism that remains visible from below"); assert.ok(rig.root.getObjectByName("electric-aircraft:canopy-spine")); assert.ok(rig.root.getObjectByName("electric-aircraft:nav-left")); assert.ok(rig.root.getObjectByName("electric-aircraft:gear-door-left")); disposeElectricAircraft(rig); assert.equal(rig.root.children.length, 0); }); it("distributes every fan blade evenly around its hub", () => { const rig = buildElectricAircraft(); for (const fan of rig.fans) { const blades = fan.children.filter((child) => child.name.includes(":blade-")); assert.equal(blades.length, 5); const centroid = blades.reduce( (sum, blade) => sum.add(blade.position), new THREE.Vector3(), ).multiplyScalar(1 / blades.length); assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`); for (const blade of blades) { assert.ok(Math.abs(blade.position.length() - 0.25) < 1e-12); const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion); assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7); } } disposeElectricAircraft(rig); }); it("animates opposing ailerons, V-tail surfaces, and electric fans", () => { const rig = buildElectricAircraft(); setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 }); assert.ok(rig.leftAileron.rotation.x > 0); assert.ok(rig.rightAileron.rotation.x < 0); assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x); setAircraftFanRotation(rig, 1); advanceAircraftFans(rig, 0.5); assert.equal(rig.fans[0]?.rotation.z, 1.5); assert.equal(rig.fans[1]?.rotation.z, 1.5); disposeElectricAircraft(rig); }); it("does not dispose caller-owned materials", () => { const materials = createElectricAircraftMaterials(); let disposals = 0; for (const material of Object.values(materials)) { material.addEventListener("dispose", () => { disposals += 1; }); } const rig = buildElectricAircraft({ materials }); assert.equal(rig.ownsMaterials, false); disposeElectricAircraft(rig); assert.equal(disposals, 0); for (const material of Object.values(materials)) material.dispose(); }); }); describe("aircraft controller", () => { it("normalizes device-neutral flight axes", () => { assert.deepEqual(normalizeAircraftActions({ throttle: 5, yaw: -4, pitch: Number.NaN, roll: 2, modeRequest: "manual", reset: true, }), { throttle: 1, yaw: -1, pitch: 0, roll: 1, modeRequest: "manual", reset: true, }); }); it("follows route and altitude deterministically in assisted mode", () => { const options = { route: ROUTE, initialPosition: ROUTE[0], initialHeadingDeg: 0, initialAltitudeM: 900, assistedAltitudeM: 1_300, }; const a = new AircraftController(options); const b = new AircraftController(options); for (let index = 0; index < 600; index += 1) { a.stepFixed(); b.stepFixed(); } assert.deepEqual(a.snapshot(), b.snapshot()); assert.equal(a.state().mode, "assisted"); assert.equal(a.state().routeWaypointId, "san-francisco"); assert.ok(a.state().altitudeM > 900); assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco"); }); it("takes manual input immediately and resumes assistance without a state jump", () => { const controller = new AircraftController({ route: ROUTE }); for (let index = 0; index < 120; index += 1) { controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 }); } assert.equal(controller.state().mode, "manual"); const before = controller.snapshot(); controller.stepFixed({ modeRequest: "assisted", roll: 0.8 }); assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume"); controller.stepFixed({ modeRequest: "assisted" }); assert.equal(controller.state().mode, "assisted"); assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3); assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3); }); it("enforces geographic, altitude, and speed boundaries", () => { const controller = new AircraftController({ mode: "manual", initialPosition: { lat: 100, lng: -200 }, initialAltitudeM: 100_000, initialSpeedMps: 1_000, maximumSpeedMps: 80, }); assert.equal(controller.state().lat, 42.1); assert.equal(controller.state().lng, -124.6); assert.equal(controller.state().altitudeM, 6_000); assert.equal(controller.state().speedMps, 80); for (let index = 0; index < 180; index += 1) { controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 }); } assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1); assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114); assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000); assert.ok(controller.state().speedMps <= 80); assert.equal(controller.state().envelopeContact, true); }); it("models deterministic wind, energy use, lift telemetry, and stalls", () => { const options = { mode: "manual" as const, initialSpeedMps: 20, minimumSpeedMps: 18, stallSpeedMps: 25, windNorthMps: 7, windEastMps: -4, turbulenceMps: 2, batteryCapacityWh: 2_000, fixedStepSeconds: 0.05, }; const a = new AircraftController(options); const b = new AircraftController(options); for (let index = 0; index < 200; index += 1) { a.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 }); b.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 }); } assert.deepEqual(a.snapshot(), b.snapshot()); assert.equal(a.state().stalled, true); assert.ok(a.state().angleOfAttackDeg > 0); assert.ok(a.state().verticalSpeedMps < 0, "a deep low-speed stall loses altitude"); assert.ok(a.state().energyUsedWh > 0); assert.ok(a.state().batteryWh < 2_000); assert.notEqual(a.state().windNorthMps, 7, "deterministic turbulence perturbs scenario wind"); assert.ok(Number.isFinite(a.state().loadFactorG)); }); it("uses deterministic terrain contact and reports a hard landing", () => { const controller = new AircraftController({ mode: "manual", envelope: { minLat: 30, maxLat: 45, minLng: -130, maxLng: -110, minAltitudeM: 0, maxAltitudeM: 6_000, }, initialAltitudeM: 104, initialSpeedMps: 22, minimumSpeedMps: 18, stallSpeedMps: 26, terrainElevationM: () => 100, fixedStepSeconds: 0.1, }); let touched = false; let hard = false; for (let index = 0; index < 120; index += 1) { controller.stepFixed({ throttle: 0, pitch: -1 }); touched ||= controller.state().onGround; hard ||= controller.state().hardLanding; } assert.equal(touched, true); assert.equal(hard, true); assert.equal(controller.state().groundClearanceM, 0); assert.equal(controller.state().altitudeM, 100); assert.equal(controller.state().verticalSpeedMps, 0); }); it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => { const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const; const controller = new AircraftController(options); const spawn = controller.snapshot(); assert.ok(controller.tick(600) <= 15); controller.stepFixed({ throttle: 1, roll: -1 }); controller.stepFixed({ reset: true }); assert.deepEqual(controller.snapshot(), spawn); const frames = [ { steps: 90, actions: { throttle: 0.9, roll: 0.4 } }, { steps: 1, actions: { modeRequest: "assisted" as const } }, { steps: 180 }, ]; assert.deepEqual( replayAircraftInputs(options, frames), replayAircraftInputs(options, frames), ); }); it("derives a finite geographic chase camera behind the aircraft", () => { const controller = new AircraftController({ initialHeadingDeg: 0 }); const camera = aircraftChaseCameraPose(controller.state()); assert.ok(camera.position.lat < controller.state().lat); assert.ok(camera.target.lat > controller.state().lat); assert.ok(camera.position.altitudeM > controller.state().altitudeM); assert.ok(Object.values(camera.position).every(Number.isFinite)); assert.ok(Object.values(camera.target).every(Number.isFinite)); }); it("rejects malformed route and envelope configuration", () => { assert.throws(() => new AircraftController({ route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }], }), /waypoints/); assert.throws(() => new AircraftController({ route: [{ id: "outside-california", lat: 45, lng: -118, altitudeM: 1_000 }], }), /waypoints/); assert.throws(() => new AircraftController({ route: [{ id: "above-envelope", lat: 37, lng: -122, altitudeM: 8_000 }], }), /waypoints/); assert.throws(() => new AircraftController({ envelope: { minLat: 40, maxLat: 30, minLng: -124, maxLng: -114, minAltitudeM: 10, maxAltitudeM: 100, }, }), /envelope/); assert.throws(() => new AircraftController({ terrainElevationM: 3 as unknown as (lat: number, lng: number) => number, }), /terrain/); }); });