Files
TheLadder/test/ui.test.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

369 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// UI wiring tests.
//
// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js).
// They prove the wiring runs — imports resolve, handlers fire, the screens
// render the right text, choices reach the engine, saves and the feedback log
// are written. They are not a substitute for playing it in a browser: layout,
// styling, file downloads and file imports are outside their reach.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
/** Boot the app fresh against a clean fake DOM. */
async function boot() {
const dom = installFakeDom();
// A fresh module instance per test: the app holds module-level state.
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
return dom;
}
const text = (root) => root.textContent;
const optionButtons = (root) => byClass(root, 'option');
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
test('the app boots to a playable turn screen', async () => {
const dom = await boot();
try {
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered');
assert.match(text(dom.root), /Day 1/, 'the day counter is showing');
assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed');
assert.match(text(dom.root), /Trevor Pike/);
assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click');
} finally {
dom.restore();
}
});
test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => {
const dom = await boot();
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$520/, 'money is formatted as money');
assert.match(stats, /Negotiation/);
assert.match(stats, /Standing/, 'reputation is renamed by the pack');
assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero');
assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header');
} finally {
dom.restore();
}
});
test('choosing an option shows the outcome, the deltas and the upkeep', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered');
assert.match(text(dom.root), /What that changed/);
assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately');
const upkeep = text(firstByClass(dom.root, 'outcome__upkeep'));
// Labelled by what caused it, not by the path it moved — this is the
// answer to "what was that daily drain for?".
assert.match(upkeep, /Coffee, transit, lunch/);
assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here');
assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed');
} finally {
dom.restore();
}
});
test('advancing moves to the next turn', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing');
assert.match(text(dom.root), /Day 2/);
} finally {
dom.restore();
}
});
test('a locked option is shown, disabled, and says what it needs', async () => {
const dom = await boot();
try {
// Play until a locked option appears — the pack gates several.
let locked = null;
for (let i = 0; i < 40 && !locked; i++) {
locked = optionButtons(dom.root).find((b) => b.disabled);
if (locked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(locked, 'a gated option should turn up within 40 turns');
assert.match(locked.textContent, /Needs /, 'the requirement is spelled out');
locked.click(); // must be inert
assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing');
} finally {
dom.restore();
}
});
test('progress is written to local storage and restored on the next boot', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(saved.state.turn, 3);
// Boot again against the same storage: the run should resume, not restart.
const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`);
const secondRoot = dom.document.createElement('div');
bootAgain(secondRoot);
assert.match(text(secondRoot), /Day 3/, 'the saved run resumed');
} finally {
dom.restore();
}
});
test('every played turn is logged, separately from the save', async () => {
const dom = await boot();
try {
for (let i = 0; i < 3; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log.length, 3);
const entry = log[0];
assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded');
assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded');
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
assert.equal(entry.rating, null, 'feedback starts empty');
const save = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(save.state.history.length, 3);
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
} finally {
dom.restore();
}
});
test('the feedback widget is optional, and records a rating when used', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
const ratings = byClass(dom.root, 'rating');
assert.equal(ratings.length, 3, 'up, neutral and down');
ratings[0].click();
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log[0].rating, 'up');
// Clicking the same rating again clears it.
byClass(dom.root, 'rating')[0].click();
assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null);
} finally {
dom.restore();
}
});
test('the comment fields write through without re-rendering under the cursor', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'feedback__toggle').click();
const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea');
assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box');
fields[0].value = 'the wheel bit made me laugh';
fields[0].dispatch('input');
fields[1].value = 'I wanted to just hide in the stairwell';
fields[1].dispatch('input');
// The same element objects must still be live — a re-render would have
// replaced them and thrown the player out of the box they are typing in.
assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]);
// Written through on the way to the next turn.
firstByClass(dom.root, 'button--next').click();
const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0];
assert.equal(entry.comment, 'the wheel bit made me laugh');
assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell');
} finally {
dom.restore();
}
});
test('the retrospective summarises the run and is not an ending', async () => {
const dom = await boot();
try {
for (let i = 0; i < 6; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
firstByClass(dom.root, 'button').click(); // "Wrap up my career"
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
const body = text(dom.root);
assert.match(body, /Career retrospective/);
assert.match(body, /6 days in the mailroom/);
assert.match(body, /Where you stand with people/);
assert.match(body, /Marlene Hobb/);
assert.match(body, /Situations you saw/);
firstByClass(dom.root, 'button--primary').click(); // "Keep going"
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on');
assert.match(text(dom.root), /Day 7/);
} finally {
dom.restore();
}
});
test('a run with nothing notable still gets a retrospective', async () => {
const dom = await boot();
try {
firstByClass(dom.root, 'button').click(); // wrap up on turn one
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /Nothing that anybody would write down/);
} finally {
dom.restore();
}
});
test('the header names the day and counts the week', async () => {
const dom = await boot();
try {
const hud = text(firstByClass(dom.root, 'hud'));
assert.match(hud, /Monday/, 'the day is named');
assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown');
} finally {
dom.restore();
}
});
test('the diary says what is coming, so the drain is not a mystery', async () => {
const dom = await boot();
try {
const diary = text(firstByClass(dom.root, 'diary'));
assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands');
assert.match(diary, /Rent in 7 days/);
assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed');
} finally {
dom.restore();
}
});
test('wages and rent appear in the ledger on the days they fall due', async () => {
const dom = await boot();
try {
const ledgerOn = [];
for (let day = 1; day <= 8; day++) {
enabledOptions(dom.root)[0].click();
const upkeep = firstByClass(dom.root, 'outcome__upkeep');
ledgerOn.push(upkeep ? upkeep.textContent : '');
firstByClass(dom.root, 'button--next').click();
}
assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day');
assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1');
assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday');
assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday');
} finally {
dom.restore();
}
});
test('the status bar says what you are playing, which pack, and who you are', async () => {
const dom = await boot();
try {
const status = text(firstByClass(dom.root, 'statusbar'));
assert.match(status, /The Ladder/, 'the game');
assert.match(status, /Corporate Ladder/, 'the pack');
assert.match(status, /Mail Associate I/, 'the current title');
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
} finally {
dom.restore();
}
});
test('the status bar title follows the player up a tier', async () => {
const { statusBar } = await import('../src/ui/components.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
assert.match(promoted.textContent, /Dispatch Coordinator/);
} finally {
dom.restore();
}
});
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
const dom = await boot();
try {
// Choosing: the first option a player can actually take.
const firstPlayable = enabledOptions(dom.root)[0];
assert.equal(dom.document.activeElement, firstPlayable,
'the first available option is focused, so Enter takes it');
dom.document.activeElement.click();
// Outcome: the button that moves the run on.
const next = firstByClass(dom.root, 'button--next');
assert.equal(dom.document.activeElement, next,
'the next-turn button is focused, so Enter advances');
dom.document.activeElement.click();
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
} finally {
dom.restore();
}
});
test('locked options are not in the tab order', async () => {
const dom = await boot();
try {
// Disabled buttons are skipped by the browser's own tab handling; the
// guarantee this test protects is that locked options stay disabled.
for (let i = 0; i < 40; i++) {
const locked = optionButtons(dom.root).filter((b) => b.disabled);
if (locked.length) {
assert.ok(locked.every((b) => b.disabled === true));
return;
}
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.fail('no locked option appeared in 40 turns');
} finally {
dom.restore();
}
});
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
const { changeList } = await import('../src/ui/components.js');
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const rendered = changeList(pack, [
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
]);
const body = rendered.textContent;
assert.match(body, /Money/);
assert.match(body, /−\$20/);
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
assert.doesNotMatch(body, /now true/, 'not as a boolean');
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
assert.doesNotMatch(body, /Kept an envelope\./);
} finally {
dom.restore();
}
});