feat(thressgame-coverage): Waves 16-18 (Playwright e2e for choice-kinds + move-gen attrs + orphan primitives)
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.
This commit is contained in:
parent
4c25277449
commit
f762b6d207
4 changed files with 2978 additions and 1 deletions
|
|
@ -96,7 +96,9 @@
|
|||
"ses_23413e9bdffemN8WkabmXJVK5t",
|
||||
"ses_233f787b9ffeYWzzTLHpG5VJks",
|
||||
"ses_233f7318effe2R0Vt2ad27KzEZ",
|
||||
"ses_233cc34d1ffe9ys7V39oRNCcO0"
|
||||
"ses_233cc34d1ffe9ys7V39oRNCcO0",
|
||||
"ses_233bcb2b3ffeI06xdt1zAAeqig",
|
||||
"ses_233bc366effeMrTyc60acFDGv1"
|
||||
],
|
||||
"plan_name": "thressgame-coverage",
|
||||
"agent": "atlas"
|
||||
|
|
|
|||
456
packages/chess/e2e/choice-kinds.spec.ts
Normal file
456
packages/chess/e2e/choice-kinds.spec.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
1015
packages/chess/e2e/move-gen-attrs.spec.ts
Normal file
1015
packages/chess/e2e/move-gen-attrs.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
1504
packages/chess/e2e/orphan-primitives.spec.ts
Normal file
1504
packages/chess/e2e/orphan-primitives.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue