401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
/**
|
|
* Rule-variants epic — vertical slice e2e.
|
|
*
|
|
* Three representative scenarios covering the full lobby → game flow
|
|
* for the 2026 rule-variants epic. These are INTEGRATION smoke tests:
|
|
* they verify the UI wiring (layout picker, suggested-rules chips,
|
|
* rules drawer category grouping, per-preset toggle) correctly
|
|
* plumbs preset activations into the solo engine. They are NOT
|
|
* chess-logic tests — the unit suites in
|
|
* packages/chess/src/presets/*.test.ts already exhaustively cover
|
|
* preset behaviour.
|
|
*
|
|
* All three scenarios are solo-play; no WS server required.
|
|
*
|
|
* 1. Knightmate: layout-picker chip toggles `knightmate-rules`
|
|
* which loads on Play Solo without errors.
|
|
* 2. Double-move: drawer toggle of `double-move`; white plays two
|
|
* consecutive moves and the turn indicator lands on black only
|
|
* after the second.
|
|
* 3. Suicide-chess: drawer toggle of `suicide-chess`; a non-capture
|
|
* move is NOT offered when a capture is available (compulsory
|
|
* capture via `filterLegalMoves`).
|
|
*
|
|
* Patterns borrowed from:
|
|
* - solo-smoke.spec.ts (localStorage reset, drag helper, console
|
|
* error collection).
|
|
* - modifier-profiles.spec.ts (rules drawer open + preset toggle).
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/**
|
|
* Reset localStorage so a previous test's preset activations or
|
|
* profile selections don't leak into this one. Mirrors the
|
|
* solo-smoke pattern.
|
|
*/
|
|
async function resetStorage(page: Page): Promise<void> {
|
|
await page.goto('/');
|
|
await page.evaluate(() => {
|
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
const k = localStorage.key(i);
|
|
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drag a piece from `from` to `to` (algebraic squares).
|
|
*/
|
|
async function drag(page: Page, from: string, to: string): Promise<void> {
|
|
await page
|
|
.locator(`[data-square="${from}"] [data-piece]`)
|
|
.dragTo(page.locator(`[data-square="${to}"]`));
|
|
// small settle for animation + state commit
|
|
await page.waitForTimeout(120);
|
|
}
|
|
|
|
/**
|
|
* Toggle a preset via the rules drawer inside a GAME VIEW.
|
|
*
|
|
* The rules drawer is part of `GameView`, not the lobby — opening
|
|
* it requires an active game. These scenarios use the pattern:
|
|
*
|
|
* 1. Play Solo (get into the game with no presets).
|
|
* 2. Open the drawer.
|
|
* 3. Toggle the preset ON (applies immediately to the running
|
|
* engine).
|
|
* 4. Close the drawer.
|
|
* 5. Drive moves with the preset active.
|
|
*/
|
|
async function togglePresetInGameDrawer(
|
|
page: Page,
|
|
presetId: string,
|
|
): Promise<void> {
|
|
const drawer = page.locator('[data-testid="rules-drawer"]');
|
|
const isOpen = await drawer.isVisible().catch(() => false);
|
|
if (!isOpen) {
|
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
|
await expect(drawer).toBeVisible();
|
|
}
|
|
|
|
const toggle = page.locator(
|
|
`[data-preset="${presetId}"] [data-role="toggle"]`,
|
|
);
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
|
|
// Give the engine a frame to re-activate with the new preset set.
|
|
await page.waitForTimeout(120);
|
|
|
|
// Close the drawer so the board is interactable.
|
|
await page.locator('[data-action="close-rules-drawer"]').click();
|
|
await expect(drawer).not.toBeVisible();
|
|
}
|
|
|
|
test.describe('F2 post-epic: extinction-chess target cycler', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.evaluate(() => {
|
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
const k = localStorage.key(i);
|
|
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('cycler renders when extinction-chess is active; cycles through target types', async ({
|
|
page,
|
|
}) => {
|
|
// Solo game (cycler is solo-only in v1 per plan decision 2a).
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game');
|
|
|
|
// Open rules drawer and enable extinction-chess.
|
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
|
const toggle = page.locator(
|
|
'[data-preset="extinction-chess"] [data-role="toggle"]',
|
|
);
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
|
|
// Default target is "Pawns" (seeded on activation).
|
|
const cycler = page.locator('[data-testid="extinction-target-cycler"]');
|
|
await expect(cycler).toBeVisible();
|
|
await expect(cycler).toHaveText('Pawns');
|
|
|
|
// Cycle: pawn → knight → bishop → rook → queen → king → pawn.
|
|
const expected = [
|
|
'Knights',
|
|
'Bishops',
|
|
'Rooks',
|
|
'Queens',
|
|
'Kings',
|
|
'Pawns',
|
|
];
|
|
for (const label of expected) {
|
|
await cycler.click();
|
|
await expect(cycler).toHaveText(label);
|
|
}
|
|
});
|
|
|
|
test('cycler is absent when extinction-chess is not active', async ({
|
|
page,
|
|
}) => {
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game');
|
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
|
// Drawer open, no preset toggled → no cycler.
|
|
await expect(
|
|
page.locator('[data-testid="extinction-target-cycler"]'),
|
|
).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Rule variants — lobby vertical slice', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await resetStorage(page);
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// Scenario 1: Knightmate
|
|
// ────────────────────────────────────────────────────────────────
|
|
test('knightmate: layout chip toggles rules, solo game loads', async ({
|
|
page,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
|
});
|
|
|
|
// Pick the knightmate layout.
|
|
await page
|
|
.locator('[data-testid="layout-picker"]')
|
|
.selectOption('knightmate');
|
|
|
|
// The layout's suggestedPresets declares "knightmate-rules".
|
|
// LayoutPicker renders a chip for it.
|
|
const chip = page.locator(
|
|
'[data-testid="layout-suggested-preset-knightmate-rules"]',
|
|
);
|
|
await expect(chip).toBeVisible();
|
|
|
|
// Activate the preset via the chip.
|
|
await chip.click();
|
|
|
|
// Start the solo game.
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game', { timeout: 5000 });
|
|
|
|
// Board renders; back rank is the knightmate shape (3 knights
|
|
// per side, no queens). We don't assert piece identity across
|
|
// every square — just sanity that the board loaded and no
|
|
// console/page errors fired through the activation path.
|
|
await expect(page.locator('[data-square="e1"]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
|
|
|
|
// Make a simple move — pawn e2→e4.
|
|
await drag(page, 'e2', 'e4');
|
|
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
|
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// Scenario 2: Double-move
|
|
// ────────────────────────────────────────────────────────────────
|
|
test('double-move: white plays two half-moves before the flip', async ({
|
|
page,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
|
|
|
// Start a solo game first (the drawer lives in GameView).
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game');
|
|
|
|
// Open the drawer and verify the multi-move category header
|
|
// rendered (Phase F.3 grouping).
|
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
|
await expect(
|
|
page.locator('[data-testid="rules-category-multi-move"]'),
|
|
).toBeVisible();
|
|
|
|
// Toggle double-move ON. Engine re-activates the preset set
|
|
// immediately — applies to the NEXT move.
|
|
const toggle = page.locator('[data-preset="double-move"] [data-role="toggle"]');
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
await page.waitForTimeout(120);
|
|
await page.locator('[data-action="close-rules-drawer"]').click();
|
|
await expect(
|
|
page.locator('[data-testid="rules-drawer"]'),
|
|
).not.toBeVisible();
|
|
|
|
// White's first half-move: e2 → e4.
|
|
await drag(page, 'e2', 'e4');
|
|
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
|
|
// At this point HalfMovesThisTurn=1 and the engine's
|
|
// shouldAdvanceTurn hook vetoed the flip → still white's turn.
|
|
// White plays a second half-move. If it's still white's turn,
|
|
// a white-owned piece like b1 knight should be movable.
|
|
await drag(page, 'g1', 'f3');
|
|
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
|
|
// Now it should be black's turn. Black plays first half-move:
|
|
// e7 → e5. If the flip didn't happen, this move would fail
|
|
// because e7 belongs to black and the engine would reject a
|
|
// white-mover attempting it (or the drag would be ignored by
|
|
// the UI gating).
|
|
await drag(page, 'e7', 'e5');
|
|
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
|
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// Scenario 3: Suicide-chess (compulsory capture)
|
|
// ────────────────────────────────────────────────────────────────
|
|
test('suicide-chess: compulsory capture hides non-capture moves', async ({
|
|
page,
|
|
}) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
|
|
|
// Play Solo FIRST (drawer lives in GameView). We'll play
|
|
// without the preset briefly to establish a capture position,
|
|
// THEN toggle suicide-chess on, THEN verify the compulsion.
|
|
//
|
|
// Approach rationale: suicide-chess' `filterLegalMoves`
|
|
// applies to BOTH sides. Driving a fresh-FIDE game forward
|
|
// with the preset active-from-start requires both sides to
|
|
// resolve captures as they arise, which is brittle to script
|
|
// through the UI. Instead: set up a known capture opportunity
|
|
// OFF-preset, then flip the preset ON and verify white's
|
|
// next move is constrained.
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game');
|
|
|
|
// Build a position with one clear capture: 1. e4 d5.
|
|
// Now white's e4 attacks d5 → e4xd5 is the only capture
|
|
// available to white on ply 3. Other white moves (pawn
|
|
// pushes, knight developments) are non-captures.
|
|
await drag(page, 'e2', 'e4');
|
|
await drag(page, 'd7', 'd5');
|
|
|
|
// Now flip suicide-chess ON. With the preset active,
|
|
// `filterLegalMoves` will restrict white's next move to
|
|
// captures only — just e4xd5.
|
|
await togglePresetInGameDrawer(page, 'suicide-chess');
|
|
|
|
// Try a non-capture: a2 → a3. Should be rejected (board
|
|
// unchanged).
|
|
await drag(page, 'a2', 'a3');
|
|
await expect(page.locator('[data-square="a2"] [data-piece]')).toBeVisible({
|
|
timeout: 2000,
|
|
});
|
|
await expect(
|
|
page.locator('[data-square="a3"] [data-piece]'),
|
|
).toHaveCount(0);
|
|
|
|
// Play the compulsory capture. e4xd5 should succeed — the
|
|
// pawn moves to d5, captures the black pawn.
|
|
await drag(page, 'e4', 'd5');
|
|
await expect(page.locator('[data-square="d5"] [data-piece]')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
// e4 should now be empty.
|
|
await expect(
|
|
page.locator('[data-square="e4"] [data-piece]'),
|
|
).toHaveCount(0);
|
|
|
|
if (errors.length > 0) throw new Error(errors.join('; '));
|
|
});
|
|
});
|
|
|
|
test.describe('F4c post-epic: transferable-royalty UI', () => {
|
|
test('transfer royalty action popover and selection flow', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// Open rules drawer
|
|
await page.locator('[data-action="play-solo"]').click();
|
|
await page.waitForURL('**/game');
|
|
|
|
// Open the drawer
|
|
await page.locator('[data-action="open-rules-drawer"]').click();
|
|
await expect(page.locator('[data-testid="rules-drawer"]')).toBeVisible();
|
|
|
|
// Toggle Transferable Royalty ON
|
|
const trToggle = page.locator('[data-preset="transferable-royalty"] [data-role="toggle"]');
|
|
await expect(trToggle).toBeVisible();
|
|
await trToggle.click();
|
|
await page.waitForTimeout(120);
|
|
|
|
// Close drawer
|
|
await page.locator('[data-action="close-rules-drawer"]').click();
|
|
await expect(page.locator('[data-testid="rules-drawer"]')).not.toBeVisible();
|
|
|
|
// Ensure game has started and is on white's turn
|
|
const turnIndicator = page.getByTestId('turn-indicator');
|
|
await expect(turnIndicator).toHaveText("White's turn");
|
|
|
|
// 3. Assert "Actions" button is visible
|
|
const actionsMenuBtn = page.getByTestId('actions-menu-button');
|
|
await expect(actionsMenuBtn).toBeVisible();
|
|
|
|
// 4. Click "Actions" -> popover opens
|
|
await actionsMenuBtn.click();
|
|
|
|
// 5. Click "Transfer royalty..." item
|
|
const transferBtn = page.getByTestId('action-transfer-royalty');
|
|
await expect(transferBtn).toBeVisible();
|
|
await transferBtn.click();
|
|
|
|
// 6. Hint card appears: "Select the piece whose royalty you want to transfer."
|
|
await expect(page.getByText('Select the piece whose royalty you want to transfer.')).toBeVisible();
|
|
|
|
// 7. Click the white king (e1).
|
|
await page.locator('[data-square="e1"]').click();
|
|
|
|
// 8. Hint updates: "Select the new royal piece."
|
|
await expect(page.getByText('Select the new royal piece.')).toBeVisible();
|
|
|
|
// 9. Click a friendly piece (e.g., the queen at d1)
|
|
await page.locator('[data-square="d1"]').click();
|
|
|
|
// 10. Board state updates: Turn flipped (meaning the action was accepted and consumed the turn)
|
|
await expect(turnIndicator).toHaveText("Black's turn");
|
|
|
|
// 11. "Transfer royalty..." is now greyed out (already used this game by White)
|
|
// Black's turn: click actions -> should be enabled for Black since Black hasn't used it.
|
|
await actionsMenuBtn.click();
|
|
const transferBtn2 = page.getByTestId('action-transfer-royalty');
|
|
await expect(transferBtn2).toBeVisible();
|
|
await expect(transferBtn2).toBeEnabled();
|
|
|
|
// Play a move for Black to get back to White
|
|
await drag(page, 'e7', 'e5');
|
|
|
|
await expect(turnIndicator).toHaveText("White's turn");
|
|
|
|
// Now White's action should be disabled
|
|
await actionsMenuBtn.click();
|
|
const transferBtn3 = page.getByTestId('action-transfer-royalty');
|
|
await expect(transferBtn3).toBeVisible();
|
|
await expect(transferBtn3).toBeDisabled();
|
|
await expect(page.getByText('Used this game')).toBeVisible();
|
|
});
|
|
});
|
|
|