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 / keys.test.ts
keys.test.ts 16.9K
1// The seam's keyboard, checked against the page it listens to.2//3// `render/keys.ts` is the second module in the lane that touches the document4// and nothing else -- it reads the application's controls, moves focus and5// which pane is up, and asks the viewport for the rest -- so the page here is6// a stub of exactly the ids shell.tmpl renders, and the assertions are about7// what a keystroke reaches and, at least as often, what it does not.8//9// THE RULES THESE TESTS EXIST FOR ARE THE SUPPRESSIONS. A shortcut that fires10// is visible the first time anybody presses it. A shortcut that fires while11// the reader is typing their own "g" into a search box, or one that leaves a12// keystroke unanswered so the machine sounds a rejection tone at every press,13// is the kind of thing that survives a whole release -- so the guard, the14// preventDefault, the repeat and the one load-bearing stopPropagation each15// get a test of their own.1617import test from "node:test";18import { strict as assert } from "node:assert";19import { wireKeyboard } from "../keys.ts";20import type { KeyboardHost } from "../keys.ts";2122/** A stand-in for an element, enough of one to be a keystroke's target. */23class StubElement {24 hidden = false;25 isContentEditable = false;26 focused = 0;27 selected = 0;28 readonly tagName: string;29 constructor(tagName: string) { this.tagName = tagName; }30 focus(): void { this.focused++; }31 select(): void { this.selected++; }32 contains(other: unknown): boolean { return other === this; }33}3435interface Listener {36 handler: (event: unknown) => void;37 capture: boolean;38}3940/** What the host was asked to do, and what the page was asked for. */41interface Harness {42 readonly nodes: Map<string, StubElement>;43 readonly labels: boolean[];44 readonly flips: number[];45 readonly zooms: number[];46 sphereUp: boolean;47 fire(name: string, event: Record<string, unknown>): Prevented;48 off(): void;49}5051interface Prevented {52 prevented: boolean;53 stopped: boolean;54}5556/**57 * A page carrying the shell's named controls and a window that records what58 * was wired to it. The AbortController is real: honouring the signal is how59 * the "wired twice, heard twice" bug is tested rather than assumed.60 */61function mount(ids: string[] = [62 "#map", "#grid-input", "#pin-search", "#globe-toggle", "#atlas-grid-navigator",63]): Harness {64 const nodes = new Map<string, StubElement>();65 for (const id of ids) {66 nodes.set(id, new StubElement(id === "#map" ? "DIV" : id === "#globe-toggle" ? "BUTTON" : "INPUT"));67 }68 const listeners = new Map<string, Listener[]>();69 globalThis.Element = StubElement as unknown as typeof Element;70 globalThis.document = {71 querySelector: (selector: string) => nodes.get(selector) ?? null,72 } as unknown as Document;73 const handlers = new Map<(event: unknown) => void, Listener>();74 const drop = (name: string, entry: Listener) => {75 listeners.set(name, (listeners.get(name) ?? []).filter((one) => one !== entry));76 };77 globalThis.window = {78 addEventListener: (name: string, handler: (event: unknown) => void,79 options?: { capture?: boolean; once?: boolean; signal?: AbortSignal }) => {80 const held = listeners.get(name) ?? [];81 // `once` is honoured because it is under test: the G key arms a82 // listener for a single settle, and one that outlived its own swap83 // would take the focus away from wherever the reader put it since.84 const entry: Listener = {85 handler: (event) => {86 if (options?.once) drop(name, entry);87 handler(event);88 },89 capture: options?.capture === true,90 };91 held.push(entry);92 listeners.set(name, held);93 options?.signal?.addEventListener("abort", () => drop(name, entry));94 handlers.set(handler, entry);95 },96 // Taking a listener off again is under test as well: the G key follows its97 // own swap and has to stop following once it has been answered, and a98 // press that kept listening would take the focus back on the next swap.99 removeEventListener: (name: string, handler: (event: unknown) => void) => {100 const entry = handlers.get(handler);101 if (entry) drop(name, entry);102 },103 } as unknown as Window & typeof globalThis;104105 const labels: boolean[] = [];106 const flips: number[] = [];107 const zooms: number[] = [];108 const harness: Harness = {109 nodes, labels, flips, zooms, sphereUp: false,110 fire(name, event) {111 const answer: Prevented = { prevented: false, stopped: false };112 const raised = {113 target: null, repeat: false, metaKey: false, ctrlKey: false, altKey: false,114 ...event,115 preventDefault: () => { answer.prevented = true; },116 stopPropagation: () => { answer.stopped = true; },117 };118 for (const listener of listeners.get(name) ?? []) listener.handler(raised);119 return answer;120 },121 off: () => { /* replaced below */ },122 };123 const host: KeyboardHost = {124 holdLabels: (down) => { labels.push(down); },125 flipPane: () => { flips.push(1); },126 zoomBy: (delta) => { zooms.push(delta); },127 get sphereUp() { return harness.sphereUp; },128 };129 harness.off = wireKeyboard(host);130 return harness;131}132133/** The window itself, which is what a synthetic key from the tour is aimed at. */134const AT_WINDOW = { target: null };135136test("Z is held: down raises the labels, up puts them back", () => {137 const page = mount();138 const down = page.fire("keydown", { key: "z", ...AT_WINDOW });139 assert.deepEqual(page.labels, [true]);140 assert.equal(down.prevented, true, "Z was not swallowed, so the machine complains about it");141 const up = page.fire("keyup", { key: "z", ...AT_WINDOW });142 assert.deepEqual(page.labels, [true, false]);143 assert.equal(up.prevented, false, "the key coming up was swallowed too");144});145146test("a repeat says what the first press already said, and is still swallowed", () => {147 const page = mount();148 page.fire("keydown", { key: "z", ...AT_WINDOW });149 const again = page.fire("keydown", { key: "z", repeat: true, ...AT_WINDOW });150 assert.deepEqual(page.labels, [true], "an autorepeating Z raised the labels twice");151 assert.equal(again.prevented, true, "a repeat was left unanswered");152});153154test("a window that loses focus mid-hold lets the labels go", () => {155 const page = mount();156 page.fire("keydown", { key: "z", ...AT_WINDOW });157 page.fire("blur", {});158 assert.deepEqual(page.labels, [true, false]);159});160161test("the key coming up is heard from anywhere, even a field", () => {162 const page = mount();163 page.fire("keydown", { key: "Z", ...AT_WINDOW });164 page.fire("keyup", { key: "z", target: page.nodes.get("#pin-search") });165 assert.deepEqual(page.labels, [true, false],166 "a hold that ended over a text field left the labels up for good");167});168169test("a reader typing into a field triggers nothing and is not swallowed", () => {170 const page = mount();171 for (const id of ["#pin-search", "#grid-input"]) {172 const pressed = page.fire("keydown", { key: "z", target: page.nodes.get(id) });173 assert.equal(pressed.prevented, false, `${id}: a typed character was swallowed`);174 }175 assert.deepEqual(page.labels, [], "typing raised the labels");176});177178test("a select counts as editable, because it answers the space bar itself", () => {179 const page = mount();180 const select = new StubElement("SELECT");181 page.fire("keydown", { key: "z", target: select });182 assert.deepEqual(page.labels, []);183});184185test("a contenteditable counts too", () => {186 const page = mount();187 const note = new StubElement("DIV");188 note.isContentEditable = true;189 page.fire("keydown", { key: "z", target: note });190 assert.deepEqual(page.labels, []);191});192193test("⌘K reaches the search field and offers what is in it", () => {194 const page = mount();195 const pressed = page.fire("keydown", { key: "k", metaKey: true, ...AT_WINDOW });196 const search = page.nodes.get("#pin-search");197 assert.equal(search?.focused, 1);198 assert.equal(search?.selected, 1, "the field was focused without offering its text");199 assert.equal(pressed.prevented, true);200 assert.deepEqual(page.labels, [], "the ladder ran on past the branch that answered");201});202203test("the backquote flips the panes only where a sphere is offered", () => {204 const page = mount();205 const toggle = page.nodes.get("#globe-toggle");206 assert.ok(toggle);207 toggle.hidden = true;208 const flat = page.fire("keydown", { key: "`", ...AT_WINDOW });209 assert.deepEqual(page.flips, [], "a flat map flipped to a sphere it does not have");210 assert.equal(flat.prevented, false, "a key nothing answered was swallowed anyway");211212 toggle.hidden = false;213 const round = page.fire("keydown", { key: "`", ...AT_WINDOW });214 assert.deepEqual(page.flips, [1]);215 assert.equal(round.prevented, true);216});217218test("Escape in the grid field hands the keyboard back and stops there", () => {219 const page = mount();220 const pressed = page.fire("keydown", { key: "Escape", target: page.nodes.get("#grid-input") });221 assert.equal(page.nodes.get("#map")?.focused, 1, "the map was not given the keyboard back");222 assert.equal(pressed.prevented, true);223 // The load-bearing half: without this the same press would also telescope224 // the grid out, and the field would never get an Escape of its own.225 assert.equal(pressed.stopped, true,226 "the field's Escape travelled on, so one press left the field and ascended");227});228229test("Escape anywhere else is left alone, because a route answers it", () => {230 const page = mount();231 const onMap = page.fire("keydown", { key: "Escape", target: page.nodes.get("#map") });232 assert.equal(onMap.stopped, false, "the map's Escape was stopped before the window heard it");233 const onWindow = page.fire("keydown", { key: "Escape", ...AT_WINDOW });234 assert.equal(onWindow.stopped, false);235 assert.equal(onWindow.prevented, false);236});237238test("the backquote reaches the field too, and is swallowed there either way", () => {239 const page = mount();240 const toggle = page.nodes.get("#globe-toggle");241 assert.ok(toggle);242 toggle.hidden = true;243 const flat = page.fire("keydown", { key: "`", target: page.nodes.get("#grid-input") });244 assert.equal(flat.prevented, true, "a backquote was typed into a field that takes a hash");245 assert.deepEqual(page.flips, []);246247 toggle.hidden = false;248 page.fire("keydown", { key: "`", target: page.nodes.get("#grid-input") });249 assert.deepEqual(page.flips, [1]);250});251252// ---- G hands the keyboard on, and takes it back ------------------------253254/**255 * The grid's own swap settling.256 *257 * A settle names the region that was swapped, and the G key follows the one258 * region its route renders: anything else on the page settling is not this259 * press's answer, and taking it would be asking a navigator that has not moved260 * yet which way it went.261 */262function gridSettled(page: Harness): void {263 page.fire("htmx:after:settle", { target: page.nodes.get("#atlas-grid-navigator") });264}265266test("G hands the keyboard to the field once the grid has arrived", () => {267 const page = mount();268 const pressed = page.fire("keydown", { key: "g", ...AT_WINDOW });269 // The key itself is the application's -- a route posts it -- so nothing is270 // swallowed here and nothing has moved yet: until the swap settles, the271 // navigator on screen is still the one from before.272 assert.equal(pressed.prevented, false, "the seam answered a key that is a route");273 assert.equal(page.nodes.get("#grid-input")?.focused, 0, "the focus raced the request");274275 gridSettled(page);276 const field = page.nodes.get("#grid-input");277 assert.equal(field?.focused, 1, "the grid opened and the field never got the keyboard");278 // Selected as well as focused, which is what makes "gm6" arrive at m6: the279 // address already in the field is replaced by what is typed next.280 assert.equal(field?.selected, 1);281 assert.equal(page.nodes.get("#map")?.focused, 0);282});283284// The bug this test is named after was intermittent, which is the worst way for285// a focus bug to be wrong: the page settles searches, camera reports and its286// own session island without the navigator moving, and a press that spent287// itself on one of those read a navigator that had not been swapped yet, called288// the grid shut, and put the keyboard back on the map -- so G opened the grid289// and left the reader typing wherever they were typing before.290test("a settle from somewhere else leaves the press waiting for its own", () => {291 const page = mount();292 page.fire("keydown", { key: "g", ...AT_WINDOW });293 page.fire("htmx:after:settle", { target: page.nodes.get("#pin-search") });294 page.fire("htmx:after:settle", { ...AT_WINDOW });295 assert.equal(page.nodes.get("#map")?.focused, 0,296 "another region's swap was read as the grid's answer");297 assert.equal(page.nodes.get("#grid-input")?.focused, 0);298299 gridSettled(page);300 assert.equal(page.nodes.get("#grid-input")?.focused, 1,301 "the press was spent on a swap that was not its own");302});303304test("G that closed the grid hands the keyboard back to the map", () => {305 const page = mount();306 const navigator = page.nodes.get("#atlas-grid-navigator");307 assert.ok(navigator);308 navigator.hidden = true;309 page.fire("keydown", { key: "g", ...AT_WINDOW });310 gridSettled(page);311 assert.equal(page.nodes.get("#map")?.focused, 1,312 "the grid closed and the keyboard stayed in a field nobody can see");313 assert.equal(page.nodes.get("#grid-input")?.focused, 0);314});315316test("one press follows one settle, and no more", () => {317 const page = mount();318 page.fire("keydown", { key: "g", ...AT_WINDOW });319 gridSettled(page);320 gridSettled(page);321 assert.equal(page.nodes.get("#grid-input")?.focused, 1,322 "a swap the reader did not ask for took the focus back");323});324325test("a page with no navigator spends the press on the map rather than waiting", () => {326 const page = mount(["#map", "#grid-input", "#pin-search", "#globe-toggle"]);327 page.fire("keydown", { key: "g", ...AT_WINDOW });328 page.fire("htmx:after:settle", { ...AT_WINDOW });329 assert.equal(page.nodes.get("#map")?.focused, 1,330 "a page that can never answer left a press armed for ever");331});332333test("a settle nobody armed moves no focus at all", () => {334 const page = mount();335 gridSettled(page);336 assert.equal(page.nodes.get("#grid-input")?.focused, 0);337 assert.equal(page.nodes.get("#map")?.focused, 0);338});339340test("a reader typing a g arms nothing, in a field or in the grid's own", () => {341 const page = mount();342 for (const id of ["#pin-search", "#grid-input"]) {343 page.fire("keydown", { key: "g", target: page.nodes.get(id) });344 }345 gridSettled(page);346 assert.equal(page.nodes.get("#grid-input")?.focused, 0,347 "typing a g into a field armed the grid's focus");348});349350test("⌘G is the system cycle and hands the keyboard nowhere", () => {351 const page = mount();352 page.fire("keydown", { key: "g", metaKey: true, ...AT_WINDOW });353 gridSettled(page);354 assert.equal(page.nodes.get("#grid-input")?.focused, 0);355 assert.equal(page.nodes.get("#map")?.focused, 0);356});357358test("the armed press leaves with the keyboard it was armed on", () => {359 const page = mount();360 page.fire("keydown", { key: "g", ...AT_WINDOW });361 page.off();362 gridSettled(page);363 assert.equal(page.nodes.get("#grid-input")?.focused, 0,364 "a seam that left the page still moved the focus");365});366367test("the map's own keys step the zoom, and only on the map", () => {368 const page = mount();369 for (const key of ["+", "=", "-"]) {370 page.fire("keydown", { key, target: page.nodes.get("#map") });371 }372 assert.deepEqual(page.zooms, [1, 1, -1]);373 page.fire("keydown", { key: "+", ...AT_WINDOW });374 assert.deepEqual(page.zooms, [1, 1, -1], "the zoom keys answered with the map unfocused");375});376377test("the zoom keys are inert behind the sphere and still swallowed", () => {378 const page = mount();379 page.sphereUp = true;380 const pressed = page.fire("keydown", { key: "+", target: page.nodes.get("#map") });381 assert.deepEqual(page.zooms, [], "a key press zoomed the chart nobody is looking at");382 assert.equal(pressed.prevented, true);383});384385test("the webview's menu is put away, except over text", () => {386 const page = mount();387 assert.equal(page.fire("contextmenu", AT_WINDOW).prevented, true);388 assert.equal(page.fire("contextmenu", { target: page.nodes.get("#map") }).prevented, true);389 assert.equal(page.fire("contextmenu", { target: page.nodes.get("#pin-search") }).prevented, false,390 "a text field lost the menu that carries cut and paste");391});392393test("the keyboard comes off, and comes off whole", () => {394 const page = mount();395 page.off();396 page.fire("keydown", { key: "z", ...AT_WINDOW });397 page.fire("keyup", { key: "z", ...AT_WINDOW });398 page.fire("blur", {});399 assert.deepEqual(page.labels, [], "a viewport that left the page kept listening");400 assert.equal(page.fire("contextmenu", AT_WINDOW).prevented, false);401});402403test("a page missing the controls loses the shortcut and nothing else", () => {404 const page = mount([]);405 assert.doesNotThrow(() => page.fire("keydown", { key: "k", metaKey: true, ...AT_WINDOW }));406 assert.doesNotThrow(() => page.fire("keydown", { key: "`", ...AT_WINDOW }));407 assert.doesNotThrow(() => page.fire("keydown", { key: "+", ...AT_WINDOW }));408 // Z asks nothing of the page, so it still answers.409 page.fire("keydown", { key: "z", ...AT_WINDOW });410 assert.deepEqual(page.labels, [true]);411});