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 / hover.test.ts
hover.test.ts 10.0K
1// The legend's pointer, and the mark two carried stylesheet rules read.2//3// `.only-button` and `.label-toggle` are drawn at `opacity: 0` and revealed4// only under `.category-row.is-hovered` / `.layer-header.is-hovered`. Nothing5// wrote that class, so both controls were on the page, wired to their routes,6// and permanently invisible -- a defect no unit test could have caught,7// because every Go test and every recorded step reads the buttons' attributes8// and all of them were right.9//10// So what is checked here is the mark itself, over a page stubbed down to the11// three things the tracker actually asks of one: a container under a selector,12// a hit test that answers with an element, and rows that can wear a class.13// The hit test is a stub *by necessity* -- `elementFromPoint` is a layout14// question and there is no layout in Node -- which is exactly why the manual15// check with a browser is part of this fix and not optional.16//17// The cases are the four ways the answer can be wrong:18//19// THE ROW UNDER THE POINTER, and only that one.20// THE POINTER LEAVING, which must take the mark with it -- a row left21// offering an action to a cursor somewhere else is the bug the reference22// wrote this function to avoid.23// THE LIST SCROLLING UNDER A STILL CURSOR, which is the same bug arriving24// the other way round and the reason the answer comes from the pointer's25// position rather than from `:hover`.26// AND THE SWAP. The legend is a morph region: the container can be replaced27// whole, and it can be handed back intact carrying marks from before. One28// pass has to survive both, and it must not leave a second listener behind29// on a container it has already wired.3031import test from "node:test";32import { strict as assert } from "node:assert";33import { RowHover } from "../hover.ts";3435/** A row of the legend: a class list, and a place in a container. */36class StubRow {37 readonly classes = new Set<string>();38 readonly matches: string;39 constructor(matches: string) {40 this.matches = matches;41 }4243 readonly classList = {44 add: (name: string) => { this.classes.add(name); },45 remove: (name: string) => { this.classes.delete(name); },46 };4748 /** The row is what a hit test lands on; a child would answer the same. */49 closest(selector: string): StubRow | null {50 return selector.includes(this.matches) ? this : null;51 }5253 get marked(): boolean {54 return this.classes.has("is-hovered");55 }56}5758interface Listener {59 handler: (event: unknown) => void;60}6162/** The `#layers` container: the rows it holds, and what was wired to it. */63class StubLayers {64 readonly listeners = new Map<string, Listener[]>();65 readonly rows: StubRow[];66 constructor(rows: StubRow[]) {67 this.rows = rows;68 }6970 addEventListener(name: string, handler: (event: unknown) => void,71 options?: { signal?: AbortSignal }): void {72 const held = this.listeners.get(name) ?? [];73 const entry: Listener = { handler };74 held.push(entry);75 this.listeners.set(name, held);76 // The signal is honoured because it is the whole of the teardown: a77 // tracker that stopped and left its listeners on the page would keep78 // marking rows for an element nobody is looking at.79 options?.signal?.addEventListener("abort", () => {80 this.listeners.set(name, (this.listeners.get(name) ?? []).filter((one) => one !== entry));81 });82 }8384 querySelectorAll(selector: string): StubRow[] {85 const wanted = selector.replace(".", "");86 return this.rows.filter((row) => row.classes.has(wanted));87 }8889 contains(other: unknown): boolean {90 return this.rows.includes(other as StubRow);91 }9293 fire(name: string, event: Record<string, unknown> = {}): void {94 for (const listener of [...this.listeners.get(name) ?? []]) listener.handler(event);95 }9697 /** How many handlers are listening, which is how double-wiring is caught. */98 get wiring(): number {99 return [...this.listeners.values()].reduce((total, held) => total + held.length, 0);100 }101}102103/** The page: one container under `#layers`, and a hit test we decide. */104function mount(layers: StubLayers | null) {105 const page = { layers, under: null as StubRow | null };106 globalThis.document = {107 querySelector: (selector: string) => (selector === "#layers" ? page.layers : null),108 elementFromPoint: () => page.under,109 } as unknown as Document;110 return page;111}112113function row(kind: "category-row" | "layer-header" | "zone-index-row" = "category-row"): StubRow {114 return new StubRow(kind);115}116117test("the row under the pointer wears the mark, and no other", () => {118 const [first, second, header] = [row(), row(), row("layer-header")];119 const layers = new StubLayers([first, second, header]);120 const page = mount(layers);121 const hover = new RowHover();122 hover.start();123124 page.under = second;125 layers.fire("pointermove", { clientX: 10, clientY: 40 });126 assert.equal(second.marked, true);127 assert.equal(first.marked, false);128129 // Moving on takes the mark with it: every pass clears before it writes.130 page.under = header;131 layers.fire("pointermove", { clientX: 10, clientY: 4 });132 assert.equal(header.marked, true, "a section head marks like a row");133 assert.equal(second.marked, false, "the mark was left behind on the old row");134 hover.stop();135});136137// A row of a feature index wears an exclusive control of its own -- highlight138// this zone and no other -- drawn at `opacity: 0` under the same carried rule139// as the collection's. It is a third kind of row to the stylesheet and the same140// kind of thing to the pointer, and leaving it out of the selector would have141// shipped exactly the defect this module exists to have fixed: a control on142// the page, wired to its route, and permanently invisible.143test("a row of a feature index marks like any other row", () => {144 const zone = row("zone-index-row");145 const category = row();146 const layers = new StubLayers([category, zone]);147 const page = mount(layers);148 const hover = new RowHover();149 hover.start();150151 page.under = zone;152 layers.fire("pointermove", { clientX: 10, clientY: 90 });153 assert.equal(zone.marked, true, "a zone row never reveals its exclusive control");154 assert.equal(category.marked, false);155 hover.stop();156});157158test("a hit outside the list marks nothing", () => {159 const inside = row();160 const outside = row();161 const layers = new StubLayers([inside]);162 const page = mount(layers);163 const hover = new RowHover();164 hover.start();165166 // A card or a menu drawn over the legend answers the hit test with an167 // element of its own, and the listener's scope does not make that element168 // one of this list's rows.169 page.under = outside;170 layers.fire("pointermove", { clientX: 10, clientY: 40 });171 assert.equal(outside.marked, false);172 assert.equal(inside.marked, false);173 hover.stop();174});175176test("the pointer leaving takes the mark with it", () => {177 const only = row();178 const layers = new StubLayers([only]);179 const page = mount(layers);180 const hover = new RowHover();181 hover.start();182183 page.under = only;184 layers.fire("pointermove", { clientX: 10, clientY: 40 });185 assert.equal(only.marked, true);186187 layers.fire("pointerleave");188 assert.equal(only.marked, false, "the list kept offering an action to a pointer that had gone");189 hover.stop();190});191192test("scrolling under a still cursor re-asks where the pointer is", () => {193 const above = row();194 const below = row();195 const layers = new StubLayers([above, below]);196 const page = mount(layers);197 const hover = new RowHover();198 hover.start();199200 page.under = above;201 layers.fire("pointermove", { clientX: 10, clientY: 40 });202 assert.equal(above.marked, true);203204 // THE POINTER DID NOT MOVE. The list did, which is the case `:hover` is205 // unreliable for and the reason this is tracked rather than styled.206 page.under = below;207 layers.fire("scroll");208 assert.equal(below.marked, true);209 assert.equal(above.marked, false);210 hover.stop();211});212213test("a swap that replaces the list is wired again, once", () => {214 const first = row();215 const layers = new StubLayers([first]);216 const page = mount(layers);217 const hover = new RowHover();218 hover.start();219 const wiring = layers.wiring;220 assert.ok(wiring > 0, "the list was never wired at all");221222 // The same container, handed back by a morph. It is already listening, and223 // a rescan that wired it again would answer one pointer move twice.224 hover.rescan();225 assert.equal(layers.wiring, wiring, "a morphed-in container was wired twice");226227 // A stale mark, which is what a morph leaves when it carries a row across228 // whole: the pointer is elsewhere and the row still says it is under it.229 first.classes.add("is-hovered");230 hover.rescan();231 assert.equal(first.marked, false, "a mark survived the swap that invalidated it");232233 // And now the other kind of swap: a different node under the same selector.234 const fresh = row();235 const replaced = new StubLayers([fresh]);236 page.layers = replaced;237 hover.rescan();238 assert.equal(replaced.wiring, wiring, "the replacement list was left unwired");239 page.under = fresh;240 replaced.fire("pointermove", { clientX: 10, clientY: 40 });241 assert.equal(fresh.marked, true);242 hover.stop();243});244245test("a page with no legend is not an error", () => {246 mount(null);247 const hover = new RowHover();248 hover.start();249 hover.rescan();250 hover.stop();251});252253test("a tracker that stopped marks nothing again", () => {254 const only = row();255 const layers = new StubLayers([only]);256 const page = mount(layers);257 const hover = new RowHover();258 hover.start();259 page.under = only;260 layers.fire("pointermove", { clientX: 10, clientY: 40 });261 assert.equal(only.marked, true);262263 hover.stop();264 assert.equal(layers.wiring, 0, "the listeners outlived the element that made them");265266 // The element comes back -- a morph can put the same one back -- and mints a267 // new tracker rather than reviving the spent one. One life, one wiring: the268 // mark still lands exactly once.269 const revived = new RowHover();270 revived.start();271 page.under = only;272 layers.fire("pointermove", { clientX: 10, clientY: 40 });273 assert.equal(only.marked, true);274 assert.equal(layers.wiring, 3, "a reconnected element stacked a second set of listeners");275 revived.stop();276});