6e77de4c1e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes: - robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a flat uniform 9-12 h wait before every nudge; the existing sleep-window gate still skips and defers a nudge that would land in the robot's night. - ui: colour the lobby/in-game unread dot by type -- the regular danger colour when a chat message is unread, a softer amber (--warn) when only nudges are. Adds a per-viewer unread_messages flag (chat_messages.kind='message') across the backend DTO, FlatBuffers wire, gateway transcode and the UI store. - ui: float games with any unread notification to the top of the lobby's your-turn and opponent-turn sections (finished keeps its order), reusing the existing unread_chat flag. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire field is backward-compatible.
133 lines
5.4 KiB
TypeScript
133 lines
5.4 KiB
TypeScript
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 });
|
|
// The player's own move flipped game "a" to the opponent's turn.
|
|
patchLobbyGame(gameView('a', 'active', 1));
|
|
const snap = getLobby();
|
|
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 });
|
|
patchLobbyGame(gameView('a', 'finished'));
|
|
expect(getLobby()?.incoming).toEqual(incoming);
|
|
expect(getLobby()?.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 });
|
|
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']);
|
|
});
|
|
|
|
it('is a no-op when there is no cached lobby yet', () => {
|
|
patchLobbyGame(gameView('a'));
|
|
expect(getLobby()).toBeNull();
|
|
});
|
|
|
|
it('does not mutate the previous snapshot array', () => {
|
|
const games = [gameView('a', 'active', 0)];
|
|
setLobby({ games, invitations: [], incoming: [], atGameLimit: 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 });
|
|
patchLobbyGame(gameView('a', 'finished'));
|
|
expect(getLobby()?.atGameLimit).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('patchLobbyInvitation', () => {
|
|
it('adds a new pending invitation to the cached lobby', () => {
|
|
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
|
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
|
expect(getLobby()?.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 });
|
|
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);
|
|
});
|
|
|
|
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 });
|
|
patchLobbyInvitation(invitation('i1', terminal));
|
|
expect(getLobby()?.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 });
|
|
patchLobbyInvitation(invitation('i1', 'declined'));
|
|
expect(getLobby()?.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();
|
|
});
|
|
|
|
it('preserves games and incoming when patching an invitation', () => {
|
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
|
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false });
|
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
|
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
|
expect(getLobby()?.incoming).toEqual(incoming);
|
|
});
|
|
});
|