houserules/packages/chess/e2e/rule-variants.spec.ts

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();
});
});