Files
scrabble-game/ui/e2e/game.spec.ts
T
Ilia Denisov a4e6727c70
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab
Lobby: remove the inert 🏆 Tournaments tab (it only raised a 'coming soon' toast); the lobby is back to three tabs, matching docs/FUNCTIONAL.md.

Game: fold the separate 🥺 Skip (pass) tab into the 🔄 tab, now Exchange/Pass. Its dialog passes when no tile is selected (button 'Pass without exchanging') and exchanges when tiles are ('Exchange N'). The tab is no longer gated on an empty bag (pass must stay reachable in the endgame); inside the dialog tile selection is disabled while the bag is below a full rack (bagLen >= RACK_SIZE).

The merge is UI-only. A pass is NOT an exchange of zero tiles: the rules allow an exchange only with a full rack left in the bag and forbid a zero-tile swap, while a pass is always legal; GCG (Poslfit) writes a pass as a bare '-' and an exchange as '-TILES'. Pass and exchange stay distinct end-to-end (wire, engine, history/GCG); the dialog dispatches the existing gateway.pass / gateway.exchange. No backend/wire/history/GCG change.

Docs: docs/UI_DESIGN.md, docs/FUNCTIONAL.md (+_ru), PRERELEASE.md. Tests: ui/e2e/game.spec.ts.
2026-06-13 16:41:55 +02:00

291 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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/Pass tab is a server action; on my turn it is live (pass is always available,
// so unlike before it no longer goes inert when the bag empties).
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('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match
// Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown
// from the start (a Russian variant is available), so selecting one does not shift the layout.
const start = page.getByRole('button', { name: /Start game/i });
await expect(start).toBeDisabled();
const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked();
// Selecting the Russian Scrabble variant highlights it and enables Start; the toggle stays put.
await page.locator('.variant', { hasText: 'Скрэббл' }).click();
await expect(page.locator('.variant.selected')).toHaveCount(1);
await expect(start).toBeEnabled();
await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked();
});
test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
// g3 is a finished Russian single-word game (vs Rick); open it from the lobby.
await page.getByRole('button', { name: /Rick/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1);
// The status bar carries the small "1️⃣" indicator (single-word, nothing pending).
await expect(page.locator('.oneword')).toBeVisible();
// Tapping the scoreboard opens the history, whose header shows the spelled-out rule label.
await page.locator('.scoreboard').click();
await expect(page.locator('.oneword-label')).toBeVisible();
});
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('Exchange/Pass dialog: a selection exchanges, no selection offers a pass', async ({ page }) => {
await openGame(page);
await page.locator('button:has-text("🔄")').click(); // the merged Exchange/Pass tab
await expect(page.locator('.exch')).toBeVisible();
// With nothing selected the confirm button offers a pass; selecting tiles flips it to an
// exchange of that many (the two are distinct game actions behind one dialog).
const confirm = page.locator('button.confirm');
await expect(confirm).toHaveText('Pass without exchanging');
await page.locator('.etile').first().click();
await expect(page.locator('.etile.sel')).toHaveCount(1);
await expect(confirm).toHaveText('Exchange 1');
await confirm.click();
await expect(page.locator('.exch')).toBeHidden();
});
test('passing goes through the Exchange/Pass dialog with no tiles selected', async ({ page }) => {
await openGame(page);
const draw = page.locator('button:has-text("🔄")'); // the merged Exchange/Pass tab
await expect(draw).toBeEnabled();
await draw.click();
// No tiles selected -> the confirm button passes the turn (a pass, not an exchange of zero).
await page.locator('button.confirm').click();
await expect(page.locator('.exch')).toBeHidden();
// The pass hands the turn over, so the tab goes inert.
await expect(draw).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);
});