1cc6c0d56e
The in-game live-event $effect read `view`/`moves`/`placement` (via cacheSnapshot and direct reads) AND wrote `view` in its branches, so those reads became the effect's own dependencies: writing `view = …` re-ran the effect, and with app.lastEvent unchanged it re-entered the same branch and wrote `view` again — a tight self-invalidating loop that pinned the main thread, freezing the board and rack. opponent_moved escaped it only because applyMoveDelta is idempotent on the move count (no cache → no write on the second pass); opponent_joined and game_over have no such guard, so an opponent joining hung the whole screen. Tracking `placement` similarly re-fired the handler on every tile the player placed after an opponent's move (a spurious reload). Fix: the effect must depend only on app.lastEvent and process each event once. Wrap the branch body in `untrack`, scoping its view/moves/placement reads out of the effect's dependency set; the writes inside no longer re-trigger it. e2e: after the opponent joins, placing a rack tile must render a pending tile — verified RED (frozen, 0 pending tiles, both engines) before the fix, GREEN after.
93 lines
5.6 KiB
TypeScript
93 lines
5.6 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: /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<void> {
|
|
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);
|
|
});
|