diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts new file mode 100644 index 0000000..d89e43c --- /dev/null +++ b/ui/e2e/hotseat.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, type Page } from './fixtures'; + +// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed +// standalone PWA with a confirmed email. The mock account has an email; force standalone so the +// Settings offline toggle shows. +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; + }); +} + +async function enterLobby(page: Page): Promise { + await page.getByRole('button', { name: /guest|гост/i }).first().click(); + await expect(page.locator('button.tab').nth(2)).toBeVisible(); +} + +async function goOffline(page: Page): Promise { + await page.locator('button.tab').nth(2).click(); // Settings + await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click(); + await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /Back|Назад/i }).click(); + await expect(page.locator('button.tab').nth(2)).toBeVisible(); +} + +// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action). +async function typePin(page: Page, digits: string): Promise { + for (const d of digits) await page.locator('.pad .key', { hasText: d }).click(); +} + +test.describe('offline hotseat (pass-and-play)', () => { + test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => { + await forceStandalone(page); + await page.goto('/'); + await enterLobby(page); + await goOffline(page); + + // A known bag seed so the locked seat deals a full rack deterministically. + await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); + + // New game -> the offline mode selector now offers "with friends" (hotseat). + await page.locator('button.tab').nth(0).click(); + await page.getByRole('button', { name: /With friends|друзьями/i }).click(); + + // The player rows are disabled until the mandatory host (master) PIN is set. + await expect(page.locator('.pname').first()).toBeDisabled(); + + // Set the host PIN (enter + confirm), then decline taking a seat. + await page.locator('.hostpin .plink').click(); + await typePin(page, '9999'); + await typePin(page, '9999'); + await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); + + // Pick the English variant if the picker offers a choice. + const variant = page.locator('.fg select').first(); + if (await variant.isEnabled()) await variant.selectOption('scrabble_en'); + + // Two players: Ann (PIN-locked) and Bob (open). + await page.locator('.pname').nth(0).fill('Ann'); + await page.locator('.pname').nth(1).fill('Bob'); + await page.locator('.prow').nth(0).locator('.plink').click(); // Ann's seat PIN + await typePin(page, '1234'); + await typePin(page, '1234'); + + await page.locator('button.invite').click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + + // Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock. + await expect(page.locator('.unlock')).toBeVisible(); + await expect(page.locator('.rack .tile')).toHaveCount(0); + + // A wrong PIN keeps it locked; the right PIN reveals the full rack. + await page.locator('.unlock').click(); + await typePin(page, '0000'); + await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN + await typePin(page, '1234'); + await expect(page.locator('.rack .tile')).toHaveCount(7); + + // Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob, + // whose seat is open, so his rack shows without a lock. + await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click(); + await typePin(page, '9999'); + await page.getByRole('button', { name: /Skip|Пропустить/ }).click(); + await page.getByRole('button', { name: /^(OK|ОК)$/ }).click(); + await expect(page.locator('.unlock')).toHaveCount(0); + await expect(page.locator('.rack .tile')).toHaveCount(7); + + // Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN. + await page.getByRole('button', { name: /Back|Назад/i }).click(); + await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); + await expect(page.locator('.rowwrap')).toHaveCount(1); + await page.locator('.kebab').first().click(); + const del = page.locator('.rowwrap.revealed .del'); + await expect(del).toBeVisible(); + await del.click(); + await typePin(page, '9999'); + await expect(page.locator('.rowwrap')).toHaveCount(0); + }); +}); diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 7b5d502..78b92aa 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -230,15 +230,18 @@ return; } const id = newLocalGameId(); + // Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy + // fails structured-clone when the record is persisted to IndexedDB (silently, since the store + // is best-effort) — so the game would vanish on reload. See lib/localgame/store. await localSource.create({ id, variant: inviteVariant, dictVersion: version, seed: randomSeed(), multipleWords: multipleWordsForRequest(inviteVariant, multipleWords), - seats: buildSeats(rows), + seats: $state.snapshot(buildSeats(rows)), hotseat: true, - hostPin, + hostPin: hostPin ? $state.snapshot(hostPin) : undefined, }); navigate(`/game/${id}`); } catch (e) {