3306a016a0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode + client codec, committed regen). The New Game screen counts the player's active games per kind from the lobby and locks a capped start: an outline button with a lock that opens a funnel modal instead of a game -- a sign-in prompt for a guest, a "finish a current game first" notice for a signed-in account (native Telegram popup, in-app modal elsewhere). The lock lifts via the existing profile refetch after a guest->durable upgrade. Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag (now the random-kind cap) conflicted with the per-kind lock -- it hid the screen where the lock lives and wrongly blocked an unfulfilled kind. The New Game tab is always enabled; the per-kind start lock is the only gate. The at_game_limit wire field stays (unused by the client) for a later cleanup. Tests: client lock logic + codec kind/game_limits roundtrip + gateway transcode encode + native popup builders (unit); a mock e2e for the lock badge and the modal.
92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
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',
|
|
vsAi: false,
|
|
unreadChat: false,
|
|
unreadMessages: false,
|
|
kind: 0,
|
|
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();
|
|
});
|
|
});
|