// 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); const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent)); test('tabbing past the last option does not land on the button that ends the run', async () => { // Document order is tab order. "Wrap up my career" sat immediately after the // last option, so one Tab too many and Enter showed the retrospective — which // a real player, on the keyboard, read as the game ending at day 31. const dom = await boot(); try { const buttons = byTag(dom.root, 'button'); const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true); const next = buttons[lastOption + 1]; assert.ok(next, 'there is something after the options'); assert.doesNotMatch(next.textContent, /Wrap up/, 'the next thing Tab reaches should not be the one that leaves the turn'); assert.equal(wrapUp(dom.root), buttons[buttons.length - 1], 'it belongs last, behind everything harmless'); } finally { dom.restore(); } }); 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(); } wrapUp(dom.root).click(); assert.ok(firstByClass(dom.root, 'screen--retrospective')); assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it'); 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 { wrapUp(dom.root).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('a save written before a stat existed gains it, rather than losing the content', async () => { // The failure this guards against is silent, which is what makes it worth a // test: conditions.js compares numbers only, so with no `lifestyle` path both // `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and // upkeep entry gated on a stat added since the save was written simply does // not exist for that career, and nothing anywhere says so. const dom = installFakeDom(); try { const { startGame } = await import('../src/engine/game.js'); const { corporateLadder } = await import('../content/corporateladder/index.js'); const before = startGame(corporateLadder, { seed: 'pre-lifestyle' }); before.turn = 40; delete before.lifestyle; dom.storage.setItem('theladder.save.v1.corporateladder', JSON.stringify({ format: 1, savedAt: null, state: before })); dom.storage.setItem('theladder.pack.v1', 'corporateladder'); const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`); bootApp(dom.root); assert.match(text(dom.root), /Day 40/, 'the career resumed where it was'); const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder')); assert.equal(saved.state.lifestyle, 0, 'the stat the run predates was filled in, and the repair written 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(); } });