Files
scrabble-game/ui/src/lib/lobbysort.test.ts
T
Ilia Denisov aaac816dc2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
feat(chat): unread read-receipts with lobby/game dot and history-open ack
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
2026-06-17 11:12:38 +02:00

111 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort';
import type { GameView, Seat } from './model';
const ME = 'me';
const seat = (s: number, accountId: string): Seat => ({
seat: s,
accountId,
displayName: accountId,
score: 0,
hintsUsed: 0,
isWinner: false,
});
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
return {
id,
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status,
players: 2,
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix,
seats: [seat(0, ME), seat(1, 'opp')],
};
}
describe('groupGames', () => {
it('partitions into your-turn, their-turn and finished', () => {
const g = groupGames(
[
game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn
game('b', 'active', 1, 100), // their turn
game('c', 'finished', 0, 100),
],
ME,
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
expect(g.finished.map((x) => x.id)).toEqual(['c']);
});
it('orders your-turn oldest-first, the other two newest-first', () => {
const g = groupGames(
[
game('y_new', 'active', 0, 200),
game('y_old', 'active', 0, 100),
game('t_new', 'active', 1, 200),
game('t_old', 'active', 1, 100),
game('f_new', 'finished', 0, 200),
game('f_old', 'finished', 0, 100),
],
ME,
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
});
it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
});
it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
const g = groupGames(
[
game('open_mine', 'open', 0, 100), // my turn while waiting
game('open_wait', 'open', 1, 100), // the empty seat's turn
],
ME,
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
expect(g.finished).toEqual([]);
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
});
});
describe('gamePhase', () => {
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
});
});
describe('shouldBlink', () => {
it('blinks on a transition into mine or finished', () => {
expect(shouldBlink('theirs', 'mine')).toBe(true);
expect(shouldBlink('mine', 'finished')).toBe(true);
expect(shouldBlink('theirs', 'finished')).toBe(true);
});
it('stays quiet on a transition into theirs, a no-op, or a first sighting', () => {
expect(shouldBlink('mine', 'theirs')).toBe(false);
expect(shouldBlink('mine', 'mine')).toBe(false);
expect(shouldBlink(undefined, 'mine')).toBe(false);
expect(shouldBlink(undefined, 'finished')).toBe(false);
});
});