feat(offline): offline lobby lists + creates local vs_ai games
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 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
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 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
In offline mode the lobby now shows only the device-local games and its New-vs-AI entry creates one through the in-browser engine — the visible payoff of the offline mode. - LocalSource.list() reconstructs a lobby GameView per stored local game by replay, exposed through the lazy gamesource proxy; unit-tested via an in-memory store. - Lobby.load() branches on offlineMode: lists local games and skips every gateway call (no online games/invitations/incoming); the Stats tab is disabled offline. - NewGame offline: find() creates a device-local vs_ai game via LocalSource.create using the profile's advertised dict version + a local seed; the friends flow and the random-opponent option are hidden, and the variant picker / Start are enabled offline (were gated on connection). - id.ts: newLocalGameId + randomSeed (tested). Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section. Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance gating, the flip-to-offline readiness wait, the offline mock e2e (needs mock-dawg support), and the local-hint UI. The offline flow is verified on the test contour — the mock e2e cannot enter offline mode (the toggle is gated to an installed PWA).
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { LocalGameRecord } from './serialize';
|
||||
|
||||
// An in-memory stand-in for the IndexedDB store, so list() — which reads the *persisted* records,
|
||||
// not the source's in-memory cache — can be exercised in the node env (the real store is a
|
||||
// best-effort no-op without IndexedDB). Declared before the vi.mock factory that closes over it.
|
||||
const store = new Map<string, LocalGameRecord>();
|
||||
vi.mock('./store', () => ({
|
||||
saveLocalGame: async (r: LocalGameRecord) => void store.set(r.id, r),
|
||||
getLocalGame: async (id: string) => store.get(id) ?? null,
|
||||
listLocalGames: async () => [...store.values()],
|
||||
deleteLocalGame: async (id: string) => void store.delete(id),
|
||||
}));
|
||||
vi.mock('../dict', () => ({
|
||||
getDawg: async () => {
|
||||
const { Dawg } = await import('../dict/dawg');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||
},
|
||||
}));
|
||||
|
||||
import { LocalSource } from './source';
|
||||
import type { Seat } from './serialize';
|
||||
|
||||
const seats: Seat[] = [
|
||||
{ kind: 'human', name: 'You' },
|
||||
{ kind: 'robot', name: 'Robot' },
|
||||
];
|
||||
const opts = (id: string, seed: bigint) => ({
|
||||
id,
|
||||
variant: 'scrabble_en' as const,
|
||||
dictVersion: 'sample',
|
||||
seed,
|
||||
multipleWords: true,
|
||||
seats,
|
||||
});
|
||||
|
||||
beforeEach(() => store.clear());
|
||||
|
||||
describe('LocalSource.list', () => {
|
||||
it('is empty when no games are stored', async () => {
|
||||
expect(await new LocalSource().list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a lobby GameView for every stored local game', async () => {
|
||||
const src = new LocalSource();
|
||||
await src.create(opts('local:a', 1n));
|
||||
await src.create(opts('local:b', 2n));
|
||||
const list = await src.list();
|
||||
expect(new Set(list.map((g) => g.id))).toEqual(new Set(['local:a', 'local:b']));
|
||||
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
|
||||
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
|
||||
await new LocalSource().create(opts('local:old', 7n));
|
||||
const list = await new LocalSource().list();
|
||||
expect(list.map((g) => g.id)).toEqual(['local:old']);
|
||||
expect(list[0].variant).toBe('scrabble_en');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user