Spaces:
Paused
Paused
File size: 9,462 Bytes
f9e883b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | /* ============================================================
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;
}
|