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 / viewport.test.ts
viewport.test.ts 19.0K
1// The seam's edge, where a bad answer must not become a broken page.2//3// Three duties live on the host, and each of them is a place the lane can be4// handed something it did not choose.5//6// THE SYSTEM IS THE GROUND'S ANSWER, NOT THE SESSION'S. A record can name a7// system this world cannot be divided by -- an older build wrote it, a hand8// edited it, a bug let it through -- and the systems are exact rather than9// approximate: S2 asked about a ground with no invertible flattening throws10// outright. The host used to fall back to the registry, which is the one11// answer that cannot be given, and the throw arrived in the middle of a12// repaint.13// SO A REPAINT IS FENCED. Whatever gets past the first rule costs one14// repaint and one line on the stream, and never the map: a reader whose grid15// is wrong can still read their world.16// AND THE NAVIGATOR'S FIELD IS ASSISTED, NOT GOVERNED. What an address is17// belongs to the server; this end shows the reader only what their system18// could keep of what they typed, keeps the caret where they left it, and19// gets out of the way of the route bound to the same field -- which is what20// the recorded tours drive, one `.value` and one `input` event at a time.2122import test from "node:test";23import { strict as assert } from "node:assert";24import { cellSystems } from "@atlas/analysis";25import type { Ground } from "@atlas/analysis";26import { setLevel } from "../log.ts";2728setLevel("error");2930/**31 * A stub node: enough of one for `globe.gl` to load over, and enough of one32 * for the card's definition list to be walked, inserted into and cleared.33 *34 * The list a node sits in is the node's own `row`, because the only structure35 * anything here reads is order: what a row was inserted after, and which rows36 * are still standing.37 */38class StubEl {39 readonly width = 0;40 readonly height = 0;41 readonly style = {};42 readonly classList = { add: () => {} };43 readonly attrs: Record<string, string> = {};44 readonly kids: StubEl[] = [];45 hidden = false;46 textContent = "";47 /** The list this node stands in, if it stands in one. */48 row: StubEl[] | null = null;4950 getContext(): null { return null; }51 appendChild(): void {}52 insertBefore(): void {}53 addEventListener(): void {}5455 setAttribute(name: string, value: string): void {56 this.attrs[name] = value;57 }5859 append(...kids: StubEl[]): void {60 this.kids.push(...kids);61 }6263 after(node: StubEl): void {64 if (!this.row) return;65 this.row.splice(this.row.indexOf(this) + 1, 0, node);66 node.row = this.row;67 }6869 remove(): void {70 if (!this.row) return;71 this.row.splice(this.row.indexOf(this), 1);72 this.row = null;73 }74}7576function stubNode(): Record<string, unknown> {77 return new StubEl() as unknown as Record<string, unknown>;78}7980const nodes = new Map<string, unknown>();81/** The open card's definition list, in the order it is rendered. */82let list: StubEl[] = [];8384const host = globalThis as unknown as Record<string, unknown>;85host.HTMLElement = class {86 /** The panes are looked up rather than held; this page renders neither. */87 querySelector(): unknown { return null; }88};89host.requestAnimationFrame = (): number => 0;90host.cancelAnimationFrame = (): void => {};91host.Image = class { src = ""; crossOrigin = ""; };92host.window = globalThis;93host.document = {94 querySelector: (selector: string) => nodes.get(selector) ?? null,95 querySelectorAll: (selector: string) => (selector === "[data-cell-system-row]"96 ? list.filter((node) => node.attrs["data-cell-system-row"] !== undefined)97 : []),98 createElement: stubNode,99 createElementNS: stubNode,100 createTextNode: stubNode,101 getElementsByTagName: () => [stubNode()],102 addEventListener: () => {},103 head: stubNode(),104 body: stubNode(),105};106107const { AtlasViewport } = await import("../viewport.ts");108109interface Seam { [key: string]: unknown }110111function viewport(): Seam {112 nodes.clear();113 list = [];114 return new AtlasViewport() as unknown as Seam;115}116117/** A game map: it says nothing about what its picture is of. */118const PLANE: Ground = { tileGridSize: 8192, lens: null, world: { attrs: {} } };119120// ---- which system divides this world ---------------------------------121122function chosen(seam: Seam, slug: string): unknown {123 return (seam.system as (scene: unknown, ground: Ground) => unknown)124 .call(seam, { gridSystem: slug, volume: "bend-or", world: "city" }, PLANE);125}126127test("the system a plane offers is the one it gets", () => {128 const seam = viewport();129 assert.equal(chosen(seam, "geohash"), cellSystems.get("geohash"));130});131132test("a system this ground refuses is not fallen back to", () => {133 const seam = viewport();134 // S2 is registered, and registered is not the question: it wants a sphere135 // with an invertible flattening and this is a game map. The old fallback136 // handed it over anyway, and it threw on the first cell it was asked about.137 assert.ok(cellSystems.get("s2"), "S2 is registered, which is what makes this a fallback");138 assert.equal(chosen(seam, "s2"), null);139});140141test("a system nobody has written is not a system either", () => {142 assert.equal(chosen(viewport(), "h3"), null);143});144145test("no system named is no grid, and no complaint", () => {146 assert.equal(chosen(viewport(), ""), null);147});148149// ---- a repaint that cannot kill the map -------------------------------150151test("a system that throws mid-repaint costs one line and not the page", () => {152 const seam = viewport();153 const thrown = new Error("s2: this ground declares no invertible flattening");154 seam.context = {155 scene: { volume: "bend-or", world: "city" },156 ground: PLANE,157 cell: "9q",158 lens: null,159 model: {},160 hovered: null,161 system: { slug: "s2", on: () => { throw thrown; } },162 };163 const said: unknown[][] = [];164 // The stream is *captured* rather than written to -- what the lane says165 // when a system refuses is the subject here, and the sink is where it says166 // it (render/log.ts). Reached through the global so the rule that forbids167 // writing to it bare has nothing to object to.168 const stream = globalThis.console;169 const held = stream.error;170 stream.error = (...args: unknown[]) => { said.push(args); };171 try {172 assert.doesNotThrow(() => (seam.refresh as () => void).call(seam));173 } finally {174 stream.error = held;175 }176 assert.equal(said.length, 1, "the failure was silent, or said twice");177 const fields = said[0]?.[1] as Record<string, unknown>;178 assert.equal(fields.system, "s2");179 assert.equal(fields.cell, "9q");180 assert.equal(fields.volume, "bend-or");181 assert.match(String(fields.error), /invertible flattening/);182});183184// ---- the navigator's field --------------------------------------------185186/** A text field, with the two things a caret is read and written through. */187function field(value: string, caret = value.length): Record<string, unknown> {188 const ranges: number[][] = [];189 return {190 value,191 selectionStart: caret,192 ranges,193 setSelectionRange: (from: number, to: number) => { ranges.push([from, to]); },194 addEventListener: () => {},195 };196}197198/** A viewport standing over a world geohash divides. */199function dividing(): Seam {200 const seam = viewport();201 seam.context = { system: cellSystems.get("geohash"), ground: PLANE };202 return seam;203}204205function normalize(seam: Seam, node: Record<string, unknown>): void {206 (seam.normalizeCell as (field: unknown) => void).call(seam, node);207}208209test("what the system cannot keep is not left in the field", () => {210 const node = field("M6");211 normalize(dividing(), node);212 assert.equal(node.value, "m6");213});214215test("the caret lands after what survived of what stood before it", () => {216 // "m!6" with the caret between the "!" and the "6": one character of what217 // was typed before it survives, so the caret belongs after one character.218 const node = field("m!6", 2);219 normalize(dividing(), node);220 assert.equal(node.value, "m6");221 assert.deepEqual(node.ranges, [[1, 1]]);222});223224test("typing at the end stays at the end", () => {225 const node = field("m!6", 3);226 normalize(dividing(), node);227 assert.deepEqual(node.ranges, [[2, 2]]);228});229230test("a keystroke that was already an address is not touched at all", () => {231 // THE TOUR'S OWN PATH. `type("#grid-input", "m")` sets the value and232 // dispatches one `input`; geohash keeps "m" whole, so the field, the caret233 // and the event the route is bound to are left exactly as they were found.234 const node = field("m");235 normalize(dividing(), node);236 assert.equal(node.value, "m");237 assert.deepEqual(node.ranges, [], "an untouched field had its caret moved");238});239240test("with no system dividing the world the field is nobody's to rewrite", () => {241 const seam = viewport();242 seam.context = { system: null, ground: PLANE };243 const node = field("M6");244 normalize(seam, node);245 assert.equal(node.value, "M6");246});247248// ---- the open card's cell addresses ------------------------------------249//250// Half of that card is the application's and half is this lane's, exactly as251// the footer's "N of M" sentence is: where a point stands is in the payload252// and the server writes it, and what that place is *called* only the analysis253// lane can say. So the row is rendered present, empty and hidden, and this254// fills it after every swap -- there is nothing to keep in step, because the255// card is an outerMorph region and the swap takes the last answer away with256// the node it was written on.257//258// The address is the fixed-depth one and not the grid's: the row says where259// this point is, and a reader descending the navigator did not ask for every260// open card to be re-spelled behind them.261262/** A sphere with an invertible flattening: the ground S2 will divide. */263const SPHERE: Ground = {264 tileGridSize: 8192,265 lens: { surface: { x: 0, y: 0, width: 8192, height: 4096 }, bounds: null },266 world: {267 attrs: {268 "atlas.geometry.surface": "sphere",269 "atlas.geometry.projection": "equirect",270 "atlas.geometry.equirect.px": "0,0,8192,4096",271 "atlas.geometry.equirect.deg": "-180,90,180,-90",272 },273 },274};275276/** The point every card below is about, and where it stands. */277const AT: [number, number] = [3000, -1500];278279/** The card as the server renders it: the row present, empty and hidden. */280function card(): { field: StubEl; value: StubEl } {281 const field = new StubEl();282 field.hidden = true;283 field.row = list;284 list.push(field);285 const value = new StubEl();286 nodes.set("#detail-cell-field", field);287 nodes.set("#detail-cell", value);288 return { field, value };289}290291/** A viewport standing over a world, with something selected on it. */292function selecting(ground: Ground, selected: string): Seam {293 const seam = viewport();294 seam.context = {295 ground,296 scene: { selected, volume: "bend-or", world: "city" },297 model: { pointByID: new Map([["1849", { id: "1849", coordinate: AT }]]) },298 };299 return seam;300}301302function written(seam: Seam): void {303 (seam.writeCell as () => void).call(seam);304}305306/** The rows this lane inserted, as the term and value a reader sees. */307function rows(): [string, string, string][] {308 return list309 .filter((node) => node.attrs["data-cell-system-row"] !== undefined)310 .map((node) => [311 node.attrs["data-cell-system-row"] ?? "",312 node.kids[0]?.textContent ?? "",313 node.kids[1]?.textContent ?? "",314 ]);315}316317test("a point's card is given the fixed-depth address, and shown", () => {318 const seam = selecting(SPHERE, "1849");319 const { field, value } = card();320 written(seam);321 // Three characters, which is the exported helper's own depth. The grid's322 // depth is somewhere else entirely and moves under the reader's feet: at323 // depth 1 this point is "d" and at 2 it is "du".324 assert.equal(value.textContent, "dum");325 assert.equal(field.hidden, false, "a row with an address in it is a row to show");326});327328test("every other system this ground offers names the place too", () => {329 const seam = selecting(SPHERE, "1849");330 card();331 written(seam);332 assert.deepEqual(rows(), [["s2", "S2", "8b3b07"]],333 "the second system's row is missing, or is not the system's own answer");334});335336test("a ground S2 refuses is never asked", () => {337 // A game map says nothing about what its picture is of, so S2 does not apply338 // -- and S2 asked anyway throws rather than shrugging. `applicableSystems` is339 // the asking, and the card is geohash's alone.340 const seam = selecting(PLANE, "1849");341 const { field, value } = card();342 written(seam);343 assert.equal(value.textContent, "ffj", "the same point, on the ground it is actually on");344 assert.equal(field.hidden, false);345 assert.deepEqual(rows(), []);346});347348test("a card that is now about a shape keeps nothing of the point before it", () => {349 const seam = selecting(SPHERE, "1849");350 const { field, value } = card();351 written(seam);352 assert.equal(rows().length, 1, "a row to be left behind");353354 // The swap that opened the shape's card rendered no cell field, and the355 // rows this lane inserted are not the server's to take away.356 (seam.context as { scene: { selected: string } }).scene.selected = "1469115845";357 written(seam);358 assert.deepEqual(rows(), [], "the shape's card is wearing the point's address");359 assert.equal(field.hidden, true);360 assert.equal(value.textContent, "");361});362363test("the swap that just landed decides, not the reconcile still in flight", () => {364 // A rescan runs the moment the card is swapped in; the context it is read365 // against still names the selection *before* the swap, because reconciling366 // is a payload away. The address written is the open card's.367 const seam = selecting(SPHERE, "");368 seam.watcher = { scene: { selected: "1849" } };369 const { field, value } = card();370 written(seam);371 assert.equal(value.textContent, "dum");372 assert.equal(field.hidden, false);373});374375test("with no card on the page there is nothing to write and nothing to clear", () => {376 const seam = selecting(SPHERE, "1849");377 assert.doesNotThrow(() => written(seam));378 assert.doesNotThrow(() => written(viewport()));379});380381// ---- wiring the field --------------------------------------------------382383test("the field is wired once, in the capture phase, and never stops the event", () => {384 const seam = viewport();385 const wirings: { capture: boolean }[] = [];386 const node = {387 value: "",388 addEventListener: (name: string, _handler: unknown, options?: { capture?: boolean }) => {389 assert.equal(name, "input");390 wirings.push({ capture: options?.capture === true });391 },392 };393 nodes.set("#grid-input", node);394 const wire = seam.wireGridInput as () => void;395 wire.call(seam);396 wire.call(seam);397 assert.deepEqual(wirings, [{ capture: true }],398 "the field was wired twice, so one keystroke would be normalized twice");399});400401test("a page with no navigator on it wires nothing and says nothing", () => {402 const seam = viewport();403 assert.doesNotThrow(() => (seam.wireGridInput as () => void).call(seam));404});405406// ---- the pane a world change leaves standing ---------------------------407//408// WHICH PANE IS UP IS SEAM STATE, and that is right until the world changes.409// A filter must not drop the reader back to the chart, so the flag survives410// every swap -- and a volume change is the one swap where surviving is wrong.411// The reader opened Night City while the Mars globe was up: the server did its412// half (the toggle is rendered `hidden` for a world that declares no sphere,413// `topbar.tmpl`), and the sphere went on turning in front of it, still wearing414// the skin of the world they had left.415//416// The scene says which surface the world declares, so the pane is put right in417// the tick the scene moved rather than a payload later.418419/** The two panes, as much of them as a pane flip touches. */420function panes() {421 const chart = {422 hidden: true, located: [] as unknown[], counted: 0, fronted: 0,423 locate(where: unknown) { this.located.push(where); },424 front() { this.fronted += 1; },425 show() {}, restyle() {}, writeCount() { this.counted += 1; }, redrawOverview() {},426 };427 const globe = { retired: 0, retire() { this.retired += 1; }, show() {} };428 return { chart, globe };429}430431/** A viewport with the sphere up over a world, and a toggle saying so. */432function standing(): {433 seam: Seam;434 chart: ReturnType<typeof panes>["chart"];435 globe: ReturnType<typeof panes>["globe"];436 toggle: { pressed: string };437} {438 const seam = viewport();439 const { chart, globe } = panes();440 seam.querySelector = (selector: string) => (selector === "atlas-chart" ? chart : globe);441 seam.globeUp = true;442 // No volume in the catalog: the scene is read and the pane decided before a443 // payload is ever asked for, which is the point -- a sphere over the wrong444 // world must come down even if nothing else about the new one ever arrives.445 seam.catalog = { volumes: [] };446 const toggle = {447 pressed: "true",448 setAttribute: (name: string, value: string) => {449 if (name === "aria-pressed") toggle.pressed = value;450 },451 };452 nodes.set("#globe-toggle", toggle);453 return { seam, chart, globe, toggle };454}455456function scene(surface: "plane" | "sphere"): Record<string, unknown> {457 return { volume: "neon-harbor", base: "/data/neon-harbor", world: "old-quarter", surface };458}459460const swapped = { volume: true, world: true, lens: true, filters: false,461 selection: false, grid: false, camera: false, any: true };462463function applied(seam: Seam, surface: "plane" | "sphere"): Promise<void> {464 return (seam.apply as (scene: unknown, change: unknown) => Promise<void>)465 .call(seam, scene(surface), swapped);466}467468test("a world that declares no sphere takes the sphere down with it", async () => {469 const { seam, chart, globe, toggle } = standing();470 await applied(seam, "plane");471 assert.equal(seam.globeUp, false, "the pane flag follows the world");472 assert.equal(chart.hidden, false, "the chart is back on screen");473 assert.deepEqual(chart.located, [null], "and reading its own camera again rather than being told");474 assert.equal(chart.fronted, 1,475 "and the camera it was left standing on behind the sphere is checked as it comes up");476 assert.equal(globe.retired, 1, "the planet is given back, not merely hidden");477 assert.equal(toggle.pressed, "false", "and the control says which pane is up");478});479480test("a world that does declare one is left exactly as it was found", async () => {481 const { seam, chart, globe, toggle } = standing();482 await applied(seam, "sphere");483 assert.equal(seam.globeUp, true, "the reader is still on the sphere");484 assert.equal(chart.hidden, true);485 assert.equal(globe.retired, 0);486 assert.equal(toggle.pressed, "true");487});488489test("putting the sphere down twice is putting it down once", async () => {490 // Every scene change over a plane comes through here -- a filter, a491 // selection, a grid -- so the second pass must be a pass over nothing. The492 // sphere is asked again and answers for free (`AtlasGlobe.retire` returns on493 // an element that is already about no world); what must not happen twice is494 // anything the reader would see.495 const { seam, chart } = standing();496 await applied(seam, "plane");497 await applied(seam, "plane");498 assert.equal(seam.globeUp, false);499 assert.equal(chart.hidden, false);500 assert.deepEqual(chart.located, [null], "the chart was put back once, not once per scene");501 assert.equal(chart.fronted, 1, "and its camera was checked once, on the pass that put it back");502});