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