various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)
This commit is contained in:
@@ -412,3 +412,85 @@ describe('card coverage', () => {
|
||||
assert.match(poling!.effect, /TBD/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
||||
it('keeps the Limits at the ends, never stranded mid-track', () => {
|
||||
// Found by watching a replay: a district grew to
|
||||
// (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
|
||||
// with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and
|
||||
// §10 both reason about "the track between the train and the Limits", and running past a
|
||||
// player's Limits is what makes a collision his fault.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.turn.option = 'draw';
|
||||
|
||||
for (let n = 0; n < 4; n++) {
|
||||
s.turn.laidThisTurn = false;
|
||||
const target = { row: area.runningRow, col: area.limitsWest.col };
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'track.lay', geometry: 'straight', hand: 'none', placement: target, variant: 0,
|
||||
});
|
||||
assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`);
|
||||
}
|
||||
|
||||
const onRunning = [...area.grid.entries()]
|
||||
.map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind }))
|
||||
.filter((x) => !Number.isNaN(x.col));
|
||||
const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b);
|
||||
const cols = onRunning.map((x) => x.col);
|
||||
|
||||
assert.equal(limits.length, 2, 'there must be exactly two Limits signs');
|
||||
assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card');
|
||||
assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
||||
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
||||
// no caboose, because placeCar checked only MAX_CONSIST and yard availability.
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||
});
|
||||
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const r = applyIntent(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true });
|
||||
assert.ok(r.ok, `hopper ${n + 1} of 3 should be accepted`);
|
||||
}
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
|
||||
'NO_SUITABLE_CAR',
|
||||
'a fourth freight car exceeds the card',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'coach', loaded: true }),
|
||||
'NO_SUITABLE_CAR',
|
||||
'this card carries no coaches',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'caboose', loaded: true }),
|
||||
null,
|
||||
'the card calls for a caboose',
|
||||
);
|
||||
});
|
||||
|
||||
it('couples nothing behind the caboose', () => {
|
||||
// §A.3 — the caboose rides last.
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
consist: [{ type: 'caboose', loaded: true }],
|
||||
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||
});
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
|
||||
'NO_SUITABLE_CAR',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +268,30 @@ describe('replay HTML', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a LATER frame, where the board state is carried forward', () => {
|
||||
// The page omits cells/facilities/division when unchanged and resolves them by walking back —
|
||||
// 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0
|
||||
// would never exercise that path, because frame 0 always carries everything.
|
||||
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const stub = {
|
||||
getElementById: (id: string) => {
|
||||
if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
|
||||
return els.get(id);
|
||||
},
|
||||
};
|
||||
// Append a jump to a late frame so render() runs against carried-forward state.
|
||||
const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);');
|
||||
assert.doesNotThrow(
|
||||
() => run(stub, () => 0, () => undefined),
|
||||
'the page threw rendering a frame whose board state was carried forward',
|
||||
);
|
||||
assert.ok(
|
||||
String(els.get('grid')?.innerHTML ?? '').length > 0,
|
||||
'the grid rendered empty on a carried-forward frame',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays a sane size', () => {
|
||||
const mb = Buffer.byteLength(html) / 1024 / 1024;
|
||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* The playable browser build.
|
||||
*
|
||||
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
|
||||
* at the new position — so "it compiles" is never mistaken for "it plays".
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||
import {
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
currentActor,
|
||||
fromSave,
|
||||
newGame,
|
||||
submit,
|
||||
toSave,
|
||||
view,
|
||||
} from '../src/web/game.ts';
|
||||
|
||||
/** Play a whole game by always taking the first offered action. */
|
||||
function playThrough(seed: number, maxTurns = 20_000) {
|
||||
const game = newGame(seed);
|
||||
let turns = 0;
|
||||
for (; turns < maxTurns; turns++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0 || options.length === 0) break;
|
||||
const first = groups[0]!.actions[0]!;
|
||||
if (!submit(game, options[first.index]!)) break;
|
||||
}
|
||||
return { game, turns };
|
||||
}
|
||||
|
||||
describe('the browser game plays', () => {
|
||||
it('reaches the end of a game through the same calls the page makes', () => {
|
||||
const { game, turns } = playThrough(77);
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
assert.equal(game.state.status, 'finished');
|
||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
||||
});
|
||||
|
||||
it('never offers an action the engine then refuses', () => {
|
||||
// The action list comes from legalActions, which delegates to check — so every button on the
|
||||
// page must be accepted. A rejection here means the UI and the rules disagree.
|
||||
const game = newGame(21);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0) break;
|
||||
const pick = groups[groups.length - 1]!.actions[0]!;
|
||||
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers every legal action somewhere, dropping none', () => {
|
||||
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
|
||||
// legal move becomes unreachable in a way that is very hard to notice.
|
||||
const game = newGame(5);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
|
||||
const kinds = new Set(options.map((o) => o.type));
|
||||
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
|
||||
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
|
||||
submit(game, options[[...shown][0]!]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores a saved game to the same position', () => {
|
||||
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
|
||||
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
|
||||
const game = newGame(909);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0) break;
|
||||
submit(game, options[groups[0]!.actions[0]!.index]!);
|
||||
}
|
||||
|
||||
const restored = fromSave(toSave(game));
|
||||
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
||||
assert.equal(restored.state.clock.day, game.state.clock.day);
|
||||
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
||||
assert.equal(restored.state.status, game.state.status);
|
||||
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
|
||||
});
|
||||
|
||||
it('is deterministic — the same seed deals the same game', () => {
|
||||
const a = playThrough(4242).game;
|
||||
const b = playThrough(4242).game;
|
||||
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
|
||||
assert.deepEqual(view(a).cells, view(b).cells);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the action menu presents choices the way they are made', () => {
|
||||
it('offers a card ONCE, with its locations underneath', () => {
|
||||
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
|
||||
// tell which square each referred to. Subject first, location second.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
const menu = actionMenu(game);
|
||||
const items = menu.placeable.flatMap((g) => g.items);
|
||||
assert.ok(items.length > 0, 'nothing placeable was offered');
|
||||
|
||||
const keys = items.map((i) => i.subjectKey);
|
||||
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
|
||||
for (const item of items) {
|
||||
const labels = item.spots.map((sp) => sp.label);
|
||||
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
|
||||
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
|
||||
}
|
||||
});
|
||||
|
||||
it('loses no legal action in the reshuffle', () => {
|
||||
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
|
||||
const game = newGame(88);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const menu = actionMenu(game);
|
||||
if (menu.options.length === 0) break;
|
||||
const reachable = new Set([
|
||||
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
|
||||
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
|
||||
]);
|
||||
const kinds = new Set(menu.options.map((o) => o.type));
|
||||
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
|
||||
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
|
||||
submit(game, menu.options[[...reachable][0]!]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the card in every face-up slot', () => {
|
||||
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
|
||||
assert.ok(draw, 'no draw group');
|
||||
for (const a of draw!.actions) {
|
||||
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
|
||||
}
|
||||
assert.ok(
|
||||
draw!.actions.some((a) => /face-up slot/.test(a.label)),
|
||||
'face-up slots are not named',
|
||||
);
|
||||
});
|
||||
|
||||
it('never offers a train card a place on the board', () => {
|
||||
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
|
||||
// rotations each — all identical, because the placement was accepted and then ignored.
|
||||
const game = newGame(555);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
for (const o of options) {
|
||||
if (o.type !== 'card.play') continue;
|
||||
const kind = game.state.cards.get(o.cardId)?.kind.kind;
|
||||
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
|
||||
assert.equal(o.placement, undefined, 'a train card was offered a board square');
|
||||
}
|
||||
}
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports what is left of the personal track supply', () => {
|
||||
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
|
||||
// board alone could never answer "how many straights have I got left".
|
||||
const game = newGame(31);
|
||||
const supply = view(game).trackSupply;
|
||||
assert.ok(supply.length > 0, 'no track supply reported');
|
||||
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
|
||||
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('board highlighting', () => {
|
||||
it('gives every spot a coordinate to highlight', () => {
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const items = actionMenu(game).placeable.flatMap((g) => g.items);
|
||||
assert.ok(items.length > 0);
|
||||
for (const it of items) {
|
||||
for (const sp of it.spots) {
|
||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('highlights squares OUTSIDE the current district', () => {
|
||||
// The commonest placement is just beyond the existing cards — that is what extending means. A
|
||||
// grid sized only to the cards already down would highlight nothing exactly when it matters.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
const cells = view(game).cells;
|
||||
const minCol = Math.min(...cells.map((c) => c.col));
|
||||
const maxCol = Math.max(...cells.map((c) => c.col));
|
||||
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
|
||||
|
||||
assert.ok(
|
||||
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
|
||||
'no legal spot lies outside the current cards — the bounds test would be vacuous',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page explains itself', () => {
|
||||
it('says what every card in hand and every face-up slot does', () => {
|
||||
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
|
||||
// this text already existed in the content tables without reaching the screen.
|
||||
for (const seed of [555, 430, 7, 99]) {
|
||||
const f = view(newGame(seed));
|
||||
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
|
||||
f.hand.forEach((name, i) => {
|
||||
const what = f.handWhat[i]!;
|
||||
assert.ok(what.length > 0, `${name} has no description`);
|
||||
// camelCase leaking to the screen is the recurring failure here.
|
||||
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
|
||||
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
|
||||
});
|
||||
f.departments.forEach((name, i) => {
|
||||
if (name === '—') return;
|
||||
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('describes every card kind in the deck, not just the easy ones', () => {
|
||||
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
|
||||
// show as a blank line under a card name only for the seeds that happen to deal it.
|
||||
const game = newGame(1);
|
||||
const seen = new Set<string>();
|
||||
for (const [id, card] of game.state.cards) {
|
||||
const what = cardDescription(game.state, id);
|
||||
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
|
||||
seen.add(card.kind.kind);
|
||||
}
|
||||
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
|
||||
});
|
||||
|
||||
it('states the objective and whether you are keeping up', () => {
|
||||
const f = view(newGame(430));
|
||||
assert.equal(f.objective.target, 20);
|
||||
assert.equal(f.objective.days, 5);
|
||||
assert.match(f.objective.note, /of 20/);
|
||||
assert.match(f.objective.note, /Days? left/);
|
||||
});
|
||||
|
||||
it('explains what each Local Operations option spends', () => {
|
||||
// The most consequential decision of the Stage, previously labelled "choose switch".
|
||||
const game = newGame(555);
|
||||
const choices = actionGroups(game)
|
||||
.options.filter((o) => o.type === 'localOps.choose')
|
||||
.map((o) => describeIntent(game.state, o));
|
||||
assert.ok(choices.length > 0);
|
||||
for (const c of choices) {
|
||||
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
|
||||
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('says which way a piece will point, never "rotation 2"', () => {
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const track = actionMenu(game)
|
||||
.placeable.flatMap((g) => g.items)
|
||||
.filter((i) => /straight|curve|turnout/.test(i.subject));
|
||||
assert.ok(track.length > 0, 'no track pieces offered');
|
||||
|
||||
for (const item of track) {
|
||||
for (const sp of item.spots) {
|
||||
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
|
||||
}
|
||||
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
|
||||
const perSquare = new Map<string, number>();
|
||||
for (const sp of item.spots) {
|
||||
const k = `${sp.coord.row},${sp.coord.col}`;
|
||||
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
|
||||
}
|
||||
if ([...perSquare.values()].some((n) => n > 1)) {
|
||||
assert.ok(
|
||||
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
|
||||
`${item.subject} offers two orientations on one square without naming them`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the static build', () => {
|
||||
const root = join(import.meta.dirname, '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
it('builds, and needs nothing but a static host', () => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
|
||||
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
|
||||
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
|
||||
});
|
||||
|
||||
it('emits no import that a browser cannot resolve', () => {
|
||||
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
|
||||
// those to .js, and nothing may reach for a bare module or a node: builtin.
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
assert.ok(files.length > 5, 'suspiciously few emitted files');
|
||||
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
|
||||
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
|
||||
// called A, which is the kind of false alarm that trains you to ignore a failing test.
|
||||
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
|
||||
for (const m of src.matchAll(imports)) {
|
||||
const spec = m[1]!;
|
||||
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
|
||||
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
|
||||
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
|
||||
}
|
||||
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
|
||||
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders clickable highlights on the board when a card is picked', async () => {
|
||||
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
|
||||
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
||||
// nothing about whether the page draws them.
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (): Record<string, unknown> => {
|
||||
let html = '';
|
||||
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
|
||||
// would drop the handlers the page assigns — the test would then report "no button" for a
|
||||
// page that works perfectly.
|
||||
let cache = new Map<string, Record<string, unknown>[]>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
querySelectorAll(sel: string) {
|
||||
const hit = cache.get(sel);
|
||||
if (hit) return hit;
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const re = sel.startsWith('[')
|
||||
? /data-at="([^"]*)"/g
|
||||
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const data: Record<string, string> = {};
|
||||
if (sel.startsWith('[')) data['at'] = m[1]!;
|
||||
else {
|
||||
const attr = /data-(\w+)="([^"]*)"/g;
|
||||
let a: RegExpExecArray | null;
|
||||
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
|
||||
}
|
||||
out.push({ dataset: data, onclick: null });
|
||||
}
|
||||
cache.set(sel, out);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', {
|
||||
get: () => html,
|
||||
set: (v: string) => {
|
||||
html = v;
|
||||
cache = new Map();
|
||||
},
|
||||
});
|
||||
return node;
|
||||
};
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!els.has(id)) els.set(id, make());
|
||||
return els.get(id);
|
||||
},
|
||||
};
|
||||
g['location'] = { search: '?seed=555' };
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
|
||||
// than compiling them.
|
||||
g['URLSearchParams'] = class {
|
||||
search: string;
|
||||
constructor(search: string) {
|
||||
this.search = search;
|
||||
}
|
||||
get(k: string): string | null {
|
||||
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
|
||||
|
||||
const actions = els.get('actions')!;
|
||||
const grid = els.get('grid')!;
|
||||
|
||||
// Take the draw option, then pick the first placeable subject.
|
||||
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
||||
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||
const nodes = fn.call(el, sel);
|
||||
const target = nodes[0];
|
||||
if (!target) return false;
|
||||
(target['onclick'] as (() => void) | null)?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
||||
|
||||
const html = String(grid['innerHTML']);
|
||||
assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
|
||||
assert.match(html, /data-at="/, 'highlighted squares carry no click target');
|
||||
assert.match(html, /ghost/, 'no empty square was offered as a destination');
|
||||
});
|
||||
|
||||
it('serves a page that loads the game as a module', () => {
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js">/);
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
|
||||
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user