feat(ui): preload ongoing games and cache the draft for an instant, jump-free game open
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
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 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
Opening a game from the lobby for the first time this session showed a brief loading flash, and every open showed a two-step rack->board jump: the saved draft (pending composition) was fetched separately and applied only after the board had already painted the full rack. Both stem from the full state and the draft not being available synchronously at first paint. Cache the draft alongside view+history (CachedGame.draft), make applyDraft take the already-fetched JSON so it runs synchronously, and fetch the draft in the same Promise.all as state+history. setCachedGame preserves the cached draft when the delta path omits it and clears it on a committed move (mirroring the server). A new preload module warms the per-game cache (state, history, draft) for the lobby's ongoing games with bounded concurrency, so opening any of them is instant. Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units; existing draft-restore e2e still green.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { clearGameCache, getCachedGame, setCachedDraft, setCachedGame } from './gamecache';
|
||||
import type { GameView, MoveRecord, StateView } from './model';
|
||||
|
||||
function gameView(id: string): GameView {
|
||||
return {
|
||||
id,
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'v1',
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 300,
|
||||
multipleWordsPerTurn: true,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: 0,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
function view(id: string, rack: string[] = ['A', 'B']): StateView {
|
||||
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
|
||||
}
|
||||
|
||||
function move(player: number): MoveRecord {
|
||||
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
|
||||
}
|
||||
|
||||
beforeEach(() => clearGameCache());
|
||||
|
||||
describe('setCachedGame', () => {
|
||||
it('stores state, history and draft', () => {
|
||||
setCachedGame('g1', view('g1'), [move(0)], '{"x":1}');
|
||||
const c = getCachedGame('g1');
|
||||
expect(c?.view.game.id).toBe('g1');
|
||||
expect(c?.moves).toHaveLength(1);
|
||||
expect(c?.draft).toBe('{"x":1}');
|
||||
});
|
||||
|
||||
it('leaves draft undefined when none is given for a new entry', () => {
|
||||
setCachedGame('g1', view('g1'), []);
|
||||
expect(getCachedGame('g1')?.draft).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves the cached draft when draft is omitted (the live-event delta path)', () => {
|
||||
setCachedGame('g1', view('g1'), [], 'DRAFT');
|
||||
// A delta advances view+moves without refetching the draft; it must not be dropped.
|
||||
setCachedGame('g1', view('g1', ['C', 'D']), [move(1)]);
|
||||
const c = getCachedGame('g1');
|
||||
expect(c?.draft).toBe('DRAFT'); // preserved
|
||||
expect(c?.view.rack).toEqual(['C', 'D']); // view advanced
|
||||
expect(c?.moves).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clears the draft when passed an empty string (a committed move)', () => {
|
||||
setCachedGame('g1', view('g1'), [], 'DRAFT');
|
||||
setCachedGame('g1', view('g1'), [move(0)], '');
|
||||
expect(getCachedGame('g1')?.draft).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCachedDraft', () => {
|
||||
it('updates only the draft of an already-cached game', () => {
|
||||
setCachedGame('g1', view('g1', ['A']), [move(0)], 'OLD');
|
||||
setCachedDraft('g1', 'NEW');
|
||||
const c = getCachedGame('g1');
|
||||
expect(c?.draft).toBe('NEW');
|
||||
expect(c?.view.rack).toEqual(['A']); // unchanged
|
||||
expect(c?.moves).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is a no-op when the game is not cached', () => {
|
||||
setCachedDraft('absent', 'X');
|
||||
expect(getCachedGame('absent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearGameCache', () => {
|
||||
it('drops every cached game', () => {
|
||||
setCachedGame('g1', view('g1'), []);
|
||||
setCachedGame('g2', view('g2'), []);
|
||||
clearGameCache();
|
||||
expect(getCachedGame('g1')).toBeUndefined();
|
||||
expect(getCachedGame('g2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user