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: /New/ }).click(); // lobby tab bar -> auto-match // Pick a variant and start; the player lands in the game at once (no "searching" screen). 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 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 { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).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); });