import test from 'node:test'; import assert from 'node:assert/strict'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { viewGame, type GameView } from '../game/engine'; import { NexusAdvisors } from '../components/nexus-advisors'; import { nexusAdvisorAction, nexusAdvisorCanAct, } from '../game/nexus-advisor-options'; import { nexusAdvisorFixture, beginNexusAdvisorFlip, holdAdvisorKarama, } from './fixture-nexus-advisors'; const aliases = registerHooks({ resolve(specifier, context, next) { return next( specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context, ); }, load(url, context, next) { if (!url.endsWith('.module.css')) return next(url, context); const css = readFileSync(new URL(url), 'utf8'); const classes = Object.fromEntries( [...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [ match[1], match[1], ]), ); return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true, }; }, }); const { GameTable } = await import('../components/game-table'); aliases.deregister(); const html = (game: GameView, busy = false) => renderToStaticMarkup(createElement(NexusAdvisors, { game, busy, act() {} })); void test('advisor controls name whole territories and counts with an explicit empty initial selection', () => { const v = viewGame(nexusAdvisorFixture(), 'p'); const offer = v.nexusAdvisors!.offer!; const before = structuredClone(v); const text = html(v); assert.match(text, /Pasty Mesa: 3 advisors/); assert.match(text, /Arrakeen: 2 advisors/); assert.equal((text.match(/class="decision-checkbox min-h-11"/g) ?? []).length, offer.territories.length); assert.match(text, /Select all available/); assert.match(text, /0 territories selected/); assert.match(text, /across all sectors/); assert.match( text, /costs no spice and uses no shipment or movement allowance/, ); assert.doesNotMatch(text, /checked=""/); assert.match(text, /disabled=""[^>]*>Convert selected advisors/); const selected = offer.territories .filter((choice) => !choice.blocked) .map((choice) => choice.territory); assert.ok(selected.length >= 2); assert.deepEqual(nexusAdvisorAction(v, offer.event, selected), { type: 'nexusAdvisors', event: offer.event, territories: selected, }); assert.deepEqual(v, before); assert.match(html(v, true), /disabled=""/); }); void test('shared advisor selection binds event, unique whole groups and projected source boundary reasons', () => { const v = viewGame(nexusAdvisorFixture(), 'p'); const offer = v.nexusAdvisors!.offer!; const place = offer.territories.find( (choice) => choice.territory === 'arrakeen', )!; assert.ok(nexusAdvisorAction(v, offer.event, ['arrakeen'])); for (const choices of [ [], ['arrakeen', 'arrakeen'], ['not-a-territory'], ['arrakeen:10'], ]) assert.equal(nexusAdvisorAction(v, offer.event, choices), null); assert.equal(nexusAdvisorAction(v, 'stale', ['arrakeen']), null); // Consumer-only projections prove reasons are displayed rather than guessed // from a source rule or the current territory's board graphics. place.blocked = 'Newly accompanied advisors await the same-turn conversion ruling.'; offer.territories.find( (choice) => choice.territory === 'pasty_mesa', )!.blocked = 'Conversion in storm awaits its timing ruling.'; assert.equal(nexusAdvisorAction(v, offer.event, ['arrakeen']), null); assert.match(html(v), /Newly accompanied advisors await/); assert.match(html(v), /Conversion in storm awaits/); offer.blocked = 'Finish the current movement response first.'; assert.equal(nexusAdvisorCanAct(v), false); assert.match(html(v), /Finish the current movement response first/); }); void test('advisor selection rejects other actors, Basic, wrong phase and all interrupted windows', () => { const original = viewGame(nexusAdvisorFixture(), 'p'); for (const mutate of [ (v: GameView) => { v.me = 'q'; }, (v: GameView) => { v.advanced = false; }, (v: GameView) => { v.phase = 4; }, (v: GameView) => { v.active = 'q'; }, (v: GameView) => { v.players[0].ally = 'q'; }, (v: GameView) => { v.nexusCards!.card = null; }, (v: GameView) => { v.automaticContinuationPending = true; }, (v: GameView) => { v.decision = { kind: 'nullentropy', player: 'p' }; }, (v: GameView) => { v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null, }; }, (v: GameView) => { v.nexusCards!.waiting = ['q']; }, (v: GameView) => { v.nexusTraitors = { offer: null, pending: { event: 'other', owner: 'q', mode: 'cunning', count: 1, choices: [], }, }; }, ]) { const v = structuredClone(original); mutate(v); assert.equal( nexusAdvisorAction(v, v.nexusAdvisors!.offer!.event, ['arrakeen']), null, ); } }); void test('public pending territory set explains one Karama cancellation without exposing the private offer', () => { const initial = nexusAdvisorFixture(); holdAdvisorKarama(initial); const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']); assert.equal(g.response?.kind, 'nexusAdvisorFlip'); for (const id of ['p', 'q', 'r']) { const v = viewGame(g, id); assert.ok(v.nexusAdvisors!.pending); Object.defineProperty(v.nexusAdvisors!, 'offer', { get() { throw new Error('Read offer during public pending response'); }, }); for (const player of v.players) Object.defineProperty(player, 'hand', { get() { throw new Error('Read private hand'); }, }); const text = html(v); assert.match(text, /Arrakeen, Pasty Mesa/); assert.match(text, /Karama can cancel the entire selection/); assert.doesNotMatch(text, /checkbox|Convert selected advisors/); } }); void test('actual response panel names Nexus conversion and offers Karama without printing its internal event', () => { const initial = nexusAdvisorFixture(); holdAdvisorKarama(initial); const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']); const v = viewGame(g, 'q'); // Real component rendering with CSS selector names preserved above; this is // structural response verification, not a browser or visual acceptance claim. const text = renderToStaticMarkup( createElement(GameTable, { game: v, send: async () => {}, onExit() {}, busy: false, }), ); assert.match(text, /Bene Gesserit Nexus advisor conversion/); assert.match(text, /Cancel with Karama/); assert.match(text, /Allow this power/); assert.doesNotMatch(text, /nexusAdvisors/); const circleTitles = [ ...text.matchAll(/(Player circle [^<]+)<\/title>/g), ].map((match) => match[1]); assert.equal(circleTitles.length, 6); assert.ok( circleTitles.every((title) => /^Player circle [1-6], sector \d+: .+/.test(title), ), ); assert.ok(circleTitles.some((title) => title.includes('Bene Gesserit'))); });