Files
TheLadder/test/fixtures/fake-dom.js
T
JesseMarkowitzandClaude Opus 5 76072edf4e Show the build, play from the keyboard, and clean up the ledger
Five things from the second playtest.

A status line at the top of every screen: the game, the pack, your current
title, and the version plus the commit it came from. `tools/stamp.js` writes the
commit into src/build-info.js before serving and before building, so a
distributed dist/theladder.html states exactly which commit produced it. An
uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a
bare SHA would not be.

The whole game is now playable on Enter. Every screen marks one control and the
app focuses it after each render: the first *available* option while choosing,
the next-turn button once the result is in, "keep going" on the retrospective.
Tab and Shift+Tab reach the other options. Locked options needed no special
handling — the browser's tab order already skips disabled controls, so they stay
visible without being in the way.

"What is notable? under What changed?" — two fixes. `notable` is the
retrospective's own list of moments; a display rule can now say
`inLedger: false` and it is left out, because "Notable — changed" tells nobody
anything. Flags are the opposite and stay, but as sentences the pack supplies
rather than as booleans: "The envelope is in your locker." instead of "the
envelope: now true".

Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest
one there when given no argument. A log records what a real person did turn by
turn, including whatever they typed into the feedback box — committing one once
would put it in the history for good.

Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface
0.2; say if you want a different scheme and I will renumber.

119 tests. Reasoning in docs/DECISIONS.md §25-28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 05:41:32 -04:00

168 lines
4.4 KiB
JavaScript

// A minimal DOM, enough to drive the UI headlessly.
//
// Not a browser and not a substitute for clicking the real thing — it exists
// so that the wiring (imports, handlers, the render/choose/log path) is
// exercised by the test suite rather than discovered by a player. Anything
// involving real files or real downloads is out of its scope and has to be
// checked by hand.
class FakeNode {
constructor() {
this.childNodes = [];
this.parentNode = null;
}
get textContent() {
return this.childNodes.map((child) => child.textContent).join('');
}
}
class FakeText extends FakeNode {
constructor(text) {
super();
this.nodeValue = String(text);
}
get textContent() {
return this.nodeValue;
}
}
class FakeElement extends FakeNode {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.className = '';
this.id = '';
this.attributes = {};
this.listeners = {};
this.dataset = {};
this.style = '';
// Predefined so `key in el` behaves the way it does in a real element:
// these are set as properties, everything else falls through to attributes.
this.disabled = false;
this.value = '';
this.rows = 0;
this.type = '';
this.accept = '';
this.href = '';
this.download = '';
this.files = null;
}
append(...nodes) {
for (const node of nodes) {
node.parentNode = this;
this.childNodes.push(node);
}
}
replaceChildren(...nodes) {
this.childNodes = [];
this.append(...nodes);
}
remove() {
const siblings = this.parentNode?.childNodes;
if (siblings) siblings.splice(siblings.indexOf(this), 1);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
addEventListener(type, handler) {
(this.listeners[type] ??= []).push(handler);
}
dispatch(type, event = {}) {
for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event });
}
click() { this.dispatch('click'); }
focus() {
if (globalThis.document) globalThis.document.activeElement = this;
}
}
function walk(node, out = []) {
out.push(node);
for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out);
return out;
}
/** Every element under `root` whose class list contains `className`. */
export function byClass(root, className) {
return walk(root).filter((el) => el.className.split(/\s+/).includes(className));
}
/** The first such element, or undefined. */
export function firstByClass(root, className) {
return byClass(root, className)[0];
}
/** Every element of a tag. */
export function byTag(root, tagName) {
return walk(root).filter((el) => el.tagName === tagName.toUpperCase());
}
class FakeStorage {
constructor() { this.map = new Map(); }
getItem(key) { return this.map.has(key) ? this.map.get(key) : null; }
setItem(key, value) { this.map.set(key, String(value)); }
removeItem(key) { this.map.delete(key); }
clear() { this.map.clear(); }
}
/**
* Install the fake DOM as globals and return a handle for the test. Call the
* returned `restore` afterwards so suites do not leak into each other.
*/
export function installFakeDom() {
const root = new FakeElement('div');
root.id = 'app';
const document = {
createElement: (tag) => new FakeElement(tag),
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
activeElement: null,
};
const listeners = {};
const window = {
addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); },
};
const previous = {};
const globals = {
document,
window,
Node: FakeNode,
localStorage: new FakeStorage(),
requestAnimationFrame: (fn) => setTimeout(fn, 0),
confirm: () => true,
alert: () => {},
};
for (const [key, value] of Object.entries(globals)) {
previous[key] = globalThis[key];
globalThis[key] = value;
}
return {
document,
window,
root,
storage: globals.localStorage,
/** Fire a window event, e.g. beforeunload. */
fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); },
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
export { FakeElement, FakeText, FakeNode };