1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/test/flights.test.ts
T
karti 2d87d9f354 The city points at its own buildings, and the sky stops depending on an API
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.

**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.

**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.

That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.

**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.

**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.

**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.

Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 04:18:00 -07:00

689 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `createFlightLayer`: the difference between a snapshot and an observation.
*
* This layer is handed a list of aircraft on the source's timer and has to turn
* that into continuous motion, a trail, and a decision about when something has
* stopped existing. Every bug it has ever had has been one of those three
* confusing the others, and none of them threw: the layer rendered perfectly and
* lied. The worst of them shipped — live ADS-B traffic could not move or grow a
* trail **at all** — and it survived because a still frame of a broken sky and a
* still frame of a correct one are the same picture.
*
* ## What is actually asserted here
*
* The layer exports `group`, `update`, `tick` and `dispose` and nothing else, so
* everything below is observed through the scene graph rather than by reaching
* into `tracks`:
*
* - **Trail length** comes from `flight-trails`'s draw range. `rebuildTrails`
* writes two vertices per segment and one segment per retained observation
* bar the newest, so `drawRange.count / 2` *is* the number of history points
* minus one, for every track being drawn. That is the number the regression
* destroyed, and it is readable without a GL context.
* - **The aircraft's position** is `mesh.position`, which `tick` copies from
* the interpolated head. Reading it a few seconds after an observation is
* how the measured `span` — a private field — becomes observable: a head
* halfway between two positions can only mean the layer believes the leg
* takes twice as long as it has so far had.
* - **Existence** is a mesh's membership of `group`; **being drawn** is
* `mesh.visible`. The two are deliberately different things in this layer
* and the tests keep them different.
*
* ## Time
*
* `nowSeconds()` reads `performance.now()`, and several of the intervals that
* matter here are tens of seconds long — `TRACK_GRACE_SECONDS` alone is 32. A
* test that waited them out would take a minute and would still be racing the
* clock it was waiting on. So the global `performance.now` is replaced with a
* counter for the duration of this file and moved by `at()`. That is legitimate
* rather than a cheat: the layer's only input from the clock is that one call,
* it reads it fresh every time, and every interval under test is defined in
* terms of it. Nothing else in the file touches wall time.
*
* ## The constants are copied, on purpose
*
* `TRAIL_POINTS`, `MAX_TRACKS` and `TRACK_GRACE_SECONDS` are module-private in
* `flights.ts` and are restated below rather than exported for the tests. That
* is the right way round: a test that imports the constant it is checking
* against asserts only that the code is self-consistent, and would follow a
* typo straight into production. These are the numbers the *comments* in
* `flights.ts` argue for, written out again, so that changing one there without
* meaning to fails here loudly.
*/
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import * as THREE from "three";
import SAN_FRANCISCO from "../cities/sf.ts";
import { createFlightLayer, type FlightLayer } from "../engine/flights.ts";
import type { Aircraft } from "../engine/types.ts";
import { World } from "../engine/world.ts";
// ---- The clock -------------------------------------------------------------
let clockMs = 0;
const realNow = performance.now;
before(() => {
performance.now = () => clockMs;
});
after(() => {
performance.now = realNow;
});
/** Put the layer's clock at this many seconds. Absolute, not a delta. */
function at(seconds: number): void {
clockMs = seconds * 1000;
}
// ---- The board -------------------------------------------------------------
/**
* A real `World` over the real San Francisco pack, and **not** a hand-made city.
*
* The constructor is arithmetic — `lngScale`, `metresPerUnit`, `lngSquash` — and
* `World`'s own header states that `project` and `metres` work the instant it
* returns. Nothing here calls `ready()`, `groundAt` or anything else that would
* touch the heightfield, so the half-million-sample build never happens and this
* costs nothing beyond parsing the pack.
*
* Using the shipped pack rather than inventing a city matters for exactly one
* reason, and it is the reason this file exists: `JUMP_UNITS_PER_SECOND = 8` is
* calibrated against *this board's* ~94 m per scene unit, and so are the
* distances every test below feeds in. A toy city with a round `latScale` would
* make the teleport tests pass or fail for arithmetic that no deployment runs.
*/
const world = new World(SAN_FRANCISCO);
/** Mirrors of `flights.ts`'s private constants. See the header. */
const TRAIL_POINTS = 72;
const MAX_TRACKS = 192;
const TRACK_GRACE_SECONDS = 32;
/**
* What a live feed's refresh actually costs, in seconds.
*
* The number the whole regression is about: `HttpFlights` is polled at 1 Hz and
* the server caches for 515 s, so this is how far apart two *distinct*
* positions arrive while the poll interval stays at 1.
*/
const REFRESH = 10;
const POLL = 1;
interface Fixture {
layer: FlightLayer;
/** Retained observations minus one, per drawn track, summed. See the header. */
segments(): number;
/** The trail's vertex buffer, sliced to what is actually drawn. */
drawnPositions(): Float32Array;
/** Aircraft meshes in the group, in the order their tracks were created. */
meshes(): THREE.Mesh[];
}
function fixture(): Fixture {
const layer = createFlightLayer(world);
const line = layer.group.getObjectByName("flight-trails") as THREE.LineSegments;
assert.ok(line, "the layer no longer has a trail line to read");
const position = line.geometry.attributes.position as THREE.BufferAttribute;
return {
layer,
segments: () => line.geometry.drawRange.count / 2,
drawnPositions: () =>
(position.array as Float32Array).subarray(0, line.geometry.drawRange.count * 3),
// `type` rather than `instanceof`: the trail is a `LineSegments`, which is a
// `Line` and not a `Mesh`, so this is exactly the aircraft and nothing else.
meshes: () => layer.group.children.filter((c): c is THREE.Mesh => c.type === "Mesh"),
};
}
/** An airliner at cruise, eastbound. Altitude and heading are rarely the point. */
function jet(id: string, lat: number, lng: number, altitude = 9000): Aircraft {
return { id, callsign: id.toUpperCase(), lat, lng, altitude, heading: 90 };
}
/** Assert a scene position, with a tolerance the `Float32Array` can meet. */
function assertNear(actual: number, expected: number, what: string): void {
assert.ok(
Math.abs(actual - expected) < 0.01,
`${what}: ${actual.toFixed(4)} is not ${expected.toFixed(4)}`,
);
}
// ---- The regression --------------------------------------------------------
/**
* A frozen snapshot polled faster than it refreshes.
*
* This is the shape of every live deployment: `update` is called once a second
* with a list that only changes every ten. Before the repeat-skip in `update`,
* each of those nine identical lists was recorded as a fresh observation, which
* made `span` the poll interval instead of the refresh interval — and then the
* tenth call, the one carrying a real ten seconds of flying, was measured
* against a one-second span, tripped the teleport guard, and **wiped the track's
* entire history**. Every refresh. For every aircraft. Forever.
*
* So the trace below is not a stress case, it is the normal case, and the
* numbers are chosen so that the broken code and the correct code disagree by
* more than a margin: 0.025° of longitude on this board is ~23 scene units,
* which is 2.3 units per second across a real refresh (comfortably under the
* ceiling of 8) and 23 units per second across a poll (comfortably over it).
*/
describe("a source that repeats itself between refreshes", () => {
const LAT = 37.62;
const LNG0 = -122.38;
/** ~23 scene units, i.e. an airliner's ten seconds. */
const LEG = 0.025;
/** Poll at 1 Hz from `from` to `until`, handing back the same aircraft. */
function holdSnapshot(f: Fixture, a: Aircraft, from: number, until: number) {
for (let t = from; t < until; t += POLL) {
at(t);
f.layer.update([a]);
}
}
it("keeps the history it has instead of wiping it on every refresh", () => {
const f = fixture();
at(0);
f.layer.update([jet("aal1", LAT, LNG0)]);
assert.equal(f.segments(), 0, "one observation is a point, not a trail");
holdSnapshot(f, jet("aal1", LAT, LNG0), POLL, REFRESH);
assert.equal(f.segments(), 0, "a repeated position must not become a second sample");
at(REFRESH);
f.layer.update([jet("aal1", LAT, LNG0 + LEG)]);
assert.equal(f.segments(), 1, "the first real leg");
holdSnapshot(f, jet("aal1", LAT, LNG0 + LEG), REFRESH + POLL, REFRESH * 2);
assert.equal(f.segments(), 1, "the leg survived nine more repeats of its own end point");
at(REFRESH * 2);
f.layer.update([jet("aal1", LAT, LNG0 + LEG * 2)]);
/**
* Two legs, which is the entire claim. The old code reached this line with
* an empty history and a draw range of zero: the step from `LNG0 + LEG` to
* `LNG0 + LEG * 2` was measured against the one-second gap to the last
* *repeat* rather than the ten-second gap to the last real position, came
* out at ~23 units per second against a ceiling of 8, and took the
* `track.samples.length = 0` branch. Remove the repeat-skip in `update` and
* this assertion reads `0` — as does every one above it that expects a leg.
*/
assert.equal(f.segments(), 2, "the trail was wiped by a refresh");
});
/**
* The other half of the same bug, and the half a user would describe: the
* aircraft did not move. It sat still for ten seconds and jumped.
*
* `span` is private, so it is read here through its only consequence — where
* the head is. Halfway between the two positions, five seconds after an
* observation, can only mean the layer is spreading the leg over the full
* refresh. With `span` mismeasured as one second the head is pinned at the
* newest sample from the first frame onward (and, in the old code, had no
* trail behind it either).
*/
it("spreads a refresh's worth of movement across the whole refresh", () => {
const f = fixture();
at(0);
f.layer.update([jet("aal1", LAT, LNG0)]);
holdSnapshot(f, jet("aal1", LAT, LNG0), POLL, REFRESH);
at(REFRESH);
f.layer.update([jet("aal1", LAT, LNG0 + LEG)]);
const [mesh] = f.meshes();
assert.ok(mesh, "the aircraft has no mesh");
const [x0] = world.project(LAT, LNG0);
const [x1] = world.project(LAT, LNG0 + LEG);
// At the instant of an observation the aircraft is at the *previous* one.
// That is the deliberate one-interval lag: the layer interpolates between
// the last two observations rather than extrapolating past the newest, so
// nothing ever overshoots and snaps back when a feed stutters.
assertNear(mesh.position.x, x0, "the leg should start where the last one ended");
at(REFRESH + REFRESH / 2);
f.layer.tick();
assertNear(mesh.position.x, (x0 + x1) / 2, "the aircraft is not halfway along its leg");
// And it arrives rather than overshooting: `tick` clamps, so polling late
// parks the aircraft on the observation instead of flying it past.
at(REFRESH * 3);
f.layer.tick();
assertNear(mesh.position.x, x1, "the aircraft overshot the observation it was heading for");
});
});
// ---- The guard the repeat-skip works alongside -----------------------------
/**
* The teleport check still has to fire, and the case it exists for is real: a
* `SimulatedFlights` route reaching the end of its leg reappears at the start,
* which on this board is several hundred scene units between two consecutive
* polls. Drawn, it is a bright line straight across San Francisco.
*
* This is the test that stops the fix above from being "delete the guard". The
* repeat-skip changed *what* `span` measures; it must not have changed what
* counts as impossible.
*/
describe("a simulator route wrapping", () => {
it("still clears the history rather than drawing a line across the map", () => {
const f = fixture();
const lng = -122.42;
// Three observations up the peninsula: ~9 units a leg, ~0.9 units a second.
at(0);
f.layer.update([jet("sim-1", 37.60, lng)]);
at(REFRESH);
f.layer.update([jet("sim-1", 37.608, lng)]);
at(REFRESH * 2);
f.layer.update([jet("sim-1", 37.616, lng)]);
assert.equal(f.segments(), 2, "the track should have two legs before it wraps");
// The leg ends and the route restarts at its origin: 0.4° of latitude is
// ~472 scene units, i.e. ~47 units a second against a ceiling of 8.
at(REFRESH * 3);
f.layer.update([jet("sim-1", 37.216, lng)]);
assert.equal(f.segments(), 0, "a wrapped route dragged its old trail across the board");
// The aircraft itself survives — it is the *history* that belonged to a
// different part of the leg, not the track.
const [mesh] = f.meshes();
assert.ok(mesh, "the wrap deleted the aircraft as well as its trail");
const [, z] = world.project(37.216, lng);
assertNear(mesh.position.z, z, "the aircraft did not restart at the head of its route");
});
/**
* And a wrap is not a repeat, which is the interaction worth pinning: the
* repeat-skip runs first, so a guard that only ever saw distinct positions
* would be dead code if `samePosition` were ever loosened into a "did it move
* much" test. It is not, and this is what would notice.
*/
it("is not mistaken for the source repeating itself", () => {
const f = fixture();
at(0);
f.layer.update([jet("sim-1", 37.60, -122.42)]);
at(REFRESH);
f.layer.update([jet("sim-1", 37.60, -122.42)]);
assert.equal(f.segments(), 0, "an unmoved aircraft has nothing to draw");
at(REFRESH * 2);
f.layer.update([jet("sim-1", 37.20, -122.42)]);
assert.equal(f.segments(), 0, "the wrap was recorded as a leg");
});
});
// ---- Targets that go quiet -------------------------------------------------
/**
* `TRACK_GRACE_SECONDS`, from both ends.
*
* An ADS-B receiver losing line of sight for one refresh is routine, and the
* layer used to answer it by deleting the track — throwing away up to
* `TRAIL_SECONDS` of history to survive a gap of one. What makes the grace worth
* having is not that the mesh stays in the group, it is that the history does,
* so the two tests below are "does it come back with its trail" and "does it
* ever actually leave".
*/
describe("an aircraft missing from a snapshot", () => {
const OTHER = "ual2";
const LOST = "swa9";
/** Two aircraft, both with a leg behind them, at t = 0 and t = REFRESH. */
function pair(f: Fixture) {
at(0);
f.layer.update([jet(OTHER, 37.70, -122.40), jet(LOST, 37.50, -122.30)]);
at(REFRESH);
f.layer.update([jet(OTHER, 37.70, -122.375), jet(LOST, 37.50, -122.275)]);
assert.equal(f.meshes().length, 2);
assert.equal(f.segments(), 2, "one leg each");
}
it("survives a gap shorter than the grace period", () => {
const f = fixture();
pair(f);
// Gone from every snapshot from here on. The first one is what sets
// `missingSince`, so the clock that matters starts at 2 × REFRESH.
const lostAt = REFRESH * 2;
at(lostAt);
f.layer.update([jet(OTHER, 37.70, -122.35)]);
at(lostAt + TRACK_GRACE_SECONDS - 1);
f.layer.update([jet(OTHER, 37.70, -122.325)]);
assert.equal(f.meshes().length, 2, "a target one second inside the grace period was dropped");
});
it("is forgotten once the grace period is past", () => {
const f = fixture();
pair(f);
const lostAt = REFRESH * 2;
at(lostAt);
f.layer.update([jet(OTHER, 37.70, -122.35)]);
const gone = f.meshes()[1];
assert.ok(gone, "the second aircraft has no mesh to lose");
at(lostAt + TRACK_GRACE_SECONDS + 1);
f.layer.update([jet(OTHER, 37.70, -122.325)]);
assert.equal(f.meshes().length, 1, "a target well past the grace period is still here");
assert.equal(gone.parent, null, "the mesh was dropped from `tracks` but left in the scene");
});
/**
* The point of holding the track at all: a target that comes back inside the
* window **resumes**. Rebuilding is the failure this replaced, and it is
* invisible in a screenshot — the aircraft is in the right place either way,
* it is just dragging a stub instead of the minute of history it had.
*/
it("resumes its trail rather than rebuilding it", () => {
const f = fixture();
pair(f);
const lostAt = REFRESH * 2;
at(lostAt);
f.layer.update([jet(OTHER, 37.70, -122.35)]);
// Back after 21 s away, having flown on: ~0.028° of longitude is ~26 units,
// spread over a span the layer clamps to `MAX_SPAN`, so ~0.9 units a second
// and nothing like a teleport.
at(lostAt + 21);
f.layer.update([jet(OTHER, 37.70, -122.325), jet(LOST, 37.50, -122.247)]);
/**
* Five legs across the two aircraft: three for the one that never left
* (four observations), and two for the one that came back — its original
* leg, still there, plus the long one it flew while nobody could hear it.
*
* A rebuilt track is what this number is really measuring. Delete the grace
* period and the returning aircraft arrives as a brand-new track with one
* observation and no trail at all, and this reads 3.
*/
assert.equal(f.segments(), 5, "the returning aircraft rebuilt its trail from nothing");
});
});
// ---- The frozen ghost ------------------------------------------------------
/**
* Holding a track is not the same as going on drawing the aeroplane.
*
* With the grace period in and this half missing, a target that genuinely left
* the feed hung in the air at full opacity, trail attached, for thirty-two
* seconds — indistinguishable from an aircraft that had stopped flying. The
* godmode traffic dial made it unmissable: 400 fabricated aircraft turned down
* to zero left 400 darts nailed to the sky.
*
* The fix is gated on having run out of interpolation rather than on being
* missing, which is what this pair of assertions is really about: the *first*
* one is the one that would catch an over-eager fix, because hiding a target the
* instant it is absent makes every aircraft blink on a single dropped refresh.
*/
describe("an aircraft that has left the feed", () => {
const LAT = 37.66;
const LNG = -122.30;
const LEG = 0.02;
function departing(): { f: Fixture; mesh: THREE.Mesh; x0: number; x1: number } {
const f = fixture();
at(0);
f.layer.update([jet("dal4", LAT, LNG)]);
at(REFRESH);
f.layer.update([jet("dal4", LAT, LNG + LEG)]);
const [mesh] = f.meshes();
assert.ok(mesh);
const [x0] = world.project(LAT, LNG);
const [x1] = world.project(LAT, LNG + LEG);
return { f, mesh, x0, x1 };
}
it("keeps flying to where it was last seen, without blinking", () => {
const { f, mesh, x0, x1 } = departing();
at(REFRESH + 1);
f.layer.update([]); // the snapshot it is missing from
assert.equal(mesh.visible, true, "one absent snapshot must not make an aircraft blink");
at(REFRESH + REFRESH / 2);
f.layer.tick();
assert.equal(mesh.visible, true, "hidden while it was still arriving");
assertNear(mesh.position.x, (x0 + x1) / 2, "a missing aircraft stopped moving early");
assert.equal(f.segments(), 1, "its trail should still be drawn while it is");
});
it("stops being drawn once it has finished arriving", () => {
const { f, mesh } = departing();
at(REFRESH + 1);
f.layer.update([]);
// Past the end of the leg — `span` is REFRESH and the newest observation was
// at REFRESH — but still well inside the grace period, so nothing has been
// deleted and this is purely about what is drawn.
at(REFRESH * 3);
f.layer.tick();
assert.equal(mesh.visible, false, "a target that left the feed is frozen in mid-air");
assert.equal(f.segments(), 0, "its trail is still being drawn under a hidden aircraft");
assert.equal(f.meshes().length, 1, "the track itself should be held, not deleted");
assert.notEqual(mesh.parent, null, "the mesh left the group before its grace ran out");
});
});
// ---- The ceiling -----------------------------------------------------------
describe("more aircraft than the trail buffer was sized for", () => {
/**
* `MAX_TRACKS` was declared and then referenced only by the buffer sizing, so
* `tracks` grew without limit and `rebuildTrails` ran off the end of the
* vertex array — which stopped being theoretical the moment the godmode dial
* could put four hundred aircraft in the sky.
*
* Note which half is dropped. Every aircraft keeps its dart; what the ones
* past the ceiling lose is the trail, because a sky missing eight trails reads
* as a sky, and a sky missing eight aeroplanes reads as a bug.
*/
it("gives every aircraft a dart and the first MAX_TRACKS of them a trail", () => {
const f = fixture();
const count = MAX_TRACKS + 8;
const flock = (dLng: number) =>
Array.from({ length: count }, (_, i) =>
jet(`ac${i}`, 37.4 + (i % 20) * 0.01, -122.6 + Math.floor(i / 20) * 0.01 + dLng),
);
at(0);
f.layer.update(flock(0));
at(REFRESH);
f.layer.update(flock(0.02));
assert.equal(f.meshes().length, count, "aircraft past the ceiling lost their dart, not their trail");
// One leg each, so the segment count is the number of tracks being drawn.
assert.equal(f.segments(), MAX_TRACKS, `${count} tracks should draw ${MAX_TRACKS} trails`);
});
/**
* A track longer than the buffer remembers loses its **oldest** end.
*
* The trail is written tail-first, so the natural way to write this loop —
* stop when the buffer is full — drops the segments nearest the aircraft, and
* that is the worst possible end to lose: a streak left floating in open air
* with no aeroplane attached to it reads as a rendering fault rather than as a
* shortened trail.
*
* The same ordering governs `trim`, which is the path that is actually
* reachable here — see the note below the test — so the property is asserted
* where it bites: after more observations than `TRAIL_POINTS`, the drawn range
* still *ends* on the aircraft, and what it no longer contains is the
* beginning of the flight.
*
* Two-second polls rather than ten, so that the count limit is what binds and
* not `TRAIL_SECONDS`: 92 observations at 2 s is 184 s of history against a
* 240 s ceiling, so every sample dropped below is dropped for being old in
* *rank*, which is the thing under test.
*/
it("drops the beginning of a long flight and keeps the end attached to the aircraft", () => {
const f = fixture();
const observations = TRAIL_POINTS + 20;
const lat = 37.74;
const lng0 = -122.5;
// ~1.9 scene units per step, i.e. under a unit a second. Nothing near a jump.
const step = 0.002;
const gap = 2;
for (let i = 0; i < observations; i += 1) {
at(i * gap);
f.layer.update([jet("nrt7", lat, lng0 + i * step)]);
}
assert.equal(
f.segments(),
TRAIL_POINTS - 1,
"the trail should saturate at the retained-sample count, not keep growing",
);
const drawn = f.drawnPositions();
const [mesh] = f.meshes();
assert.ok(mesh);
// The last vertex written is the interpolated head, which is the aircraft.
const lastX = drawn[drawn.length - 3];
assert.ok(lastX !== undefined, "nothing was drawn");
assertNear(lastX, mesh.position.x, "the trail does not reach the aircraft");
// The first is the oldest observation still retained — number 20, not
// number 0 — which is what "loses the oldest, not the newest" means when
// read off the buffer.
const oldestKept = observations - TRAIL_POINTS;
const [xKept] = world.project(lat, lng0 + oldestKept * step);
const [xStart] = world.project(lat, lng0);
const firstX = drawn[0];
assert.ok(firstX !== undefined);
assertNear(firstX, xKept, "the trail starts somewhere other than its oldest retained sample");
assert.ok(
Math.abs(firstX - xStart) > 1,
"the trail still reaches back to the start of the flight, so nothing was trimmed",
);
});
});
// ---- Banking ---------------------------------------------------------------
/**
* Roll is the one channel that feeds itself.
*
* Position, heading, pitch and altitude are all recomputed from the last two
* observations every time, so a bad value washes out on the next poll. The bank
* is a first-order lag on its own previous value — that is what makes it settle
* smoothly instead of stepping — and the price of that is that a `NaN`, or a
* sign error, or a failure to reset, persists for the life of the track rather
* than for one frame. These are the cases where that would bite.
*
* Read through `mesh.rotation.z`, which needs no GL context.
*/
describe("aircraft banking", () => {
/** Fly `headings` in order, one distinct observation per refresh. */
function fly(f: Fixture, headings: number[]): THREE.Mesh {
let lng = -122.4;
let t = 0;
for (const heading of headings) {
at(t);
// A real step each time, or the repeat-skip correctly ignores the sample
// and the heading never lands.
lng += 0.02;
f.layer.update([{ ...jet("bank", 37.77, lng), heading }]);
t += REFRESH;
}
at(t);
f.layer.tick();
const mesh = f.meshes()[0];
assert.ok(mesh, "no aircraft");
return mesh;
}
it("stays dead level on a straight leg", () => {
const f = fixture();
const mesh = fly(f, [90, 90, 90, 90, 90]);
assert.equal(mesh.rotation.z, 0, "a straight leg should have no bank at all");
});
it("banks into a sustained turn, and not past the limiter", () => {
const f = fixture();
const mesh = fly(f, [90, 105, 120, 135, 150, 165]);
assert.ok(mesh.rotation.z !== 0, "a turning aircraft should be banked");
// 30° is the stated ceiling; anything past it is a knife-edge airliner.
assert.ok(
Math.abs(mesh.rotation.z) <= (30 * Math.PI) / 180 + 1e-9,
`banked ${((mesh.rotation.z * 180) / Math.PI).toFixed(1)}°, past the limiter`,
);
});
/**
* The sign, which is the half nobody can check by reading.
*
* A left turn and a right turn of the same size must produce equal and
* opposite rolls. That does not prove the absolute direction is right — the
* geometry argument in `flights.ts` does that — but it does catch the whole
* class of errors where the roll is derived from something that is not the
* signed turn, which would break the symmetry.
*/
it("rolls opposite ways for opposite turns", () => {
const right = fly(fixture(), [90, 105, 120, 135]).rotation.z;
const left = fly(fixture(), [90, 75, 60, 45]).rotation.z;
assert.ok(Math.abs(right) > 1e-3, "the right turn produced no bank");
assert.ok(Math.abs(right + left) < 1e-6, `${right} and ${left} are not mirrored`);
});
/**
* The 0/360 wrap, which is where a naive `to - from` produces a 350° turn out
* of a 10° one and rolls the aircraft onto its back.
*/
it("does not flick as a track crosses north", () => {
const f = fixture();
const mesh = fly(f, [340, 350, 0, 10, 20]);
const degrees = (mesh.rotation.z * 180) / Math.PI;
assert.ok(Number.isFinite(degrees), "the bank went non-finite across the wrap");
// A steady 10°-per-refresh right turn. If the wrap were mishandled this
// would be pinned at the limiter with the opposite sign.
assert.ok(degrees > 0, `crossing north banked ${degrees.toFixed(1)}°, the wrong way`);
assert.ok(degrees <= 30 + 1e-9, `crossing north banked ${degrees.toFixed(1)}°`);
});
/**
* A looping simulator route teleports, and the teleport branch clears the
* samples. It must clear the roll too — a track that starts its next leg still
* banked has no observation pair to wash it out, so it would simply stay that
* way.
*/
it("comes level again when a route wraps", () => {
const f = fixture();
fly(f, [90, 105, 120, 135]);
// Half a degree of longitude in one refresh: hundreds of units, well past
// the teleport ceiling.
at(REFRESH * 5);
f.layer.update([{ ...jet("bank", 37.77, -121.9), heading: 135 }]);
at(REFRESH * 5);
f.layer.tick();
const mesh = f.meshes()[0];
assert.ok(mesh);
assert.equal(mesh.rotation.z, 0, "a wrapped route kept its bank");
});
it("stays finite when a feed reports a nonsense heading", () => {
const f = fixture();
fly(f, [90, 105, 120]);
at(REFRESH * 4);
f.layer.update([{ ...jet("bank", 37.77, -122.3), heading: Number.NaN }]);
at(REFRESH * 4);
f.layer.tick();
const mesh = f.meshes()[0];
assert.ok(mesh);
assert.ok(
Number.isFinite(mesh.rotation.z),
"one bad heading poisoned the roll for the life of the track",
);
});
});