98 lines
5.4 KiB
TypeScript
98 lines
5.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { registerHooks } from 'node:module';
|
|
import { resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import test from 'node:test';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { MOBILE_STRONGHOLD, location, territory } from '../game/board';
|
|
|
|
// SSR cannot operate a select. Seed exactly the state normally set by the
|
|
// destination/sector and force controls while still rendering real GameTable.
|
|
// Derive hook indices from declaration names so unrelated inserted hooks do not
|
|
// change the selected state. This does not claim a hydrated browser interaction.
|
|
const file = resolve('components/game-table.tsx');
|
|
const sourceText = readFileSync(file, 'utf8');
|
|
const names = [...sourceText.matchAll(/const\s+\[\s*([\w$]+)\s*,[^\]]+\]\s*=\s*useState/g)].map(match => match[1]);
|
|
const indices = Object.fromEntries(['selectedId', 'sector', 'rideForces'].map(name => {
|
|
const index = names.indexOf(name); assert.ok(index >= 0, `Missing ${name} table state`); return [name, index];
|
|
}));
|
|
const reactUrl = pathToFileURL(resolve('node_modules/react/index.js')).href;
|
|
const mockReact = `import {useState as original} from ${JSON.stringify(reactUrl)};
|
|
export function useState(initial) {
|
|
const config = globalThis.__duneWormRideHarness;
|
|
const index = config.next++;
|
|
if (index === config.indices.selectedId) initial = config.destination;
|
|
if (index === config.indices.sector) initial = 0;
|
|
if (index === config.indices.rideForces) initial = {[config.source]: 4};
|
|
return original(initial);
|
|
}`;
|
|
const aliases = registerHooks({
|
|
resolve(specifier, context, next) {
|
|
if (specifier === 'react' && context.parentURL?.endsWith('/components/game-table.tsx'))
|
|
return {url:`data:text/javascript,${encodeURIComponent(mockReact)}`, shortCircuit:true};
|
|
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
|
specifier === 'next/link' ? 'vinext/shims/link' : 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(match => [match[1], match[1]]));
|
|
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
|
},
|
|
});
|
|
const { GameTable } = await import('../components/game-table');
|
|
aliases.deregister();
|
|
|
|
const sourceSector = territory('the_great_flat').sectors[0];
|
|
const sourceLocation = location('the_great_flat', sourceSector);
|
|
/** Staged Basic Ix worm decision, matching the focused runtime fixture; only
|
|
* forces, source, storm and mobile placement are staged, not a played journey. */
|
|
function fixture(storm: number): Game {
|
|
const game = createGame('WORMHMSUI', newPlayer('f', 'Fremen', 'fremen'), false, ['ix']);
|
|
game.players.push(newPlayer('i', 'Ixians', 'ixians'), newPlayer('e', 'Emperor', 'emperor'));
|
|
Object.assign(game, {status:'playing', phase:1, turn:2, storm, active:'f', order:['f','i','e'],
|
|
spiceSequence:{pile:1, skipped:[]}, wormRides:[],
|
|
decision:{kind:'wormRide', player:'f', territory:'the_great_flat'},
|
|
mobileStronghold:{location:'arsunt:11'}});
|
|
for (const player of game.players) Object.assign(player, {hand:[], forces:{}, reserves:20, spice:10});
|
|
game.players[0].forces = {[sourceLocation]:4}; game.players[0].reserves = 16;
|
|
return game;
|
|
}
|
|
function render(game: Game) {
|
|
const config = {next:0, indices, source:sourceLocation, destination:MOBILE_STRONGHOLD};
|
|
(globalThis as unknown as {__duneWormRideHarness: typeof config}).__duneWormRideHarness = config;
|
|
try {
|
|
return renderToStaticMarkup(createElement(GameTable, {game:viewGame(game,'f'),busy:false,
|
|
send:async () => { assert.fail('Server rendering must not send a decision.'); },onExit() {}}));
|
|
} finally {
|
|
Reflect.deleteProperty(globalThis, '__duneWormRideHarness');
|
|
}
|
|
}
|
|
function rideButton(html: string) {
|
|
const match = html.match(/<button\b[^>]*>Ride Shai-Hulud/); assert.ok(match, 'The original Ride button renders');
|
|
return match[0];
|
|
}
|
|
|
|
void test('selected mobile interior blocks a worm ride through a storm-covered pointer with a readable reason', () => {
|
|
const game = fixture(11), before = structuredClone(game), html = render(game);
|
|
assert.match(html, /Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it\./);
|
|
assert.match(rideButton(html), /\bdisabled=""/);
|
|
assert.match(html, /<option value="hidden_mobile_stronghold" selected="">/);
|
|
assert.match(html, /<option value="0" selected="">Inside stronghold/);
|
|
assert.match(html, new RegExp(`From sector ${sourceSector} · 4 forces`));
|
|
assert.deepEqual(game, before);
|
|
});
|
|
|
|
void test('the same selected mobile interior remains actionable when the pointer sector is clear', () => {
|
|
const game = fixture(12), before = structuredClone(game), html = render(game);
|
|
assert.doesNotMatch(html, /Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it\./);
|
|
assert.doesNotMatch(rideButton(html), /\bdisabled=""/);
|
|
assert.match(html, /<option value="hidden_mobile_stronghold" selected="">/);
|
|
assert.match(html, /<option value="0" selected="">Inside stronghold/);
|
|
assert.match(html, new RegExp(`From sector ${sourceSector} · 4 forces`));
|
|
assert.deepEqual(game, before);
|
|
});
|