import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Player, GameState } from '../../types'; describe('Kick and Leave Feature Logic', () => { const createPlayer = (overrides: Partial = {}): Player => ({ id: 'player-1', name: 'Test Player', score: 0, previousScore: 0, streak: 0, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null, isBot: false, avatarSeed: 0.5, color: '#ff0000', ...overrides, }); describe('kickPlayer logic', () => { const processKick = ( playerId: string, players: Player[], presenterId: string | null, role: 'HOST' | 'CLIENT' ): { updatedPlayers: Player[]; newPresenterId: string | null; shouldKick: boolean; } => { if (role !== 'HOST') { return { updatedPlayers: players, newPresenterId: presenterId, shouldKick: false }; } if (playerId === 'host') { return { updatedPlayers: players, newPresenterId: presenterId, shouldKick: false }; } const updatedPlayers = players.filter(p => p.id !== playerId); const newPresenterId = presenterId === playerId ? null : presenterId; return { updatedPlayers, newPresenterId, shouldKick: true }; }; it('removes player from players list', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('player-2'); expect(result.shouldKick).toBe(true); }); it('does not allow kick when role is CLIENT', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processKick('player-1', players, null, 'CLIENT'); expect(result.updatedPlayers).toHaveLength(2); expect(result.shouldKick).toBe(false); }); it('does not allow kicking host player', () => { const players = [ createPlayer({ id: 'host', name: 'Host' }), createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processKick('host', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(2); expect(result.shouldKick).toBe(false); }); it('clears presenter when kicked player was presenter', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processKick('player-1', players, 'player-1', 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.newPresenterId).toBeNull(); expect(result.shouldKick).toBe(true); }); it('preserves presenter when different player kicked', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processKick('player-2', players, 'player-1', 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.newPresenterId).toBe('player-1'); expect(result.shouldKick).toBe(true); }); it('handles kicking non-existent player gracefully', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processKick('player-999', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.shouldKick).toBe(true); }); it('handles kicking last player', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processKick('player-1', players, 'player-1', 'HOST'); expect(result.updatedPlayers).toHaveLength(0); expect(result.newPresenterId).toBeNull(); expect(result.shouldKick).toBe(true); }); it('handles empty player id string', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processKick('', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.shouldKick).toBe(true); }); it('handles player id with special characters', () => { const players = [ createPlayer({ id: 'peer-abc123-xyz', name: 'Alice' }), createPlayer({ id: 'peer-def456-uvw', name: 'Bob' }), ]; const result = processKick('peer-abc123-xyz', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('peer-def456-uvw'); expect(result.shouldKick).toBe(true); }); it('handles kicking from empty players array', () => { const players: Player[] = []; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(0); expect(result.shouldKick).toBe(true); }); it('preserves player scores and data when kicking another player', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice', score: 1000, streak: 5 }), createPlayer({ id: 'player-2', name: 'Bob', score: 500, streak: 2 }), ]; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers[0].score).toBe(500); expect(result.updatedPlayers[0].streak).toBe(2); }); it('handles kicking player with same name as host', () => { const players = [ createPlayer({ id: 'host', name: 'Host' }), createPlayer({ id: 'player-1', name: 'Host' }), ]; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('host'); expect(result.shouldKick).toBe(true); }); it('handles multiple sequential kicks', () => { let players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), createPlayer({ id: 'player-3', name: 'Charlie' }), ]; const result1 = processKick('player-1', players, null, 'HOST'); players = result1.updatedPlayers; const result2 = processKick('player-2', players, null, 'HOST'); players = result2.updatedPlayers; expect(players).toHaveLength(1); expect(players[0].id).toBe('player-3'); }); it('handles kicking when multiple players have similar ids', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-10', name: 'Bob' }), createPlayer({ id: 'player-100', name: 'Charlie' }), ]; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(2); expect(result.updatedPlayers.find(p => p.id === 'player-10')).toBeDefined(); expect(result.updatedPlayers.find(p => p.id === 'player-100')).toBeDefined(); }); it('does not modify original players array', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const originalLength = players.length; processKick('player-1', players, null, 'HOST'); expect(players).toHaveLength(originalLength); }); it('handles bot players same as regular players', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice', isBot: false }), createPlayer({ id: 'bot-1', name: 'Bot Player', isBot: true }), ]; const result = processKick('bot-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.shouldKick).toBe(true); }); it('handles player who answered correctly being kicked', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice', lastAnswerCorrect: true, selectedShape: 'triangle' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processKick('player-1', players, null, 'HOST'); expect(result.updatedPlayers).toHaveLength(1); expect(result.shouldKick).toBe(true); }); }); describe('leaveGame logic', () => { interface LeaveGameContext { role: 'HOST' | 'CLIENT'; gameState: GameState; hasConnection: boolean; hasPeer: boolean; } const processLeave = (context: LeaveGameContext): { shouldLeave: boolean; shouldCloseConnection: boolean; shouldDestroyPeer: boolean; shouldClearSession: boolean; shouldClearTimers: boolean; newGameState: GameState; } => { if (context.role !== 'CLIENT') { return { shouldLeave: false, shouldCloseConnection: false, shouldDestroyPeer: false, shouldClearSession: false, shouldClearTimers: false, newGameState: context.gameState, }; } return { shouldLeave: true, shouldCloseConnection: context.hasConnection, shouldDestroyPeer: context.hasPeer, shouldClearSession: true, shouldClearTimers: true, newGameState: 'LANDING', }; }; it('allows client to leave', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: true, }); expect(result.shouldLeave).toBe(true); }); it('does not allow host to leave via leaveGame', () => { const result = processLeave({ role: 'HOST', gameState: 'LOBBY', hasConnection: false, hasPeer: true, }); expect(result.shouldLeave).toBe(false); }); it('closes connection when leaving with active connection', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: true, }); expect(result.shouldCloseConnection).toBe(true); }); it('does not close connection when no connection exists', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: false, hasPeer: true, }); expect(result.shouldCloseConnection).toBe(false); }); it('destroys peer when leaving', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: true, }); expect(result.shouldDestroyPeer).toBe(true); }); it('does not destroy peer when none exists', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: false, }); expect(result.shouldDestroyPeer).toBe(false); }); it('clears session when leaving', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: true, }); expect(result.shouldClearSession).toBe(true); }); it('clears timers when leaving', () => { const result = processLeave({ role: 'CLIENT', gameState: 'QUESTION', hasConnection: true, hasPeer: true, }); expect(result.shouldClearTimers).toBe(true); }); it('sets game state to LANDING when leaving', () => { const result = processLeave({ role: 'CLIENT', gameState: 'LOBBY', hasConnection: true, hasPeer: true, }); expect(result.newGameState).toBe('LANDING'); }); it('preserves game state when host tries to leave', () => { const result = processLeave({ role: 'HOST', gameState: 'QUESTION', hasConnection: false, hasPeer: true, }); expect(result.newGameState).toBe('QUESTION'); }); it('handles leaving during different game states', () => { const states: GameState[] = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD']; states.forEach(state => { const result = processLeave({ role: 'CLIENT', gameState: state, hasConnection: true, hasPeer: true, }); expect(result.shouldLeave).toBe(true); expect(result.newGameState).toBe('LANDING'); }); }); it('handles leaving when already disconnected', () => { const result = processLeave({ role: 'CLIENT', gameState: 'DISCONNECTED', hasConnection: false, hasPeer: false, }); expect(result.shouldLeave).toBe(true); expect(result.newGameState).toBe('LANDING'); }); }); describe('KICKED message handling', () => { interface KickedPayload { reason?: string; } interface KickedContext { hasHostConnection: boolean; hasPeer: boolean; hasTimer: boolean; } const processKickedMessage = ( payload: KickedPayload, currentGameState: string, context: KickedContext = { hasHostConnection: true, hasPeer: true, hasTimer: true } ): { newGameState: 'LANDING'; error: string; shouldClearSession: boolean; shouldCloseConnection: boolean; shouldDestroyPeer: boolean; shouldClearTimer: boolean; shouldClearGameData: boolean; } => { return { newGameState: 'LANDING', error: payload.reason || 'You were kicked from the game', shouldClearSession: true, shouldCloseConnection: context.hasHostConnection, shouldDestroyPeer: context.hasPeer, shouldClearTimer: context.hasTimer, shouldClearGameData: true, }; }; it('sets game state to LANDING when kicked', () => { const result = processKickedMessage({ reason: 'Kicked by host' }, 'LOBBY'); expect(result.newGameState).toBe('LANDING'); }); it('uses provided reason as error message', () => { const result = processKickedMessage({ reason: 'Custom kick reason' }, 'LOBBY'); expect(result.error).toBe('Custom kick reason'); }); it('uses default error message when no reason provided', () => { const result = processKickedMessage({}, 'LOBBY'); expect(result.error).toBe('You were kicked from the game'); }); it('clears session when kicked', () => { const result = processKickedMessage({ reason: 'Kicked' }, 'LOBBY'); expect(result.shouldClearSession).toBe(true); }); it('handles kick during different game states', () => { const states = ['LOBBY', 'QUESTION', 'REVEAL', 'SCOREBOARD']; states.forEach(state => { const result = processKickedMessage({ reason: 'Kicked' }, state); expect(result.newGameState).toBe('LANDING'); expect(result.shouldClearSession).toBe(true); }); }); it('handles empty reason string', () => { const result = processKickedMessage({ reason: '' }, 'LOBBY'); expect(result.error).toBe('You were kicked from the game'); }); it('handles reason with special characters', () => { const result = processKickedMessage({ reason: 'Kicked for ' }, 'LOBBY'); expect(result.error).toBe('Kicked for '); }); it('handles very long reason string', () => { const longReason = 'A'.repeat(1000); const result = processKickedMessage({ reason: longReason }, 'LOBBY'); expect(result.error).toBe(longReason); }); it('closes host connection when kicked', () => { const result = processKickedMessage( { reason: 'Kicked' }, 'LOBBY', { hasHostConnection: true, hasPeer: true, hasTimer: true } ); expect(result.shouldCloseConnection).toBe(true); }); it('does not try to close connection when none exists', () => { const result = processKickedMessage( { reason: 'Kicked' }, 'LOBBY', { hasHostConnection: false, hasPeer: true, hasTimer: true } ); expect(result.shouldCloseConnection).toBe(false); }); it('destroys peer when kicked', () => { const result = processKickedMessage( { reason: 'Kicked' }, 'LOBBY', { hasHostConnection: true, hasPeer: true, hasTimer: true } ); expect(result.shouldDestroyPeer).toBe(true); }); it('clears timer when kicked during question', () => { const result = processKickedMessage( { reason: 'Kicked' }, 'QUESTION', { hasHostConnection: true, hasPeer: true, hasTimer: true } ); expect(result.shouldClearTimer).toBe(true); }); it('handles kick when already in LANDING state', () => { const result = processKickedMessage({ reason: 'Kicked' }, 'LANDING'); expect(result.newGameState).toBe('LANDING'); expect(result.shouldClearSession).toBe(true); }); it('handles kick during COUNTDOWN', () => { const result = processKickedMessage({ reason: 'Kicked' }, 'COUNTDOWN'); expect(result.newGameState).toBe('LANDING'); }); it('clears all game data when kicked', () => { const result = processKickedMessage({ reason: 'Kicked' }, 'QUESTION'); expect(result.shouldClearGameData).toBe(true); }); it('handles undefined reason gracefully', () => { const result = processKickedMessage({ reason: undefined }, 'LOBBY'); expect(result.error).toBe('You were kicked from the game'); }); }); describe('PLAYER_LEFT message handling', () => { const processPlayerLeft = ( playerId: string, players: Player[], presenterId: string | null = null ): { updatedPlayers: Player[]; shouldUpdatePresenter: boolean; newPresenterId: string | null } => { const updatedPlayers = players.filter(p => p.id !== playerId); const shouldUpdatePresenter = presenterId === playerId; const newPresenterId = shouldUpdatePresenter ? null : presenterId; return { updatedPlayers, shouldUpdatePresenter, newPresenterId }; }; it('removes player from list when they leave', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), createPlayer({ id: 'player-3', name: 'Charlie' }), ]; const result = processPlayerLeft('player-2', players); expect(result.updatedPlayers).toHaveLength(2); expect(result.updatedPlayers.find(p => p.id === 'player-2')).toBeUndefined(); expect(result.updatedPlayers.find(p => p.id === 'player-1')).toBeDefined(); expect(result.updatedPlayers.find(p => p.id === 'player-3')).toBeDefined(); }); it('handles player leaving who does not exist', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processPlayerLeft('player-999', players); expect(result.updatedPlayers).toHaveLength(1); }); it('handles last player leaving', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processPlayerLeft('player-1', players); expect(result.updatedPlayers).toHaveLength(0); }); it('updates presenter when presenter leaves', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processPlayerLeft('player-1', players, 'player-1'); expect(result.shouldUpdatePresenter).toBe(true); expect(result.newPresenterId).toBeNull(); }); it('does not update presenter when non-presenter leaves', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processPlayerLeft('player-2', players, 'player-1'); expect(result.shouldUpdatePresenter).toBe(false); expect(result.newPresenterId).toBe('player-1'); }); it('preserves player order when someone leaves', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), createPlayer({ id: 'player-3', name: 'Charlie' }), ]; const result = processPlayerLeft('player-2', players); expect(result.updatedPlayers[0].id).toBe('player-1'); expect(result.updatedPlayers[1].id).toBe('player-3'); }); it('preserves player scores when someone leaves', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice', score: 1000 }), createPlayer({ id: 'player-2', name: 'Bob', score: 500 }), ]; const result = processPlayerLeft('player-2', players); expect(result.updatedPlayers[0].score).toBe(1000); }); it('handles empty players array', () => { const players: Player[] = []; const result = processPlayerLeft('player-1', players); expect(result.updatedPlayers).toHaveLength(0); }); it('handles player with complex id leaving', () => { const players = [ createPlayer({ id: 'peer-abc123-xyz789-def', name: 'Alice' }), createPlayer({ id: 'peer-111222-333444-555', name: 'Bob' }), ]; const result = processPlayerLeft('peer-abc123-xyz789-def', players); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('peer-111222-333444-555'); }); it('does not modify original players array', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const originalLength = players.length; processPlayerLeft('player-1', players); expect(players).toHaveLength(originalLength); }); it('handles multiple players leaving in sequence', () => { let players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), createPlayer({ id: 'player-3', name: 'Charlie' }), createPlayer({ id: 'player-4', name: 'Diana' }), ]; const result1 = processPlayerLeft('player-1', players); players = result1.updatedPlayers; const result2 = processPlayerLeft('player-3', players); players = result2.updatedPlayers; expect(players).toHaveLength(2); expect(players.map(p => p.id)).toEqual(['player-2', 'player-4']); }); it('handles leaving player with active answer state', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice', lastAnswerCorrect: true, selectedShape: 'triangle', pointsBreakdown: { basePoints: 100, streakBonus: 0, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 100 } }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processPlayerLeft('player-1', players); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('player-2'); }); }); describe('edge cases and race conditions', () => { it('handles kick request for already-kicked player', () => { const processKick = (playerId: string, players: Player[]) => { return players.filter(p => p.id !== playerId); }; const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const afterFirstKick = processKick('player-1', players); const afterSecondKick = processKick('player-1', afterFirstKick); expect(afterSecondKick).toHaveLength(0); }); it('handles concurrent kick and leave for same player', () => { const processRemoval = (playerId: string, players: Player[]) => { return players.filter(p => p.id !== playerId); }; const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const afterKick = processRemoval('player-1', players); const afterLeave = processRemoval('player-1', afterKick); expect(afterLeave).toHaveLength(1); expect(afterLeave[0].id).toBe('player-2'); }); it('handles kick during answer submission', () => { const player = createPlayer({ id: 'player-1', name: 'Alice', lastAnswerCorrect: null, selectedShape: null, }); const players = [player]; const updatedPlayers = players.filter(p => p.id !== 'player-1'); expect(updatedPlayers).toHaveLength(0); }); it('handles multiple rapid kicks', () => { const processKick = (playerId: string, players: Player[]) => { return players.filter(p => p.id !== playerId); }; let players = Array.from({ length: 10 }, (_, i) => createPlayer({ id: `player-${i}`, name: `Player ${i}` }) ); for (let i = 0; i < 10; i++) { players = processKick(`player-${i}`, players); } expect(players).toHaveLength(0); }); it('validates player id before kick', () => { const isValidKickTarget = (playerId: string, role: 'HOST' | 'CLIENT') => { if (role !== 'HOST') return false; if (playerId === 'host') return false; if (!playerId || playerId.trim() === '') return false; return true; }; expect(isValidKickTarget('player-1', 'HOST')).toBe(true); expect(isValidKickTarget('player-1', 'CLIENT')).toBe(false); expect(isValidKickTarget('host', 'HOST')).toBe(false); expect(isValidKickTarget('', 'HOST')).toBe(false); expect(isValidKickTarget(' ', 'HOST')).toBe(false); }); it('handles kick when game is in PODIUM state', () => { const canKickInState = (gameState: GameState) => { return gameState !== 'PODIUM'; }; expect(canKickInState('LOBBY')).toBe(true); expect(canKickInState('QUESTION')).toBe(true); expect(canKickInState('PODIUM')).toBe(false); }); it('handles leave when game transitions state', () => { const canLeaveInState = (gameState: GameState) => { const nonLeavableStates: GameState[] = ['PODIUM', 'LANDING']; return !nonLeavableStates.includes(gameState); }; expect(canLeaveInState('LOBBY')).toBe(true); expect(canLeaveInState('QUESTION')).toBe(true); expect(canLeaveInState('SCOREBOARD')).toBe(true); expect(canLeaveInState('PODIUM')).toBe(false); expect(canLeaveInState('LANDING')).toBe(false); }); it('preserves game integrity after player removal', () => { const validateGameIntegrity = (players: Player[]) => { const ids = players.map(p => p.id); const uniqueIds = new Set(ids); return ids.length === uniqueIds.size; }; const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const afterRemoval = players.filter(p => p.id !== 'player-1'); expect(validateGameIntegrity(afterRemoval)).toBe(true); }); }); describe('network message validation', () => { it('validates KICK message structure', () => { const isValidKickMessage = (message: unknown): boolean => { if (typeof message !== 'object' || message === null) return false; const msg = message as { type?: string; payload?: { playerId?: string } }; return msg.type === 'KICK' && typeof msg.payload?.playerId === 'string' && msg.payload.playerId.length > 0; }; expect(isValidKickMessage({ type: 'KICK', payload: { playerId: 'player-1' } })).toBe(true); expect(isValidKickMessage({ type: 'KICK', payload: { playerId: '' } })).toBe(false); expect(isValidKickMessage({ type: 'KICK', payload: {} })).toBe(false); expect(isValidKickMessage({ type: 'KICK' })).toBe(false); expect(isValidKickMessage({ type: 'OTHER', payload: { playerId: 'player-1' } })).toBe(false); expect(isValidKickMessage(null)).toBe(false); expect(isValidKickMessage(undefined)).toBe(false); }); it('validates KICKED message structure', () => { const isValidKickedMessage = (message: unknown): boolean => { if (typeof message !== 'object' || message === null) return false; const msg = message as { type?: string; payload?: { reason?: string } }; return msg.type === 'KICKED' && typeof msg.payload === 'object'; }; expect(isValidKickedMessage({ type: 'KICKED', payload: { reason: 'test' } })).toBe(true); expect(isValidKickedMessage({ type: 'KICKED', payload: {} })).toBe(true); expect(isValidKickedMessage({ type: 'KICKED' })).toBe(false); expect(isValidKickedMessage({ type: 'OTHER', payload: {} })).toBe(false); }); it('validates PLAYER_LEFT message structure', () => { const isValidPlayerLeftMessage = (message: unknown): boolean => { if (typeof message !== 'object' || message === null) return false; const msg = message as { type?: string; payload?: { playerId?: string } }; return msg.type === 'PLAYER_LEFT' && typeof msg.payload?.playerId === 'string'; }; expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: { playerId: 'player-1' } })).toBe(true); expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: {} })).toBe(false); expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT' })).toBe(false); }); it('validates LEAVE message structure', () => { const isValidLeaveMessage = (message: unknown): boolean => { if (typeof message !== 'object' || message === null) return false; const msg = message as { type?: string; payload?: object }; return msg.type === 'LEAVE' && typeof msg.payload === 'object'; }; expect(isValidLeaveMessage({ type: 'LEAVE', payload: {} })).toBe(true); expect(isValidLeaveMessage({ type: 'LEAVE' })).toBe(false); expect(isValidLeaveMessage({ type: 'OTHER', payload: {} })).toBe(false); expect(isValidLeaveMessage(null)).toBe(false); }); }); describe('host handling LEAVE message', () => { const processLeaveMessage = ( leavingPlayerId: string, players: Player[], presenterId: string | null ): { updatedPlayers: Player[]; newPresenterId: string | null; shouldBroadcastPlayerLeft: boolean; shouldBroadcastPresenterChanged: boolean; } => { const updatedPlayers = players.filter(p => p.id !== leavingPlayerId); const wasPresenter = presenterId === leavingPlayerId; let newPresenterId = presenterId; if (wasPresenter) { const realPlayers = updatedPlayers.filter(p => p.id !== 'host'); newPresenterId = realPlayers.length > 0 ? realPlayers[0].id : null; } return { updatedPlayers, newPresenterId, shouldBroadcastPlayerLeft: true, shouldBroadcastPresenterChanged: wasPresenter, }; }; it('removes leaving player from players list', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processLeaveMessage('player-1', players, null); expect(result.updatedPlayers).toHaveLength(1); expect(result.updatedPlayers[0].id).toBe('player-2'); }); it('broadcasts PLAYER_LEFT to remaining players', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processLeaveMessage('player-1', players, null); expect(result.shouldBroadcastPlayerLeft).toBe(true); }); it('reassigns presenter when leaving player was presenter', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processLeaveMessage('player-1', players, 'player-1'); expect(result.newPresenterId).toBe('player-2'); expect(result.shouldBroadcastPresenterChanged).toBe(true); }); it('preserves presenter when non-presenter leaves', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const result = processLeaveMessage('player-2', players, 'player-1'); expect(result.newPresenterId).toBe('player-1'); expect(result.shouldBroadcastPresenterChanged).toBe(false); }); it('sets presenter to null when last non-host player leaves', () => { const players = [ createPlayer({ id: 'host', name: 'Host' }), createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processLeaveMessage('player-1', players, 'player-1'); expect(result.newPresenterId).toBeNull(); expect(result.shouldBroadcastPresenterChanged).toBe(true); }); it('handles leave when player does not exist', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), ]; const result = processLeaveMessage('player-999', players, null); expect(result.updatedPlayers).toHaveLength(1); expect(result.shouldBroadcastPlayerLeft).toBe(true); }); it('does not modify original players array', () => { const players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), ]; const originalLength = players.length; processLeaveMessage('player-1', players, null); expect(players).toHaveLength(originalLength); }); it('handles multiple sequential leaves', () => { let players = [ createPlayer({ id: 'player-1', name: 'Alice' }), createPlayer({ id: 'player-2', name: 'Bob' }), createPlayer({ id: 'player-3', name: 'Charlie' }), ]; let presenterId: string | null = 'player-1'; const result1 = processLeaveMessage('player-1', players, presenterId); players = result1.updatedPlayers; presenterId = result1.newPresenterId; const result2 = processLeaveMessage('player-2', players, presenterId); players = result2.updatedPlayers; presenterId = result2.newPresenterId; expect(players).toHaveLength(1); expect(players[0].id).toBe('player-3'); expect(presenterId).toBe('player-3'); }); }); });