Files
Dune/tests/worm-ride-mobile-storm-controls.test.tsx
T
Quintine ac1f32c859
Verify and publish checkpoint container / container (push) Failing after 10m28s
Respect storm sector for rides into mobile stronghold
2026-10-29 07:33:59 +11:00

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);
});