100 lines
5.5 KiB
TypeScript
100 lines
5.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { createElement, type ReactNode } from 'react';
|
|
import * as jsxRuntime from 'react/jsx-runtime';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import ts from 'typescript';
|
|
import { RicheseAuctionCountdown } from '../components/richese-auction-countdown';
|
|
import type { AuctionClockView } from '../game/auction-clock';
|
|
|
|
const frame = (remainingMs = 10_000, running = true, event: string | null = 'bid-event'): AuctionClockView =>
|
|
({ event, player: 'p', remainingMs, running });
|
|
const html = (clock: AuctionClockView | null, method: 'onceAround' | 'normal', paused = false) =>
|
|
renderToStaticMarkup(createElement(RicheseAuctionCountdown, { clock, playerName: 'Respondent', method, paused }));
|
|
|
|
void test('sequential Richese countdown presents the live bidder, method and server-controlled expiry', () => {
|
|
for (const [method, label] of [['onceAround', 'Once Around'], ['normal', 'Black Market']] as const) {
|
|
const clock = frame(1001), before = structuredClone(clock), markup = html(clock, method);
|
|
assert.match(markup, new RegExp(`${label} auction countdown`));
|
|
assert.match(markup, /Respondent may bid or pass/);
|
|
assert.match(markup, /2 seconds remaining/);
|
|
assert.match(markup, /role="timer" aria-live="off"/);
|
|
assert.match(markup, /server controls the deadline/);
|
|
if (method === 'onceAround') {
|
|
assert.match(markup, /provisional online interpretation/);
|
|
assert.match(markup, /binding Truthtrance answer/);
|
|
}
|
|
assert.doesNotMatch(markup, /<button|<form|Continue/);
|
|
assert.deepEqual(clock, before);
|
|
}
|
|
assert.equal(html(null, 'onceAround'), '');
|
|
});
|
|
|
|
void test('Richese countdown keeps unarmed, held, paused and expired states distinct', () => {
|
|
assert.match(html(frame(10_000, false, null), 'onceAround'), /Clock starts when bidding is ready/);
|
|
assert.match(html(frame(6_200, false), 'normal'), /held while the current game decision finishes/);
|
|
assert.match(html(frame(6_200), 'onceAround', true), /Auction paused/);
|
|
assert.match(html(frame(0, false), 'onceAround'), /Awaiting server resolution/);
|
|
assert.match(html(frame(0, false), 'onceAround'), /0 seconds remaining/);
|
|
});
|
|
|
|
/** Exercise the real effect with a controlled monotonic browser clock. */
|
|
function browserDisplay(initial: Parameters<typeof RicheseAuctionCountdown>[0]) {
|
|
type Effect = { dependencies: readonly unknown[]; cleanup?: () => void };
|
|
const states: unknown[] = [], effects: Effect[] = [], pending: (() => void)[] = [];
|
|
const intervals = new Map<number, () => void>();
|
|
let stateIndex = 0, effectIndex = 0, now = 0, nextTimer = 0, props = initial;
|
|
const exports: { RicheseAuctionCountdown?: typeof RicheseAuctionCountdown } = {};
|
|
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/richese-auction-countdown.tsx', import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
|
}).outputText, {
|
|
exports,
|
|
performance: { now: () => now },
|
|
setInterval(callback: () => void, ms: number) { assert.equal(ms, 200); const timer = ++nextTimer; intervals.set(timer, callback); return timer; },
|
|
clearInterval(timer: number) { intervals.delete(timer); },
|
|
require(name: string) {
|
|
if (name === 'react/jsx-runtime') return jsxRuntime;
|
|
assert.equal(name, 'react');
|
|
return {
|
|
useState(initialValue: unknown) {
|
|
const index = stateIndex++;
|
|
if (!(index in states)) states[index] = initialValue;
|
|
return [states[index], (next: unknown) => { states[index] = next; }];
|
|
},
|
|
useEffect(callback: () => (() => void) | undefined, dependencies: readonly unknown[]) {
|
|
const index = effectIndex++, previous = effects[index];
|
|
if (previous && dependencies.every((dependency, i) => Object.is(dependency, previous.dependencies[i]))) return;
|
|
pending.push(() => { previous?.cleanup?.(); effects[index] = { dependencies, cleanup: callback() }; });
|
|
},
|
|
};
|
|
},
|
|
});
|
|
assert.ok(exports.RicheseAuctionCountdown);
|
|
const render = () => {
|
|
stateIndex = 0; effectIndex = 0;
|
|
const node: ReactNode = exports.RicheseAuctionCountdown!(props);
|
|
const markup = renderToStaticMarkup(node);
|
|
while (pending.length) pending.shift()!();
|
|
return markup;
|
|
};
|
|
return {
|
|
render,
|
|
update(next: typeof initial) { props = next; return render(); },
|
|
advance(ms: number) { now += ms; for (const callback of intervals.values()) callback(); return render(); },
|
|
get ticking() { return intervals.size; },
|
|
};
|
|
}
|
|
|
|
void test('local countdown follows elapsed time and stops while paused or awaiting the server', () => {
|
|
const display = browserDisplay({ clock: frame(), playerName: 'Respondent', method: 'onceAround' });
|
|
assert.match(display.render(), /10 seconds remaining/); assert.equal(display.ticking, 1);
|
|
assert.match(display.advance(5401), /5 seconds remaining/);
|
|
assert.match(display.update({ clock: frame(2000, false), playerName: 'Respondent', method: 'onceAround' }), /2 seconds remaining/);
|
|
assert.equal(display.ticking, 0);
|
|
assert.match(display.update({ clock: frame(1000), playerName: 'Respondent', method: 'normal' }), /Black Market/);
|
|
assert.match(display.advance(1000), /Awaiting server resolution/); assert.equal(display.ticking, 0);
|
|
assert.equal(display.update({ clock: null, playerName: 'Respondent', method: 'normal' }), '');
|
|
});
|