Files
Dune/tests/truth-movement-native-early-controls.test.tsx
Quintine c311ed4251 Support native early Basic movement promises
Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation.

Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
2026-10-24 16:05:41 +11:00

96 lines
5.5 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createTruthMovementNativeEarlyFixture, actTruthMovementNativeEarly as act,
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
type TruthMovementNativeEarlyFixture,
} from './fixture-truth-movement-native-early';
type Props = Parameters<typeof MovementPromises>[0];
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
function controls(node: unknown): Control[] {
if (Array.isArray(node)) return node.flatMap(controls);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
}
const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props));
/** Real source controls bind the answer and decline the original discard
* reaction. No promise, native auction, submitted bid or policy is installed. */
function bind(f: TruthMovementNativeEarlyFixture): Game {
let game = act(f.game, f.asker, f.declarationStep.action);
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
game = act(game, player.id, { type: 'truthPass' });
}
game = act(game, f.asker, f.askStep.action);
assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
assert.ok(reaction?.stage === 'offer');
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
game = act(game, player.id, { type: 'semutaPass', event: pending.batch.event });
}
assert.ok(clean(game), 'Finish the original Truthtrance and Semuta controls before the native auction');
assert.equal(game.phase, 2);
return game;
}
function nativeControl(method: 'onceAround' | 'silent') {
const f = createTruthMovementNativeEarlyFixture();
const game = advance(bind(f), state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
!state.richeseAuction.outcome && (method === 'silent'
? !Object.hasOwn(state.richeseAuction.sealed, f.target)
: state.richeseAuction.active === f.target), [], { method, card: 'richese-nullentropy-box' });
const lot = game.richeseAuction!;
assert.equal(lot.method, method); assert.equal(lot.source, 'cache');
const expected: Action = { type: 'richeseBid', event: lot.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
const view = viewGame(game, f.target);
assert.deepEqual(view.movementPromisePolicy, [expected]);
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
const html = markup(props);
assert.match(html, /Your Movement turn is still ahead/);
assert.match(html, /movement controls appear on your turn/);
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.match(html, /a shipment can prepare that move but does not fulfill it/);
assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/);
const button = controls(MovementPromises(props));
assert.equal(button.length, 1); assert.equal(button[0].disabled, false);
assert.equal(button[0].children, method === 'silent'
? 'Submit zero in this Silent auction and preserve later movement'
: 'Pass this Richese bid and preserve later movement');
const busy = controls(MovementPromises({ ...props, busy: true }));
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
busy[0].onClick(); assert.deepEqual(sent, []);
assert.match(markup({ ...props, busy: true }), /Your Movement turn is still ahead/);
button[0].onClick(); assert.deepEqual(sent, [expected]);
assert.equal(sent[0], view.movementPromisePolicy![0], 'Forward the actual event-bound private policy action');
const next = act(game, f.target, sent[0]);
assert.equal(next.movementPromises![0].released, undefined);
assert.equal(next.movementPromises![0].fulfilled, undefined);
if (method === 'silent') assert.equal(next.richeseAuction!.sealed[f.target], 0);
else assert.ok(next.richeseAuction!.passed.includes(f.target));
const waiting = { ...props, game: viewGame(next, f.target) };
assert.deepEqual(waiting.game.movementPromisePolicy, []);
assert.equal(controls(MovementPromises(waiting)).length, 0);
const after = markup(waiting);
assert.match(after, /Your ground-movement promises/);
assert.match(after, /Your Movement turn is still ahead/);
assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.doesNotMatch(after, /<button/);
}
void test('real Once Around movement guidance submits only the current native pass and retains its notice', () => {
nativeControl('onceAround');
});
void test('real Silent movement guidance submits zero with the original native event and retains its notice', () => {
nativeControl('silent');
});