Files
scrabble-game/ui/src/lib/gamedelta.test.ts
T
Ilia Denisov ac62d29ef7
CI / changes (pull_request) Successful in 1s
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 58s
feat(ui): name the previous player in the your-turn toast
The in-app "your turn" toast (shown when it becomes your turn in another
game) now reads "<Opponent>: Your turn!", naming the player who moved just
before this one — the previous seat in turn order, so it reads the same in
games with any number of players. Falls back to the bare "Your turn" label
when no name is present (an older peer, or a gap event without one).

YourTurnEvent already carries opponent_name end to end: the backend sets it
(game.displayName of the last mover) and the gateway forwards the payload
opaquely, so this is a client-only change — decode the field, thread it
through PushEvent, and pick the localized string.
2026-06-14 22:52:15 +02:00

171 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount,
endReason: over ? 'standard' : '',
lastActivityUnix: 0,
seats: [],
};
}
function move(player: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
}
function cache(moveCount: number, seat = 0, over = false): CachedGame {
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
return { view, moves: [] };
}
function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
return { move: move(player), game: gameView(moveCount), bagLen };
}
describe('seedInitialState', () => {
it('wraps an initial view with an empty journal', () => {
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
expect(seedInitialState(view)).toEqual({ view, moves: [] });
});
});
describe('applyMoveDelta', () => {
it('ignores a delta for a game not in the cache', () => {
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
});
it('refetches when the payload carries no delta (older peer / dropped payload)', () => {
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
});
it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
});
it('refetches on a gap (an intermediate move was missed)', () => {
expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
});
it("refetches the actor's own move (the new rack is not in the delta)", () => {
// seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
});
it("applies an opponent's next move, preserving the rack and appending the move", () => {
const before = cache(3, 0);
const res = applyMoveDelta(before, delta(4, 1, 45));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.moveCount).toBe(4);
expect(res.cache?.view.bagLen).toBe(45);
expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
expect(res.cache?.view.seat).toBe(0);
expect(res.cache?.moves).toHaveLength(1);
expect(res.cache?.moves[0].player).toBe(1);
expect(before.moves).toHaveLength(0); // input not mutated
});
});
describe('applyGameOver', () => {
it('ignores a finished game not in the cache', () => {
expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
});
it('refetches a missing final summary only when the game is not already finished', () => {
expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
});
it('refetches when the cached board is behind the final move count', () => {
expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
});
it('settles the final summary when the board is current', () => {
const res = applyGameOver(cache(10), gameView(10, true));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.status).toBe('finished');
expect(res.cache?.view.game.moveCount).toBe(10);
});
});
function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}
function gameOverEvent(moveCount: number): PushEvent {
return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) };
}
describe('advanceCached', () => {
it("advances the cache from an opponent's move for a game not on screen", () => {
const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42));
expect(res?.view.game.moveCount).toBe(4);
expect(res?.view.bagLen).toBe(42);
expect(res?.moves).toHaveLength(1);
});
it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => {
expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined();
});
it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => {
expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined();
});
it('settles a game_over event when the cached board is current', () => {
const res = advanceCached(cache(10, 0), gameOverEvent(10));
expect(res?.view.game.status).toBe('finished');
});
it('returns undefined for game_over when the cached board is behind the final count', () => {
expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined();
});
it('returns undefined when nothing is cached for the game', () => {
expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined();
});
it('ignores event kinds that do not advance a game board', () => {
const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, opponentName: 'Ann', moveCount: 4 };
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});
describe('applyOpponentJoined', () => {
// joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
// active and both seats are filled.
function joinedState(): StateView {
const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
] };
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
}
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2);
expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
expect(res?.moves).toHaveLength(1);
});
it('returns undefined when nothing is cached for the game', () => {
expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
});
});