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