// 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(); } });