156 lines
8.3 KiB
TypeScript
156 lines
8.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { isValidElement, type ReactElement, type ReactNode } from 'react';
|
|
import * as jsxRuntime from 'react/jsx-runtime';
|
|
import ts from 'typescript';
|
|
import * as board from '../game/board';
|
|
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
|
import { mobileStrongholdCollectionCapacity } from '../game/mobile-stronghold-collection';
|
|
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
|
import type { MobileStronghold } from '../components/mobile-stronghold';
|
|
|
|
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
|
function elements(node: ReactNode): Element[] {
|
|
if (Array.isArray(node)) return node.flatMap(elements);
|
|
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
|
return [node, ...elements(node.props.children)];
|
|
}
|
|
|
|
/** Controlled public projection; the hook harness operates the real component
|
|
* callbacks without claiming browser hydration or server-side validation. */
|
|
function fixture() {
|
|
const g = createGame('HMSUI', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
|
|
g.players.push(newPlayer('a', 'Atreides', 'atreides'));
|
|
g.status = 'playing'; g.phase = 5; g.turn = 2; g.storm = 18;
|
|
g.active = 'i'; g.order = ['i', 'a']; g.movementRemaining = ['i', 'a'];
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
g.players[0].forces = { [board.MOBILE_LOCATION]: 6 };
|
|
g.players[0].reserves = 14;
|
|
const next = board.GRAPH['polar_sink:0'].find(key =>
|
|
board.splitLocation(key).sector !== g.storm &&
|
|
board.territory(board.splitLocation(key).territory).type !== 'stronghold');
|
|
assert.ok(next);
|
|
g.spice = { 'polar_sink:0': 3, [next]: 20 };
|
|
return { game: g, view: viewGame(g, 'i'), next };
|
|
}
|
|
|
|
function controller(game: GameView, sandmasterPile?: string, card?: string) {
|
|
const states: unknown[] = [], sent: Action[] = [];
|
|
let cursor = 0;
|
|
const exports: { MobileStronghold?: typeof MobileStronghold } = {};
|
|
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/mobile-stronghold.tsx', import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
|
}).outputText, {
|
|
exports,
|
|
require(name: string) {
|
|
if (name === 'react/jsx-runtime') return jsxRuntime;
|
|
if (name === 'react') return { useState(initial: unknown) {
|
|
const index = cursor++;
|
|
if (!(index in states)) states[index] = initial;
|
|
return [states[index], (next: unknown) => { states[index] = next; }];
|
|
} };
|
|
if (name === '@/components/ui/button') return { Button: 'button' };
|
|
if (name === '@/components/ui/input') return { Input: 'input' };
|
|
if (name === './help-tip') return { HelpTip: () => null };
|
|
if (name === '@/game/board') return board;
|
|
if (name === '@/game/mobile-stronghold-collection') return { mobileStrongholdCollectionCapacity };
|
|
if (name === '@/game/sandmaster-movement') return { quoteSandmasterStronghold: () => sandmasterPile
|
|
? { piles: { [board.splitLocation(sandmasterPile).territory]: [sandmasterPile] } } : null };
|
|
throw new Error(`Unexpected import ${name}`);
|
|
},
|
|
});
|
|
assert.ok(exports.MobileStronghold);
|
|
const render = () => { cursor = 0; return elements(exports.MobileStronghold!({ game, busy: false, card, act: action => sent.push(action) })); };
|
|
const chooseNext = (value: string) => {
|
|
const select = render().find(element => element.props['aria-label'] === 'Next route sector'); assert.ok(select);
|
|
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
|
};
|
|
const inputs = () => render().filter(element => element.type === 'input' && element.props.type === 'number');
|
|
const edit = (index: number, value: string) => {
|
|
const input = inputs()[index]; assert.ok(input);
|
|
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
|
};
|
|
const submit = () => {
|
|
const button = render().find(element => element.type === 'button' &&
|
|
(element.props.children === 'Declare stronghold route' || element.props.children === 'Spend Karama and relocate'));
|
|
assert.ok(button); return button;
|
|
};
|
|
return { render, chooseNext, inputs, edit, submit, sent };
|
|
}
|
|
|
|
void test('route controls default to per-sector maximum and submit partial choices', () => {
|
|
const { view, next } = fixture(), ui = controller(view);
|
|
ui.chooseNext(next);
|
|
assert.deepEqual(ui.inputs().map(input => input.props.value), [3, 12]);
|
|
ui.edit(0, '1'); ui.edit(1, '4');
|
|
assert.deepEqual(ui.inputs().map(input => input.props.value), ['1', '4']);
|
|
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
|
(button.props.onClick as () => void)();
|
|
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'decision', route: ['polar_sink:0', next],
|
|
collect: true, collections: [{ location: 'polar_sink:0', amount: 1 }, { location: next, amount: 4 }] });
|
|
// A forged selection cannot enable a route that repeats a sector.
|
|
ui.chooseNext('polar_sink:0');
|
|
assert.equal(ui.submit().props.disabled, true);
|
|
});
|
|
|
|
void test('invalid collection values disable submission; skipping sends route-ordered zeros', () => {
|
|
const { view, next } = fixture(), ui = controller(view, undefined, 'karama-card');
|
|
ui.chooseNext(next);
|
|
for (const value of ['', '-1', '13', '1.5']) {
|
|
ui.edit(1, value);
|
|
assert.equal(ui.inputs()[1].props['aria-invalid'], true);
|
|
assert.equal(ui.submit().props.disabled, true);
|
|
}
|
|
const checkbox = ui.render().find(element => element.type === 'input' && element.props.type === 'checkbox');
|
|
assert.ok(checkbox);
|
|
(checkbox.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: false } });
|
|
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
|
(button.props.onClick as () => void)();
|
|
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'card', mode: 'special', card: 'karama-card',
|
|
route: ['polar_sink:0', next], collect: false,
|
|
collections: [{ location: 'polar_sink:0', amount: 0 }, { location: next, amount: 0 }] });
|
|
});
|
|
|
|
void test('controlled Sandmaster pile is debited before the route input cap', () => {
|
|
const { view, next } = fixture(); view.spice[next] = 3;
|
|
const ui = controller(view, next); ui.chooseNext(next);
|
|
assert.equal(ui.inputs()[1].props.max, 2); assert.equal(ui.inputs()[1].props.value, 2);
|
|
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
|
(button.props.onClick as () => void)();
|
|
const action = JSON.parse(JSON.stringify(ui.sent[0])) as Action;
|
|
assert.deepEqual(action.sandmasterPiles, [next]);
|
|
assert.deepEqual(action.collections, [{ location: 'polar_sink:0', amount: 3 }, { location: next, amount: 2 }]);
|
|
});
|
|
|
|
void test('route capacity includes foreign fighters and a public No-Field, but excludes advisors', () => {
|
|
for (const tokenId of ['hidden-zero', 'hidden-five']) {
|
|
const { game, next } = fixture();
|
|
game.advanced = true;
|
|
game.players[1].forces = { [board.MOBILE_LOCATION]: 2 };
|
|
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
|
|
bg.forces = { [board.MOBILE_LOCATION]: 4 };
|
|
bg.advisors = { [board.MOBILE_STRONGHOLD]: {} };
|
|
const richese = newPlayer('r', 'Richese', 'richese');
|
|
richese.noField = deployRicheseNoField(
|
|
createRicheseNoField(['hidden-zero', 'hidden-three', 'hidden-five']),
|
|
{ tokenId, controller: richese.id, location: { territory: board.MOBILE_STRONGHOLD, sector: 0 } },
|
|
);
|
|
game.players.push(bg, richese);
|
|
game.spice[next] = 40;
|
|
const view = viewGame(game, 'i');
|
|
const publicMarker = view.players.find(player => player.id === 'r')!.noField;
|
|
assert.ok(publicMarker?.deployed);
|
|
assert.equal('private' in publicMarker, false);
|
|
assert.equal('tokenId' in publicMarker.deployed, false);
|
|
const ui = controller(view); ui.chooseNext(next);
|
|
// Six Ixians + two foreign fighters + one No-Field, two spice each.
|
|
assert.equal(ui.inputs()[1].props.max, 18);
|
|
assert.equal(ui.inputs()[1].props.value, 18);
|
|
delete bg.advisors[board.MOBILE_STRONGHOLD];
|
|
const fighters = controller(viewGame(game, 'i')); fighters.chooseNext(next);
|
|
assert.equal(fighters.inputs()[1].props.max, 26);
|
|
}
|
|
});
|