Files
scrabble-game/ui/e2e/game.spec.ts
T
Ilia Denisov 70110effd9
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Chat + word-check as their own screens; in-game unread badge (review item 7)
- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
  header back to the game and no tab-bar, replacing their modals. The soft keyboard just
  resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
  does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
  relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
  (per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.

Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
2026-06-08 23:23:05 +02:00

199 lines
9.3 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's header (its menu button) is also in the
// DOM, which would make shared locators like .burger ambiguous.
await expect(page.locator('.pane')).toHaveCount(1);
}
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 (Stage 17 #4/#6).
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 (changed in Stage 17 — 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 and closes on a board tap', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(0).click(); // History
await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
await page.locator('.boardwrap').click(); // tapping the board closes it
await expect(page.locator('.history')).toBeHidden();
});
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('check-word sanitises input and shows a verdict', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(2).click(); // Check word
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('.burger').click();
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
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 (Stage 17 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('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.getByRole('button', { name: /^Chat$/ }).click();
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();
await page.locator('.back').click(); // header back chevron returns to the game
await expect(page).toHaveURL(/\/game\/g1$/);
await expect(page.locator('.pane')).toHaveCount(1);
await page.locator('.burger').click();
await page.getByRole('button', { name: /Check word/ }).click();
await expect(page).toHaveURL(/\/game\/g1\/check$/);
await expect(page.locator('.pane')).toHaveCount(1);
await expect(page.locator('.check input')).toBeVisible();
});
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);
});