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 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s

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, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline 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.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
+9 -51
View File
@@ -1,8 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
import type { Variant } from './model';
import { clearOfflinePref, offlinePreloadEligible } from './offline';
// A minimal in-memory localStorage for the persistence tests (node has none).
// A minimal in-memory localStorage for the cleanup test (node has none).
beforeEach(() => {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
@@ -15,27 +14,7 @@ beforeEach(() => {
} as Storage;
});
describe('offline mode helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
describe('offline helpers', () => {
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
});
});
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
const never = (): Promise<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => Promise.resolve();
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
const run = Promise.resolve({ failed: [] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
});
it('is not ready when a variant is still missing after the fetch', async () => {
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
});
it('is not ready when the budget elapses before the fetch resolves', async () => {
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
localStorage.setItem('scrabble.offlineMode', '1');
clearOfflinePref();
expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
// Idempotent: a second call on an already-clear key is a no-op, not an error.
expect(() => clearOfflinePref()).not.toThrow();
});
});