Files
scrabble-game/ui/e2e/quickmatch.spec.ts
T
Ilia Denisov 4adb608ad1
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI
  game has no comms at all, so its 💬 history-header entry is hidden too.
- Confirm-move : redone as an absolute overlay pinned to the right edge (its
  right edge sits under the preview caption), so the rack keeps a fixed tile
  size — this reverts the tile-shrink that resized the letters at 6 tiles.
- Recall: dragging a placed tile back to the rack now drops it at the slot the
  pointer is over (drag-to-position, a gap opens), instead of snapping to its
  original slot; double-tap still recalls to the origin. New pure recallToSlot.

Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e
updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
2026-06-19 09:54:30 +02:00

148 lines
8.8 KiB
TypeScript

import { expect, test } from './fixtures';
// The quick-match flow drops the player straight into a game that is still waiting for an
// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is
// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven
// entirely by the mock transport (no backend).
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
// Choose a random human opponent (the default is the AI); pick a variant and start. The player
// lands in an open game at once (no "searching" screen).
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
// history panel) is disabled.
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await page.locator('.scoreboard').click(); // open the history panel
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
// Attach the opponent deterministically (the mock otherwise joins on a timer).
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
});
// The honest-AI quick game is the default opponent: the player starts a game already seated with a
// robot shown as 🤖, with no "searching" wait. Chat and nudge are disabled (the opponent is a robot),
// add-friend is never offered, and the dictionary word-check stays available. Mock transport only.
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click();
// AI is the default opponent: the move-clock line is replaced by the 7-day inactivity rule.
await expect(page.getByText(/Loss after 7 days of inactivity/)).toBeVisible();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
// The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖.
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
// An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary
// alone, which stays usable.
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub
await expect(page).toHaveURL(/\/game\/.+\/chat$/);
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab
await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts
await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface
});
// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all
// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms
// entry are gone. Start an AI game, resign it, reopen the history and check the header.
test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click();
await page.locator('.variant').first().click(); // AI is the default opponent
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
// Resign the practice game from the history header.
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Drop game' }).click();
await page.locator('button.danger').click();
await expect(page.locator('.status .over')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// no comms entry at all.
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0);
});
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live
// stream is down is lost. The waiting game screen recovers it without a push: a poll while the
// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops /
// restores the stream; while it is dropped the mock has no subscriber, so joinOpponent emits to
// no one — exactly the missed-event case.
async function enterOpenGame(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
}
test('quick game: a poll recovers a join missed while the live stream is down', async ({ page }) => {
await enterOpenGame(page);
// Drop the stream (no auto-reconnect), then let the opponent join: the push reaches no one, so
// only the poll fallback can restore the UI.
await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop());
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
});
test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => {
await enterOpenGame(page);
// The opponent joins while the stream is down (the push is lost), then it reconnects before the
// poll could tick — the reconnect refetch is what catches up.
await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop());
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore());
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
});
test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => {
await enterOpenGame(page);
// The opponent joins but the event is shed with the stream still alive (no reconnect, and the
// poll only runs while the stream is down): nothing has recovered yet.
await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently());
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
// Returning to the foreground resyncs the open game (pageshow drives goForeground).
await page.evaluate(() => window.dispatchEvent(new Event('pageshow')));
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
});
// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked
// the `view` it also writes, so opponent_joined re-invalidated itself in a tight loop (opponent_moved
// was spared only by its delta's move-count idempotency). The board must still respond afterwards.
test('quick game: the board stays interactive after the opponent joins', async ({ page }) => {
await enterOpenGame(page);
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// Place a rack tile on the centre star: a frozen screen renders no pending tile.
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // row 7, col 7
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
});