Files
Dune/tests/advisor-preparation-controls.test.tsx
Quintine 660b0a0c25 Open native advisor preparation before the first shipment
Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving
original phase/auction/readiness and native eligibility. Close a current-turn
receipt only on committed shipments, including marker and Sneak Attack entry.
Retain the legacy phase5 queue and qualify the per-territory prevention policy.
Connect persistent controls, minimal bot support and focused native evidence.
Complete modes and browser/deployed acceptance remain open.
2026-10-24 18:36:55 +11:00

159 lines
7.2 KiB
TypeScript
Raw Permalink 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 { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AdvisorPreparation } from '../components/advisor-preparation';
import {
createGame,
newPlayer,
viewGame,
type Action,
type GameView,
} from '../game/engine';
// Client-only public projections isolate persistent controls. The source suite
// exercises actual advisor groups, window closure and Karama continuations.
function projected(): GameView {
const game = createGame('ADVISORPREPARATIONCONTROLS', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), true);
game.players.push(newPlayer('a', 'Atreides', 'atreides'));
Object.assign(game, {
status: 'playing', phase: 2, turn: 3, order: ['b', 'a'],
});
const view = viewGame(game, 'b');
view.advisorPreparation = { blocked: null, territories: ['arrakeen', 'carthag'] };
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(AdvisorPreparation, {
game, busy, act() {},
}));
}
function click(node: ReactNode, place: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === `advisor-preparation-${place}` && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, place)) return true;
}
return false;
}
function prepare(game: GameView, place = 'arrakeen', busy = false): Action[] {
const actions: Action[] = [];
click(AdvisorPreparation({ game, busy, act(action) { actions.push(action); } }), place);
return actions;
}
void test('the native Advanced owner can prepare each projected territory with its exact original action', () => {
const game = projected();
const html = markup(game);
assert.match(html, /aria-label="Bene Gesserit advisor preparation"/);
assert.match(html, /Prepare advisors · Arrakeen/);
assert.match(html, /Prepare advisors · Carthag/);
assert.doesNotMatch(html, /disabled=""/);
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
assert.deepEqual(prepare(game, 'carthag'), [{ type: 'prepareAdvisors', territory: 'carthag' }]);
assert.deepEqual(prepare(game, 'sietch_tabr'), []);
});
void test('the persistent guide names the printed window and keeps native choices and independent flips separate', () => {
const html = markup(projected());
assert.match(html, /After Spice Blow and any Nexus/);
assert.match(html, /until the first actual shipment has occurred/);
assert.match(html, /Charity, Bidding, Revival and the opening of Shipment and Movement/);
assert.match(html, /turn all your advisors there into fighters, across every sector/);
assert.match(html, /Storm, ordinary allied occupation and fresh-advisor restrictions\s*still apply/);
assert.match(html, /Provisional Karama policy: a canceled preparation blocks that territory until this window ends/);
assert.match(html, /Other eligible territories remain available/);
assert.match(html, /Intrusion, Nexus conversion and automatic advisor flips remain independent/);
assert.match(html, /ordinary advisor chooser during Shipment and Movement remains available/);
assert.match(html, /Declining that choice does not close this preparation window/);
});
void test('busy, Truthtrance and pending Nexus block the actual buttons without removing their guide', () => {
const game = projected();
assert.deepEqual(prepare(game, 'arrakeen', true), []);
assert.equal((markup(game, true).match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
const truthtrance = structuredClone(game);
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
assert.deepEqual(prepare(truthtrance), []);
const nexus = structuredClone(game);
nexus.nexusTraitors = {
offer: null,
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
};
assert.deepEqual(prepare(nexus), []);
assert.match(markup(nexus), /Provisional Karama policy/);
});
void test('current response, decision and phase-opening priority defensively prevent preparation', () => {
for (const field of ['response', 'decision', 'phaseOpening'] as const) {
const game = projected();
if (field === 'response') game.response = {} as NonNullable<GameView['response']>;
if (field === 'decision') game.decision = {} as NonNullable<GameView['decision']>;
if (field === 'phaseOpening') game.phaseOpening = {} as NonNullable<GameView['phaseOpening']>;
assert.deepEqual(prepare(game), [], field);
const html = markup(game);
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2, field);
assert.match(html, /Finish the current response, decision or phase opening before preparing advisors/, field);
}
});
void test('projected blocks remain readable and authoritative across renders while unavailable territories never gain buttons', () => {
const game = projected();
game.advisorPreparation!.blocked = 'Finish the pending revival before preparing advisors.';
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Finish the pending revival before preparing advisors/);
assert.match(html, /aria-describedby="advisor-preparation-unavailable"/);
assert.match(html, /Provisional Karama policy/);
assert.deepEqual(prepare(game), []);
}
game.advisorPreparation = { blocked: null, territories: [] };
assert.match(markup(game), /No advisor territory is currently eligible for preparation/);
assert.doesNotMatch(markup(game), /<button\b/);
assert.deepEqual(prepare(game), []);
});
void test('only the native Advanced owner sees an open phase-two-through-five preparation descriptor', () => {
for (const phase of [2, 3, 4, 5]) {
const game = projected();
game.phase = phase;
assert.match(markup(game), /Prepare advisors for battle/);
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
}
for (const field of ['basic', 'viewer', 'faction', 'before', 'after', 'closed', 'status'] as const) {
const game = projected();
if (field === 'basic') game.advanced = false;
if (field === 'viewer') game.me = 'a';
if (field === 'faction') game.players[0].faction = 'atreides';
if (field === 'before') game.phase = 1;
if (field === 'after') game.phase = 6;
if (field === 'closed') game.advisorPreparation = null;
if (field === 'status') game.status = 'lobby';
assert.equal(markup(game), '', field);
assert.deepEqual(prepare(game), [], field);
}
});
void test('preparation controls never inspect either seat’s private wallets, cards or force counts', () => {
const game = projected();
for (const player of game.players) {
for (const field of ['spice', 'hand', 'forces', 'elites', 'advisors', 'tanks']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Preparation read ${player.id} private ${field}`); },
});
}
}
assert.match(markup(game), /Prepare advisors · Arrakeen/);
assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
});