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: 'Removing a token awaits a custody ruling.',
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('Grumman controls require an explicit available-token addition before the four-spice reward', () => {
const view = fixture();
const before = structuredClone(view);
assert.deepEqual(grummanCollectionChoice(view, 'terror-2', 'carthag'), {
blocked: null,
action: {
type: 'decision',
event: 'collection-1',
mode: 'add',
token: 'terror-2',
territory: 'carthag',
},
});
const html = markup(view);
assert.match(html, /Your Terror token/);
assert.match(html, /Robbery/);
assert.match(html, /Sabotage/);
assert.match(html, /only after the addition resolves/);
assert.match(html, /Add token and collect 4 spice/);
assert.match(
html,
/Removal unavailable: Removing a token awaits a custody ruling/,
);
assert.doesNotMatch(html, /