Shadows land on the building, and the sky layers stop repeating themselves
**Shadows were the right size and pointed at nothing.** Last round fixed the missing `updateProjectionMatrix()`, so the frustum finally became the size every caller asks for — but nothing aimed it, and `sun.target` sits at the world origin. A pack's origin is the **north-west corner of its slab**, so for lumbridge-hq the box was off-centre by half the building: 14.4 m of a 48 m plate, about a third of the floor, fell outside the frustum and neither cast nor received. Invisible while three's broken ±5 default made shadows useless everywhere; obvious the moment they started working. `SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and the light's target is added to the scene — which is the part that actually matters, because `LightShadow.updateMatrices` reads `target.matrixWorld` and an unparented `Object3D` is never reached by the traversal that updates it. The sun is also placed relative to the target rather than the origin, so light-to-target is exactly `sunDistance` for every direction, which is the invariant each caller's `shadowNear`/`shadowFar` were chosen against. **`flights.ts` could not be tested, and that is why it was untested.** It used a TypeScript parameter property — the one piece of TS syntax that *emits code* rather than annotating a type — so Node's type stripping refused the whole module. The bundler never cared, so nobody found out until the first `node --test` file tried to import it. The module carrying the worst bug this project has shipped was, by construction, the one module that could not have a test. It has eleven now, including one that fails if the live-aircraft repeat-skip is removed. **Robots are on the plan panel** — a turned marker with a bow for heading, in the one hue left that is neither the people-blue nor the camera-amber. Review findings cleared across the four new sky/robot modules: a real 24 mm void at the ankle and an 8 mm hole through each forearm, a per-frame allocation in the robot heading picker, a per-frame sort in the starlink ranking, `uTime` growing unbounded until the cloud breath quantises, and `DAY_REFERENCE`'s derivation which did not reproduce. `createStarlinkMeshLayer` now takes a **board** radius — the same unit its sibling takes — instead of a dome radius with nothing in the types to tell them apart. That is the exact confusion that has already caused one real bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied: the points and the meshes must be on the same dome or a satellite that grows geometry also jumps. Several comments were wrong rather than merely stale — a fabricated claim about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted `DoubleSide` argument, a triangle ledger citing a function that no longer exists, and a defensive-call rationale that contradicted the paragraph above it. In a codebase where the comments are the design record, those are defects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* `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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user