Files
Dune/tests/tupile-intelligence-controls.test.ts
T

159 lines
8.3 KiB
TypeScript
Raw 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 test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TupileIntelligence } from '../components/tupile-intelligence';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import {
tupileIntelligenceActions,
tupileIntelligenceCanAct,
tupileIntelligenceChoice,
} from '../game/tupile-intelligence-options';
/** Projected facts isolate client/policy behavior. The engine supplies contact,
* lifetime use, timing and private answer custody; the client cannot infer them. */
function fixture(): GameView {
const g = createGame('TUPILECONTROLS', newPlayer('c', 'CHOAM', 'choam'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 2, turn: 3, active: 'a', order: ['a', 'e', 'c'], storm: 18 });
const view = viewGame(g, 'c');
view.tupileIntelligence = {
owner: 'c', blocked: null,
targets: [
{ player: 'a', faction: 'atreides', contact: ['homeworld:atreides'], blocked: null },
{ player: 'e', faction: 'emperor', contact: ['homeworld:emperor:salusa'], blocked: null },
],
receipts: [],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(TupileIntelligence, { game, busy, act() {} }));
}
void test('Tupile choice emits exactly one selected category for a current eligible target without mutation', () => {
const view = fixture();
const before = structuredClone(view);
for (const category of ['weapons', 'defenses'] as const)
assert.deepEqual(tupileIntelligenceChoice(view, 'e', category), {
blocked: null, action: { type: 'tupileIntelligence', target: 'e', category },
});
for (const target of ['c', 'missing'])
assert.equal(tupileIntelligenceChoice(view, target, 'weapons').action, null);
assert.equal(tupileIntelligenceChoice(view, 'a', 'both' as never).action, null);
assert.deepEqual(view, before);
view.tupileIntelligence!.targets.push({ ...view.tupileIntelligence!.targets[0] });
assert.equal(tupileIntelligenceChoice(view, 'a', 'weapons').action, null);
});
void test('all policy profiles skip spent or ineligible factions and obey the server timing block', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = fixture();
view.players.find((player) => player.id === view.me)!.bot = profile;
view.tupileIntelligence!.targets[0].blocked = 'Already used against Atreides in this game.';
assert.deepEqual(tupileIntelligenceActions(view), [
{ type: 'tupileIntelligence', target: 'e', category: 'weapons' },
]);
view.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.';
assert.equal(tupileIntelligenceCanAct(view), false);
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').blocked, view.tupileIntelligence!.blocked);
view.tupileIntelligence!.blocked = null;
view.tupileIntelligence!.targets[1].blocked = 'No current Homeworld contact.';
assert.deepEqual(tupileIntelligenceActions(view), []);
}
});
void test('owned controls expose accessible opponent/category selection and one request button', () => {
const html = markup(fixture());
assert.match(html, /aria-label="Opposing faction"/);
assert.match(html, /aria-label="Card count"/);
assert.match(html, /value="weapons" selected=""/);
assert.match(html, /value="defenses"/);
assert.match(html, />Atreides<\/option>/);
assert.match(html, />Emperor<\/option>/);
assert.equal((html.match(/<button\b/g) ?? []).length, 1);
assert.match(html, />Request intelligence<\/button>/);
assert.doesNotMatch(html, /confirm|Reveal hand|Your private observations/i);
});
void test('private historical observations remain readable during a block and after JSON restoration', () => {
const view = fixture();
view.tupileIntelligence!.blocked = 'Occupation must be resolved before another request.';
view.tupileIntelligence!.receipts = [
{ event: 'old-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 2, turn: 1, phase: 3 },
{ event: 'old-e', target: 'e', faction: 'emperor', category: 'defenses', spice: 0, count: 1, turn: 2, phase: 7 },
];
const before = structuredClone(view);
const html = markup(JSON.parse(JSON.stringify(view)));
assert.match(html, /Your private observations/);
assert.match(html, /13 spice, 2 weapons/);
assert.match(html, /0 spice, 1 defense/);
assert.match(html, /Turn 1 · Bidding/);
assert.match(html, /Turn 2 · Spice collection/);
assert.ok(html.indexOf('0 spice, 1 defense') < html.indexOf('13 spice, 2 weapons'));
assert.match(html, /do not update/);
assert.match(html, /Occupation must be resolved/);
assert.match(html, /<button[^>]* disabled=""[^>]*>Request intelligence/);
assert.deepEqual(view, before);
});
void test('busy, empty and spent-target controls cannot submit a new request', () => {
const busy = markup(fixture(), true);
assert.equal((busy.match(/<select[^>]* disabled=""/g) ?? []).length, 2);
assert.match(busy, /<button[^>]* disabled=""[^>]*>Request intelligence/);
const view = fixture();
view.tupileIntelligence!.targets = [];
assert.match(markup(view), /No eligible faction/);
assert.match(markup(view), /No faction is currently available for a request/);
assert.doesNotMatch(markup(view), /Choose a current opposing faction/);
assert.match(markup(view), /<button[^>]* disabled=""[^>]*>Request intelligence/);
assert.deepEqual(tupileIntelligenceActions(view), []);
const spent = fixture();
spent.tupileIntelligence!.targets[0].blocked = 'Already used against this faction in this game.';
spent.tupileIntelligence!.targets[1].blocked = 'CHOAM must be on that faction’s Homeworld, or that faction must be on Tupile.';
spent.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.';
spent.tupileIntelligence!.receipts = [
{ event: 'spent-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 7, count: 1, turn: 1, phase: 5 },
];
const unavailable = markup(spent);
assert.match(unavailable, /No eligible faction/);
assert.match(unavailable, /Atreides<\/strong>: Already used against this faction in this game/);
assert.match(unavailable, /Emperor<\/strong>: CHOAM must be on that faction/);
assert.match(unavailable, /Finish the current response before requesting intelligence/);
assert.match(unavailable, /7 spice, 1 weapon/);
assert.doesNotMatch(unavailable, /Choose a current opposing faction/);
assert.deepEqual(tupileIntelligenceActions(spent), []);
});
void test('other seats cannot render or consult private offers, even if a malformed projection includes one', () => {
const view = fixture();
view.me = 'a';
for (const field of ['targets', 'receipts'] as const)
Object.defineProperty(view.tupileIntelligence!, field, { get() { throw new Error(`Private ${field} accessed`); } });
assert.equal(markup(view), '');
assert.equal(tupileIntelligenceCanAct(view), false);
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'weapons').action, null);
const missing = fixture();
missing.tupileIntelligence = null;
assert.equal(markup(missing), '');
assert.deepEqual(tupileIntelligenceActions(missing), []);
});
void test('policy and panel never inspect opponent hands, balances, plans or private source state', () => {
const view = fixture();
const expected = markup(view);
Object.defineProperty(view, 'players', { get() { throw new Error('Player source inspected'); } });
for (const field of ['hand', 'spice', 'battle', 'homeworlds', 'noField', 'leaders'])
Object.defineProperty(view, field, { get() { throw new Error(`Hidden ${field} inspected`); } });
assert.equal(markup(view), expected);
Object.defineProperty(view.tupileIntelligence!, 'receipts', { get() { throw new Error('History used for eligibility'); } });
assert.deepEqual(tupileIntelligenceActions(view), [{ type: 'tupileIntelligence', target: 'a', category: 'weapons' }]);
assert.ok(tupileIntelligenceChoice(view, 'e', 'defenses').action);
view.status = 'finished';
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').action, null);
});