import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { GrummanCollection } from '../components/grumman-collection';
import { MoritaniEntry } from '../components/moritani-terror';
import { TerrorBoardMarkers } from '../components/terror-board-markers';
import {
createGame,
joinGame,
newPlayer,
viewGame,
type GameView,
} from '../game/engine';
import {
grummanCollectionActions,
grummanCollectionCanAct,
grummanCollectionChoice,
} from '../game/grumman-collection-options';
/** Private projected choices isolate client behavior. Engine and recovery suites
* cover actual collection entry, token custody and paid receipts. */
function fixture(): GameView {
const g = createGame(
'GRUMMANCONTROLS',
newPlayer('m', 'Moritani', 'moritani'),
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
Object.assign(g, {
status: 'playing',
phase: 7,
active: 'a',
order: ['m', 'a'],
storm: 18,
});
const view = viewGame(g, 'm');
view.decision = {
kind: 'grummanCollection',
player: 'm',
event: 'collection-1',
};
view.grummanCollection = {
event: 'collection-1',
player: 'm',
blocked: null,
removeBlocked: 'No placed Terror token is available to return.',
removable: [],
cunning: null,
tokens: [
{ id: 'terror-1', kind: 'robbery' },
{ id: 'terror-2', kind: 'sabotage' },
],
destinations: [
{ id: 'arrakeen', name: 'Arrakeen' },
{ id: 'carthag', name: 'Carthag' },
],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(GrummanCollection, { game, busy, act() {} }),
);
}
function entryMarkup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(MoritaniEntry, { game, busy, act() {} }),
);
}
void test('public map markers group hidden Terror stacks without reading token faces', () => {
const tokens = [
{ status: 'placed', location: 'arrakeen' },
{ status: 'placed', location: 'arrakeen' },
{ status: 'placed', location: 'carthag' },
{ status: 'available', location: null },
{ status: 'removed', location: null },
];
for (const token of tokens)
Object.defineProperty(token, 'kind', {
get() {
throw new Error('Map read a hidden Terror face');
},
});
const html = renderToStaticMarkup(
createElement('svg', null, createElement(TerrorBoardMarkers, { tokens })),
);
assert.equal(
(html.match(/aria-label="2 hidden Terror tokens in Arrakeen"/g) ?? [])
.length,
1,
);
assert.equal(
(html.match(/aria-label="1 hidden Terror token in Carthag"/g) ?? []).length,
1,
);
assert.equal((html.match(/T2<\/text>/);
assert.match(html, />T<\/text>/);
assert.doesNotMatch(html, /available|removed/);
});
void test('stale tokens or destinations cannot add; blocked or exhausted collection can still be declined', () => {
const view = fixture();
assert.equal(
grummanCollectionChoice(view, 'used-token', 'arrakeen').action,
null,
);
assert.equal(
grummanCollectionChoice(view, 'terror-1', 'polar_sink').action,
null,
);
for (const patch of [
{ blocked: 'Grumman is now low.' },
{ tokens: [] },
{ destinations: [] },
]) {
const blocked = {
...view,
grummanCollection: { ...view.grummanCollection!, ...patch },
};
assert.deepEqual(grummanCollectionActions(blocked), [
{ type: 'decision', event: 'collection-1', decline: true },
]);
const html = markup(blocked);
assert.match(
html,
/