v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist
This commit is contained in:
+186
-4
@@ -11,6 +11,10 @@ import { turnOf } from '../src/engine/state.ts';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line
|
||||
// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page
|
||||
// whichever stub happened to run last.
|
||||
import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
@@ -1324,8 +1328,23 @@ describe('the static build', () => {
|
||||
// it, and a stub without one throws on load — which is a page that never starts, not a
|
||||
// cosmetic gap.
|
||||
const ownClasses = new Set<string>();
|
||||
// The New Game dialog is a native <dialog>: the page listens for `close` on it at load, and
|
||||
// opens it with `showModal`. Without these the page throws before it draws anything — which
|
||||
// is a page that never starts, exactly what this stub exists to catch.
|
||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal() {
|
||||
(node as { open: boolean }).open = true;
|
||||
},
|
||||
close(value?: string) {
|
||||
(node as { open: boolean; returnValue: string }).open = false;
|
||||
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
|
||||
for (const fn of listeners.get('close') ?? []) fn();
|
||||
},
|
||||
classList: {
|
||||
add: (c: string) => void ownClasses.add(c),
|
||||
remove: (c: string) => void ownClasses.delete(c),
|
||||
@@ -1740,10 +1759,11 @@ describe('the static build', () => {
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||
}
|
||||
// Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9
|
||||
// to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting
|
||||
// longer for a good reason rather than the cross-products this test was written to kill.
|
||||
assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`);
|
||||
// Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable
|
||||
// squares — the list getting longer for a good reason rather than the cross-products this test
|
||||
// was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three
|
||||
// opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`.
|
||||
assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`);
|
||||
});
|
||||
|
||||
it('makes up ONE train at a time, and names that train', () => {
|
||||
@@ -2494,3 +2514,165 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the New Game dialog', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
|
||||
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
|
||||
* wrong element, or a navigation that assigns the search string the page already has and so does
|
||||
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
|
||||
*
|
||||
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
||||
* settings, so what this really pins is that all four ride in the URL and come back out.
|
||||
*/
|
||||
const load = async (search: string) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
|
||||
);
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const listeners = new Map<string, (() => void)[]>();
|
||||
const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({
|
||||
value,
|
||||
checked: false,
|
||||
}));
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
||||
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
radios,
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
close: () => {
|
||||
(node as { open: boolean }).open = false;
|
||||
for (const fn of listeners.get('close') ?? []) fn();
|
||||
},
|
||||
// Only the radio-group selectors the dialog actually uses; anything else is not this
|
||||
// element's business and answering it with a guess would hide a typo in the real selector.
|
||||
querySelectorAll: (sel: string) => (sel === 'input[name="ng-hand"]' ? radios : []),
|
||||
querySelector: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
};
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make('style'),
|
||||
addEventListener: () => {},
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
};
|
||||
const nav: { search: string; reloads: number } = { search, reloads: 0 };
|
||||
g['location'] = {
|
||||
get search() { return nav.search; },
|
||||
set search(v: string) { nav.search = v; },
|
||||
reload: () => void (nav.reloads += 1),
|
||||
};
|
||||
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),
|
||||
};
|
||||
g['URLSearchParams'] = NodeURLSearchParams;
|
||||
g['confirm'] = () => true;
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
|
||||
return { els, nav };
|
||||
};
|
||||
|
||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||
// Re-entering four settings for every comparison game is how a comparison silently stops
|
||||
// comparing. The seed is the one field that clears: the same seed twice is not a second sample.
|
||||
const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
|
||||
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
||||
assert.equal(els.get('ng-passenger')!['value'], '2');
|
||||
assert.equal(els.get('ng-freight')!['value'], '3');
|
||||
assert.equal(els.get('ng-transit')!['value'], '4');
|
||||
const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked);
|
||||
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
|
||||
});
|
||||
|
||||
it('puts the seed and all three settings into the URL when it deals', async () => {
|
||||
const { els, nav } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = '99';
|
||||
for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther';
|
||||
els.get('ng-passenger')!['value'] = '5';
|
||||
els.get('ng-freight')!['value'] = '0';
|
||||
els.get('ng-transit')!['value'] = '2';
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
|
||||
});
|
||||
|
||||
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||||
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
|
||||
// to be the test rather than "cancel" — the two arrive identically.
|
||||
for (const returnValue of ['cancel', '']) {
|
||||
const { els, nav } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = returnValue;
|
||||
(dlg['close'] as () => void)();
|
||||
assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||||
assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
|
||||
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
|
||||
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0';
|
||||
const { els, nav } = await load(url);
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case');
|
||||
assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||
});
|
||||
|
||||
it('deals the game the URL describes, and says so in the header', async () => {
|
||||
// The other half of the round trip: the dialog wrote those parameters, and this is the page
|
||||
// reading them back. Without this the two halves can drift and each still pass its own test.
|
||||
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
|
||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
|
||||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
||||
});
|
||||
|
||||
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
|
||||
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
|
||||
// optional box is not worth writing.
|
||||
const { els, nav } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = 'not a number';
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user