The MVP is playable: a turn screen, a career retrospective, an optional feedback widget, local-storage autosave with file export/import, and a build that flattens everything into one self-contained HTML file. A turn has two phases — the scenario and its options, then what the choice did and, separately, what the day cost regardless. Numbers that move without the player seeing why are most of what makes them meaningless. Locked options are shown greyed with the requirement spelled out rather than hidden, so a player can see the door they cannot open yet. The interface is named entirely by the pack. `pack.display` maps a state path to a label, a format and an order, which is why the header reads "Standing" for a path called `reputation` and why debt disappears while it is zero. The first playtest then found three things, all of them fair. *"Wasn't clear what daily drain was for."* The ledger was labelling changes by the path that moved — "Money −$18" — when the upkeep entry that caused them had a perfectly good name. It now says "Coffee, transit, lunch". The labels were in the data the whole time and never reached the screen. *"Should probably pay rent weekly / get paid every other week… would be good to have day of week and week # shown."* Packs can now declare a calendar, and the engine derives the day name, cycle and index from the turn number before the event is drawn — so content can require a Friday and the header can say "Thursday · Week 2 · Day 12". Upkeep entries take `every` and `offset`, making wages fall on alternate Fridays and rent every Monday. The turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from whatever a pack schedules. *"Always in debt — could never get ahead."* The old economy bled $25 a day regardless of play. It is now roughly break-even at baseline, with a `spare_shift` event that appears *because* you are broke: the way out of a hole should be visible from inside it. Across archetypes a careful player ends around $1,400, an unplanned one treads water, and a careless one sinks into real debt. That retune broke the coverage test, usefully. Random play stopped reaching hard times at all, which made the entire debt branch look dead — it was not, since random play is not a plausible player. Coverage is now the union across five archetypes, including one that is broke *and* well-liked, because the "ask a friend for money" options sit in a state no single-axis strategy reaches. Not covered by any of this: styling and dark mode, which only a human with a browser can check. This machine has none that can render a page. 109 tests. Reasoning in docs/DECISIONS.md §14-21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
// 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();
|
|
}
|
|
});
|