import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { MixedActionFields, type MixedActionConfig } from '../components/mixed-action-fields'; import { MixedBribeFields, mixedBribeInputError } from '../components/mixed-bribe-fields'; import { MixedBribePromises } from '../components/mixed-bribe-promises'; import { MixedMovementFields } from '../components/mixed-movement-fields'; import { TruthFactFields } from '../components/mixed-shipment-fields'; import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises'; import { BribeExpressionPromises } from '../components/bribe-expression-promises'; import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; import type { MixedActionExpression } from '../game/mixed-action-question'; import type { BribePaymentClaim } from '../game/mixed-bribe-question'; import type { TruthFact } from '../game/truthtrance'; import { createTechTokens } from '../game/tech-tokens'; type Expression = MixedActionExpression; type ElementProps = { children?: ReactNode; [key: string]: unknown }; type Element = ReactElement; const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value }); const payment = (minimum = 1, recipient = 'r'): Expression => ({ kind: 'action', action: { recipient, minimum } }); const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [payment(3), payment(2, 'a')] }] }); function elements(node: ReactNode): Element[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement(node)) return []; return [node, ...elements(node.props.children)]; } function find(tree: ReactNode, key: string, value: string): Element { const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found; } const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-bribe-control', key); const click = (button: Element) => (button.props.onClick as () => void)(); function select(tree: ReactNode, path: string, field: string, value: string) { const input = find(tree, 'id', `truth-mixed-bribe-${path}-${field}`); (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); } function minimum(tree: ReactNode, path: string, valueAsNumber: number) { const input = find(tree, 'id', `truth-mixed-bribe-${path}-minimum`); (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } }); } function editFact(tree: ReactNode, path: string, next: TruthFact) { const scope = find(tree, 'data-mixed-bribe-fact-path', path); const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields, `Current fact fields ${path}`); (fields.props.onChange as (fact: TruthFact) => void)(next); } /** Controlled personalized projections exercise UI ownership and callbacks. * Native answer-time snapshots, funding and real payments have separate proof. */ function projected(phase = 3, advanced = false): GameView { const source = createGame('TRUTHMIXEDBRIBECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); joinGame(source, newPlayer('a', 'Asker', 'atreides')); joinGame(source, newPlayer('r', 'Recipient', 'harkonnen')); const game = viewGame(source, 'p'); game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a'; game.auction = { bid: 0, bidder: null, active: 'a', remaining: 2, card: null, inspectionBlocked: null }; game.bribeOptions = { available: 8, incoming: 0, blocked: null, targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] }; game.bribePromises = []; game.bribeExpressionPromises = []; game.mixedBribePromises = [{ event: 'controlled-mixed-bribe-event', player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, progress: [{ recipient: 'r', amount: 1 }], fulfilled: false, released: false }]; game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-mixed-bribe-event' } }; return game; } function editor(initial: Expression, disabled = false, game = projected()) { let value = initial, changes = 0; return { get value() { return value; }, get changes() { return changes; }, tree: () => MixedBribeFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }), html: () => renderToStaticMarkup(createElement(MixedBribeFields, { value, game, target: 'p', disabled, onChange() {} })), error: () => mixedBribeInputError(value, game, 'p'), }; } function guide(props: Parameters[0]) { const button = BribePromiseGuideButton(props); assert.ok(button); return button; } const press = (button: ReturnType) => button.props.onClick(); const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBribePromises, { game, busy: false, send() {} })); void test('shared mixed editor adapts actions and keeps movement wrapper identifiers and fact controls stable', () => { type Probe = { token: string }; let value: MixedActionExpression = { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }; const game = projected(); let parsed = 0; const config: MixedActionConfig = { prefix: 'mixed-probe', legend: 'Probe claim', actionLabel: 'probe condition', actionTiming: 'Future probe', actionHelp: 'Probe mechanics', fallbackError: 'Choose a probe', maxLeaves: 16, maxDepth: 4, newAction: () => ({ token: 'exact-token' }), parseAction: input => { parsed++; return input as Probe; }, renderAction: props => , }; const tree = () => MixedActionFields({ value, game, target: 'p', disabled: false, config, onChange(next) { value = next; } }); click(find(tree(), 'data-mixed-probe-control', 'or-1')); assert.deepEqual(value, { kind: 'or', terms: [fact(), { kind: 'action', action: { token: 'exact-token' } }] }); find(tree(), 'htmlFor', 'truth-mixed-probe-1.2-token'); assert.ok(parsed > 0); let movementChanges = 0; const movement = MixedMovementFields({ value: { kind: 'and', terms: [{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }, { kind: 'action', action: { territory: 'carthag', minimum: 2 } }] }, game, target: 'p', disabled: false, onChange() { movementChanges++; } }); find(movement, 'id', 'truth-mixed-movement-1.2-territory'); find(movement, 'data-mixed-movement-fact-path', '1.1'); click(find(movement, 'data-mixed-movement-control', 'nested-or-1')); assert.equal(movementChanges, 1); }); void test('mixed bribe editor preserves sixteen clauses, public recipients, unique IDs and safe integer minima without a spice cap', () => { const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'recipient', 'r'); for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); select(e.tree(), `1.${count}`, 'recipient', count % 2 ? 'r' : 'a'); } assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null); minimum(e.tree(), '1.16', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null); const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]); assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/); assert.equal((html.match(/Minimum future ordinary bribe payments in spice/g) ?? []).length, 15); assert.match(html, /max="9007199254740991"/); const before = structuredClone(e.value), changes = e.changes; for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) { assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); } assert.equal(e.changes, changes); assert.deepEqual(e.value, before); const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'r'; const allied = editor({ kind: 'and', terms: [fact(), payment()] }, false, game); assert.ok(allied.error()); assert.doesNotMatch(allied.html(), /