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 { MixedAllianceFields, mixedAllianceInputError } from '../components/mixed-alliance-fields'; import { MixedAlliancePromises } from '../components/mixed-alliance-promises'; import { TruthFactFields } from '../components/mixed-shipment-fields'; import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises'; import { AllianceExpressionPromises } from '../components/alliance-expression-promises'; import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; import type { MixedActionExpression } from '../game/mixed-action-question'; import type { AllianceActionClaim } from '../game/mixed-alliance-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 partner = (id = 'r'): Expression => ({ kind: 'action', action: { partner: id } }); const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [partner(), partner('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-alliance-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-alliance-${path}-${field}`); (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); } function editFact(tree: ReactNode, path: string, next: TruthFact) { const scope = find(tree, 'data-mixed-alliance-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); } /** These personalized projections exercise controlled UI and canonical guide * ownership, without claiming native answer-time snapshots or consent. */ function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView { const source = createGame('TRUTHMIXEDALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); joinGame(source, newPlayer('a', 'Asker', 'harkonnen')); joinGame(source, newPlayer('r', 'Partner', 'atreides')); const game = viewGame(source, 'p'); game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null; game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 }; game.alliancePromises = []; game.allianceExpressionPromises = []; game.mixedAlliancePromises = [{ event: 'controlled-mixed-alliance', player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: kind !== 'break', fulfilled: false, released: false }]; game.allianceOffers = kind === 'accept' ? { r: 'p' } : {}; if (kind !== 'accept') { game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; } game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance', ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-mixed-alliance' } }; 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: () => MixedAllianceFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }), html: () => renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled, onChange() {} })), error: () => mixedAllianceInputError(value, game, 'p'), }; } function guide(props: Parameters[0]) { const button = AlliancePromiseGuideButton(props); assert.ok(button); return button; } const press = (button: ReturnType) => button.props.onClick(); const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedAlliancePromises, { game, busy: false, send() {} })); void test('shared mixed editor preserves sixteen clauses and offers every public other partner without predicting consent', () => { const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'partner', 'r'); for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a'); } assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); 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(/Named alliance partner/g) ?? []).length, 15); 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.allianceOffers = {}; game.players.find(player => player.id === 'a')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'a'; const allied = editor({ kind: 'and', terms: [fact(), partner()] }, false, game); assert.equal(allied.error(), null); assert.match(allied.html(), /