1// What the two panes let go of when they leave the page — and take back up.2//3// A custom element's lifetime is not the page's. A morph swap replaces the4// viewport on every volume navigation, and everything the two panes hold that5// the browser does not hold *for* them survives that replacement unless the6// element says otherwise: a WebGL context and the animation frame that keeps7// asking for the next one, a `ResizeObserver` the browser owns and this lane8// merely started, a debounced camera report, and — worst of the four, because9// it is invisible — a listener closing over an element nobody can see, still10// drawing a planet out of a context nobody owns.11//12// THE OTHER HALF IS COMING BACK. A morph may put the *same* element back, so13// a teardown that left a memo standing is a teardown that breaks the next14// life: a lens key surviving its skin makes `openLens` skip compositing a15// texture that no longer exists, and the sphere comes up black. Every test16// below therefore disconnects and reconnects, and asks for one camera report17// per move afterwards rather than one per life the element has had.18//19// WHAT IS *NOT* GIVEN BACK is checked here too, and it is the subtler duty: a20// pin's material belongs to its collection and is shared with every pin21// wearing the same mark, its geometry is three's own quad shared with every22// sprite in the scene, and an element leaving the page is not a collection23// losing its mark.24//25// The panes are driven through their lifecycle callbacks over stubs — no26// WebGL, no OpenLayers target, no fixtures. Where a test reaches past27// `private` it is reaching for the subject itself: what these callbacks are28// *about* is the lifetime of fields no public method exposes.2930import test from "node:test";31import { strict as assert } from "node:assert";32import * as THREE from "three";33import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";34import { setLevel } from "../log.ts";3536setLevel("error");3738// ---- the page the panes are drawn in ---------------------------------3940/** A stub node: enough of one for `globe.gl` to load and for three to hold. */41function stubNode(): Record<string, unknown> {42 return {43 width: 0, height: 0, style: {}, classList: { add: () => {} },44 getContext: () => null, appendChild: () => {}, insertBefore: () => {},45 setAttribute: () => {}, addEventListener: () => {},46 };47}4849/** Every observer this file has minted, live or not. */50const observers: StubObserver[] = [];5152/** A `ResizeObserver` that says who it watched and whether it was stopped. */53class StubObserver {54 readonly targets: unknown[] = [];55 live = true;56 private readonly told: () => void;5758 constructor(told: () => void) {59 this.told = told;60 observers.push(this);61 }6263 /** The browser saying the box moved, which is the only thing it ever says. */64 fire(): void {65 this.told();66 }6768 observe(target: unknown): void {69 this.targets.push(target);70 }7172 unobserve(): void {}7374 disconnect(): void {75 this.live = false;76 }77}7879const host = globalThis as unknown as Record<string, unknown>;80/**81 * The one thing a pane's teardown asks of its own element: that it can be82 * emptied. globe.gl's destructor gives back the renderer and leaves its canvas83 * exactly where it put it, holding the last frame it drew, so the container is84 * emptied by hand -- and the count is what a test reads that by.85 */86host.HTMLElement = class {87 emptied = 0;8889 replaceChildren(): void {90 this.emptied += 1;91 }92};93host.window = globalThis;94host.requestAnimationFrame = (): number => 0;95host.cancelAnimationFrame = (): void => {};96host.Image = class { src = ""; crossOrigin = ""; };97host.ResizeObserver = StubObserver;98host.document = {99 createElement: stubNode, createElementNS: stubNode, createTextNode: stubNode,100 getElementsByTagName: () => [stubNode()], querySelector: () => null,101 querySelectorAll: () => [], addEventListener: () => {},102 head: stubNode(), body: stubNode(),103};104105// Imported after the page exists: `globe.gl` reads the document as it loads.106const { AtlasGlobe, markerMaterial, nameCard } = await import("../globe/element.ts");107const { AtlasChart } = await import("../chart/element.ts");108109// ---- what the panes hold, seen from outside ---------------------------110111/**112 * The sphere's own fields, named so a test can put a globe where a build113 * would have left one. Lifetime is what is under test, and a lifetime is a114 * statement about fields.115 */116interface Inside {117 globe: unknown;118 texture: THREE.CanvasTexture | null;119 skin: unknown;120 pins: THREE.Group;121 labels: THREE.Group;122 cells: THREE.Group;123 lensKey: string;124 watchCamera(globe: unknown): void;125}126127function inside(element: object): Inside {128 return element as unknown as Inside;129}130131interface Charted {132 map: unknown;133 sizes: StubObserver | null;134 settle: number | undefined;135 report(): void;136}137138function charted(element: object): Charted {139 return element as unknown as Charted;140}141142/** The little of globe.gl the teardown speaks to, and a way to move it. */143function stubGlobe() {144 const handlers = new Set<() => void>();145 const scene = new THREE.Scene();146 const dimensions: [string, number][] = [];147 let destructed = 0;148 const controls = {149 addEventListener: (_type: string, handler: () => void) => { handlers.add(handler); },150 removeEventListener: (_type: string, handler: () => void) => { handlers.delete(handler); },151 };152 return {153 scene: () => scene,154 controls: () => controls,155 camera: () => ({ position: { x: 0, y: 0, z: 300 } }),156 pointOfView: () => ({ lat: 12, lng: 34, altitude: 0.5 }),157 _destructor: () => { destructed += 1; },158 width: (px: number) => { dimensions.push(["width", px]); },159 height: (px: number) => { dimensions.push(["height", px]); },160 /** The test's own handles on the stub. */161 dimensions,162 handlers,163 graph: scene,164 destructs: () => destructed,165 move: () => { for (const handler of [...handlers]) handler(); },166 };167}168169/** The little of OpenLayers the chart's lifecycle speaks to. */170function stubMap() {171 const targets: unknown[] = [];172 return {173 targets,174 setTarget: (target: unknown) => { targets.push(target); },175 getSize: () => [800, 600],176 updateSize: () => {},177 render: () => {},178 };179}180181/** Count what a teardown frees: three dispatches `dispose` on the way out. */182function watch(183 target: THREE.EventDispatcher<{ dispose: object }>, seen: string[], name: string,184): void {185 target.addEventListener("dispose", () => { seen.push(name); });186}187188const somewhere = { x: 0, y: 0, z: 101 };189190const mark = {191 asset: "", url: "", picture: false, color: "#4fb3d5",192 outset: "rgba(7, 9, 7, 0.98)", title: "Impact Craters",193};194195/** A sphere with a globe under it, as a build would have left it. */196function built(): { element: InstanceType<typeof AtlasGlobe>; globe: ReturnType<typeof stubGlobe> } {197 const element = new AtlasGlobe();198 const globe = stubGlobe();199 const inner = inside(element);200 inner.globe = globe;201 inner.watchCamera(globe);202 globe.graph.add(inner.pins, inner.labels, inner.cells);203 return { element, globe };204}205206// ---- the sphere -------------------------------------------------------207208test("the camera's listener leaves with the element that was following it", () => {209 const { element, globe } = built();210 const seen: unknown[] = [];211 element.onCamera = (pov) => { seen.push(pov); };212 globe.move();213 assert.equal(seen.length, 1, "a move is one report");214 element.disconnectedCallback();215 assert.equal(globe.handlers.size, 0, "the handler came off the controls");216 assert.equal(element.onCamera, null, "and the corner is no longer being told");217 globe.move();218 assert.equal(seen.length, 1, "a detached sphere reports nothing");219});220221test("a disconnected sphere gives back its renderer, its skin and its cards", () => {222 const { element, globe } = built();223 const inner = inside(element);224 const seen: string[] = [];225226 const texture = new THREE.CanvasTexture(stubNode() as unknown as HTMLCanvasElement);227 inner.texture = texture;228 watch(texture, seen, "skin");229 inner.skin = {};230231 for (const [group, title] of [[inner.labels, "Elysium"], [inner.cells, "Hellas"]] as const) {232 const card = nameCard(title, somewhere);233 watch((card.material as THREE.SpriteMaterial).map as THREE.Texture, seen, `${title}: texture`);234 watch(card.material as THREE.SpriteMaterial, seen, `${title}: material`);235 group.add(card);236 }237238 element.disconnectedCallback();239 assert.equal(globe.destructs(), 1, "the renderer, the controls and the scene are disposed");240 assert.deepEqual(seen.sort(), [241 "Elysium: material", "Elysium: texture",242 "Hellas: material", "Hellas: texture",243 "skin",244 ], "and nothing the sphere minted is still held");245 assert.equal(inner.labels.children.length, 0, "the names are down");246 assert.equal(inner.cells.children.length, 0, "the grid with them");247 assert.equal(globe.graph.children.length, 0,248 "and the three groups came out of the scene before globe.gl emptied it");249 assert.equal(inner.texture, null);250 assert.equal(inner.skin, null);251});252253test("a second disconnect is not a second teardown", () => {254 const { element, globe } = built();255 element.disconnectedCallback();256 element.disconnectedCallback();257 assert.equal(globe.destructs(), 1, "the renderer is disposed once, whatever the page does");258});259260test("the pins' shared marks survive the element that wore them", () => {261 const { element } = built();262 const material = markerMaterial({ ...mark, title: "Dunes" }, false);263 const seen: string[] = [];264 watch(material, seen, "the collection's mark");265 const sprite = new THREE.Sprite(material);266 watch(sprite.geometry, seen, "three's shared sprite quad");267 inside(element).pins.add(sprite);268 element.seam.sprites.set("dunes", sprite);269270 element.disconnectedCallback();271 assert.deepEqual(seen, [], "a sphere leaving is not a collection losing its mark");272 assert.equal(markerMaterial({ ...mark, title: "Dunes" }, false), material,273 "the cache is still good for the next sphere");274 assert.equal(inside(element).pins.children.length, 0, "the pins are down all the same");275 assert.equal(element.seam.sprites.size, 0, "and nothing is standing");276});277278test("a sphere that comes back is built afresh rather than wired twice", () => {279 const { element, globe } = built();280 inside(element).lensKey = "mars/mars#viking-mdim";281 element.seam.detail.lens = "viking-mdim";282 element.seam.labels.key = "0:0:0.68:geohash:";283 element.disconnectedCallback();284285 // What makes the next entry a build rather than a resumption.286 assert.equal(element.built, false, "nothing is built, so the next entry builds");287 assert.equal(inside(element).lensKey, "",288 "and the skin is composited again rather than skipped over a texture that is gone");289 assert.deepEqual(element.seam.labels, { key: "", group: null });290 assert.deepEqual(element.seam.grid, { group: null, cell: null, fitKey: "" });291 assert.equal(element.seam.detail.lens, "");292 assert.equal(element.seam.detail.tiles.size, 0);293294 // The rebuild, and the one thing it must not do twice.295 const next = stubGlobe();296 inside(element).globe = next;297 inside(element).watchCamera(next);298 const seen: unknown[] = [];299 element.onCamera = (pov) => { seen.push(pov); };300 next.move();301 globe.move();302 assert.equal(seen.length, 1, "one report per move, from the sphere that is live");303});304305// ---- the sphere, and the box it is drawn in ---------------------------306//307// The pane moves without the window doing anything: the dock folds under a308// keystroke and the panel beside the map comes out the first time a search has309// something to say. globe.gl is told its dimensions once, at entry, so a310// sphere that did not hear about the fold went on drawing at the old size311// until something else poked it -- which is the defect. The chart has watched312// its own box since the lifecycle work; this is the same duty on the sphere,313// and it carries two more units that remember a size: a `Line2` carries its314// width in pixels and can only do it by being told the window it is measured315// against, and the horizon cull is a question about the camera's aspect.316317/** A pane with a box, which is what a stub element otherwise has no idea of. */318function sized(element: object, width: number, height: number): void {319 Object.assign(element, { clientWidth: width, clientHeight: height });320}321322/** A grid boundary as `cellBoundary` leaves one: an object wearing a fat line. */323function boundary(width: number, height: number): { material: LineMaterial } {324 const material = new LineMaterial({ linewidth: 2 });325 material.resolution.set(width, height);326 const line = new THREE.Object3D() as THREE.Object3D & { material: LineMaterial };327 line.material = material;328 return line;329}330331test("a sphere whose pane changed size is told, in every unit that remembers one", () => {332 observers.length = 0;333 const { element, globe } = built();334 sized(element, 900, 600);335 // A re-connect is what puts the observer back; the first one is the build's,336 // and there is no globe to measure before there is a globe.337 element.connectedCallback();338 assert.equal(observers.length, 1, "one observer");339 assert.deepEqual(observers[0]?.targets, [element], "watching its own pane");340341 const line = boundary(900, 600);342 inside(element).cells.add(line as unknown as THREE.Object3D);343 const card = nameCard("Elysium", { x: 0, y: 0, z: -101 });344 card.visible = true;345 inside(element).cells.add(card);346347 sized(element, 1280, 720);348 observers[0]?.fire();349 assert.deepEqual(globe.dimensions, [["width", 1280], ["height", 720]],350 "globe.gl measures once and has to be told");351 assert.deepEqual([line.material.resolution.x, line.material.resolution.y], [1280, 720],352 "a fat line not told the new window draws at the old scale");353 assert.equal(card.visible, false,354 "and the horizon is asked again: a new aspect is a new answer");355});356357test("a sphere put away behind the chart is not told it is zero pixels wide", () => {358 observers.length = 0;359 const { element, globe } = built();360 sized(element, 0, 0);361 element.connectedCallback();362 observers[0]?.fire();363 assert.deepEqual(globe.dimensions, [],364 "a pane measured mid-transition, or hidden, has no size to draw a planet at");365});366367test("the sphere's size observer comes off with the element and goes back on", () => {368 observers.length = 0;369 const { element } = built();370 sized(element, 900, 600);371 element.connectedCallback();372 assert.equal(observers.length, 1);373374 element.disconnectedCallback();375 assert.equal(observers[0]?.live, false, "the observer is stopped, not merely forgotten");376377 // A disconnect took the globe with it, so the element that comes back has378 // nothing to measure until it is entered again -- exactly the chart's rule.379 element.connectedCallback();380 assert.equal(observers.length, 1, "there is no planet yet to resize");381382 const next = built();383 sized(next.element, 900, 600);384 next.element.connectedCallback();385 assert.equal(observers.filter((seen) => seen.live).length, 1,386 "and there is never more than one live: a pane measured twice is a pane counted twice");387});388389// ---- the chart --------------------------------------------------------390391test("the chart's size observer comes off with the element and goes back on", () => {392 observers.length = 0;393 const chart = new AtlasChart();394 const map = stubMap();395 charted(chart).map = map;396397 chart.connectedCallback();398 assert.equal(observers.length, 1, "one observer");399 assert.deepEqual(observers[0]?.targets, [chart], "watching its own pane");400 assert.deepEqual(map.targets, [chart], "and the map is drawing into it again");401402 chart.disconnectedCallback();403 assert.equal(observers[0]?.live, false, "the observer is stopped, not merely forgotten");404 assert.equal(charted(chart).sizes, null);405 assert.deepEqual(map.targets, [chart, undefined], "and the map has let the element go");406407 chart.connectedCallback();408 assert.equal(observers.length, 2, "coming back wires a new one");409 assert.equal(observers.filter((seen) => seen.live).length, 1,410 "and there is never more than one live: a pane measured twice is a pane counted twice");411});412413test("the first connect wires nothing, because there is no map to wire yet", () => {414 observers.length = 0;415 const chart = new AtlasChart();416 chart.connectedCallback();417 assert.deepEqual(observers, [],418 "the observer arrives with the world, from show(), not before there is one");419});420421test("a chart that leaves the page does not report a camera afterwards", () => {422 const chart = new AtlasChart();423 charted(chart).report();424 assert.notEqual(charted(chart).settle, undefined, "a settle is pending");425 chart.disconnectedCallback();426 assert.equal(charted(chart).settle, undefined,427 "and it is cancelled: a camera reported after the pane went is a report about a world the reader has left");428});429430// ---- and the sphere a world change withdraws --------------------------431//432// A disconnect is the element leaving the page. This is the page staying and433// the *world* leaving: the reader opens a volume whose world is a game map,434// and a sphere is no longer a thing this page offers. The reference destroys435// the instance, forgets what it was textured for and empties the container436// (`syncGlobe`, frontend/src/globe.js); leaving it standing is what put Night437// City's map under a planet still wearing Mars's skin.438439/** What a retirement is measured by, past the fields a disconnect clears. */440interface Retired extends Inside {441 context: unknown;442 equirect: unknown;443 emptied: number;444 hidden: boolean;445}446447function retired(element: object): Retired {448 return element as unknown as Retired;449}450451test("a sphere the world no longer offers is put down, not merely hidden", () => {452 const { element, globe } = built();453 retired(element).context = { model: { slug: "mars" } };454 retired(element).equirect = { px: [0, 0, 3600, 1800] };455 const texture = new THREE.CanvasTexture(stubNode() as unknown as HTMLCanvasElement);456 inside(element).texture = texture;457 const seen: string[] = [];458 watch(texture, seen, "skin");459460 element.retire();461 assert.equal(element.hidden, true, "the pane is off screen");462 assert.equal(globe.destructs(), 1, "the renderer is given back");463 assert.deepEqual(seen, ["skin"], "and the skin with it");464 assert.equal(retired(element).emptied, 1,465 "the container is emptied: globe.gl's destructor leaves its canvas holding the last frame");466 assert.equal(element.built, false);467 // The difference from a disconnect, and the whole of the stale-skin defect:468 // this element is not about that world any more. A disconnect keeps the469 // context, because the same world is what the next entry builds from.470 assert.equal(retired(element).context, null, "the world it was showing is forgotten");471 assert.equal(retired(element).equirect, null, "and the flattening that world declared");472});473474test("retiring a sphere that was never built costs nothing", () => {475 const element = new AtlasGlobe();476 element.retire();477 element.retire();478 assert.equal(retired(element).emptied, 0, "there was no canvas to take away");479 assert.equal(element.hidden, true);480});