Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and its own relationship and skill vocabulary. src/engine/ was not touched. Adding it needed three changes outside the engine: one save slot per pack, app.js holding the current pack, and turn.js no longer naming the mailroom (DECISIONS #34). build.js now handles imports wrapped across lines. A conformance suite runs over every registered pack. DECISIONS #35 records three calls on open questions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
480 lines
19 KiB
JavaScript
480 lines
19 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.
|
||
*
|
||
* `pack` is written to the remembered-pack slot before booting, which is how a
|
||
* returning player lands back in a setting. Pass null to boot with nothing
|
||
* remembered and nothing saved, which is the first-time player who gets the
|
||
* setting picker instead of a turn.
|
||
*/
|
||
async function boot({ pack = 'corporateladder' } = {}) {
|
||
const dom = installFakeDom();
|
||
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
|
||
// 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.corporateladder'));
|
||
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.corporateladder'));
|
||
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();
|
||
}
|
||
});
|
||
|
||
test('a first-time player is asked which setting to play', async () => {
|
||
// Nothing saved and nothing remembered: the front door is the picker rather
|
||
// than whichever pack happens to be first in the registry.
|
||
const dom = await boot({ pack: null });
|
||
try {
|
||
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
|
||
assert.match(text(dom.root), /Corporate Ladder/);
|
||
assert.match(text(dom.root), /The Frontier/);
|
||
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
|
||
|
||
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
|
||
assert.ok(start, 'the picker offers a way in');
|
||
start.click();
|
||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('switching settings keeps both runs', async () => {
|
||
// The defect this guards against: one global save slot, so the first switch
|
||
// silently threw the other career away.
|
||
const dom = await boot({ pack: 'corporateladder' });
|
||
try {
|
||
enabledOptions(dom.root)[0].click();
|
||
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
|
||
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
|
||
|
||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||
const toFrontier = byClass(dom.root, 'button')
|
||
.find((b) => /Start here|Go back to this run/.test(b.textContent));
|
||
toFrontier.click();
|
||
|
||
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
|
||
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
|
||
|
||
// Both slots are on disk, independently.
|
||
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
|
||
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
|
||
assert.equal(frontier.state.turn, 1);
|
||
assert.equal(corporate.state.meta.packId, 'corporateladder');
|
||
assert.equal(frontier.state.meta.packId, 'frontier');
|
||
|
||
// And going back resumes rather than restarting.
|
||
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
|
||
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
|
||
'the picker reports where the other run is');
|
||
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
|
||
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
|
||
assert.match(text(dom.root), /Marlene Hobb/);
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('a run saved before settings were selectable is not lost', async () => {
|
||
// The old single slot, migrated on read. Someone mid-career when this
|
||
// shipped should find their career, not a new one.
|
||
const dom = installFakeDom();
|
||
try {
|
||
const { startGame } = await import('../src/engine/game.js');
|
||
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||
const legacy = startGame(corporateLadder, { seed: 'legacy' });
|
||
legacy.turn = 61;
|
||
dom.storage.setItem('theladder.save.v1',
|
||
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
|
||
|
||
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||
bootApp(dom.root);
|
||
|
||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
|
||
assert.match(text(dom.root), /Day 61/, 'the old career came back');
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
||
// `display` is a per-pack contract with the interface, and a typo in it is
|
||
// only visible at runtime. Corporate Ladder's version is covered above; this
|
||
// is the same assertion against a pack with entirely different stat names.
|
||
const dom = await boot({ pack: 'frontier' });
|
||
try {
|
||
const stats = text(firstByClass(dom.root, 'stats'));
|
||
assert.match(stats, /Money/);
|
||
assert.match(stats, /\$40/, 'money is formatted as money');
|
||
assert.match(stats, /Nerve/);
|
||
assert.match(stats, /Trade/);
|
||
assert.match(stats, /Standing/);
|
||
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
|
||
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
|
||
|
||
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
|
||
assert.match(text(dom.root), /Hollis Vane/);
|
||
assert.match(text(dom.root), /Marshal Ward/);
|
||
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
|
||
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|