Files
scrabble-game/ui/e2e/game.spec.ts
T
Ilia Denisov 74455c7b12
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
feat: "multiple words per turn" rule for Russian games
Add a per-game rule chosen on New Game for Russian variants (default off = the
single-word rule; on = standard Scrabble). Off, only the main word along the play
direction is validated and scored; perpendicular cross-words are ignored,
including in robot move generation. The rule rides every create and enqueue
request and joins the matchmaking key, so games and auto-match stay one uniform
path; "Russian-only" is a UI affordance (English always sends standard and shows
no toggle).

- Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded
  through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score
  and generate.
- Backend: thread the flag through game CreateParams/Game + store (games column),
  lobby InvitationSettings + invitation row, and the matchmaker queue key (variant
  + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration
  gains multiple_words_per_turn (DB not versioned); jet regenerated.
- Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest
  FlatBuffers tables (Go + TS regenerated) and threaded through the gateway.
- UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants
  only (auto-match and friend invite), default off; English silently sends standard.
- Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver
  corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.
2026-06-12 02:17:30 +02:00

262 lines
13 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/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('new game: Russian games offer the "multiple words per turn" toggle, off by default', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // auto-match
// The mock session supports Russian, so the single-word-rule toggle is offered and starts off.
const toggle = page.getByLabel('Multiple words per turn');
await expect(toggle).toBeVisible();
await expect(toggle).not.toBeChecked();
await toggle.check();
await expect(toggle).toBeChecked();
});
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);
});