118 lines
6.3 KiB
TypeScript
118 lines
6.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 { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import * as jsxRuntime from 'react/jsx-runtime';
|
||
import ts from 'typescript';
|
||
import * as catalog from '../game/catalog';
|
||
import * as bribeOptions from '../game/bribe-options';
|
||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||
import { actTruthBid, advanceTruthBid, createTruthBidFixture } from './fixture-truth-bid';
|
||
import { actFremenRevivalGrant, createFremenRevivalGrantFixture } from './fixture-fremen-revival-grant';
|
||
|
||
type BribesComponent = (props: {game: GameView; busy: boolean; act: (action: Action) => void}) => ReactNode;
|
||
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)];
|
||
}
|
||
|
||
/** Exercise the actual component's callbacks with deterministic React hooks.
|
||
* This is a component harness, not a hydrated-browser assertion. */
|
||
function controls(game: GameView) {
|
||
const states:unknown[] = [], sent:Action[] = [];
|
||
let cursor = 0;
|
||
const exported:{Bribes?:BribesComponent} = {};
|
||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/bribes.tsx', import.meta.url), 'utf8'), {
|
||
compilerOptions: {module:ts.ModuleKind.CommonJS, target:ts.ScriptTarget.ES2022, jsx:ts.JsxEmit.ReactJSX},
|
||
}).outputText, {exports:exported, require(name:string) {
|
||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||
if (name === 'react') return {useId:() => 'bribe-control-test', useState(initial:unknown) {
|
||
const index = cursor++;
|
||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
|
||
return [states[index], (next:unknown) => {states[index] = typeof next === 'function' ? next(states[index]) : next;}];
|
||
}};
|
||
if (name === '@/game/catalog') return catalog;
|
||
if (name === '@/game/bribe-options') return bribeOptions;
|
||
if (name === '@/components/ui/button') return {Button:'button'};
|
||
throw Error(`Unexpected import ${name}`);
|
||
}});
|
||
assert.ok(exported.Bribes);
|
||
const renderTree = (busy = false) => {
|
||
cursor = 0;
|
||
return exported.Bribes!({game,busy,act(action) {sent.push(action);}});
|
||
};
|
||
return {render:(busy = false) => elements(renderTree(busy)), renderTree, sent};
|
||
}
|
||
|
||
/** Real classic setup and bidding establish the winning bid and its optional
|
||
* Karama payment choice; only original unopened starting cards are selected. */
|
||
function auctionPayment() {
|
||
const f = createTruthBidFixture({window:'active',targetKarama:true});
|
||
let game = actTruthBid(f.game, f.target, {type:'bid',amount:3});
|
||
game = advanceTruthBid(game, state => state.decision?.kind === 'auctionPayment' && !state.response);
|
||
assert.equal(game.auction?.bidder, f.target);
|
||
assert.equal(game.auction?.bid, 3);
|
||
return {game,payer:f.target,recipient:f.opponent};
|
||
}
|
||
|
||
/** The same source reaches the original Atreides inspection response before
|
||
* any bid; Emperor's genuinely dealt Karama keeps that response open. */
|
||
function atreidesAuction() {
|
||
const f = createTruthBidFixture({targetKarama:true});
|
||
const game = advanceTruthBid(structuredClone(f.afterSetup), state =>
|
||
state.phase === 3 && state.response?.kind === 'atreidesAuction');
|
||
assert.equal(game.response?.owner, 'atreides');
|
||
return {game,payer:f.target,recipient:f.opponent};
|
||
}
|
||
|
||
/** The fixture supplies a real battle, Nexus alliance and grant response. */
|
||
function fremenGrant() {
|
||
const f = createFremenRevivalGrantFixture();
|
||
const game = actFremenRevivalGrant(f.game, f.fremen, f.grant);
|
||
assert.equal(game.response?.kind, 'fremenRevivalGrant');
|
||
return {game,payer:f.observer,recipient:f.fremen};
|
||
}
|
||
|
||
for (const [label,source] of [
|
||
['auction payment',auctionPayment], ['Atreides inspection',atreidesAuction], ['Fremen grant',fremenGrant],
|
||
] as const) void test(`the ${label} exposes usable private native bribe controls`, () => {
|
||
const {game,payer,recipient} = source();
|
||
const view = viewGame(game,payer), quote = view.bribeOptions;
|
||
assert.equal(quote.blocked,null);
|
||
const option = quote.targets.find(row => row.id === recipient); assert.ok(option);
|
||
assert.ok(option.maximum >= 1);
|
||
assert.equal(quote.receiptTiming,'upcoming');
|
||
const ui = controls(view);
|
||
const initial = ui.render();
|
||
const select = initial.find(node => node.type === 'select'); assert.ok(select);
|
||
assert.equal(select.props.disabled,false);
|
||
const initialButton = initial.find(node => node.type === 'button'); assert.ok(initialButton);
|
||
assert.equal(initialButton.props.disabled,true);
|
||
(select.props.onChange as (event:{target:{value:string}}) => void)({target:{value:recipient}});
|
||
const chosen = ui.render();
|
||
const amount = chosen.find(node => node.type === 'input'); assert.ok(amount);
|
||
assert.equal(amount.props.max,option.maximum);
|
||
const payment = chosen.find(node => node.type === 'button'); assert.ok(payment);
|
||
assert.equal(payment.props.disabled,false);
|
||
(payment.props.onClick as () => void)();
|
||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [{type:'bribe',target:recipient,amount:1}]);
|
||
(amount.props.onChange as (event:{target:{value:string}}) => void)({target:{value:String(option.maximum + 1)}});
|
||
assert.equal(ui.render().find(node => node.type === 'button')?.props.disabled,true);
|
||
assert.equal(ui.render(true).find(node => node.type === 'select')?.props.disabled,true);
|
||
assert.equal(ui.render(true).find(node => node.type === 'input')?.props.disabled,true);
|
||
|
||
const html = renderToStaticMarkup(createElement(() => ui.renderTree()));
|
||
assert.match(html,/collected at the upcoming Mentat Pause/);
|
||
assert.match(html,/separate from your spendable supply/);
|
||
assert.equal(view.players.find(player => player.id === recipient)?.spice,undefined);
|
||
const changed = structuredClone(game);
|
||
const other = changed.players.find(player => player.id === recipient); assert.ok(other);
|
||
other.spice += 100; other.bribes += 100;
|
||
assert.deepEqual(viewGame(changed,payer).bribeOptions,quote,
|
||
'Another seat’s private resources must not alter this payer’s quote.');
|
||
});
|