fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby
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
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
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.
This commit is contained in:
@@ -43,10 +43,10 @@ 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 });
|
||||
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();
|
||||
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);
|
||||
@@ -54,27 +54,27 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
it('preserves invitations and incoming when patching a game', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false, offline: false });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games[0].status).toBe('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 });
|
||||
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()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
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()).toBeNull();
|
||||
expect(getLobby(false)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the previous snapshot array', () => {
|
||||
const games = [gameView('a', 'active', 0)];
|
||||
setLobby({ games, invitations: [], incoming: [], atGameLimit: false });
|
||||
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
|
||||
});
|
||||
@@ -82,51 +82,67 @@ describe('patchLobbyGame', () => {
|
||||
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 });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby()?.atGameLimit).toBe(true);
|
||||
expect(getLobby(false)?.atGameLimit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
|
||||
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
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 });
|
||||
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()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
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 });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
||||
patchLobbyInvitation(invitation('i1', terminal));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
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 });
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
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()).toBeNull();
|
||||
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 });
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user