Files
scrabble-game/ui/src/lib/lobbysort.test.ts
T
Ilia Denisov 12d128f1cc
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
feat(nudge): name the sender; blink lobby cards; re-animate toasts
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:

- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);

falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.

Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.

Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.

Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
2026-06-16 19:09:24 +02:00

110 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,
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);
});
});