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(); (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 => new Promise(() => {}); // A sleep that elapses immediately: the budget always wins the race, exercising the timeout. const now = (): Promise => 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); }); });