/** * `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 5–15 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", ); }); });