import { test, expect, type Page } from './fixtures'; // The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account // has an email; force the standalone display mode so the Settings offline toggle is offered. Only the // `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through. async function forceStandalone(page: Page): Promise { await page.addInitScript(() => { const orig = window.matchMedia.bind(window); window.matchMedia = (q: string) => (q.includes('display-mode: standalone') ? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false } : orig(q)) as MediaQueryList; }); } // After a load or reload, land in the lobby (dismiss the login screen if it is shown). async function enterLobby(page: Page): Promise { const guest = page.getByRole('button', { name: /guest|гост/i }); if (await guest.count()) await guest.first().click(); // The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is // robust to locale, emoji variation selectors and the coachmark anchors (which the first-run // onboarding strips after it completes). await expect(page.locator('button.tab').nth(2)).toBeVisible(); } // Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has // all-distinct letters, so the glyph is unambiguous). async function placeTile(page: Page, glyph: string, row: number, col: number): Promise { await page.locator('.rack .tile', { hasText: glyph }).first().click(); await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click(); } test.describe('offline mode', () => { test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => { await forceStandalone(page); await page.goto('/'); await enterLobby(page); // Online: a seeded online game (vs Ann) is listed. await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); // Enter offline through the real Settings toggle (its readiness check fetches every enabled // variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip. await page.locator('button.tab').nth(2).click(); await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click(); await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); // Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled. await page.getByRole('button', { name: /Back|Назад/i }).click(); await expect(page.locator('button.tab').nth(2)).toBeVisible(); await expect(page.getByText('Ann', { exact: false })).toHaveCount(0); await expect(page.locator('button.tab').nth(1)).toBeDisabled(); // Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.locator('button.tab').nth(0).click(); // The English variant's display name is "Scrabble" (Latin) in both locales — the Russian // variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game. await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('button.invite').click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); // The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge). await expect(page.locator('.lock')).toHaveCount(0); // The human plays WAY horizontally across the centre (7,5)-(7,7). await placeTile(page, 'W', 7, 5); await placeTile(page, 'A', 7, 6); await placeTile(page, 'Y', 7, 7); await expect(page.locator('[data-cell].pending')).toHaveCount(3); await page.locator('.make').click(); // The play commits and the local robot replies with a real move, so the board carries more than // WAY's three tiles. await expect(async () => { expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3); }).toPass({ timeout: 15000 }); const filled = await page.locator('[data-cell].filled').count(); // Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint // button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here). await expect(page.locator('.lock')).toBeVisible(); // Reload: the hash router restores the /game/ route and the local game replays from // IndexedDB with every committed tile intact. await page.reload(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell].filled')).toHaveCount(filled); }); });