The screen does what you tell it — three Display items, and a fourth declined
Reviewed with Jesse out of TODO.md's Display section. Stays in the unshipped v0.7.9. #17 (hiding the Division map) was DECLINED, and the reason is that its premise had already died. Gitea#18 replaced the wrapped layout with a single row, and the reason to fold the map away was that it GREW — a horseshoe of three or a square of four pushed the board off the screen. One row is boardH = PAD * 2 + CH + 30: 150px, fixed, at every seat count. That is not worth a control, three states and a persisted preference. It was a sixth member of the drawing pass that got closed with Gitea#18 and stayed open only because it reads as a control question rather than a drawing one — recorded in TODO.md as an explicit decision, with the design that had already been worked out kept, and with the one thing that would justify reopening it: the map growing again. #16 THE OFFICE AREA'S AUTO-HIDE COULD NOT REACH EVERY STATE. One button cycling auto -> pinned -> auto, where the pin was `open ? 'closed' : 'open'` and `open` is what auto is doing AT THAT MOMENT. So the pin a press offered depended on the phase, and going from always-show to always-hide meant clicking back to auto, waiting for the phase to turn over, and clicking again. Three controls now, one per mode. The labels still say what pressing DOES, which was an earlier deliberate fix; what the cycle could not do was report the state it was in, and aria-pressed carries that now. They are addressed by id rather than queried off the container, and that is testability rather than style: the stub DOM the web suite runs against only models markup the page WROTE, so a child query finds nothing and the control would have shipped green and unexercised. The test presses always-show to always-hide directly — the transition the cycle could not make. #23 THE HISTORY READS NEWEST FIRST. Jesse: "the top line is the most recent and the further down you go, the older the entry." The phase headings now trail the lines they announce, ruled acceptable rather than overlooked: "stage changes will be beneath (prior to / older than) the following events. That is OK." Reading down is reading backwards. Grouping by phase and reversing the groups was offered and declined as more machinery than the complaint needs. replays.ts keeps its oldest-first log deliberately — it is paired with a frame stepper, where newest-first would fight the stepping. The slice(-60) cap is untouched and stays open. #28 THE SETTINGS MOVED INTO A CARD. The top line carried six things and now carries four: Revenue, the objective, the collision counts and the game code. The rest is a This Game card at the foot of the right-hand column, folded by default. Nothing new travels for it — configFromFrame already existed and main.ts already called it three times, so rulesListHtml(configFromFrame(f), ...) needed no refactor, and the card draws from the same renderer as the lobby's join preview so the two cannot drift. THE COLLISION COUNTS ARE NEW ON THE BOARD, NOT MOVED. The Frame has carried collisionsToday and collisionsTotal since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran invisibly — the second time this release that the Frame had the answer and the view never asked (see #43's actingPlayer). They stay on the top line while the limits go in the card: a limit is agreed to once, "2 of 3 today" changes how you play the next Stage. One stub gap closed to get here: none of the five element factories in test/web.test.ts had setAttribute, so the first render threw and any control reporting state through ARIA was untestable. WHAT IS NOT VERIFIED: the layout. There is no browser on this box, so nothing has confirmed the segmented control, the card or the reversed panel look right on screen. The logic is tested; the appearance is not, and wants the next play session. 881 tests pass, fourteen new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
This commit is contained in:
co-authored by
Claude Opus 5
parent
31b942cc38
commit
d267f89a82
+154
-6
@@ -1462,7 +1462,17 @@ describe('the static build', () => {
|
||||
// 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)[]>();
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1707,7 +1717,17 @@ describe('the static build', () => {
|
||||
let html = '';
|
||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||
const ownClasses = new Set<string>();
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1812,7 +1832,17 @@ describe('the static build', () => {
|
||||
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (): Record<string, unknown> => {
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: () => {},
|
||||
@@ -3792,7 +3822,17 @@ describe('the lobby screen', () => {
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const classes = new Set<string>();
|
||||
let html = '';
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
||||
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
||||
@@ -4165,7 +4205,17 @@ describe('the solitaire setup screen', () => {
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const listeners = new Map<string, (() => void)[]>();
|
||||
let html = '';
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
@@ -4403,18 +4453,116 @@ describe('the solitaire setup screen', () => {
|
||||
assert.equal(n.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 () => {
|
||||
it('deals the game the URL describes, and says so in the This Game card', 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.
|
||||
//
|
||||
// MOVED OFF THE HEADER 2026-08-30 (TODO #28). It used to read `#houserules`, an abbreviation
|
||||
// along the top line. The card is the whole of it now, so this checks both halves: the summary
|
||||
// line a folded card still shows, and the full list behind it.
|
||||
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);
|
||||
assert.match(
|
||||
String(els.get('gamecardsummary')!['textContent']),
|
||||
/6 cards · 4\/2\/1/,
|
||||
'the folded card does not say what this game was dealt under',
|
||||
);
|
||||
|
||||
// Folded, the body is not built at all — there is no point rendering what display:none hides.
|
||||
assert.equal(String(els.get('gamecardbody')!['innerHTML']), '', 'a folded card built its body anyway');
|
||||
|
||||
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||
assert.match(body, /Starting hand<\/dt><dd[^>]*>six random/, 'the opened card does not name the opening hand');
|
||||
assert.match(body, /Passenger per coach<\/dt><dd[^>]*>4/, 'the opened card does not carry the revenue rates');
|
||||
assert.match(body, /Seed<\/dt><dd[^>]*>430/, 'the opened card does not say which seed this is');
|
||||
});
|
||||
|
||||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
it('reaches every Office Area mode directly, including the one the cycle could not', async () => {
|
||||
/**
|
||||
* TODO #16, AND THIS IS THE DEFECT ITSELF. The control used to be ONE button stepping
|
||||
* `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing at that
|
||||
* moment — `FOCUS_PHASES.has(f.phaseKey)`. So the pin a press reached depended on the phase,
|
||||
* and going from "always showing" to "always hidden" meant clicking back to auto, waiting for
|
||||
* the phase to turn over, and clicking again. Three controls, one per mode, and every mode is
|
||||
* one press away from every other.
|
||||
*/
|
||||
const { els } = await load('?seed=430');
|
||||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||
const press = (m: string): void => (els.get(`dm-${m}`)!['onclick'] as () => void)();
|
||||
const pressed = (m: string): string =>
|
||||
String((els.get(`dm-${m}`)!['attrs'] as Record<string, string>)['aria-pressed']);
|
||||
// Which mode is LIT is what this test is about. Whether the panel then folds is `renderDistrict`'s
|
||||
// existing behaviour, covered where the fold rules are — this suite's stub has a no-op classList.
|
||||
|
||||
assert.equal(pressed('auto'), 'true', 'the page did not start on auto');
|
||||
|
||||
press('open');
|
||||
assert.equal(pressed('open'), 'true', 'pressing "always show" did not light it');
|
||||
assert.equal(pressed('auto'), 'false', 'auto stayed lit after pinning the panel open');
|
||||
|
||||
// THE PRESS THE CYCLE COULD NOT MAKE: straight from one pin to the other, in one click, with
|
||||
// no trip through auto and no waiting for a phase.
|
||||
press('closed');
|
||||
assert.equal(pressed('closed'), 'true', 'could not go from always-show to always-hide');
|
||||
assert.equal(pressed('open'), 'false', 'two modes were lit at once');
|
||||
|
||||
press('auto');
|
||||
assert.equal(pressed('auto'), 'true', 'could not get back to auto');
|
||||
assert.equal(pressed('closed'), 'false', 'the previous mode stayed lit');
|
||||
});
|
||||
|
||||
it('puts the newest line at the top of the history', async () => {
|
||||
/**
|
||||
* TODO #23. Jesse, 2026-08-30: "it should be reversed so the top line is the most recent and
|
||||
* the further down you go, the older the entry." The panel used to run oldest-first and scroll
|
||||
* itself to the bottom, so what had just happened was the line you had to go and find.
|
||||
*/
|
||||
const { els } = await load('?seed=430&hand=sixRandom');
|
||||
const lines = [...String(els.get('log')!['innerHTML']).matchAll(/<div class="line[^"]*">([^<]*)<\/div>/g)].map(
|
||||
(m) => m[1]!,
|
||||
);
|
||||
assert.ok(lines.length > 1, 'the log was too short to have an order at all');
|
||||
|
||||
// The deal is the OLDEST thing that has happened, so it must now be LAST rather than first.
|
||||
const dealtAt = lines.findIndex((l) => /dealt|begins|opening/i.test(l));
|
||||
assert.notEqual(dealtAt, -1, 'nothing in the log looks like the opening line');
|
||||
assert.equal(
|
||||
dealtAt,
|
||||
lines.length - 1,
|
||||
`the opening line is at ${dealtAt} of ${lines.length} — the log is still oldest-first`,
|
||||
);
|
||||
assert.equal(els.get('log')!['scrollTop'], 0, 'the panel still scrolls itself to the bottom');
|
||||
});
|
||||
|
||||
it('shows the collision counts only while a collision limit is switched on', async () => {
|
||||
/**
|
||||
* TODO #28. The limits moved into the This Game card; the running counts stay on the top line,
|
||||
* because a setting agreed to once and a number that changes how you play the next Stage are
|
||||
* different kinds of thing. `0` means the limit is off, and a half that is off is left out
|
||||
* rather than shown as "1 of 0".
|
||||
*/
|
||||
const on = await load('?seed=430&colday=3&coltotal=5');
|
||||
assert.match(String(on.els.get('collisions')!['textContent']), /0 of 3 today · 0 of 5 total/);
|
||||
|
||||
const dayOnly = await load('?seed=430&colday=3&coltotal=0');
|
||||
const text = String(dayOnly.els.get('collisions')!['textContent']);
|
||||
assert.match(text, /0 of 3 today/);
|
||||
assert.doesNotMatch(text, /total/, 'a switched-off limit was counted towards anyway');
|
||||
|
||||
const off = await load('?seed=430&colday=0&coltotal=0');
|
||||
assert.equal(off.els.get('collisions')!['textContent'], '', 'a game that cannot end this way still counted');
|
||||
});
|
||||
|
||||
it('names the game type in the card, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
// The summary line carries it whether the card is open or shut — which is what replaces the
|
||||
// header's `#gametype`, and is why solitaire losing the (empty) game-code tooltip costs nothing.
|
||||
assert.match(String(els.get('gamecardsummary')!['textContent']), /^Solitaire · 5 Days/);
|
||||
|
||||
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||
assert.match(body, /Type<\/dt><dd[^>]*>Solitaire/, 'the opened card does not name the game type');
|
||||
assert.match(body, /Combined Revenue floor<\/dt><dd[^>]*>15/, 'the card does not carry the victory conditions');
|
||||
assert.match(body, /Days<\/dt><dd[^>]*>5/, 'the card does not say how long the game is');
|
||||
});
|
||||
|
||||
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
||||
|
||||
Reference in New Issue
Block a user