ff486c80f8
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Per the owner's call, the idle-hint gate now PERSISTS across leaving and reopening the app, instead of the session-scoped monotonic clock: the unlock is a wall-clock instant (hintUnlockAtMs) stamped from the robot's reply, stored on the local record, carried on the game view + the opponent_moved move delta, and read through a sanitiser that caps it at now + the window. So: - the wait survives a relaunch (a stuck turn is not forgotten); - a device clock set BACK cannot freeze the gate (the cap bounds the remaining to the window and self-heals on the next read); - a clock set FORWARD just opens the hint early -- accepted as harmless for a solo game. - lib/hints.ts hintGateRemainingMs now takes the unlock instant + clamps to the window. - localgame: re-add hintUnlockAtMs to the record/meta; stamp it off the robot's reply; sanitise on read (a shared hintUnlock helper feeds stateView + the opponent_moved event). - gamedelta + PushEvent: the move delta carries hintUnlockAtMs so the view stays fresh. - Game.svelte: hintUnlockAt derives from the view; the tick + lock + toast unchanged. - offline.spec.ts: also assert the lock survives a reload (the wait persisted). - Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated (persist + sanitiser, not monotonic-resets).
94 lines
5.1 KiB
TypeScript
94 lines
5.1 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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/<id> 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);
|
|
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
|
// the wait was not reset by relaunching.
|
|
await expect(page.locator('.lock')).toBeVisible();
|
|
});
|
|
});
|