Files
Dune/tests/truth-fact-expression-controls.test.ts

377 lines
20 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import { TruthFactQuestion } from '../components/truth-fact-question';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { truthFactExpressionError, truthFactExpressionSize } from '../game/truth-fact-expression';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
// Controlled entitled projections supplement the separate native card journeys.
// No authoritative hand, question result or continuation is installed in a Game.
// Only hook-free components are invoked directly. React renders TruthFactFields
// and its stateful descendants; leaf edits cross its actual onChange boundary.
function projected(): GameView {
const game = createGame('TRUTHFACTCONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
joinGame(game, newPlayer('t', 'Target', 'emperor'));
joinGame(game, newPlayer('o', 'Observer', 'harkonnen'));
const view = viewGame(game, 'a');
view.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth-card' }], passed: [], question: null };
return view;
}
const hand = (name = 'Shield'): TruthFact => ({ kind: 'hand', name });
const spice = (value: number): TruthFact => ({ kind: 'spice', compare: 'gte', value });
const factQuestion = (fact: TruthFact, target = 't'): Extract<TruthQuestion, { kind: 'fact' }> => ({ kind: 'fact', target, fact });
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
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 control(tree: ReactNode, key: string): Element {
const found = elements(tree).find(element => element.props['data-truth-fact-control'] === key);
assert.ok(found, `Control ${key} is rendered`);
return found;
}
function click(button: Element) { (button.props.onClick as () => void)(); }
function fieldset(tree: ReactNode, path: string): Element {
const found = elements(tree).find(element => element.props['data-truth-fact-path'] === path);
assert.ok(found, `Fact or group ${path} is rendered`);
return found;
}
function editLeaf(tree: ReactNode, path: string, next: TruthFact) {
const field = elements(fieldset(tree, path)).find(element => element.type === TruthFactFields);
assert.ok(field, `Current fact fields for ${path} are rendered`);
(field.props.onChange as (fact: TruthFact) => void)(next);
}
function switchGroup(tree: ReactNode, path: string, kind: string) {
const select = elements(fieldset(tree, path)).find(element => element.type === 'select');
assert.ok(select, `Group meaning selector ${path} is rendered`);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: kind } });
}
function editor(initial: TruthFact, game = projected(), disabled = false) {
let value = initial;
let changes = 0;
return {
get value() { return value; },
get changes() { return changes; },
tree: () => TruthFactExpressionFields({ value, game, target: 't', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(TruthFactExpressionFields, {
value, game, target: 't', disabled, onChange() {},
})),
};
}
function askTree(game: GameView, question: Extract<TruthQuestion, { kind: 'fact' }>, busy = false) {
const actions: Action[] = [];
const changes: TruthFact[] = [];
const props = { game, question, busy, act(action: Action) { actions.push(action); }, onChange(next: TruthFact) { changes.push(next); } };
const tree = TruthFactQuestion(props);
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-fact-ask');
assert.ok(button, 'The actual Ask action is rendered');
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthFactQuestion, props)) };
}
function groupsDeep(depth: number): TruthFact {
let fact = hand();
for (let level = 1; level <= depth; level++) fact = { kind: 'and', terms: [fact, spice(level)] };
return fact;
}
void test('actual add and leaf-edit callbacks author three through eight terms without losing prior facts', () => {
const first: TruthFact = { kind: 'handCount', name: 'Shield', compare: 'eq', value: 2 };
const e = editor(first);
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', { kind: 'traitor', leader: CHEAP_HERO_TRAITOR });
for (let count = 3; count <= 8; count++) {
const before = structuredClone(e.value);
click(control(e.tree(), 'add-1'));
editLeaf(e.tree(), `1.${count}`, spice(count));
assert.ok(e.value.kind === 'and');
assert.equal(e.value.terms.length, count);
assert.deepEqual(e.value.terms.slice(0, count - 1), (before as Extract<TruthFact, { kind: 'and' | 'or' }>).terms);
assert.deepEqual(e.value.terms[count - 1], spice(count));
}
assert.ok(e.value.kind === 'and');
assert.deepEqual(e.value.terms[0], first);
const html = e.html();
assert.equal((html.match(/Current fact type/g) ?? []).length, 8);
assert.match(html, /9 \/ 16 total clauses/);
const before = structuredClone(e.value), changes = e.changes;
assert.equal(control(e.tree(), 'add-1').props.disabled, true);
click(control(e.tree(), 'add-1'));
assert.equal(e.changes, changes);
assert.deepEqual(e.value, before);
});
void test('nested AND and OR callbacks preserve every branch while switching group meaning', () => {
const e = editor(hand('Snooper'));
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', spice(7));
click(control(e.tree(), 'or-1.2'));
editLeaf(e.tree(), '1.2.2', { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 });
click(control(e.tree(), 'add-1.2'));
editLeaf(e.tree(), '1.2.3', { kind: 'stormForecast', compare: 'gte', value: 2 });
const before = structuredClone(e.value);
assert.ok(before.kind === 'and');
const originalNested = before.terms[1];
assert.ok(originalNested.kind === 'or');
switchGroup(e.tree(), '1.2', 'and');
assert.deepEqual(e.value, { kind: 'and', terms: [hand('Snooper'), {
kind: 'and', terms: [spice(7), { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 }, { kind: 'stormForecast', compare: 'gte', value: 2 }],
}] });
switchGroup(e.tree(), '1.2', 'or');
assert.deepEqual(e.value, before);
const changes = e.changes;
switchGroup(e.tree(), '1.2', 'single');
assert.equal(e.changes, changes);
click(control(e.tree(), 'nested-1.2'));
assert.ok(e.value.kind === 'and');
const nested = e.value.terms[1];
assert.ok(nested.kind === 'or');
assert.deepEqual(nested.terms.slice(0, 3), originalNested.terms);
assert.deepEqual(nested.terms[3], { kind: 'and', terms: [hand(), hand()] });
assert.match(e.html(), /Group 1\.2 · OR/);
assert.match(e.html(), /Group 1\.2\.4 · AND/);
});
void test('removing a fact collapses a singleton group and removing a group keeps the full survivor', () => {
const retained: TruthFact = { kind: 'forceCount', zone: { kind: 'location', territory: 'carthag', sector: 10 }, counter: 'total', compare: 'eq', value: 4 };
const e = editor({ kind: 'or', terms: [hand(), { kind: 'and', terms: [spice(9), retained] }] });
click(control(e.tree(), 'remove-1.2.1'));
assert.deepEqual(e.value, { kind: 'or', terms: [hand(), retained] });
assert.equal(truthFactExpressionError(e.value), null);
click(control(e.tree(), 'remove-1.1'));
assert.deepEqual(e.value, retained);
assert.equal(truthFactExpressionError(e.value), null);
const nestedSurvivor: TruthFact = { kind: 'or', terms: [spice(1), spice(2), spice(3)] };
const whole = editor({ kind: 'and', terms: [{ kind: 'and', terms: [hand(), hand('Snooper')] }, nestedSurvivor] });
click(control(whole.tree(), 'remove-1.1'));
assert.deepEqual(whole.value, nestedSurvivor);
});
void test('the sixteen-node envelope guards add and wrap callbacks without truncating the maximum expression', () => {
const full: TruthFact = { kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: Array.from({ length: 5 }, (_, index) => spice(index + 8)) },
] };
assert.deepEqual(truthFactExpressionSize(full), { nodes: 16, depth: 2 });
const e = editor(full), before = structuredClone(full);
for (const key of ['add-1', 'nested-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
assert.deepEqual(e.value, before);
assert.equal((e.html().match(/Current fact type/g) ?? []).length, 13);
editLeaf(e.tree(), '1.2.5', { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 4 });
assert.equal(e.changes, 1, 'Editing an existing leaf is still available at the maximum');
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 16, depth: 2 });
});
void test('four levels allow shallow nesting and deepest leaf edits while rejecting a fifth level in actual callbacks', () => {
const e = editor(groupsDeep(4));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 9, depth: 4 });
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-1.1.1.1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
editLeaf(e.tree(), '1.1.1.1.1', hand('Snooper'));
click(control(e.tree(), 'add-1.1.1.1'));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 10, depth: 4 });
assert.equal(control(e.tree(), 'or-1.2').props.disabled, false);
click(control(e.tree(), 'or-1.2'));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 12, depth: 4 });
assert.equal(truthFactExpressionError(e.value), null);
});
void test('busy disables the outer fieldset and guards directly invoked add, wrap, remove, switch and leaf callbacks', () => {
const initial: TruthFact = { kind: 'and', terms: [hand(), spice(3)] };
const e = editor(initial, projected(), true);
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="truth-fact-expression"/);
for (const key of ['add-1', 'nested-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
switchGroup(e.tree(), '1', 'or');
editLeaf(e.tree(), '1.2', spice(999));
assert.equal(e.changes, 0);
assert.deepEqual(e.value, initial);
});
void test('Ask sends the exact complete three-term, eight-term and nested question payload', () => {
const facts: TruthFact[] = [
{ kind: 'and', terms: [hand('Shield'), hand('Snooper'), spice(6)] },
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: [hand(), { kind: 'or', terms: [spice(5), hand('Snooper'), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] }] },
];
for (const fact of facts) {
const game = projected(), question = factQuestion(fact), before = structuredClone(question);
const ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false);
click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
assert.deepEqual(Object.keys(ask.actions[0]).sort(), ['question', 'type']);
assert.deepEqual(question, before);
assert.match(ask.html(), /data-testid="truth-fact-preview"/);
}
});
void test('the stateful parent renders the default Shield expression with one public Ask action', () => {
for (const busy of [false, true]) {
const game = projected();
const html = renderToStaticMarkup(createElement(Truthtrance, { game, busy, act() {} }));
assert.match(html, /Verified current facts/);
assert.match(html, /data-testid="truth-fact-expression"/);
assert.match(html, /Is it true that you hold Shield\?/);
assert.equal((html.match(/Current fact type/g) ?? []).length, 1);
assert.equal((html.match(/Ask publicly/g) ?? []).length, 1);
const button = html.match(/<button\b(?=[^>]*data-testid="truth-fact-ask")[^>]*>[\s\S]*?<\/button>/)?.[0];
assert.ok(button);
assert.equal(/\sdisabled=/.test(button), busy);
assert.doesNotMatch(html, /Both · AND|Either · OR|Combine conditions/);
}
});
void test('a malformed third leaf blocks the whole AND or OR despite valid earlier clauses', () => {
const invalid: TruthFact[] = [
spice(-1), { kind: 'handCount', name: 'Shield', compare: 'eq', value: 0.5 },
{ kind: 'handInventory', category: 'all', compare: 'gte', value: NaN },
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 },
{ kind: 'stormDial', compare: 'gte', value: 21 }, { kind: 'stormForecast', compare: 'lte', value: 0 },
{ kind: 'noFieldValue', value: 7 } as unknown as TruthFact,
{ kind: 'faceDancer', leader: 'not-a-leader' },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: -1 },
];
for (const kind of ['and', 'or'] as const) for (const fact of invalid) {
const ask = askTree(projected(), factQuestion({ kind, terms: [hand(), spice(0), fact] }));
assert.equal(ask.button.props.disabled, true, `${kind}: ${fact.kind}`);
click(ask.button);
assert.deepEqual(ask.actions, []);
assert.match(ask.html(), /Resolve the marked current-fact fields before asking/);
}
});
void test('over-width, over-node, over-depth and singleton expressions cannot reach Ask and remain fully rendered', () => {
const invalid: TruthFact[] = [
{ kind: 'and', terms: [hand()] },
{ kind: 'or', terms: Array.from({ length: 9 }, (_, index) => spice(index)) },
{ kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: Array.from({ length: 6 }, (_, index) => spice(index + 8)) },
] },
groupsDeep(5),
];
for (const fact of invalid) {
const ask = askTree(projected(), factQuestion(fact));
assert.equal(ask.button.props.disabled, true);
click(ask.button);
assert.deepEqual(ask.actions, []);
const html = ask.html();
assert.match(html, /role="alert"/);
const leafCount = (value: TruthFact): number => value.kind === 'and' || value.kind === 'or'
? value.terms.reduce((count, term) => count + leafCount(term), 0) : 1;
assert.equal((html.match(/Current fact type/g) ?? []).length, leafCount(fact));
}
});
void test('busy, wrong owner, absent windows and invalid targets guard Ask and the editor change callback', () => {
const question = factQuestion(hand());
const cases: { game: GameView; question?: typeof question; busy?: boolean }[] = [{ game: projected(), busy: true }];
for (const stage of ['priority', 'answer', 'unknown'] as const) {
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
}
for (const owner of ['t', 'not-seated']) {
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
}
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
for (const target of ['a', 'not-seated']) cases.push({ game: projected(), question: factQuestion({
kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 1,
}, target) });
for (const entry of cases) {
const ask = askTree(entry.game, entry.question ?? question, entry.busy);
assert.equal(ask.button.props.disabled, true);
click(ask.button);
const fields = elements(ask.tree).find(element => element.type === TruthFactExpressionFields);
if (fields) {
assert.equal(fields.props.disabled, true);
(fields.props.onChange as (next: TruthFact) => void)(spice(9));
}
assert.deepEqual(ask.actions, []);
assert.deepEqual(ask.changes, []);
assert.doesNotThrow(ask.html);
}
const game = projected(), stale = askTree(game, question);
game.truthtrance!.stage = 'answer';
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live ask stage');
game.truthtrance!.stage = 'ask'; game.truthtrance!.queue[0].player = 't';
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live queue owner');
game.truthtrance!.queue[0].player = 'a';
game.players = game.players.filter(player => player.id !== question.target);
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live target seat');
});
void test('meaning, current custody, app limits and public leader labels persist with the editor and preview', () => {
const game = projected(), leader = game.allLeaders[0];
const ask = askTree(game, factQuestion({ kind: 'and', terms: [
{ kind: 'traitor', leader: leader.id },
{ kind: 'or', terms: [hand(), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] },
] }));
for (let render = 0; render < 3; render++) {
const html = ask.html();
assert.match(html, /AND requires all conditions; OR requires at least one/);
assert.match(html, /one aggregate answer/);
assert.match(html, /No makes the whole expression false, not necessarily every condition/);
assert.match(html, /Current facts are evaluated when the player answers/);
assert.match(html, /no promise to keep cards, spice or forces afterward/);
assert.match(html, /App limits: 8 terms per group, 16 total clauses/);
assert.match(html, /not additional printed rules/);
assert.ok(html.includes(`${leader.name} is your traitor`));
assert.match(html, /Cheap Hero \/ Heroine is your traitor/);
assert.doesNotMatch(html, /Confirm question|Acknowledge/);
}
});
void test('all current-fact controls and actual Ask callbacks avoid opponent private holdings or expected answers', () => {
const game = projected();
for (const player of game.players) for (const field of [
'hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces', 'handLimit', 'prediction', 'noField',
]) Object.defineProperty(player, field, { get() { throw Error(`Current fact controls read private ${field}`); } });
for (const field of ['truthAnswer', 'truthKnowledge', 'truthHistory'])
Object.defineProperty(game, field, { get() { throw Error(`Current fact controls read ${field}`); } });
const facts: TruthFact[] = [
hand(), spice(5), { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 1 },
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 2 },
{ kind: 'traitor', leader: game.allLeaders[0].id }, { kind: 'faceDancer', leader: CHEAP_HERO_TRAITOR },
{ kind: 'prediction', field: 'faction', faction: 'atreides' },
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 2 },
{ kind: 'stormDial', compare: 'lte', value: 20 }, { kind: 'stormForecast', compare: 'gte', value: 1 },
{ kind: 'noFieldValue', value: 3 },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 4 },
];
for (const fact of facts) {
const question = factQuestion(fact), ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false, fact.kind);
assert.doesNotThrow(ask.html);
click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
}
});