190 lines
9.7 KiB
TypeScript
190 lines
9.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { registerHooks } from 'node:module';
|
|
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
|
|
import { createDukeVidal } from '../game/duke-vidal';
|
|
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
|
|
|
const aliases = registerHooks({
|
|
resolve(specifier, context, next) {
|
|
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
|
},
|
|
});
|
|
const { EcazEntry } = await import('../components/ecaz-entry');
|
|
aliases.deregister();
|
|
|
|
// Controlled projections supplement the separate native acquisition sources.
|
|
// Only the public view is changed: no played alliance, Duke custody, Ambassador
|
|
// token, force, hand or entry is installed in a Game. React renders the stateful
|
|
// wrapper; only the hook-free choices leaf is called directly for callbacks.
|
|
function projected(advanced = false): GameView {
|
|
const game = createGame('ECAZDUKEALLYCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
|
joinGame(game, newPlayer('a', 'Atreides ally', 'atreides'));
|
|
joinGame(game, newPlayer('i', 'Emperor entrant', 'emperor'));
|
|
const view = viewGame(game, 'e');
|
|
view.status = 'playing'; view.phase = 5; view.turn = 2;
|
|
view.decision = { kind: 'ecazAmbassador', player: 'e' };
|
|
view.dukeVidal = createDukeVidal();
|
|
view.ambassadorEntry = {
|
|
event: 'controlled-ecaz-duke-entry', owner: 'e', entrant: 'i',
|
|
territory: 'arrakeen', stage: 'offer', effect: 'ecaz', beneficiary: null,
|
|
noField: null, shipment: null, movement: null,
|
|
allianceOffer: { blocked: 'The entrant is already allied.' },
|
|
dukeAcquisition: { blocked: null }, dukeLoan: null,
|
|
beneficiaries: [{ player: 'e', blocked: null }, { player: 'a', blocked: null }],
|
|
copies: [], cards: [], revival: null,
|
|
};
|
|
return view;
|
|
}
|
|
function markup(game: GameView, busy = false, wrapper = false) {
|
|
return renderToStaticMarkup(createElement(wrapper ? EcazEntry : EcazDukeAcquisitionChoices, {
|
|
game, busy, act() {},
|
|
}));
|
|
}
|
|
function button(html: string, recipient: string) {
|
|
const found = html.match(new RegExp(`<button\\b(?=[^>]*data-testid="ecaz-duke-acquisition-${recipient}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
|
|
assert.ok(found, `Duke choice for ${recipient}`); return found;
|
|
}
|
|
function click(node: ReactNode, recipient: 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'] === `ecaz-duke-acquisition-${recipient}` && child.props.onClick) {
|
|
child.props.onClick(); return true;
|
|
}
|
|
if (click(child.props.children, recipient)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function choose(game: GameView, recipient: string, busy = false): Action[] {
|
|
const actions: Action[] = [];
|
|
click(EcazDukeAcquisitionChoices({ game, busy, act(action) { actions.push(action); } }), recipient);
|
|
return actions;
|
|
}
|
|
|
|
void test('Ecaz Duke choices allow the projected existing ally when self acquisition is blocked', () => {
|
|
for (const advanced of [false, true]) {
|
|
const game = projected(advanced), entry = game.ambassadorEntry!;
|
|
entry.dukeAcquisition!.blocked = 'Ecaz already controls Duke Vidal.';
|
|
entry.beneficiaries[0].blocked = 'Ecaz already controls Duke Vidal.';
|
|
const html = markup(game);
|
|
assert.match(button(html, 'e'), /\sdisabled=/);
|
|
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
|
|
assert.match(html, /Ecaz already controls Duke Vidal/);
|
|
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
|
|
assert.deepEqual(choose(game, 'e'), []);
|
|
assert.deepEqual(choose(game, 'a'), [{
|
|
type: 'decision', event: entry.event, trigger: true, beneficiary: 'a', choice: 'duke',
|
|
}]);
|
|
}
|
|
});
|
|
|
|
void test('each offered Duke recipient sends exactly its own event-bound acquisition action', () => {
|
|
const game = projected(), before = structuredClone(game);
|
|
for (const recipient of ['e', 'a']) {
|
|
assert.deepEqual(choose(game, recipient), [{
|
|
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true,
|
|
beneficiary: recipient, choice: 'duke',
|
|
}]);
|
|
assert.doesNotMatch(button(markup(game), recipient), /\sdisabled=/);
|
|
}
|
|
assert.deepEqual(choose(game, 'i'), [], 'No unoffered entrant choice is manufactured');
|
|
assert.deepEqual(game, before);
|
|
});
|
|
|
|
void test('busy and per-recipient blocks suppress actual Duke callbacks while reasons remain visible', () => {
|
|
const game = projected();
|
|
assert.deepEqual(choose(game, 'e', true), []); assert.deepEqual(choose(game, 'a', true), []);
|
|
const busy = markup(game, true);
|
|
assert.match(button(busy, 'e'), /\sdisabled=/); assert.match(button(busy, 'a'), /\sdisabled=/);
|
|
game.ambassadorEntry!.beneficiaries[1].blocked = 'The chosen beneficiary already controls Duke Vidal.';
|
|
for (let render = 0; render < 3; render++) {
|
|
const html = markup(game);
|
|
assert.match(html, /The chosen beneficiary already controls Duke Vidal/);
|
|
assert.match(button(html, 'a'), /\sdisabled=/);
|
|
assert.match(button(html, 'a'), /aria-describedby="ecaz-duke-controlled-ecaz-duke-entry-a-blocked"/);
|
|
assert.doesNotMatch(button(html, 'e'), /\sdisabled=/);
|
|
assert.deepEqual(choose(game, 'a'), []);
|
|
}
|
|
});
|
|
|
|
void test('an empty beneficiary list retains only the old self descriptor and its unavailable reason', () => {
|
|
const game = projected(), entry = game.ambassadorEntry!;
|
|
entry.beneficiaries = [];
|
|
assert.match(markup(game), /Acquire Duke Vidal for Ecaz/);
|
|
assert.doesNotMatch(markup(game), /Acquire Duke Vidal for Atreides ally/);
|
|
assert.deepEqual(choose(game, 'e'), [{
|
|
type: 'decision', event: entry.event, trigger: true, beneficiary: 'e', choice: 'duke',
|
|
}]);
|
|
assert.deepEqual(choose(game, 'a'), []);
|
|
entry.dukeAcquisition!.blocked = 'Duke Vidal is unavailable for this acquisition.';
|
|
assert.match(markup(game), /Duke Vidal is unavailable for this acquisition/);
|
|
assert.deepEqual(choose(game, 'e'), []);
|
|
entry.dukeAcquisition = null;
|
|
assert.match(markup(game), /Duke acquisition is unavailable for this opportunity/);
|
|
assert.deepEqual(choose(game, 'e'), []);
|
|
});
|
|
|
|
void test('waiting foreign and closed entry views never expose a standalone Duke transfer', () => {
|
|
for (const change of ['absent', 'viewer', 'owner', 'actor', 'decision', 'stage', 'effect'] as const) {
|
|
const game = projected();
|
|
if (change === 'absent') game.ambassadorEntry = null;
|
|
if (change === 'viewer') game.me = 'a';
|
|
if (change === 'owner') game.ambassadorEntry!.owner = 'i';
|
|
if (change === 'actor') game.decision!.player = 'i';
|
|
if (change === 'decision') game.decision = null;
|
|
if (change === 'stage') game.ambassadorEntry!.stage = 'loan';
|
|
if (change === 'effect') game.ambassadorEntry!.effect = 'fremen';
|
|
assert.equal(markup(game), '', change);
|
|
assert.deepEqual(choose(game, 'e'), [], change); assert.deepEqual(choose(game, 'a'), [], change);
|
|
}
|
|
const waiting = projected(); waiting.decision!.player = 'i';
|
|
assert.equal(markup(waiting, false, true), '');
|
|
});
|
|
|
|
void test('ordinary Duke tenure guidance stays readable and distinguishes the new-alliance turn loan', () => {
|
|
const game = projected();
|
|
for (let render = 0; render < 3; render++) {
|
|
const html = markup(game);
|
|
assert.match(html, /Provisional ordinary acquisition/);
|
|
assert.match(html, /until battle use, including when unused beyond this turn/);
|
|
assert.match(html, /The separate Duke loan after a new alliance lasts for the current turn/);
|
|
assert.match(html, /Only Ecaz may revive him/);
|
|
assert.match(html, /The Ambassador returns to your supply/);
|
|
assert.doesNotMatch(html, /Confirm|Acknowledge|countdown|once per turn/);
|
|
}
|
|
});
|
|
|
|
void test('the React-rendered entry retains one Duke inspector and its original alliance and loan controls', () => {
|
|
const game = projected();
|
|
game.ambassadorEntry!.beneficiaries[0].blocked = 'The self choice is blocked.';
|
|
const html = markup(game, false, true);
|
|
assert.equal((html.match(/aria-label="Inspect leader: Duke Prad Vidal"/g) ?? []).length, 1);
|
|
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
|
|
assert.match(html, /Offer alliance to Emperor entrant/);
|
|
assert.match(html, /The entrant is already allied/);
|
|
assert.match(html, /Leave Ambassador in place/);
|
|
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
|
|
game.ambassadorEntry!.stage = 'loan';
|
|
const loan = markup(game, false, true);
|
|
assert.match(loan, /Lend Duke Vidal to the new ally/);
|
|
assert.match(loan, /Continue without lending Duke/);
|
|
assert.match(loan, /unused living Duke is set aside at turn end/);
|
|
assert.doesNotMatch(loan, /ecaz-duke-acquisition-|Provisional ordinary acquisition/);
|
|
});
|
|
|
|
void test('Duke recipient controls use only public names and authoritative beneficiary reasons', () => {
|
|
const game = projected();
|
|
Object.defineProperty(game.ambassadorEntry!, 'dukeAcquisition', { get() { throw Error('Read the compatibility self descriptor despite offered beneficiaries'); } });
|
|
Object.defineProperty(game, 'dukeVidal', { get() { throw Error('Read Duke custody to recreate projected eligibility'); } });
|
|
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces'])
|
|
Object.defineProperty(player, field, { get() { throw Error(`Duke choices read private or custody field ${field}`); } });
|
|
assert.match(markup(game), /Acquire Duke Vidal for Atreides ally/);
|
|
assert.deepEqual(choose(game, 'a'), [{
|
|
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true, beneficiary: 'a', choice: 'duke',
|
|
}]);
|
|
});
|