Files
scrabble-game/ui/src/lib/lobbycache.test.ts
T
Ilia Denisov a8f78c7880
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
feat(offline): implicit net-state model, two-tier version gate, unified lobby
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).
2026-07-13 01:51:32 +02:00

135 lines
5.3 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache';
import type { AccountRef, GameView, Invitation } from './model';
function invitation(id: string, status: string): Invitation {
return {
id,
inviter: { accountId: 'inv', displayName: 'Inv' },
invitees: [],
variant: 'scrabble_en',
turnTimeoutSecs: 300,
hintsAllowed: true,
hintsPerPlayer: 0,
multipleWordsPerTurn: true,
dropoutTiles: 'remove',
status,
gameId: '',
expiresAtUnix: 0,
};
}
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: 2,
toMove,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats: [],
};
}
beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
});
it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a'));
expect(getLobby()).toBeNull();
});
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
});
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
});
it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()).toBeNull();
});
it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
});
});
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
});
});