Files
scrabble-game/ui/src/lib/lobbycache.test.ts
T
Ilia Denisov 359af83c34
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 1m29s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby
Two pre-existing offline bugs, both exposed once offline mode became usable (cold boot +
toggling), owner-reported on the contour.

Bug 1 -- offline tiles render weight 0. The rack/board read tile values from the
lib/alphabet cache, populated only by the online wire codec (server-sent alphabet) or the
mock (so the e2e masked it). A local game never goes through the codec, so on a cold
offline boot with no prior online session the cache was empty and every value read 0
(scores stayed correct -- they come from the offline ruleset). Fix: the local source
seeds lib/alphabet from the static offline ruleset (letters + values) when it builds a
game view.

Bug 2 -- the lobby showed (and could open) the other mode's game after a toggle. Two
causes: (a) the lobby cache was not mode-tagged, so getLobby() instant-rendered the last
snapshot regardless of mode; (b) load() wrote the module list after an await with no
generation guard, so a slow online gamesList() finishing after a fast offline list() (or
vice versa) left the wrong mode's games on screen. Fix: tag the snapshot with offline and
gate getLobby() on it; add a load-sequence guard so only the latest load() writes.

- localgame/source.ts: ensureAlphabet from the ruleset in gameView (+ unit test).
- lobbycache.ts: LobbySnapshot.offline + getLobby(offline) mode check (+ tests).
- Lobby.svelte: setLobby tags the mode; load() carries a generation guard.

check 0 / unit 485 / e2e 198 / app entry 113.8/114.
2026-07-06 23:11:17 +02:00

149 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 modes 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']);
});
});