An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.
A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.
All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
579 lines
24 KiB
JavaScript
579 lines
24 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);
|
||
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();
|
||
}
|
||
});
|
||
|
||
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
|
||
|
||
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
|
||
const dom = await boot({ pack: 'countinghouse' });
|
||
try {
|
||
const people = byClass(dom.root, 'person');
|
||
// One partner, one chief clerk, and two to four clerks.
|
||
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
|
||
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
|
||
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
|
||
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
|
||
const clerks = titles.filter((t) => t === 'Clerk').length;
|
||
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
|
||
for (let turn = 0; turn < 25; turn++) {
|
||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
|
||
enabledOptions(dom.root)[0].click();
|
||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
|
||
firstByClass(dom.root, 'button--next').click();
|
||
}
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
|
||
const dom = await boot({ pack: 'countinghouse' });
|
||
try {
|
||
let checked;
|
||
for (let turn = 0; turn < 40 && !checked; turn++) {
|
||
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
|
||
if (checked) break;
|
||
enabledOptions(dom.root)[0].click();
|
||
firstByClass(dom.root, 'button--next').click();
|
||
}
|
||
assert.ok(checked, 'a checked option turned up within forty days');
|
||
const odds = firstByClass(checked, 'option__odds').textContent;
|
||
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
|
||
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
|
||
|
||
checked.click();
|
||
const said = text(firstByClass(dom.root, 'outcome__check'));
|
||
assert.match(said, /and it (came|did not come) off\./);
|
||
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
|
||
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
|
||
} finally {
|
||
dom.restore();
|
||
}
|
||
});
|