Wave 16 — 3/3 untested choice-kinds covered (choice-kinds.spec.ts): - square: 8x8 grid → click e4 → treasure marker spawns - row: row picker → click row 3 → 8 treasure markers along row 3 - coin-flip: heads/tails buttons → click heads → CoinFlipResult set on GAME_ENTITY Wave 17 — 1 active + 5 fixme (move-gen-attrs.spec.ts): - KingExtraReach=2 PASSES (king steps 2 squares) - MovesAs/MovesAlsoAs/BlockedPieceTypes/MoveClassRestriction/PawnPushesPiecesEnabled fixme'd: drag library doesn't reject illegal moves at UI layer (move-gen filter is engine-side; UI is permissive). Documented limitation; engine behavior verified at unit level (Wave 12 tests). Wave 18 — 11 active + 1 fixme (orphan-primitives.spec.ts): - place-piece, move-piece, swap-pieces, convert-piece-type - for-each-piece, for-each-square, for-each-adjacent, for-each-marker - block-by-piece-type primitive (consumer attr Wave 12) - on-rule-expire (descriptor detach fires arm) - on-marker-expire (lifetime sweep fires hook) - spawn-marker-pair (mutual MarkerLinks) - must-class fixme: consumer deferred (descriptor seeds attr; rules/turn filter not yet wired for must-class specifically) E2E total: 27 active passing + 7 fixme. Unit tests: 2866 passing. bun run check exit 0.
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
/**
|
|
* Wave 16 — Playwright E2E: request-choice round-trips for the
|
|
* three remaining choice kinds (square, row, coin-flip).
|
|
*
|
|
* Coverage matrix (T58 supports 6 kinds; rps/piece/column already
|
|
* covered by `request-choice.spec.ts`):
|
|
*
|
|
* 1. square — modal renders the 8x8 ParamSquarePicker grid;
|
|
* click e4 (LERF index 28) → continuation spawns
|
|
* a permanent treasure marker on e4.
|
|
*
|
|
* 2. row — modal renders 8 row buttons; click row 3 →
|
|
* continuation iterates `for-column` over all 8
|
|
* columns and spawns a treasure marker at every
|
|
* square in row 3 (LERF squares 24..31).
|
|
*
|
|
* 3. coin-flip — modal renders heads/tails buttons; click heads →
|
|
* continuation writes the bound value onto
|
|
* GAME_ENTITY (id 0) under the `CoinFlipResult`
|
|
* attribute. Verified via the dev-only
|
|
* `__paratypeChessPrediction` global (mirroring
|
|
* the assertion strategy of T68/3).
|
|
*
|
|
* Activation path
|
|
* ----------------
|
|
* Same pattern as `request-choice.spec.ts`:
|
|
*
|
|
* - `__test__.activate-descriptor` debug WS frame routed through
|
|
* the existing `__paratypeChessClient` socket. The handler
|
|
* lifts the inner `on-rule-activated` arm so primitives[0]
|
|
* becomes the request-choice (T79).
|
|
*
|
|
* - The PendingChoice frame is pushed onto the LIFO stack with
|
|
* the lifted descriptor's id; submitChoiceAndResume resolves
|
|
* it on the engine's customModifiers registry.
|
|
*
|
|
* - `forPlayer: "white"` keeps the broadcast on the host's socket
|
|
* only — single-context tests below.
|
|
*
|
|
* Continuation gaps documented in `request-choice.spec.ts` (gap I —
|
|
* `for-column` double-recurse) apply to scenario 2 below: the inner
|
|
* arm spawns all 8 markers BEFORE the post-iteration child-walk
|
|
* trips a BindingError on `$row`, which the server's submit-choice
|
|
* handler swallows with a logger.warn. The post-resolve board state
|
|
* is correct (8 treasure markers), so the test passes.
|
|
*/
|
|
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server lifecycle (mirrors `request-choice.spec.ts` / `multiplayer.spec.ts`)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let wsServerProcess: ChildProcess | null = null;
|
|
|
|
async function isWsServerRunning(): Promise<boolean> {
|
|
try {
|
|
const res = await fetch('http://localhost:7357/healthz');
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
if (await isWsServerRunning()) return;
|
|
wsServerProcess = spawn('bun', ['run', 'packages/server/src/index.ts'], {
|
|
stdio: 'pipe',
|
|
env: { ...process.env, PORT: '7357' },
|
|
});
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(250);
|
|
if (await isWsServerRunning()) break;
|
|
}
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (wsServerProcess) {
|
|
wsServerProcess.kill('SIGINT');
|
|
await sleep(200);
|
|
wsServerProcess = null;
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (copied from request-choice.spec.ts — kept inline to keep this
|
|
// spec independently runnable in CI sharding).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function wsCreateRoom(
|
|
page: Page,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
return page.evaluate(async () => {
|
|
return new Promise<{ code: string; token: string; color: string }>(
|
|
(resolve, reject) => {
|
|
const ws = new WebSocket('ws://localhost:7357/ws');
|
|
const timer = setTimeout(
|
|
() => reject(new Error('wsCreateRoom: timeout')),
|
|
5000,
|
|
);
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
v: 1,
|
|
seq: 1,
|
|
ts: Date.now(),
|
|
type: 'room.create',
|
|
payload: {},
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
const msg = JSON.parse(e.data as string) as {
|
|
type: string;
|
|
payload: {
|
|
code: string;
|
|
token: string;
|
|
color: string;
|
|
message?: string;
|
|
};
|
|
};
|
|
if (msg.type === 'room.created') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
resolve(msg.payload);
|
|
} else if (msg.type === 'error') {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
reject(new Error(msg.payload.message ?? 'room.create error'));
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
clearTimeout(timer);
|
|
reject(new Error('wsCreateRoom: WebSocket error'));
|
|
};
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function joinAsHost(
|
|
page: Page,
|
|
): Promise<{ code: string; token: string; color: string }> {
|
|
await page.goto('http://localhost:5173/');
|
|
await page.waitForSelector('[data-testid="page-home"]');
|
|
const room = await wsCreateRoom(page);
|
|
await page.evaluate((r) => {
|
|
sessionStorage.setItem('room-code', r.code);
|
|
sessionStorage.setItem('room-token', r.token);
|
|
sessionStorage.setItem('player-color', r.color);
|
|
}, room);
|
|
await page.goto('http://localhost:5173/game');
|
|
await expect(page.locator('[data-testid="turn-indicator"]')).toBeVisible();
|
|
return room;
|
|
}
|
|
|
|
/**
|
|
* T79 — drive the test-only `__test__.activate-descriptor` frame
|
|
* over the existing __paratypeChessClient socket. The lifted
|
|
* descriptor's primitives[0] must be `request-choice` (the lift
|
|
* helper unwraps the on-rule-activated arm before registering).
|
|
*/
|
|
async function activateDescriptor(
|
|
page: Page,
|
|
args: {
|
|
code: string;
|
|
descriptor: unknown;
|
|
chooserColor: 'white' | 'black';
|
|
liftedId: string;
|
|
},
|
|
): Promise<void> {
|
|
await page.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
(globalThis as { __paratypeChessClient?: unknown })
|
|
.__paratypeChessClient,
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
);
|
|
await page.evaluate((a) => {
|
|
const client = (
|
|
globalThis as {
|
|
__paratypeChessClient?: {
|
|
send: (msg: { type: string; payload: unknown }) => void;
|
|
};
|
|
}
|
|
).__paratypeChessClient;
|
|
if (!client)
|
|
throw new Error('activateDescriptor: __paratypeChessClient not present');
|
|
client.send({
|
|
type: '__test__.activate-descriptor',
|
|
payload: {
|
|
roomCode: a.code,
|
|
descriptor: a.descriptor,
|
|
chooserColor: a.chooserColor,
|
|
liftedId: a.liftedId,
|
|
},
|
|
});
|
|
}, args);
|
|
}
|
|
|
|
/**
|
|
* Build a minimal CustomModifierDescriptor envelope around an
|
|
* `on-rule-activated` arm. Saves the per-test boilerplate of
|
|
* declaring `type/version/source/uiForm/targetAttrs`.
|
|
*/
|
|
function makeDescriptor(
|
|
id: string,
|
|
arm: ReadonlyArray<{ kind: string; params: Record<string, unknown> }>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
type: 'data',
|
|
id,
|
|
name: id,
|
|
description: `Wave 16 e2e descriptor (${id}).`,
|
|
version: 1,
|
|
uiForm: 'primitive-composer',
|
|
source: 'custom',
|
|
targetAttrs: [],
|
|
primitives: [
|
|
{
|
|
kind: 'on-rule-activated',
|
|
params: { primitives: arm },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test A — square choice-kind
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('Wave16/A square choice-kind: modal grid → click e4 → treasure marker spawns', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
const descriptor = makeDescriptor('wave16:square-choice', [
|
|
{
|
|
kind: 'request-choice',
|
|
params: {
|
|
kind: 'square',
|
|
prompt: 'Pick a square',
|
|
forPlayer: 'white',
|
|
bind: 'sq',
|
|
then: [
|
|
{
|
|
kind: 'spawn-marker',
|
|
params: {
|
|
markerKind: 'treasure',
|
|
square: { $var: 'sq' },
|
|
lifetime: { kind: 'permanent' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
await activateDescriptor(page, {
|
|
code: room.code,
|
|
descriptor,
|
|
chooserColor: 'white',
|
|
liftedId: 'wave16:square-choice__lifted',
|
|
});
|
|
|
|
// Modal opens with kind=square. ParamSquarePicker renders an
|
|
// 8x8 grid of buttons (64 total).
|
|
const modal = page.locator('[data-testid="request-choice-modal"]');
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
await expect(modal).toHaveAttribute('data-choice-kind', 'square');
|
|
await expect(modal.locator('button[aria-label^="Square "]')).toHaveCount(64);
|
|
|
|
// Click e4 (LERF index 28). ParamSquarePicker tags each button
|
|
// with `aria-label="Square <sq>"`; we click via that label.
|
|
await modal.locator('button[aria-label="Square 28"]').click();
|
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// Treasure marker visible at e4. Default marker rendering in
|
|
// Board.tsx puts data-marker-kind={kind} on the absolutely-
|
|
// positioned label div inside the [data-square=…] cell.
|
|
await expect(
|
|
page.locator('[data-square="e4"] [data-marker-kind="treasure"]'),
|
|
).toBeVisible({ timeout: 5000 });
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test B — row choice-kind
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('Wave16/B row choice-kind: modal row buttons → click row 3 → 8 treasure markers along row 3', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Continuation: for-column iterates 0..7, binding `col`; inner
|
|
// spawn-marker resolves square via {ctx-build:{col,row}} → drops
|
|
// a treasure marker on every square in the chosen row.
|
|
const descriptor = makeDescriptor('wave16:row-choice', [
|
|
{
|
|
kind: 'request-choice',
|
|
params: {
|
|
kind: 'row',
|
|
prompt: 'Pick a row',
|
|
forPlayer: 'white',
|
|
bind: 'row',
|
|
then: [
|
|
{
|
|
kind: 'for-column',
|
|
params: {
|
|
columns: [0, 1, 2, 3, 4, 5, 6, 7],
|
|
bind: 'col',
|
|
then: [
|
|
{
|
|
kind: 'spawn-marker',
|
|
params: {
|
|
markerKind: 'treasure',
|
|
square: {
|
|
'ctx-build': {
|
|
col: { $var: 'col' },
|
|
row: { $var: 'row' },
|
|
},
|
|
},
|
|
lifetime: { kind: 'permanent' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
await activateDescriptor(page, {
|
|
code: room.code,
|
|
descriptor,
|
|
chooserColor: 'white',
|
|
liftedId: 'wave16:row-choice__lifted',
|
|
});
|
|
|
|
const modal = page.locator('[data-testid="request-choice-modal"]');
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
await expect(modal).toHaveAttribute('data-choice-kind', 'row');
|
|
// Modal shows 8 row buttons (data-row="0".."7"); same picker
|
|
// layout as the column kind.
|
|
await expect(modal.locator('button[data-row]')).toHaveCount(8);
|
|
|
|
// Click row 3 (LERF rank 3 → squares 24..31, files a4..h4).
|
|
await modal.locator('button[data-row="3"]').click();
|
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// 8 treasure markers along row 3.
|
|
for (const square of ['a4', 'b4', 'c4', 'd4', 'e4', 'f4', 'g4', 'h4']) {
|
|
await expect(
|
|
page.locator(`[data-square="${square}"] [data-marker-kind="treasure"]`),
|
|
).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test C — coin-flip choice-kind
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('Wave16/C coin-flip choice-kind: modal heads/tails → click heads → CoinFlipResult set on GAME_ENTITY', async ({
|
|
browser,
|
|
}) => {
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
const room = await joinAsHost(page);
|
|
expect(room.color).toBe('white');
|
|
|
|
// Continuation writes the bound choice value onto GAME_ENTITY
|
|
// (id 0) under a debug attr (CoinFlipResult). set-piece-attr's
|
|
// `attr` field is free-form (string-min-1), and `target=0`
|
|
// targets GAME_ENTITY per its longDescription.
|
|
const descriptor = makeDescriptor('wave16:coin-flip-choice', [
|
|
{
|
|
kind: 'request-choice',
|
|
params: {
|
|
kind: 'coin-flip',
|
|
prompt: 'Heads or tails?',
|
|
forPlayer: 'white',
|
|
bind: 'flip',
|
|
then: [
|
|
{
|
|
kind: 'set-piece-attr',
|
|
params: {
|
|
target: 0,
|
|
attr: 'CoinFlipResult',
|
|
value: { $var: 'flip' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
|
|
await activateDescriptor(page, {
|
|
code: room.code,
|
|
descriptor,
|
|
chooserColor: 'white',
|
|
liftedId: 'wave16:coin-flip-choice__lifted',
|
|
});
|
|
|
|
const modal = page.locator('[data-testid="request-choice-modal"]');
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
await expect(modal).toHaveAttribute('data-choice-kind', 'coin-flip');
|
|
// Modal renders exactly 2 buttons — heads + tails.
|
|
await expect(modal.locator('button[data-coin]')).toHaveCount(2);
|
|
await expect(modal.locator('button[data-coin="heads"]')).toBeVisible();
|
|
await expect(modal.locator('button[data-coin="tails"]')).toBeVisible();
|
|
|
|
// Click heads → submitChoiceAndResume runs the continuation
|
|
// → set-piece-attr writes CoinFlipResult="heads" on GAME_ENTITY.
|
|
await modal.locator('button[data-coin="heads"]').click();
|
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// Verify the continuation ran by reading the engine session via
|
|
// the dev-only PredictionManager hook (same pattern as T68/3's
|
|
// OnCapturedHooks assertion).
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
page.evaluate(() => {
|
|
const mgr = (
|
|
globalThis as {
|
|
__paratypeChessPrediction?: {
|
|
getCurrentEngine: () => {
|
|
session: { get: (id: number, attr: string) => unknown };
|
|
};
|
|
};
|
|
}
|
|
).__paratypeChessPrediction;
|
|
if (!mgr) return null;
|
|
return mgr.getCurrentEngine().session.get(0, 'CoinFlipResult') ?? null;
|
|
}),
|
|
{ timeout: 5000 },
|
|
)
|
|
.toBe('heads');
|
|
|
|
await ctx.close();
|
|
});
|