Files
Dune/tests/public-deals-deadline-rollover.test.tsx
T

128 lines
6.1 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { runInNewContext } from 'node:vm';
import { isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as publicDeals from '../game/public-deals';
import type { Action, GameView } from '../game/engine';
import type { PublicDeals } from '../components/public-deals';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
const base = { status: 'playing', turn: 2, me: 'a', roomControl: null,
players: [{ id: 'a', name: 'Alice', faction: 'atreides' }, { id: 'b', name: 'Basil', faction: 'harkonnen' }],
deals: [],
} as unknown as GameView;
/** Render one persistent instance and commit effects when their dependencies change. */
function controller() {
const states: unknown[] = [], refs: unknown[] = [], dependencies: unknown[][] = [], sent: Action[] = [];
let cursor = 0, dirty = false, game = base;
let effects: (() => void)[] = [];
const exported: { PublicDeals?: typeof PublicDeals } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports: exported,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return {
useState(initial: unknown) {
const index = cursor++;
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (next: unknown) => {
const value = typeof next === 'function' ? next(states[index]) : next;
if (!Object.is(states[index], value)) { states[index] = value; dirty = true; }
}];
},
useRef(initial: unknown) {
const index = cursor++;
if (!(index in refs)) refs[index] = { current: initial };
return refs[index];
},
useEffect(effect: () => void, deps: unknown[]) {
const index = cursor++, previous = dependencies[index];
if (!previous || deps.length !== previous.length || deps.some((value, i) => !Object.is(value, previous[i]))) {
dependencies[index] = [...deps]; effects.push(effect);
}
},
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exported.PublicDeals);
const render = () => {
let nodes: Element[] = [];
for (let attempt = 0; attempt < 4; attempt++) {
cursor = 0; dirty = false; effects = [];
nodes = elements(exported.PublicDeals!({ game, busy: false, act: action => sent.push(action) }));
effects.forEach(effect => effect());
if (!dirty) return nodes;
}
throw new Error('The form did not settle after its effects.');
};
const checkbox = (index: number) => {
const input = render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[index];
assert.ok(input);
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
};
const select = (index: number, value: string) => {
const input = render().filter(node => node.type === 'select')[index]; assert.ok(input);
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
checkbox(0); checkbox(1); select(0, 'deferredBribe'); select(1, 'a'); select(2, 'b');
const terms = render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'Terms' } });
const due = () => {
const input = render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === game.turn);
assert.ok(input); return input;
};
return {
render, due, sent,
turn(turn: number) { game = { ...game, turn }; },
edit(value: string) { (due().props.onChange as (event: { target: { value: string } }) => void)({ target: { value } }); },
submit() {
const form = render().find(node => node.type === 'form'); assert.ok(form);
(form.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
},
};
}
void test('the mounted deferred form advances an expired default and submits the new current turn', () => {
const ui = controller();
assert.equal(ui.due().props.value, '2');
ui.turn(3);
assert.equal(ui.due().props.value, '3');
assert.equal(ui.due().props['aria-invalid'], false);
ui.submit();
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'dealOffer', target: 'b', terms: 'Terms',
payment: { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } });
});
void test('a chosen future deadline stays until the actual turn passes it', () => {
const ui = controller();
ui.edit('5'); ui.turn(3); assert.equal(ui.due().props.value, '5');
ui.turn(5); assert.equal(ui.due().props.value, '5');
ui.turn(6); assert.equal(ui.due().props.value, '6');
});
void test('invalid edits remain editable and are not overwritten on ordinary rerenders', () => {
const ui = controller();
ui.edit('1');
assert.equal(ui.due().props.value, '1'); assert.equal(ui.due().props['aria-invalid'], true);
ui.submit(); assert.equal(ui.sent.length, 0);
ui.edit(''); ui.turn(3); assert.equal(ui.due().props.value, '');
ui.edit('3.5'); ui.turn(4); assert.equal(ui.due().props.value, '3.5');
assert.equal(ui.due().props['aria-invalid'], true);
});