30770a759b
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 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m44s
Flipping the Settings toggle to offline now checks that every enabled variant's dictionary is on the device before entering offline mode: it fetches missing ones cache-first and waits up to ~5 s (raceOfflineReady + the lazy dict/offlineready), greying the toggle meanwhile. If they cannot be readied in time it stays online and shows a 'needs internet' note, while the fetch keeps warming the cache in the background so a later flip is instant. Leaving offline is never gated. Prevents entering a half-baked offline mode (no dawg -> cannot create/play a local game) when the background preload has not finished (poor connection, or an immediate flip right after install). - offline.ts: raceOfflineReady (pure, injected sleep; unit-tested red->green) - dict/offlineready.ts: ensureOfflineDicts (cache-first preloadDicts, lazy chunk) - offline.svelte.ts: requestOffline + TOGGLE_READY_BUDGET_MS - Settings.svelte: checking/needsData state, disabled toggle, inline note - i18n: settings.offlineChecking / settings.offlineNeedsData (en+ru) - docs: FUNCTIONAL(+_ru) offline story + ARCHITECTURE offline paragraph
78 lines
3.8 KiB
TypeScript
78 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
|
|
import type { Variant } from './model';
|
|
|
|
// A minimal in-memory localStorage for the persistence tests (node has none).
|
|
beforeEach(() => {
|
|
const store = new Map<string, string>();
|
|
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void store.set(k, v),
|
|
removeItem: (k: string) => void store.delete(k),
|
|
clear: () => store.clear(),
|
|
key: () => null,
|
|
length: 0,
|
|
} 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']);
|
|
});
|
|
|
|
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);
|
|
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
|
|
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
|
|
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
|
|
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
|
|
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);
|
|
});
|
|
});
|