feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s

Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README).

Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later.

Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit a8f78c7880
48 changed files with 1953 additions and 973 deletions
+71 -27
View File
@@ -1,8 +1,9 @@
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.
// Offline is now implicit — the net-state machine detects it; there is no toggle. The e2e drives the
// machine through the mock's __net hook (the real probe watcher is inert under the mock). Standalone
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA; only the `(display-mode: standalone)` query is overridden.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
@@ -15,25 +16,30 @@ async function forceStandalone(page: Page): Promise<void> {
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
// this deterministic.
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).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).
// robust to locale, emoji variation selectors and the coachmark anchors.
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).
// Drive the net-state machine offline / online through the mock hook (no toggle, no dialog).
async function goOffline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
}
async function goOnline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
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 }) => {
test.describe('offline mode (implicit)', () => {
test('auto-detects offline, plays a local vs_ai game, persists across a reload', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
@@ -41,23 +47,22 @@ test.describe('offline mode', () => {
// 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();
// The network drops: the machine auto-detects offline (no toggle, no dialog) — a toast announces it
// and the header turns blue with the chip.
await goOffline(page);
await expect(page.locator('.toast')).toContainText(/offline|соединени/i);
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);
// The (now offline) lobby stays mounted: the server game (vs Ann) rides along GREYED from the cache
// (un-openable), and the Stats tab disables.
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
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.
// 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();
@@ -72,8 +77,8 @@ test.describe('offline mode', () => {
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.
// The play commits and the local robot replies with a real move (which needs the bundled
// dictionary), 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 });
@@ -83,13 +88,52 @@ test.describe('offline mode', () => {
// 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.
// 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.
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload.
await expect(page.locator('.lock')).toBeVisible();
});
test('self-heals to online when the network returns', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Offline, then the network returns: the machine heals to online on its own (a "back online" toast),
// the offline tint drops and the online games return — no user action.
await goOffline(page);
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
await goOnline(page);
await expect(page.locator('header.nav.offline')).toHaveCount(0, { timeout: 15000 });
// Back online, the server game is active again (no longer greyed).
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
});
test('a stale deliberate-offline pref is ignored and cleared; Settings has no toggle', async ({ page }) => {
await forceStandalone(page);
// Seed a pre-redesign deliberate-offline flag: offline is implicit now, so it must be ignored
// (nobody stuck offline) and cleared on boot.
await page.addInitScript(() => localStorage.setItem('scrabble.offlineMode', '1'));
await page.goto('/');
await enterLobby(page);
// Booted online despite the stale pref: the online game (vs Ann) shows and the header is not blue.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('header.nav.offline')).toHaveCount(0);
// The orphaned key was cleared at boot.
expect(await page.evaluate(() => localStorage.getItem('scrabble.offlineMode'))).toBeNull();
// Settings no longer offers the offline toggle (the "Play mode" Online/Offline segment is gone),
// even in the eligible (standalone + email) context where it used to appear.
await page.locator('button.tab').nth(2).click();
await expect(page.getByRole('button', { name: /^(Offline|Оффлайн)$/ })).toHaveCount(0);
});
});