Files
Dune/tests/bribe-classic-pending-controls.test.tsx
T
Quintine 6ecd26c8d1
Verify and publish checkpoint container / container (push) Successful in 10m47s
Allow bribes during classic pending decisions
2026-10-29 08:26:34 +11:00

118 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.');
});