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;
}