clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / render / test / resilience.test.ts
resilience.test.ts 22.6K
1// The camera's boundary discipline: resilience at every door.2//3// THE DEFECT, in a reader's words: "we could end up too zoomed in that we4// enter an invalid state that is corruptive across the 2d/3d transition. it5// rendered blank due to being unable to place the camera correctly." Their6// Mars pane was entirely blank — geohash `3qd` held, the MOLA lens, the chart7// pane up — with no way back but throwing the page away.8//9// WHAT MAKES IT CORRUPTIVE RATHER THAN MERELY WRONG, confirmed in a real10// browser against the Mars fixture before a line of this was written:11//12// 1. The sphere is placed at a point of view that is not a place. three's13// camera is `NaN` from that frame on, so the planet renders nothing —14// "unable to place the camera correctly", exactly.15// 2. The reader flips down. The write-back inverts that camera honestly and16// hands the chart `[NaN, NaN]`. OpenLayers keeps it: every constraint17// passes it through, and the chart draws nothing either.18// 3. Nothing gets it back. Zoom in, zoom out, ascend the grid, swap the19// lens: all four move the zoom and leave the centre `NaN`, because a pan20// adds to a centre and a zoom does not touch one. Recorded readings, in21// order: `zoom 2 centre [NaN, NaN]`, `zoom 3 centre [NaN, NaN]`,22// `zoom 2 …`, `zoom 5.994 …`, `zoom 5.994 …`.23//24// The reference had exactly one guard against this — both coordinates checked25// for being numbers before the map was told (`frontend/src/globe.js`, "a26// broken number here once blacked out both panes with no way back") — and the27// rewrite had lost it. But finiteness alone is not the whole of the class,28// and the second half was measured in the same session: behind the sphere the29// chart's map reports a size of `[0, 0]`, and the fit a cell descent makes30// there subtracts its own padding from that, gets a negative window, and pins31// the resolution to the deepest level the lens has. So the camera the chart is32// left standing on while nobody is looking is `zoom 8` — the ceiling — over33// the cell's middle, chosen by a window that does not exist. That is right34// while the pane is away and it is the reader's view the moment it fronts.35//36// So: one sanitizer, applied wherever a camera crosses a pane boundary or a37// pane fronts, and a fallback to the world's own opening view for a camera38// that cannot be repaired. Everything below holds that.3940import test from "node:test";41import { strict as assert } from "node:assert";42import { setLevel } from "../log.ts";43import type { Lens, TileGrid } from "../data/payload.ts";44import type { WorldContext } from "../context.ts";4546setLevel("error");4748// The seam's elements extend `HTMLElement` at definition time, and `globe.gl`49// reads the document as it loads. Both modules arrive after the page does.50const host = globalThis as unknown as Record<string, unknown>;51host.HTMLElement = class {};52host.window = globalThis;53host.requestAnimationFrame = (): number => 0;54host.cancelAnimationFrame = (): void => {};55const node = () => ({56 style: {}, classList: { add() {} }, getContext: () => null,57 appendChild() {}, insertBefore() {}, setAttribute() {}, addEventListener() {},58});59host.document = {60 createElement: node,61 createElementNS: node,62 createTextNode: node,63 getElementsByTagName: () => [node()],64 querySelector: () => null,65 querySelectorAll: () => [],66 addEventListener() {},67 head: node(),68 body: node(),69};7071const { AtlasChart, saneCamera, sameCamera } = await import("../chart/element.ts");72const { AtlasGlobe, altitudeForZoom } = await import("../globe/element.ts");73type Camera = import("../chart/element.ts").Camera;7475// ---- the world these tests are about ---------------------------------76//77// Mars, as `testdata/corpus/bundles/mars` declares it: one 8192-pixel square,78// two lenses of six native levels each, so the view's ceiling is 8.7980const GRID: TileGrid = { sourceZoom: 13, firstTile: 4064, tileSize: 256, size: 8192 };8182const MOLA: Lens = {83 name: "MOLA Elevation", tiles: "global__mola-elevation", minZoom: 0, maxZoom: 6,84 fullZoom: 0, sourceZoom: 13, formats: ["png"], interpolate: false,85 bounds: { x: 0, y: 0, width: 8192, height: 4096 },86};8788/** The lens ceiling: six native levels and two of overzoom. */89const CEILING = 8;9091/** The world square, in the seam's own coordinates: y decreases downward. */92const WORLD = { minX: 0, minY: -4096, maxX: 8192, maxY: 0 };9394/** The reader's camera in the reproduction: the `3qd` cell, at the ceiling. */95const HELD: Camera = { x: 1360, y: -2224, zoom: 8, rotation: 0 };9697// ---- the sanitizer's table -------------------------------------------9899test("a healthy camera comes back out of the sanitizer as itself", () => {100 const healthy: Camera[] = [101 HELD,102 { x: 4096, y: -2048, zoom: 0, rotation: 0 },103 { x: 0, y: -4096, zoom: CEILING, rotation: 0 },104 { x: 3605.162673611111, y: -1713.4931195970112, zoom: 4.394578925885768, rotation: 0 },105 // `globe-left` in the recorded Mars tour: the camera the sphere hands back106 // from its farthest distance. Every bound globe step's camera goes through107 // this function now, so "unmoved" has to mean unmoved to the last bit.108 { x: 4096, y: -2048, zoom: 1.3219280948873622, rotation: 0 },109 ];110 for (const camera of healthy) {111 const sane = saneCamera(camera, MOLA, GRID);112 assert.ok(sane, `${JSON.stringify(camera)} is a place`);113 assert.ok(sameCamera(sane, camera), `and is answered bit for bit: ${JSON.stringify(sane)}`);114 }115});116117test("a camera that is not a number is refused rather than repaired", () => {118 // Refused, not clamped: there is no number `NaN` was nearly, and a guess119 // put on a view is a reader looking at somewhere nobody chose. The caller's120 // answer to a refusal is the world's own fit.121 const broken: Camera[] = [122 { x: Number.NaN, y: -2224, zoom: 8, rotation: 0 },123 { x: 1360, y: Number.NaN, zoom: 8, rotation: 0 },124 { x: Number.NaN, y: Number.NaN, zoom: 2, rotation: 0 },125 { x: 1360, y: -2224, zoom: Number.NaN, rotation: 0 },126 { x: 1360, y: -2224, zoom: 8, rotation: Number.NaN },127 { x: Number.POSITIVE_INFINITY, y: -2224, zoom: 8, rotation: 0 },128 { x: 1360, y: Number.NEGATIVE_INFINITY, zoom: 8, rotation: 0 },129 { x: 1360, y: -2224, zoom: Number.POSITIVE_INFINITY, rotation: 0 },130 ];131 for (const camera of broken) {132 assert.equal(saneCamera(camera, MOLA, GRID), null, JSON.stringify(camera));133 }134 assert.equal(saneCamera(null, MOLA, GRID), null, "and nothing at all is not a place either");135 assert.equal(saneCamera(undefined, MOLA, GRID), null);136});137138test("a zoom past the lens's own depth is held to it", () => {139 // Finite and useless: the pyramid has no level there, so the raster serves140 // nothing however long the reader waits.141 const deep = saneCamera({ ...HELD, zoom: 99 }, MOLA, GRID);142 assert.equal(deep?.zoom, CEILING, "the ceiling is the lens's, plus its overzoom");143 const shallow = saneCamera({ ...HELD, zoom: -3 }, MOLA, GRID);144 assert.equal(shallow?.zoom, 0, "and the floor is the whole world");145 assert.equal(deep?.x, HELD.x, "clamping the depth moves nothing else");146 assert.equal(deep?.y, HELD.y);147});148149test("a centre outside the world square is brought back onto it", () => {150 const east = saneCamera({ ...HELD, x: 99_999 }, MOLA, GRID);151 assert.equal(east?.x, WORLD.maxX, "east of everything drawn is the eastern edge");152 const west = saneCamera({ ...HELD, x: -5 }, MOLA, GRID);153 assert.equal(west?.x, WORLD.minX);154 const under = saneCamera({ ...HELD, y: -99_999 }, MOLA, GRID);155 assert.equal(under?.y, WORLD.minY, "y decreases downward, so the floor is negative");156 const over = saneCamera({ ...HELD, y: 5 }, MOLA, GRID);157 // `===` rather than `assert.equal`, which is `Object.is`: negating the158 // lens's own top edge spells `-0`, and `-0` is the top edge.159 assert.ok(over?.y === WORLD.maxY, `${over?.y}`);160});161162test("a lens that draws only part of the square keeps the camera inside its part", () => {163 // The extent is the *lens's*, not the world's: a split sheet's layer draws164 // one box out of one pyramid, and a camera outside that box is over ground165 // this lens never drew.166 const corner: Lens = { ...MOLA, bounds: { x: 1024, y: 512, width: 2048, height: 1024 } };167 const sane = saneCamera({ x: 8000, y: -4000, zoom: 3, rotation: 0 }, corner, GRID);168 assert.deepEqual([sane?.x, sane?.y], [1024 + 2048, -(512 + 1024)],169 "the far corner of what this lens drew");170});171172// ---- every boundary applies it ---------------------------------------173//174// The chart is asked as an object rather than as an element: nothing below175// wants a page, a WebGL context or a tile server, and what is under test is176// which numbers reach the view.177178/** A view that records what it was told, and answers what it was last told. */179function stubView(camera: Camera = HELD) {180 return {181 centre: [camera.x, camera.y] as number[] | undefined,182 zoom: camera.zoom as number | undefined,183 rotation: camera.rotation,184 fits: [] as { extent: number[]; size: number[] }[],185 getCenter() { return this.centre; },186 getZoom() { return this.zoom; },187 getRotation() { return this.rotation; },188 getResolution() { return this.zoom === undefined ? undefined : 32 / 2 ** this.zoom; },189 setCenter(at: number[]) { this.centre = at; },190 setZoom(to: number) { this.zoom = to; },191 setRotation(to: number) { this.rotation = to; },192 fit(extent: number[], options: { size: number[] }) {193 const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;194 this.fits.push({ extent, size: options.size });195 this.centre = [(minX + maxX) / 2, (minY + maxY) / 2];196 this.zoom = 0;197 },198 animate() {},199 };200}201202function context(lens: Lens | null = MOLA): WorldContext {203 return {204 lens, grid: GRID, model: { slug: "global" },205 } as unknown as WorldContext;206}207208/**209 * The host's own two lines when the sphere goes down, in the host's own order210 * (`AtlasViewport.flipPane`): the write-back if there is one, then the check211 * that the pane coming up is standing somewhere.212 */213function flipDown(chart: InstanceType<typeof AtlasChart>, handed: Camera | null): void {214 if (handed) chart.goTo(handed.x, handed.y, handed.zoom, handed.rotation);215 chart.front();216}217218/** A chart with a view under it and nothing else: no page, no map, no tiles. */219function chartOver(220 view: ReturnType<typeof stubView>, { size = [909, 648] as number[] | undefined } = {},221): InstanceType<typeof AtlasChart> {222 const chart = Object.create(AtlasChart.prototype) as Record<string, unknown>;223 chart.view = view;224 chart.context = context();225 chart.map = { getSize: () => size };226 chart.clientWidth = 909;227 chart.clientHeight = 648;228 return chart as unknown as InstanceType<typeof AtlasChart>;229}230231test("the one door: a camera that is not a place never reaches the view", () => {232 const view = stubView();233 const chart = chartOver(view);234 chart.goTo(Number.NaN, Number.NaN, 4);235 assert.deepEqual(view.centre, [4096, -2048], "the world's own middle, from the fit");236 assert.equal(view.fits.length, 1, "and it got there by fitting the world rather than by guessing");237 // `-0` is what negating the lens's own top edge answers, and it is the same238 // place as `0`: compared as numbers rather than as JSON.239 assert.ok(view.fits[0]?.extent.every((edge, at) => edge === [0, -4096, 8192, 0][at]),240 JSON.stringify(view.fits[0]?.extent));241});242243test("the one door holds a camera deeper than the lens to the lens", () => {244 const view = stubView();245 chartOver(view).goTo(1360, -2224, 99);246 assert.equal(view.zoom, CEILING);247 assert.deepEqual(view.centre, [1360, -2224]);248 assert.equal(view.fits.length, 0, "a camera that can be repaired is repaired, not thrown away");249});250251test("a healthy camera through the door is put on the view untouched", () => {252 const view = stubView({ x: 0, y: 0, zoom: 0, rotation: 0 });253 chartOver(view).goTo(HELD.x, HELD.y, HELD.zoom, HELD.rotation);254 assert.deepEqual(view.centre, [HELD.x, HELD.y]);255 assert.equal(view.zoom, HELD.zoom);256});257258test("fronting leaves a healthy standing camera completely alone", () => {259 // Not "leaves it equal": leaves it *untouched*. A `setCenter` here would260 // raise a move on every pane swap, and a move is a report to the session, a261 // recount in the footer and a redrawn corner locator — three things the262 // recorded globe steps pin and none of which a pane coming up is.263 const view = stubView(HELD);264 const centre = view.centre;265 chartOver(view).front();266 assert.equal(view.centre, centre, "the very same array, never written again");267 assert.equal(view.fits.length, 0);268});269270test("fronting over a camera that is not a place opens the world instead", () => {271 const view = stubView({ x: Number.NaN, y: Number.NaN, zoom: 2, rotation: 0 });272 const chart = chartOver(view);273 chart.front();274 assert.equal(view.fits.length, 1, "the world's own opening view, which is the way back");275 assert.deepEqual(view.centre, [4096, -2048]);276});277278test("fronting a camera deeper than the lens brings it back to the ceiling", () => {279 // The camera behind the sphere is fitted into a window of no size and lands280 // at whatever the deepest level is. Under a lens that goes shallower than281 // the one it was fitted under, that is past the ceiling — and fronting is282 // where it is asked.283 const view = stubView({ ...HELD, zoom: 12 });284 chartOver(view).front();285 assert.equal(view.zoom, CEILING);286});287288test("the fit a broken camera falls back to is measured in a real window", () => {289 // A pane coming up out from behind the sphere still reports `[0, 0]` until290 // the size observer catches up, and fitting into no window is the very291 // arithmetic that makes a camera suspect. The element's own measurement is292 // the fallback.293 const view = stubView({ x: Number.NaN, y: 0, zoom: 2, rotation: 0 });294 chartOver(view, { size: [0, 0] }).front();295 assert.deepEqual(view.fits[0]?.size, [909, 648], "the pane's, not the map's zero");296 const none = stubView({ x: Number.NaN, y: 0, zoom: 2, rotation: 0 });297 chartOver(none, { size: undefined }).front();298 assert.deepEqual(none.fits[0]?.size, [909, 648]);299});300301test("a zoom press over a camera standing nowhere puts the world back", () => {302 // `?? 0` catches a view with no zoom and not a view whose zoom is not a303 // number, and `NaN + 1` is `NaN`: this is why the reader's every press did304 // nothing once the pane had gone. A press is now the way out.305 const view = stubView({ x: 1360, y: -2224, zoom: Number.NaN, rotation: 0 });306 const chart = chartOver(view);307 chart.nudgeZoom(1);308 assert.equal(view.fits.length, 1, "one press is enough to be looking at the world again");309 assert.equal(view.zoom, 0);310});311312// ---- the sphere's two ends -------------------------------------------313314/** A globe with the Mars mapping under it and a stub sphere in place of one. */315function globeOver(pov: { lat: number; lng: number; altitude: number }) {316 const placed: { pov: unknown; ms: number }[] = [];317 const sphere = {318 at: { ...pov },319 pointOfView(want?: { lat: number; lng: number; altitude: number }, ms?: number) {320 if (!want) return this.at;321 this.at = { ...want };322 placed.push({ pov: { ...want }, ms: ms ?? 0 });323 return this.at;324 },325 width() {}, height() {},326 };327 const globe = Object.create(AtlasGlobe.prototype) as Record<string, unknown>;328 // The three passes a sphere makes over its own scene graph, stood down: what329 // is under test is which numbers reach the camera, and none of them wants a330 // pin, a name or a tile.331 globe.update = () => {};332 globe.refreshDetail = () => {};333 globe.clearDetail = () => {};334 globe.globe = sphere;335 globe.context = context();336 globe.equirect = {337 px: [0, 0, 8192, 4096],338 deg: [-180, 90, 180, -90],339 mapping: {340 toLatLng: (x: number, y: number): [number, number] =>341 [90 - (y / 4096) * 180, -180 + (x / 8192) * 360],342 toWorld: (lat: number, lng: number): [number, number] =>343 [((lng + 180) / 360) * 8192, ((90 - lat) / 180) * 4096],344 },345 };346 globe.clientWidth = 909;347 globe.clientHeight = 648;348 globe.hidden = true;349 globe.given = null;350 globe.handed = null;351 return { globe: globe as unknown as InstanceType<typeof AtlasGlobe>, sphere, placed };352}353354test("the sphere is never placed at a point of view that is not a place", () => {355 // The one-way half of the corruption. A map given `NaN` can still be told a356 // good centre afterwards; three's camera cannot be told anything at all —357 // every reading off it is `NaN` from that frame on, which is what turns one358 // broken number into two blank panes.359 const { globe, sphere, placed } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });360 globe.enter({ x: Number.NaN, y: -2224, zoom: 8, rotation: 0 }, { width: 909, height: 648 });361 assert.equal(placed.length, 0, "nothing was placed");362 assert.deepEqual(sphere.at, { lat: 0, lng: 0, altitude: 2.5 }, "and the sphere did not move");363});364365test("entering with a camera deeper than the lens still enters, at the lens's depth", () => {366 const { globe, placed } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });367 globe.enter({ ...HELD, zoom: 99 }, { width: 909, height: 648 });368 assert.equal(placed.length, 1, "a camera that can be repaired is not a refusal");369 const at = placed[0]?.pov as { altitude: number };370 assert.equal(at.altitude, altitudeForZoom(CEILING), "the distance the ceiling reads as");371});372373test("the write-back refuses a camera it cannot invert into a place", () => {374 // The reference's own guard, at the reference's own place: both375 // coordinates, checked before the map is told (`globe.js:182`).376 const { globe } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });377 globe.enter({ x: 4096, y: -2048, zoom: 2, rotation: 0 }, { width: 909, height: 648 });378 const sphere = (globe as unknown as { globe: { at: unknown } }).globe;379 sphere.at = { lat: Number.NaN, lng: Number.NaN, altitude: 0.5 };380 assert.equal(globe.leave(), null, "nothing is handed back rather than a place that is not one");381 assert.equal(globe.cameraOf({ lat: Number.NaN, lng: 0, altitude: 1 }), null);382 assert.equal(globe.cameraOf({ lat: 0, lng: Number.NaN, altitude: 1 }), null);383});384385test("the write-back holds what it does hand back to the lens it will be drawn under", () => {386 const { globe } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });387 globe.enter({ x: 4096, y: -2048, zoom: 2, rotation: 0 }, { width: 909, height: 648 });388 const sphere = (globe as unknown as { globe: { at: unknown } }).globe;389 // Pressed hard against the sphere's nearest distance, which reads deeper390 // than the whole-disc anchor and is exactly where a reader who kept zooming391 // ends up.392 sphere.at = { lat: -7.734375, lng: -120.234375, altitude: 0.0005 };393 const back = globe.leave();394 assert.ok(back);395 assert.ok(back.zoom <= CEILING, `the lens's ceiling, not the pairing's: ${back.zoom}`);396 assert.ok(back.x >= WORLD.minX && back.x <= WORLD.maxX);397 assert.ok(back.y >= WORLD.minY && back.y <= WORLD.maxY);398 assert.ok(sameCamera(back, saneCamera(back, MOLA, GRID)), "and it is already sane");399});400401test("an unmoved sphere still hands back the exact camera it was given", () => {402 // The pairing is lossy in both directions on purpose, so a reader who never403 // touched the sphere must get their own numbers back rather than numbers404 // that round-trip to within a float of them. Sanitizing is exact, so this405 // survives it — `globe-returned` in the Mars tour is the recorded reading.406 const { globe } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });407 const given: Camera = { x: 3605.162673611111, y: -1713.4931195970112, zoom: 4.394578925885768,408 rotation: 0 };409 globe.enter(given, { width: 909, height: 648 });410 assert.ok(sameCamera(globe.leave(), given), "bit for bit");411});412413// ---- the reproduction, at the unit level -----------------------------414415test("the reader's sequence: 3qd held, MOLA, into the sphere and back", () => {416 // The browser run, step for step, against the Mars fixture:417 //418 // 1. `g`, `3qd` typed, the MOLA lens chosen: the chart stands at zoom 8419 // over [1360, -2224], the deepest the lens allows.420 // 2. The sphere comes up. The chart's map now reports `[0, 0]`.421 // 3. Something places the sphere at a point of view that is not a place —422 // a `NaN` out of a degenerate camera, a cell whose middle could not be423 // computed, a pane with no window. three's camera is `NaN` afterwards424 // and the planet is blank.425 // 4. The reader flips back down.426 //427 // Before the guard, step 4 handed the chart `[NaN, NaN]` and the second428 // pane went blank with it. Now the sphere hands back nothing, and the chart429 // fronts on the camera it was standing on — which is checked as it comes up.430 const { globe } = globeOver({ lat: 0, lng: 0, altitude: 2.5 });431 globe.enter(HELD, { width: 909, height: 648 });432 const sphere = (globe as unknown as { globe: { at: unknown } }).globe;433 sphere.at = { lat: Number.NaN, lng: Number.NaN, altitude: Number.NaN };434435 const handed: Camera | null = globe.leave();436 assert.ok(handed === null, "the sphere refuses to hand a blank pane on");437438 const view = stubView(HELD);439 const chart = chartOver(view, { size: [0, 0] });440 flipDown(chart, handed);441 assert.deepEqual(view.centre, [1360, -2224], "the reader is back over the cell they held");442 assert.equal(view.zoom, 8, "at the depth they were reading it at");443 assert.equal(view.fits.length, 0, "and nothing was thrown away to get there");444});445446test("and the same sequence under a lens that cannot go that deep", () => {447 // The other half of the class, and the reason finiteness is not enough. The448 // camera the chart is left standing on behind the sphere is a fit into a449 // window of no size, which lands at the deepest level *the lens it was450 // fitted under* has. Front it under a shallower one and it is a camera the451 // pyramid has no tile for: finite, and blank.452 const shallow = context({ ...MOLA, maxZoom: 2 });453 const view = stubView({ ...HELD, zoom: 8 });454 const chart = chartOver(view);455 (chart as unknown as { context: WorldContext }).context = shallow;456 chart.front();457 assert.equal(view.zoom, 4, "two native levels and two of overzoom");458 assert.deepEqual(view.centre, [1360, -2224], "the place is kept; only the depth is given up");459});460461test("a session record from another build cannot open a world nowhere", () => {462 // The record is written by whatever build the reader last had, over463 // whatever lens they were on, and it is applied through the same one door.464 // The server refuses one it can see is wrong; this is the half the server465 // cannot see, and it is a clamp on apply rather than a change to the record.466 const view = stubView({ x: 0, y: 0, zoom: 0, rotation: 0 });467 const chart = chartOver(view);468 chart.goTo(20_000, 500, 14, 0);469 assert.ok(view.centre?.[0] === WORLD.maxX);470 assert.ok(view.centre?.[1] === WORLD.maxY, "the top edge, which negation spells `-0`");471 assert.equal(view.zoom, CEILING);472 assert.equal(view.fits.length, 0);473});