diff --git a/packages/chess/e2e/rule-variants.spec.ts b/packages/chess/e2e/rule-variants.spec.ts new file mode 100644 index 0000000..ce8de37 --- /dev/null +++ b/packages/chess/e2e/rule-variants.spec.ts @@ -0,0 +1,267 @@ +/** + * 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 { + 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 { + 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 { + 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('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('; ')); + }); +});