131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import * as THREE from "three";
|
|
import {
|
|
CALIFORNIA_AIR_ROUTE,
|
|
createSceneAircraft,
|
|
type SceneAircraftOptions,
|
|
} from "../aircraft/index.ts";
|
|
|
|
function options(overrides: Partial<SceneAircraftOptions> = {}): SceneAircraftOptions {
|
|
return {
|
|
project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20],
|
|
groundAt: () => 2,
|
|
route: CALIFORNIA_AIR_ROUTE,
|
|
initialPosition: { lat: 34.0522, lng: -118.2437 },
|
|
initialAltitudeM: 1_000,
|
|
initialHeadingDeg: 0,
|
|
fixedStepSeconds: 0.1,
|
|
altitudeSceneUnitsPerMetre: 0.005,
|
|
visualSceneUnitsPerMetre: 0.1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("playable scene aircraft", () => {
|
|
it("projects a stable root and stays inert until activated", () => {
|
|
const aircraft = createSceneAircraft(options());
|
|
const root = aircraft.root;
|
|
assert.equal(root.name, "playable-scene-aircraft");
|
|
assert.ok(root.getObjectByName("electric-aircraft"));
|
|
assert.deepEqual(root.position.toArray(), [( -118.2437 + 121) * 20, 7, -(34.0522 - 36) * 20]);
|
|
assert.equal(root.scale.x, 0.1);
|
|
aircraft.setActions({ throttle: 1, roll: 0.8 });
|
|
aircraft.tick(0.2);
|
|
assert.equal(aircraft.state().elapsedSteps, 0);
|
|
assert.equal(aircraft.root, root);
|
|
aircraft.setActive(true);
|
|
assert.equal(aircraft.actions().throttle, 0, "activation clears stale input");
|
|
aircraft.setActions({ throttle: 1, roll: 0.8 });
|
|
aircraft.tick(0.2);
|
|
assert.equal(aircraft.state().elapsedSteps, 2);
|
|
assert.equal(aircraft.state().mode, "manual");
|
|
assert.equal(aircraft.view.position, aircraft.view.position);
|
|
assert.deepEqual(aircraft.view.position.toArray(), root.position.toArray());
|
|
aircraft.dispose();
|
|
});
|
|
|
|
it("maps heading, pitch, and right-bank roll under Tera's -Z convention", () => {
|
|
const aircraft = createSceneAircraft(options({ active: true }));
|
|
aircraft.setActions({ throttle: 0.7, pitch: 1, roll: 1, yaw: 0.2 });
|
|
aircraft.tick(1);
|
|
const state = aircraft.state();
|
|
assert.equal(aircraft.root.rotation.order, "YXZ");
|
|
assert.ok(Math.abs(aircraft.root.rotation.x - state.pitchDeg * Math.PI / 180) < 1e-12);
|
|
assert.ok(Math.abs(aircraft.root.rotation.y + state.headingDeg * Math.PI / 180) < 1e-12);
|
|
assert.ok(Math.abs(aircraft.root.rotation.z + state.rollDeg * Math.PI / 180) < 1e-12);
|
|
assert.ok(aircraft.root.rotation.z < 0, "positive controller roll presents right wing down");
|
|
aircraft.dispose();
|
|
});
|
|
|
|
it("animates articulated surfaces and fans from authoritative state", () => {
|
|
const aircraft = createSceneAircraft(options({ active: true }));
|
|
const leftAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-left");
|
|
const rightAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-right");
|
|
const leftFan = aircraft.root.getObjectByName("electric-aircraft.fan-left");
|
|
assert.ok(leftAileron && rightAileron && leftFan);
|
|
aircraft.setActions({ throttle: 1, roll: 0.8, pitch: 0.4, yaw: -0.3 });
|
|
aircraft.tick(0.5);
|
|
assert.ok(leftAileron.rotation.x > 0);
|
|
assert.ok(rightAileron.rotation.x < 0);
|
|
assert.notEqual(leftFan.rotation.z, 0);
|
|
aircraft.dispose();
|
|
});
|
|
|
|
it("publishes defensive finite chase poses for north and east headings", () => {
|
|
const north = createSceneAircraft(options({ initialHeadingDeg: 0 }));
|
|
const northPose = north.followPose();
|
|
assert.ok(northPose.position.z > north.root.position.z, "camera is south/behind northbound flight");
|
|
assert.ok(northPose.target.z < north.root.position.z);
|
|
northPose.position.x = 999;
|
|
assert.notEqual(north.followPose().position.x, 999);
|
|
north.dispose();
|
|
|
|
const east = createSceneAircraft(options({ initialHeadingDeg: 90 }));
|
|
const eastPose = east.followPose();
|
|
assert.ok(eastPose.position.x < east.root.position.x);
|
|
assert.ok(eastPose.target.x > east.root.position.x);
|
|
assert.ok(eastPose.position.toArray().every(Number.isFinite));
|
|
assert.ok(eastPose.target.toArray().every(Number.isFinite));
|
|
east.dispose();
|
|
});
|
|
|
|
it("clears edge actions, resumes assistance, and resets controller plus rig", () => {
|
|
const aircraft = createSceneAircraft(options({ active: true }));
|
|
const spawn = aircraft.state();
|
|
aircraft.setActions({ throttle: 1, roll: 1, modeRequest: "manual", reset: false });
|
|
aircraft.tick(0.2);
|
|
assert.equal(aircraft.actions().modeRequest, "none");
|
|
assert.equal(aircraft.actions().throttle, 1);
|
|
aircraft.setActions({ modeRequest: "assisted" });
|
|
aircraft.tick(0.1);
|
|
assert.equal(aircraft.state().mode, "assisted");
|
|
aircraft.reset();
|
|
assert.deepEqual(aircraft.state(), spawn);
|
|
assert.deepEqual(aircraft.actions(), {
|
|
throttle: 0,
|
|
yaw: 0,
|
|
pitch: 0,
|
|
roll: 0,
|
|
modeRequest: "none",
|
|
reset: false,
|
|
});
|
|
aircraft.dispose();
|
|
});
|
|
|
|
it("disposes idempotently and refuses invalid projection/scaling", () => {
|
|
const aircraft = createSceneAircraft(options());
|
|
const parent = new THREE.Group();
|
|
parent.add(aircraft.root);
|
|
aircraft.dispose();
|
|
aircraft.dispose();
|
|
assert.equal(aircraft.root.parent, null);
|
|
const before = aircraft.state();
|
|
aircraft.tick(1);
|
|
assert.deepEqual(aircraft.state(), before);
|
|
assert.throws(() => createSceneAircraft(options({ visualSceneUnitsPerMetre: 0 })), RangeError);
|
|
assert.throws(() => createSceneAircraft(options({ project: () => [Infinity, 0] })), RangeError);
|
|
assert.throws(() => createSceneAircraft(options({ groundAt: () => Number.NaN })), RangeError);
|
|
});
|
|
});
|