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', vsAi: false, unreadChat: false, unreadMessages: false, 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(); }); });