SpaceCities / test /_dom.js
Claude
Tier 3d: one DOM test double, not nine
f9e883b unverified
Raw History Blame
9.46 kB
/* ============================================================
The shared DOM double for the browser-layer tests.
Eight test files each grew their own `class FakeElement extends EventTarget`
β€” hud, hudSelection, observer, overlays, saveload, starmap, techChart,
update β€” plus four copies of the same no-op-Proxy `fakeCtx()` and eight
copies of a byte-identical `fakeDocument()`. They were ~90% the same code,
and the 10% that differed was not a considered difference: it was whichever
subset of the DOM the file that copied it happened to need that week. So
overlays' copy grew real `.remove()` parent tracking and hudSelection's did
not; hud's grew a real `querySelector` that walks the tree and update's kept
the always-null stub; observer's `getBoundingClientRect` returned a real
800x600 box and saveload's returned zeros. A test written against a weak
copy silently asserts less than the same test written against a strong one,
and nothing tells you which copy you landed on.
This is the union, with the strongest implementation of each member β€” real
child arrays, real per-instance class state, real parent tracking so
`.remove()` splices, and a real (if shallow) `querySelector` β€” so every file
starts from the best version rather than the weakest.
TWO THINGS ARE DELIBERATELY CONFIGURABLE, because making them uniform would
change what the code under test actually does:
β€’ `context` β€” what `getContext()` hands back. dom.js calls
`.getContext("2d")` on the canvas handles at module scope no matter what
the test is about, so it always has to succeed. Returning null (the
default) leaves modules that guard `if (!ctx) return` inert; returning
`fakeCtx()` lets them run all the way through. Files whose subject
actually draws (render.js's spriteIcon reads `canvas.toDataURL()` back,
and its own catch fires a console.error on EVERY icon button of EVERY
render if either half fails) pass `{ context: fakeCtx }`.
β€’ `clientWidth` / `clientHeight` β€” the viewport `getBoundingClientRect`
reports. Camera and pointer-mapping code divides by these, so zeros and
800x600 are genuinely different test fixtures, not a detail.
USAGE β€” dom.js resolves every element handle exactly ONCE, at import time,
via `doc.getElementById(id)`, so the document has to exist BEFORE anything
that transitively imports dom.js is first imported. Under `node --test`'s
native ESM loader that means installFakeDom() at the top of the file and
`await import(...)` for the modules under test, never a static import:
import { installFakeDom, fakeCtx } from "./_dom.js";
installFakeDom({ context: fakeCtx });
const { renderSelectionPanel } = await import("../hudSelection.js");
============================================================ */
"use strict";
// Module-level rather than threaded through every constructor: `node --test` gives each test
// FILE its own process, so there is exactly one document per process and installFakeDom() sets
// this once, before the first dynamic import.
const DEFAULTS = { context: () => null, clientWidth: 800, clientHeight: 600 };
let config = { ...DEFAULTS };
// Any method call is a silent no-op; any property read/write round-trips through a plain backing
// object. That tolerates whatever drawUnitShape/drawBuildingShape happen to call (scale/translate/
// fillStyle/strokeStyle/gradients/…) without hand-enumerating the 2D canvas API, and stays robust
// to unrelated changes in those drawing functions. Tests that need to ASSERT on the draw calls
// want a recording proxy instead β€” see the golden-trace harness in test/render.test.js.
export function fakeCtx() {
return new Proxy({}, { get: (t, p) => (p in t ? t[p] : () => {}) });
}
export class FakeElement extends EventTarget {
constructor(tag = "div") {
super();
this.tagName = tag;
this.children = [];
this.dataset = {};
this.style = {};
this.clientWidth = config.clientWidth;
this.clientHeight = config.clientHeight;
this._classes = new Set();
this.classList = {
add: (...c) => c.forEach(x => this._classes.add(x)),
remove: (...c) => c.forEach(x => this._classes.delete(x)),
toggle: (c, f) => {
const on = f === undefined ? !this._classes.has(c) : f;
if (on) this._classes.add(c); else this._classes.delete(c);
return on; // real classList.toggle returns the resulting state
},
contains: c => this._classes.has(c),
};
}
get className() { return [...this._classes].join(" "); }
set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); }
// Parent tracking is what makes `.remove()` real: showGalaxyToast's eviction (overlays.js) and
// showBanner's "replace any prior banner" (update.js) both call .remove() on a CHILD element,
// which in the real DOM splices itself out of its own parent.
appendChild(c) { c._parent = this; this.children.push(c); return c; }
append(...cs) { cs.forEach(c => { c._parent = this; this.children.push(c); }); }
prepend(c) { c._parent = this; this.children.unshift(c); return c; }
removeChild(c) {
const i = this.children.indexOf(c);
if (i !== -1) { this.children.splice(i, 1); c._parent = null; }
return c;
}
remove() {
if (!this._parent) return;
const i = this._parent.children.indexOf(this);
if (i !== -1) this._parent.children.splice(i, 1);
this._parent = null;
}
// children are pushed in append order, so index 0 IS the oldest β€” what showGalaxyToast's
// all-clickable eviction branch (`stack.firstElementChild`) needs.
get firstElementChild() { return this.children[0] || null; }
closest(selector) {
const cls = selector.startsWith(".") ? selector.slice(1) : null;
for (let n = this; n; n = n._parent) if (cls && n._classes?.has(cls)) return n;
return null;
}
// A real (if shallow) innerHTML round-trip: `= ""` clears the children the way the real thing
// does β€” that is the only value most rebuild paths ever assign β€” and any other string is
// readable back as the string it was, so a panel built from a raw template (showGameOver's
// score breakdown, showScenarioEnd) is inspectable rather than always reading empty. No real
// HTML PARSING: nothing here needs the assigned string to become child nodes.
set innerHTML(v) { this._html = v; if (v === "") this.children = []; }
get innerHTML() { return this._html ?? ""; }
// A minimal REAL selector. Every call site in the browser layer queries a single class name
// (".market-row", ".queue-label", ".recycle-progress", …) β€” never a combinator or an attribute
// selector β€” so a depth-first walk matching one class per node is enough. The always-null stub
// some copies used is tolerated by every live-patch call site (`if (row) …` / `rows[i] && …`),
// which is exactly the problem: a test written against it can never tell "the code patched
// nothing" apart from "the code patched a node I could not find".
_queryAll(selector) {
const cls = selector.startsWith(".") ? selector.slice(1) : null;
const out = [];
const walk = kids => { for (const c of kids) { if (cls && c._classes?.has(cls)) out.push(c); if (c.children) walk(c.children); } };
walk(this.children);
return out;
}
querySelector(selector) { return this._queryAll(selector)[0] || null; }
querySelectorAll(selector) { return this._queryAll(selector); }
// Attributes are kept as a real map rather than no-ops: openLandingPicker (boot.js) sets
// type/aria attributes on the controls it builds, and a no-op setter makes "did it set the
// attribute?" permanently unanswerable.
setAttribute(name, value) { (this._attrs ??= {})[name] = String(value); }
getAttribute(name) { return this._attrs?.[name] ?? null; }
removeAttribute(name) { if (this._attrs) delete this._attrs[name]; }
focus() { this._focused = true; }
blur() { this._focused = false; }
getContext() { return config.context(); }
toDataURL() { return "data:image/fake,"; }
getBoundingClientRect() { return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }; }
// Mirrors real HTMLElement#click(); hudSelection.js's own prodButton calls it for hotkey replay.
click() { this.dispatchEvent(new Event("click")); }
}
// Real per-id identity. dom.js resolves each handle exactly ONCE at import time and every later
// doc.getElementById(sameId) must keep returning that SAME object, or dom.js's exported handles
// (panelEl, galaxyToastEl, starmapEl, …) silently diverge from what the code under test is
// actually appending to β€” and the test then inspects an empty tree that nothing ever wrote.
export function fakeDocument() {
const byId = new Map();
const body = new FakeElement("body");
return {
getElementById(id) { if (!byId.has(id)) byId.set(id, new FakeElement("div")); return byId.get(id); },
createElement(tag) { return new FakeElement(tag); },
body,
};
}
/**
* Install a fake `document` on globalThis and return it. Call BEFORE the first
* `await import()` of anything that reaches dom.js.
* @param {{context?: () => any, clientWidth?: number, clientHeight?: number}} [opts]
*/
export function installFakeDom(opts = {}) {
config = { ...DEFAULTS, ...opts };
globalThis.document = fakeDocument();
return globalThis.document;
}