import { expect, test, type Page } from './fixtures'; // Behaviour/display coverage for the polished game screen, driven entirely by the mock // transport (no backend). These lock the round-1..4 interactions so future UI edits // surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them // (placement, check-word, board labels, result badges) is unit-tested separately. async function openGame(page: Page): Promise { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await expect(page.locator('[data-cell]').first()).toBeVisible(); // Wait for the screen-slide transition to settle so only the game pane remains; until it // does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous. await expect(page.locator('.pane')).toHaveCount(1); } test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => { await openGame(page); // The exchange/draw tab is a server action; on my turn with tiles in the bag it is live. const draw = page.locator('.tab').first(); await expect(draw).toBeEnabled(); await expect(page.getByText('Connecting…')).toHaveCount(0); // Drop the connection (mock-only hook): the header swaps the title for the spinner + // "Connecting…", and the server action goes inert. await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline()); await expect(page.getByText('Connecting…')).toBeVisible(); await expect(draw).toBeDisabled(); // Reconnect: the indicator clears and the action is live again. await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online()); await expect(page.getByText('Connecting…')).toHaveCount(0); await expect(draw).toBeEnabled(); }); test('placing a tile and confirming via ✅ commits the move', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); await page.locator('.make').click(); // ✅ commits the move directly (no popover) // After the commit the placement is cleared: no pending tile, no ✅ control. await expect(page.locator('[data-cell].pending')).toHaveCount(0); await expect(page.locator('.make')).toBeHidden(); }); test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); await page.waitForTimeout(600); // let the debounced draft save flush to the mock store // Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is // restored without re-placing it. await page.evaluate(() => (location.hash = '/')); await page.getByRole('button', { name: /Ann/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); }); test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); // A single tap must NOT recall it (recall was too easy to trigger). await page.waitForTimeout(350); // clear the double-tap window from the placing tap await page.locator('[data-cell].pending').first().click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); // A double-tap (two synchronous clicks on the same cell) recalls it to the rack. await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => { el.click(); el.click(); }); await expect(page.locator('[data-cell].pending')).toHaveCount(0); }); test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => { await openGame(page); await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default await page.evaluate(() => (location.hash = '/settings')); await page.locator('.gridlines input').check(); // turn grid lines on await page.evaluate(() => (location.hash = '/game/g1')); await expect(page.locator('.grid')).toBeVisible(); await expect(page.locator('.grid.gridless')).toHaveCount(0); }); test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => { await openGame(page); const before = await page.locator('.rack .tile').allTextContents(); expect(before.length).toBeGreaterThan(1); await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles) await page.waitForTimeout(650); // let the hop animation settle // Same multiset of tiles after the shuffle — no tile is dropped or duplicated. const after = await page.locator('.rack .tile').allTextContents(); expect([...after].sort()).toEqual([...before].sort()); }); test('history slides the board down on a score tap and closes on a board tap', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // tapping the score bar opens the history await expect(page.locator('.history')).toBeVisible(); await expect(page.locator('.boardwrap.slid')).toBeVisible(); // Tap the (now inert) board's visible top strip below the history to close it. await page.locator('.boardwrap').click({ position: { x: 30, y: 12 } }); await expect(page.locator('.history')).toBeHidden(); }); test('history: a swipe-up close does not make a follow-up score tap jump', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); await expect(page.locator('.boardwrap.slid')).toBeVisible(); // Swipe up on the inert board to close it. Dispatched on the board so the gesture is // engine-deterministic — a mouse swipe over a pointer-events:none child is simulated // inconsistently across engines, whereas a real touch swipe lands the same way this does. const bw = page.locator('.boardwrap'); const box = (await bw.boundingBox())!; const cx = box.x + box.width / 2; await bw.dispatchEvent('pointerdown', { clientX: cx, clientY: 500, bubbles: true }); await bw.dispatchEvent('pointermove', { clientX: cx, clientY: 450, bubbles: true }); await expect(page.locator('.history')).toBeHidden(); // A score tap now cleanly reopens the history — the stale-open "jump" no longer happens. await page.locator('.scoreboard').click(); await expect(page.locator('.history')).toBeVisible(); await expect(page.locator('.boardwrap.slid')).toBeVisible(); }); test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => { await openGame(page); await page.locator('button:has-text("🔄")').click(); // Draw tab await expect(page.locator('.exch')).toBeVisible(); await page.locator('.etile').first().click(); await expect(page.locator('.etile.sel')).toHaveCount(1); await page.locator('button.confirm').click(); await expect(page.locator('.exch')).toBeHidden(); }); test('pass confirms with a tap on the fading ✅ instead of a popup', async ({ page }) => { await openGame(page); const pass = page.getByRole('button', { name: 'Skip' }); // the 🥺 tab (aria-label) await expect(pass).toBeEnabled(); await pass.click(); // arm: 🥺 -> a fading ✅ await pass.click(); // tap the ✅ to confirm within the window // The pass hands the turn over, so the control goes inert. await expect(pass).toBeDisabled(); }); test('check-word sanitises input and shows a verdict', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub await expect(page.locator('.pane')).toHaveCount(1); await page.getByRole('button', { name: 'Dictionary' }).click(); // 🔎 -> dictionary tab const input = page.locator('.check input'); await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased await expect(input).toHaveValue('QZA'); await page.locator('.check button').click(); // Check (enabled: length 3) await expect(page.locator('.ok, .bad')).toBeVisible(); }); test('dropping the game ends it and shows the result', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header await page.locator('button.danger').click(); // confirm in the modal await expect(page.locator('.status .over')).toBeVisible(); }); test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); const pending = page.locator('[data-cell].pending'); await expect(pending).toHaveCount(1); const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45); const fb = await pending.first().boundingBox(); const tb = await target.boundingBox(); // Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events). await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); await page.mouse.down(); await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 }); await page.mouse.up(); // Still exactly one pending tile (relocated, not duplicated), now at a different cell. await expect(pending).toHaveCount(1); const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; expect(to).not.toBe(from); }); test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header await expect(page).toHaveURL(/\/game\/g1\/chat$/); await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle await expect(page.locator('.chat')).toBeVisible(); // The Dictionary tab switches in place (same screen, no navigation). await page.getByRole('button', { name: 'Dictionary' }).click(); await expect(page.locator('.check input')).toBeVisible(); // The header back chevron returns to the game. await expect(page.locator('.back')).toHaveCount(1); await page.locator('.back').click(); await expect(page).toHaveURL(/\/game\/g1$/); await expect(page.locator('.pane')).toHaveCount(1); }); test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { await openGame(page); // beginner (default) renders split "3× / word" labels. await expect(page.locator('.bsplit').first()).toBeVisible(); await expect(page.locator('.b1')).toHaveCount(0); // Switch to "classic" in Settings (in-SPA hash nav keeps the guest session). await page.evaluate(() => (location.hash = '/settings')); await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic await page.evaluate(() => (location.hash = '/game/g1')); // classic renders single "3W"/"2L" labels and no split labels. await expect(page.locator('.b1').first()).toBeVisible(); await expect(page.locator('.bsplit')).toHaveCount(0); });