import { test, expect } from '@playwright/test'; test.describe('Solo-play smoke (T2 preview tests)', () => { 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('play-solo button navigates to /game with fresh board', 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()}`); }); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game', { timeout: 5000 }); await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 }); await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible(); if (errors.length > 0) throw new Error(errors.join('; ')); }); test('drag pawn e2 to e4 resolves', async ({ page }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page .locator('[data-square="e2"] [data-piece]') .dragTo(page.locator('[data-square="e4"]')); await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 }); await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0); }); test('solo play with modifier profile renders modifier-indicator dots', async ({ page }) => { const LIBRARY_KEY = 'houserules:modifier-profiles:v1'; const entry = { id: 'solo-smoke-indicator', name: 'Solo Smoke Indicator', profile: { id: 'solo-smoke-indicator', name: 'Solo Smoke Indicator', description: '', layoutId: 'classic', perType: [ { kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 }, ], perInstance: [], version: 1, source: 'custom', }, starred: false, updatedAt: Date.now(), }; await page.goto('/'); await page.evaluate( ({ key, e }) => localStorage.setItem(key, JSON.stringify([e])), { key: LIBRARY_KEY, e: entry }, ); await page.reload(); const picker = page.getByTestId('profile-picker'); if (!(await picker.isVisible())) { await page.locator('[data-action="open-rules-drawer"]').click(); } await expect(picker).toBeVisible(); await picker.selectOption('solo-smoke-indicator'); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game', { timeout: 5000 }); await expect(page.locator('[data-testid^="modifier-indicator-"]').first()).toBeVisible({ timeout: 3000, }); }); test('can play multiple moves in sequence (4-ply)', async ({ page }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); const drag = async (from: string, to: string) => { await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`)); // small wait for animation / state update await page.waitForTimeout(100); }; await drag('e2', 'e4'); await drag('e7', 'e5'); await drag('g1', 'f3'); await drag('b8', 'c6'); // After 4 plies: e4, e5, f3, c6 all occupied; origins empty await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible(); await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible(); await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible(); await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible(); }); test('rules drawer opens without breaking board interaction', async ({ page }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.locator('[data-action="open-rules-drawer"]').click(); await expect(page.getByTestId('rules-drawer')).toBeVisible(); // Close drawer (esc or similar) and verify board still works await page.keyboard.press('Escape'); await page.waitForTimeout(300); // Drag a move await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]')); await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 }); }); test('no console errors on solo game start', 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()}`); }); await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.waitForTimeout(1000); // let any async init complete await expect(page.locator('[data-square="e2"]')).toBeVisible(); if (errors.length > 0) { console.log('Errors:', errors); throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`); } }); // T2 regression — clicking the drawer backdrop closes the drawer AND // leaves the board interactive afterwards. Regression guard for a // stuck-overlay bug where the backdrop `pointer-events-none` timing // wasn't cleared, silently blocking drag-to-move. test('rules drawer: backdrop click closes drawer and restores board interactivity', async ({ page }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.locator('[data-action="open-rules-drawer"]').click(); await expect(page.getByTestId('rules-drawer')).toBeVisible(); // Click the far-left edge of the viewport — the drawer itself // sits on the right (`fixed top-0 right-0 max-w-md`), so x=50 is // guaranteed to land on the backdrop overlay, not the drawer. await page.mouse.click(50, 300); await expect(page.getByTestId('rules-drawer')).not.toBeVisible({ timeout: 1000 }); // Board must be interactive: drag e2→e4 and see the pawn land on e4. // Any stuck-overlay regression shows up here as a silent no-op drag. await page .locator('[data-square="e2"] [data-piece]') .dragTo(page.locator('[data-square="e4"]')); await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000, }); }); // T2 regression — nested-modal Esc ordering. With BOTH the rules // drawer AND the modifier editor open, a single Esc must close the // editor ONLY; the drawer stays. A second Esc then closes the // drawer. This is enforced by ModifierProfileEditor installing its // keydown handler in the capture phase with `stopImmediatePropagation`, // so the drawer's window-level Esc handler never fires on the same // keystroke. Without that guard both would close, leaving the board // behind a briefly-lingering pointer-events-blocking backdrop. test('modifier editor: Esc closes editor first, drawer stays open; 2nd Esc closes drawer', async ({ page }) => { await page.locator('[data-action="play-solo"]').click(); await page.waitForURL('**/game'); await page.locator('[data-action="open-rules-drawer"]').click(); await page.locator('[data-testid="open-modifier-editor"]').click(); await expect(page.getByTestId('modifier-editor-modal')).toBeVisible(); // First Esc: editor closes; drawer still visible. // // The editor's capture-phase keydown listener handles this event // with stopImmediatePropagation(), so the drawer's bubble-phase // listener is suppressed. Editor unmounts → its useEffect cleanup // removes the listener, BUT that cleanup runs during React's // commit phase — not synchronously after the state update. A // too-fast second Esc can still hit the stale editor listener // before it has fully detached. The small wait below lets // React complete its commit so the second Esc sees only the // drawer's listener. await page.keyboard.press('Escape'); await expect(page.getByTestId('modifier-editor-modal')).not.toBeVisible({ timeout: 500, }); await expect(page.getByTestId('rules-drawer')).toBeVisible(); // Second Esc: drawer closes. (See note above re: the wait.) // // The drawer exits via a framer-motion spring animation that // takes ~300–500ms to fully unmount the `