Add an enlarged public sheet beneath each player, available while actions are pending. Only faction identity and table rules mode enter the reader; it sends no action and receives no private player data. Basic/Advanced preview explicitly leaves the table mode unchanged. Native mode control, keyboard scrolling, Escape/focus restoration and internal links use existing dialog components. The internal gallery covers all twelve factions with bundled Homeworld artwork. Share every faction's Basic and Advanced paragraphs with the main reference. Replace Richese's generic placeholder, centralize Ecaz/Moritani guidance from settled contracts, correct CHOAM Auditor/Baliset/Jubba availability and align the Fremen/Ecaz Sietch Tabr exception. Full printed text, unresolved powers and combined-rule acceptance remain explicit. No new rule, AI strategy or gate. Four focused tests cover all12 identities, shared paragraphs, artwork files, internal links, mode separation, gallery defaults and per-viewer busy controls. The first busy-control assertion falsely matched CSS disabled variants; it now checks actual disabled attributes. All16 related focused cases pass. Independent review caught missing Moritani optionality and stale Fremen exception text; those were fixed and final review found no material issue. Auditor limits and full actual-card cancellation cost were checked against the existing contract. Types, lint, all 5000 offline tests and production build pass on stable source. No API/auth/persistence changes; HTTP tests were not repeated. Browser QA reused the existing two-player Basic auction without game actions. Both own and opposing inspectors work; Enter opens, Escape closes and returns focus, reopening resets Basic, and Advanced preview explicitly preserves the Basic table. ArrowDown scrolls the focused long sheet. At390px, dialog359x828, scroll client/width342 without horizontal overflow,16px text and44px controls. Gallery334px fits without overflow; Richese, Ecaz and Moritani selection and Advanced paragraphs render, faction change resets Basic and bundled art loads. Internal help and Advanced Moritani links expand their topics. Back and refresh preserve four hand cards and the pending minimum bid2; normal viewport restored. Screenshot capture timed out; full visual/screen-reader acceptance remains open. All 1539 opening games, 3259 saved seats and auxiliary rows remain identical, including the QA room. SQLite integrity and foreign keys pass. No new games, server restart, migration, reset or recurring automation. Private evidence: 20260921-faction-sheets. Verified source tree: 6b738958f30fc381d3e9eb26398239bbd88f1d4281f8ff5ff2bb3b3259494a93
75 lines
4.4 KiB
TypeScript
75 lines
4.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { registerHooks } from 'node:module';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { FACTIONS } from '../game/catalog';
|
|
import { FACTION_RULES } from '../game/faction-reference';
|
|
import { RULE_TOPICS } from '../game/reference';
|
|
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
|
|
|
const aliases = registerHooks({
|
|
resolve(specifier, context, next) { return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); },
|
|
load(url, context, next) {
|
|
if (!url.endsWith('.module.css')) return next(url, context);
|
|
const css = readFileSync(new URL(url), 'utf8');
|
|
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]]));
|
|
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
|
},
|
|
});
|
|
const { FactionSheet, FactionSheetGallery } = await import('../components/faction-inspector');
|
|
const { GameTable } = await import('../components/game-table');
|
|
aliases.deregister();
|
|
|
|
void test('all twelve faction sheets share reference text, distinct identities, bundled art and valid internal links', () => {
|
|
assert.deepEqual(Object.keys(FACTION_RULES).sort(), FACTIONS.map(f => f.id).sort());
|
|
for (const house of FACTIONS) {
|
|
const basic = RULE_TOPICS.find(t => t.id === `faction-${house.id}`)!;
|
|
const advanced = RULE_TOPICS.find(t => t.id === `advanced-${house.id}`)!;
|
|
assert.equal(basic.steps, FACTION_RULES[house.id].basic);
|
|
assert.equal(advanced.steps, FACTION_RULES[house.id].advanced);
|
|
assert.ok(basic.steps.length > 1 && advanced.steps.length > 0);
|
|
const html = renderToStaticMarkup(createElement(FactionSheet, { factionId: house.id, advanced: true }));
|
|
assert.ok(html.includes(`aria-label="${house.name} faction sheet"`));
|
|
assert.ok(html.includes(`href="/rules?topic=faction-${house.id}#faction-${house.id}"`));
|
|
assert.ok(html.includes(`href="/rules?topic=advanced-${house.id}#advanced-${house.id}"`));
|
|
const art = html.match(/src="(\/art\/homeworlds\/[^" ]+)"/)?.[1];
|
|
assert.ok(art && existsSync(new URL(`../public${art}`, import.meta.url)));
|
|
assert.doesNotMatch(html, /https?:\/\//);
|
|
}
|
|
});
|
|
|
|
void test('Basic sheets omit Advanced rules; Advanced sheets retain the underlying Basic powers', () => {
|
|
const basic = renderToStaticMarkup(createElement(FactionSheet, { factionId: 'harkonnen', advanced: false }));
|
|
const advanced = renderToStaticMarkup(createElement(FactionSheet, { factionId: 'harkonnen', advanced: true }));
|
|
assert.match(basic, /Harkonnen Basic powers and alliances/);
|
|
assert.doesNotMatch(basic, /aria-label="Harkonnen Advanced powers"|Advanced Harkonnen reference/);
|
|
assert.match(advanced, /Harkonnen Basic powers and alliances/);
|
|
assert.match(advanced, /aria-label="Harkonnen Advanced powers"/);
|
|
assert.match(advanced, /The rules and game are still being completed/);
|
|
});
|
|
|
|
void test('the reference gallery includes every faction and starts in Basic without a saved-table mode claim', () => {
|
|
const html = renderToStaticMarkup(createElement(FactionSheetGallery));
|
|
for (const house of FACTIONS) assert.ok(html.includes(`value="${house.id}"`));
|
|
assert.match(html, /Atreides faction sheet/);
|
|
assert.match(html, /Include Advanced powers/);
|
|
assert.doesNotMatch(html, /This table uses|Atreides Advanced powers/);
|
|
});
|
|
|
|
void test('every occupied player has a public inspector for either viewer even while actions are busy', () => {
|
|
const game = createGame('SHEETUI', newPlayer('a', 'Own name', 'atreides'));
|
|
joinGame(game, newPlayer('h', 'Other name', 'harkonnen'));
|
|
const before = JSON.stringify(game);
|
|
for (const id of ['a', 'h']) {
|
|
const html = renderToStaticMarkup(createElement(GameTable, { game: viewGame(game, id), busy: true, send: async () => { assert.fail('Inspection must not act.'); }, onExit() {} }));
|
|
for (const name of ['Atreides', 'Harkonnen']) {
|
|
const button = html.match(new RegExp(`<button[^>]*aria-label="Inspect faction: ${name}"[^>]*>`))?.[0];
|
|
assert.ok(button);assert.doesNotMatch(button, /\s(?:disabled(?:=|[ >])|aria-disabled="true")/);
|
|
}
|
|
assert.equal((html.match(/>Inspect faction</g) ?? []).length, 2);
|
|
}
|
|
assert.equal(JSON.stringify(game), before);
|
|
});
|