275 lines
20 KiB
TypeScript
275 lines
20 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { MixedBidFields, mixedBidInputError } from '../components/mixed-bid-fields';
|
||
import { MixedBidPromises } from '../components/mixed-bid-promises';
|
||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||
import { BidPromiseGuideButton, BidPromises } from '../components/bid-promises';
|
||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||
import type { BidActionClaim } from '../game/mixed-bid-question';
|
||
import { createTechTokens } from '../game/tech-tokens';
|
||
import type { TruthFact } from '../game/truthtrance';
|
||
|
||
type Expression = MixedActionExpression<BidActionClaim>;
|
||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||
type Element = ReactElement<ElementProps>;
|
||
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
|
||
const bid = (minimum = 1): Expression => ({ kind: 'action', action: { minimum } });
|
||
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [bid(3), bid(7)] }] });
|
||
function elements(node: ReactNode): Element[] {
|
||
if (Array.isArray(node)) return node.flatMap(elements);
|
||
if (!isValidElement<ElementProps>(node)) return [];
|
||
return [node, ...elements(node.props.children)];
|
||
}
|
||
function find(tree: ReactNode, key: string, value: string): Element {
|
||
const found = elements(tree).find(element => element.props[key] === value);
|
||
assert.ok(found, `${key}=${value}`); return found;
|
||
}
|
||
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-bid-control', key);
|
||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||
function select(tree: ReactNode, path: string, field: string, value: string) {
|
||
const input = find(tree, 'id', `truth-mixed-bid-${path}-${field}`);
|
||
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
|
||
}
|
||
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
|
||
const input = find(tree, 'id', `truth-mixed-bid-${path}-minimum`);
|
||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||
}
|
||
function editFact(tree: ReactNode, path: string, next: TruthFact) {
|
||
const scope = find(tree, 'data-mixed-bid-fact-path', path);
|
||
const fields = elements(scope).find(element => element.type === TruthFactFields);
|
||
assert.ok(fields, `Current fact fields ${path}`);
|
||
(fields.props.onChange as (fact: TruthFact) => void)(next);
|
||
}
|
||
|
||
/** Controlled projections exercise fields and exact callback guards only.
|
||
* Source-earned Truthtrance, declarations and lot close have native proof. */
|
||
function projected(type: 'bid' | 'passBid' = 'bid', advanced = false): GameView {
|
||
const source = createGame('TRUTHMIXEDBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
|
||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||
const game = viewGame(source, 'p');
|
||
game.status = 'playing'; game.phase = 3; game.turn = 2;
|
||
game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
|
||
game.bidPromiseLot = { turn: 2, lot: 3 };
|
||
game.bidPromises = [];
|
||
game.mixedBidPromises = [{ event: 'controlled-mixed-bid-event', player: 'p', asker: 'a', turn: 2, lot: 3,
|
||
mixed: claim(), answer: type === 'bid', fulfilled: false, released: false }];
|
||
game.bidPromiseGuide = { action: type === 'bid'
|
||
? { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event' }
|
||
: { type, bidPromiseEvent: 'controlled-mixed-bid-event' } };
|
||
return game;
|
||
}
|
||
function editor(initial: Expression, disabled = false, game = projected(), target = 'p') {
|
||
let value = initial, changes = 0;
|
||
return {
|
||
get value() { return value; }, get changes() { return changes; },
|
||
tree: () => MixedBidFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
|
||
html: () => renderToStaticMarkup(createElement(MixedBidFields, { value, game, target, disabled, onChange() {} })),
|
||
error: () => mixedBidInputError(value, game, target),
|
||
};
|
||
}
|
||
function guide(props: Parameters<typeof BidPromiseGuideButton>[0]) {
|
||
const button = BidPromiseGuideButton(props); assert.ok(button); return button;
|
||
}
|
||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||
const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBidPromises, { game, busy: false, send() {} }));
|
||
const pureMarkup = (game: GameView) => renderToStaticMarkup(createElement(BidPromises, { game, busy: false, send() {} }));
|
||
|
||
void test('mixed bid editor retains sixteen original conditions with only per-action minimum fields and fixed public turn and lot', () => {
|
||
const e = editor(fact()); click(control(e.tree(), 'or-1'));
|
||
for (let count = 3; count <= 16; count++) {
|
||
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
|
||
}
|
||
minimum(e.tree(), '1.2', Number.MAX_SAFE_INTEGER);
|
||
const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16);
|
||
assert.deepEqual(full.terms[0], fact()); assert.deepEqual(full.terms[1], bid(Number.MAX_SAFE_INTEGER));
|
||
assert.deepEqual(full.terms[15], bid(16)); assert.equal(e.error(), null);
|
||
const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
|
||
assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /16 \/ 16 conditions/);
|
||
assert.equal((html.match(/Minimum bid in spice/g) ?? []).length, 15);
|
||
assert.match(html, /max="9007199254740991"/); assert.equal(new Set(ids).size, ids.length);
|
||
assert.ok(!elements(e.tree()).some(element => typeof element.props.id === 'string' && /-(turn|lot)$/.test(element.props.id)));
|
||
const before = structuredClone(full), changes = e.changes;
|
||
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-and-1.1', 'or-1']) {
|
||
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
|
||
}
|
||
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||
});
|
||
|
||
void test('mixed bid and embedded fact groups preserve all branches through edits, collapse and the shared depth-four envelope', () => {
|
||
const e = editor({ kind: 'and', terms: [fact(), bid(3)] });
|
||
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
|
||
click(control(e.tree(), 'nested-or-1')); click(control(e.tree(), 'nested-and-1'));
|
||
assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, 4);
|
||
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
|
||
select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
|
||
editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 });
|
||
assert.match(e.html(), /Current-fact group 1\.1/); assert.equal(e.error(), null);
|
||
click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
|
||
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.4')); click(control(e.tree(), 'remove-1.3'));
|
||
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), bid(3)] });
|
||
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
|
||
select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
|
||
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, bid()); assert.ok(e.error());
|
||
click(control(e.tree(), 'and-1')); assert.equal(e.error(), null);
|
||
let nested = fact();
|
||
for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, bid()] };
|
||
const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||
for (const key of ['and-1', 'or-1.1.1.1.1', 'fact-and-1.1.1.1.1', 'nested-and-1.1.1.1']) {
|
||
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
|
||
}
|
||
assert.equal(deep.changes, 0);
|
||
assert.ok(mixedBidInputError({ kind: 'and', terms: [nested, bid()] }, projected(), 'p'));
|
||
});
|
||
|
||
void test('mixed bid numeric drafts remain repairable while strict leaves reject metadata and busy callbacks preserve the original tree', () => {
|
||
for (const invalid of [NaN, -1, 0, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||
const e = editor({ kind: 'and', terms: [fact(), bid()] });
|
||
minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
|
||
const raw = e.value; assert.ok(raw.kind === 'and');
|
||
assert.deepEqual(raw.terms[1], { kind: 'or', terms: [bid(invalid), fact()] });
|
||
assert.match(e.html(), /role="alert"/); minimum(e.tree(), '1.2.1', 2); assert.equal(e.error(), null);
|
||
}
|
||
for (const action of [{ minimum: 1, turn: 2 }, { minimum: 1, lot: 3 }, { minimum: 1, source: 'hidden-lot' },
|
||
{ minimum: 1, answer: true }, { minimum: 1, fulfilled: true }, { kind: 'or', terms: [{ minimum: 1 }] }, { minimum: '2' }])
|
||
assert.ok(mixedBidInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p'));
|
||
const frozen = editor({ kind: 'and', terms: [fact(), bid()] }, true), before = structuredClone(frozen.value);
|
||
for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
|
||
minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'timing', 'fact');
|
||
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
|
||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
|
||
});
|
||
|
||
void test('mixed bid authoring uses public current-lot scope and explains declaration, unknown and original payment semantics without inspecting holdings', () => {
|
||
const game = projected();
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden auction face inspection'); } });
|
||
for (const key of ['deck', 'discard', 'spiceDeck']) Object.defineProperty(game, key, {
|
||
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
|
||
});
|
||
const e = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), bid(5)] }, false, game);
|
||
assert.equal(e.error(), null);
|
||
const html = e.html(); assert.match(html, /Turn 2 · Ordinary lot 3/);
|
||
assert.match(html, /Smaller bids do not add together/); assert.match(html, /even if another player wins/);
|
||
assert.match(html, /If you win, the original payment rules still apply/);
|
||
assert.match(html, /Existing funding, Karama and prepaid ally aid rules still apply/);
|
||
assert.match(html, /optional modules and native special auctions are excluded/);
|
||
assert.match(html, /conservative future commitment/); assert.match(html, /If no such value is available, the answer is I don’t know/);
|
||
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(e.error());
|
||
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null);
|
||
});
|
||
|
||
void test('own mixed bid panel displays the original question and aggregate answer without progress, frozen facts or reduced thresholds', () => {
|
||
const game = projected(); game.bidPromiseGuide = null;
|
||
game.mixedBidPromises.push(
|
||
{ ...game.mixedBidPromises[0], event: 'no-event', answer: false },
|
||
{ ...game.mixedBidPromises[0], event: 'fulfilled-event', fulfilled: true },
|
||
{ ...game.mixedBidPromises[0], event: 'released-event', released: true },
|
||
{ ...game.mixedBidPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), bid(99)] } },
|
||
);
|
||
for (const promise of game.mixedBidPromises) for (const key of ['binding', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'source', 'minimum', 'progress'])
|
||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
const html = markup(game);
|
||
assert.match(html, /Your mixed bid promises/); assert.match(html, /Turn 2, ordinary lot 3/);
|
||
assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, /you hold Shield/);
|
||
assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /at least 3 spice/); assert.match(html, /at least 7 spice/);
|
||
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
|
||
assert.match(html, /Individual fact answers and any reduced future threshold remain private/);
|
||
assert.match(html, /No negates the whole expression, not each condition separately/);
|
||
assert.match(html, /fulfilled immediately by one qualifying native declaration/);
|
||
assert.match(html, /binding No remains in force until this ordinary lot actually closes/);
|
||
assert.doesNotMatch(html, /Poison Blade|99 spice|controlled-mixed-bid-event|fulfilled-event|Future ordinary progress/);
|
||
assert.equal(MixedBidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||
});
|
||
|
||
void test('shared Basic and Advanced guides forward the exact native mixed bid or pass packet including original funding and card fields', () => {
|
||
for (const advanced of [false, true]) for (const type of ['bid', 'passBid'] as const) {
|
||
const game = projected(type, advanced), sent: Action[] = [];
|
||
if (type === 'bid') game.bidPromiseGuide!.action = { ...game.bidPromiseGuide!.action,
|
||
karama: true, card: 'controlled-original-karama' };
|
||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||
assert.equal(button.props.disabled, false); press(button);
|
||
assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
|
||
assert.match(markup(game), /data-testid="bid-promise-guide"/); assert.doesNotMatch(markup(game), /controlled-original-karama/);
|
||
if (type === 'bid') assert.deepEqual(sent[0], { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event',
|
||
karama: true, card: 'controlled-original-karama' });
|
||
else assert.deepEqual(sent[0], { type, bidPromiseEvent: 'controlled-mixed-bid-event' });
|
||
}
|
||
});
|
||
|
||
void test('mixed bid guide callbacks reject busy, stale, changed lot, event, owner, funding and quiet-window changes', () => {
|
||
const changes: ((game: GameView) => void)[] = [
|
||
game => { game.me = 'a'; }, game => { game.auction!.active = 'a'; },
|
||
game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
|
||
game => { game.bidPromiseLot = { turn: 2, lot: 4 }; }, game => { game.bidPromiseLot = { turn: 1, lot: 3 }; },
|
||
game => { game.bidPromiseLot = null; }, game => { game.auction = null; },
|
||
game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; }, game => { game.bidPromiseGuide = null; },
|
||
game => { game.bidPromiseGuide!.action.amount = 8; }, game => { game.bidPromiseGuide!.action.allyPayment = 2; },
|
||
game => { game.bidPromiseGuide!.action.karama = true; }, game => { game.bidPromiseGuide!.action.type = 'ready'; },
|
||
game => { game.bidPromiseGuide!.action.bidPromiseEvent = 'later-event'; },
|
||
game => { game.mixedBidPromises[0].event = 'expired'; }, game => { game.mixedBidPromises[0].player = 'a'; },
|
||
game => { game.mixedBidPromises[0].turn = 1; }, game => { game.mixedBidPromises[0].lot = 4; },
|
||
game => { game.mixedBidPromises[0].fulfilled = true; }, game => { game.mixedBidPromises[0].released = true; },
|
||
game => { game.mixedBidPromises = []; },
|
||
game => { game.phaseOpening = { passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||
game => { game.techTokens = createTechTokens(); },
|
||
];
|
||
for (const change of changes) {
|
||
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||
change(game); press(button); assert.deepEqual(sent, []);
|
||
}
|
||
const busy = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
|
||
const button = guide(busy); busy.busy = true; press(button);
|
||
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
|
||
const previous = guide(replaced); replaced.game = { ...replaced.game }; press(previous);
|
||
});
|
||
|
||
void test('pure and mixed bid panels share exactly one current-lot guide while historical or foreign rows neither duplicate nor suppress it', () => {
|
||
const game = projected(), assertGuides = (pure: boolean, mixed: boolean) => {
|
||
assert.equal(pureMarkup(game).includes('data-testid="bid-promise-guide"'), pure);
|
||
assert.equal(markup(game).includes('data-testid="bid-promise-guide"'), mixed);
|
||
};
|
||
assertGuides(false, true);
|
||
game.bidPromises.push({ event: 'controlled-pure-event', player: 'p', asker: 'a', turn: 2, lot: 3, minimum: 4, answer: true });
|
||
assertGuides(true, false);
|
||
for (const change of [() => { game.bidPromises[0].fulfilled = true; },
|
||
() => { game.bidPromises[0].fulfilled = false; game.bidPromises[0].released = true; },
|
||
() => { game.bidPromises[0].released = false; game.bidPromises[0].turn = 1; },
|
||
() => { game.bidPromises[0].turn = 2; game.bidPromises[0].lot = 2; },
|
||
() => { game.bidPromises[0].lot = 3; game.bidPromises[0].player = 'a'; }]) {
|
||
change(); assertGuides(false, true);
|
||
}
|
||
game.bidPromises[0].player = 'p'; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-pure-event';
|
||
assertGuides(true, false);
|
||
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
|
||
game.bidPromises = []; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-mixed-bid-event';
|
||
game.auction!.active = 'a'; assert.match(markup(game), /Your next bid opportunity is still ahead/);
|
||
for (const change of [() => { game.mixedBidPromises[0].fulfilled = true; },
|
||
() => { game.mixedBidPromises[0].fulfilled = false; game.mixedBidPromises[0].released = true; },
|
||
() => { game.mixedBidPromises[0].released = false; game.mixedBidPromises[0].turn = 1; },
|
||
() => { game.mixedBidPromises[0].turn = 2; game.mixedBidPromises[0].lot = 2; }]) {
|
||
change(); assertGuides(false, false); assert.doesNotMatch(markup(game), /Your next bid opportunity is still ahead/);
|
||
}
|
||
});
|
||
|
||
void test('mixed bid panels and shared guide use public lot and own original question without hidden card, source, holdings or reduced-branch inspection', () => {
|
||
const game = projected(), sent: Action[] = [];
|
||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
|
||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden ordinary-lot face inspection'); } });
|
||
for (const promise of game.mixedBidPromises) for (const key of ['source', 'minimum', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'progress'])
|
||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||
assert.match(markup(game), /Bid 7 spice while honoring your answers/);
|
||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
|
||
});
|