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, 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: [], atGameLimit: false, offline: false }); // The player's own move flipped game "a" to the opponent's turn. patchLobbyGame(gameView('a', 'active', 1)); const snap = getLobby(false); 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, atGameLimit: false, offline: false }); patchLobbyGame(gameView('a', 'finished')); expect(getLobby(false)?.incoming).toEqual(incoming); expect(getLobby(false)?.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: [], atGameLimit: false, offline: false }); patchLobbyGame(gameView('z', 'active')); // Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership. expect(getLobby(false)?.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(false)).toBeNull(); }); it('does not mutate the previous snapshot array', () => { const games = [gameView('a', 'active', 0)]; setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false }); patchLobbyGame(gameView('a', 'active', 1)); expect(games[0].toMove).toBe(0); // the original array/object is left intact }); it('preserves the at-game-limit flag across a game patch', () => { // A finished-game patch arriving while the lobby is unmounted must not drop the flag — // the lobby renders the "New Game" button from the cached snapshot before the refresh. setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false }); patchLobbyGame(gameView('a', 'finished')); expect(getLobby(false)?.atGameLimit).toBe(true); }); }); describe('patchLobbyInvitation', () => { it('adds a new pending invitation to the cached lobby', () => { setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'pending')); expect(getLobby(false)?.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: [], atGameLimit: false, offline: false }); const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 }; patchLobbyInvitation(updated); expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); expect(getLobby(false)?.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: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', terminal)); expect(getLobby(false)?.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: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'declined')); expect(getLobby(false)?.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(false)).toBeNull(); }); it('preserves games and incoming when patching an invitation', () => { const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'pending')); expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']); expect(getLobby(false)?.incoming).toEqual(incoming); }); }); describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => { it('returns the snapshot only for the mode it was stored under', () => { setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false }); expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']); // Reading it as the OTHER (offline) mode must not surface the online snapshot. expect(getLobby(true)).toBeNull(); }); it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => { setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false }); setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true }); expect(getLobby(false)).toBeNull(); expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']); }); });