diff --git a/App.tsx b/App.tsx index 676e173..caa5320 100644 --- a/App.tsx +++ b/App.tsx @@ -94,7 +94,9 @@ function App() { resumeGame, presenterId, setPresenterPlayer, - sendAdvance + sendAdvance, + kickPlayer, + leaveGame } = useGame(); const handleSaveQuiz = async () => { @@ -204,6 +206,8 @@ function App() { hostParticipates={gameConfig.hostParticipates} presenterId={presenterId} onSetPresenter={setPresenterPlayer} + onKickPlayer={role === 'HOST' ? kickPlayer : undefined} + onLeaveGame={role === 'CLIENT' ? leaveGame : undefined} /> {auth.isAuthenticated && pendingQuizToSave && ( void; + onKickPlayer?: (playerId: string) => void; + onLeaveGame?: () => void; } -export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => { +export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter, onKickPlayer, onLeaveGame }) => { const isHost = role === 'HOST'; const hostPlayer = players.find(p => p.id === 'host'); const realPlayers = players.filter(p => p.id !== 'host'); @@ -209,6 +211,18 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, {isPlayerPresenter && ( PRESENTER )} + {onKickPlayer && ( + + )} ); })} @@ -272,6 +286,19 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, ) : (

Waiting for the host to start...

)} + + {onLeaveGame && ( + + + Leave Game + + )} )} diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 013c821..dd5168e 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1446,6 +1446,31 @@ export const useGame = () => { if (data.type === 'PRESENTER_CHANGED') { setPresenterId(data.payload.presenterId); } + + if (data.type === 'KICKED') { + if (hostConnectionRef.current) { + hostConnectionRef.current.close(); + hostConnectionRef.current = null; + } + if (peerRef.current) { + peerRef.current.destroy(); + peerRef.current = null; + } + clearStoredSession(); + if (timerRef.current) clearInterval(timerRef.current); + setError(data.payload.reason || 'You were kicked from the game'); + setGamePin(null); + setQuiz(null); + setPlayers([]); + setCurrentPlayerId(null); + setCurrentPlayerName(null); + setGameState('LANDING'); + navigate('/', { replace: true }); + } + + if (data.type === 'PLAYER_LEFT') { + setPlayers(prev => prev.filter(p => p.id !== data.payload.playerId)); + } }; const handleAnswer = (arg: boolean | AnswerOption) => { @@ -1535,6 +1560,54 @@ export const useGame = () => { broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } }); }; + const kickPlayer = (playerId: string) => { + if (role !== 'HOST') return; + if (playerId === 'host') return; + + const conn = connectionsRef.current.get(playerId); + if (conn?.open) { + conn.send({ type: 'KICKED', payload: { reason: 'You were kicked by the host' } }); + conn.close(); + } + connectionsRef.current.delete(playerId); + + const updatedPlayers = playersRef.current.filter(p => p.id !== playerId); + playersRef.current = updatedPlayers; + setPlayers(updatedPlayers); + + if (presenterIdRef.current === playerId) { + setPresenterId(null); + broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: null } }); + } + + broadcast({ type: 'PLAYER_LEFT', payload: { playerId } }); + }; + + const leaveGame = () => { + if (role !== 'CLIENT') return; + + if (hostConnectionRef.current?.open) { + hostConnectionRef.current.close(); + } + hostConnectionRef.current = null; + + if (peerRef.current) { + peerRef.current.destroy(); + peerRef.current = null; + } + + clearStoredSession(); + if (timerRef.current) clearInterval(timerRef.current); + + setGamePin(null); + setQuiz(null); + setPlayers([]); + setCurrentPlayerId(null); + setCurrentPlayerName(null); + setGameState('LANDING'); + navigate('/', { replace: true }); + }; + const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => { if (role !== 'CLIENT' || !hostConnectionRef.current) return; hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } }); @@ -1544,6 +1617,6 @@ export const useGame = () => { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId, startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, - updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance + updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame }; }; diff --git a/services/geminiService.ts b/services/geminiService.ts index a8794d4..5206c86 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -130,11 +130,20 @@ function transformToQuiz(data: any): Quiz { } async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> { - const buffer = typeof doc.content === 'string' - ? Buffer.from(doc.content, 'base64') - : doc.content; + let data: ArrayBuffer; - const blob = new Blob([buffer], { type: doc.mimeType }); + if (typeof doc.content === 'string') { + const binaryString = atob(doc.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + data = bytes.buffer as ArrayBuffer; + } else { + data = doc.content; + } + + const blob = new Blob([data], { type: doc.mimeType }); const uploadedFile = await ai.files.upload({ file: blob, diff --git a/tests/components/Lobby.test.tsx b/tests/components/Lobby.test.tsx index 13c761f..f5607cc 100644 --- a/tests/components/Lobby.test.tsx +++ b/tests/components/Lobby.test.tsx @@ -14,6 +14,7 @@ vi.mock('react-hot-toast', () => ({ vi.mock('framer-motion', () => ({ motion: { div: ({ children, ...props }: React.PropsWithChildren>) =>
{children}
, + button: ({ children, ...props }: React.PropsWithChildren>) => , }, AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, })); @@ -541,4 +542,604 @@ describe('Lobby', () => { expect(presenterBadges).toHaveLength(1); }); }); + + describe('kick player feature - host view', () => { + const playersWithKick = [ + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + { id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 }, + ]; + + it('shows kick button on each player when onKickPlayer provided', () => { + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + expect(kickButtons).toHaveLength(2); + }); + + it('does not show kick button when onKickPlayer not provided', () => { + render( + + ); + + expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument(); + }); + + it('calls onKickPlayer with correct player id when kick button clicked', async () => { + const user = userEvent.setup(); + const onKickPlayer = vi.fn(); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + await user.click(kickButtons[0]); + + expect(onKickPlayer).toHaveBeenCalledWith('player-1'); + }); + + it('kick button click does not trigger presenter selection', async () => { + const user = userEvent.setup(); + const onKickPlayer = vi.fn(); + const onSetPresenter = vi.fn(); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + await user.click(kickButtons[0]); + + expect(onKickPlayer).toHaveBeenCalledWith('player-1'); + expect(onSetPresenter).not.toHaveBeenCalled(); + }); + + it('does not show kick button for host player', () => { + const playersWithHost = [ + { id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 }, + ...playersWithKick, + ]; + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + expect(kickButtons).toHaveLength(2); + }); + + it('can kick second player without affecting first player kick button', async () => { + const user = userEvent.setup(); + const onKickPlayer = vi.fn(); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + await user.click(kickButtons[1]); + + expect(onKickPlayer).toHaveBeenCalledWith('player-2'); + expect(onKickPlayer).toHaveBeenCalledTimes(1); + }); + + it('can kick multiple players in sequence', async () => { + const user = userEvent.setup(); + const onKickPlayer = vi.fn(); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + await user.click(kickButtons[0]); + await user.click(kickButtons[1]); + + expect(onKickPlayer).toHaveBeenCalledTimes(2); + expect(onKickPlayer).toHaveBeenNthCalledWith(1, 'player-1'); + expect(onKickPlayer).toHaveBeenNthCalledWith(2, 'player-2'); + }); + + it('shows kick button for presenter player', () => { + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + expect(kickButtons).toHaveLength(2); + }); + + it('can kick presenter player', async () => { + const user = userEvent.setup(); + const onKickPlayer = vi.fn(); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + await user.click(kickButtons[0]); + + expect(onKickPlayer).toHaveBeenCalledWith('player-1'); + }); + + it('shows kick button with single player', () => { + const singlePlayer = [{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }]; + + render( + + ); + + expect(screen.getByTitle('Kick player')).toBeInTheDocument(); + }); + + it('shows kick button for many players', () => { + const manyPlayers = Array.from({ length: 10 }, (_, i) => ({ + id: `player-${i}`, + name: `Player ${i}`, + score: 0, + avatarSeed: i * 0.1, + })); + + render( + + ); + + const kickButtons = screen.getAllByTitle('Kick player'); + expect(kickButtons).toHaveLength(10); + }); + + it('updates kick buttons when players list changes', () => { + const { rerender } = render( + + ); + + expect(screen.getAllByTitle('Kick player')).toHaveLength(2); + + const newPlayers = [ + ...playersWithKick, + { id: 'player-3', name: 'Charlie', score: 0, avatarSeed: 0.3 }, + ]; + + rerender( + + ); + + expect(screen.getAllByTitle('Kick player')).toHaveLength(3); + }); + + it('removes kick buttons when onKickPlayer becomes undefined', () => { + const { rerender } = render( + + ); + + expect(screen.getAllByTitle('Kick player')).toHaveLength(2); + + rerender( + + ); + + expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument(); + }); + + it('does not show kick buttons for client role even if onKickPlayer provided', () => { + render( + + ); + + expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument(); + }); + }); + + describe('leave game feature - client view', () => { + it('shows leave game button when onLeaveGame provided', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('does not show leave game button when onLeaveGame not provided', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + }); + + it('calls onLeaveGame when leave button clicked', async () => { + const user = userEvent.setup(); + const onLeaveGame = vi.fn(); + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + await user.click(screen.getByText('Leave Game')); + + expect(onLeaveGame).toHaveBeenCalled(); + }); + + it('does not show leave game button for host', () => { + const players = [ + { id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 }, + ]; + + render( + + ); + + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + }); + + it('shows leave game button alongside presenter info', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + expect(screen.getByText('You are the Presenter')).toBeInTheDocument(); + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('calls onLeaveGame only once per click', async () => { + const user = userEvent.setup(); + const onLeaveGame = vi.fn(); + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + await user.click(screen.getByText('Leave Game')); + + expect(onLeaveGame).toHaveBeenCalledTimes(1); + }); + + it('leave button remains clickable after multiple clicks', async () => { + const user = userEvent.setup(); + const onLeaveGame = vi.fn(); + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + await user.click(screen.getByText('Leave Game')); + await user.click(screen.getByText('Leave Game')); + await user.click(screen.getByText('Leave Game')); + + expect(onLeaveGame).toHaveBeenCalledTimes(3); + }); + + it('shows leave button when player is not in players list', () => { + const players = [ + { id: 'player-2', name: 'Other Player', score: 0, avatarSeed: 0.5 }, + ]; + + render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('shows leave button with empty players list', () => { + render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('removes leave button when onLeaveGame becomes undefined', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + const { rerender } = render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + }); + + it('removes leave button when role changes to HOST', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + + const { rerender } = render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + }); + }); + + describe('kick and leave interaction edge cases', () => { + it('host view shows kick buttons but not leave button', () => { + const players = [ + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + { id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 }, + ]; + + render( + + ); + + expect(screen.getAllByTitle('Kick player')).toHaveLength(2); + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + }); + + it('client view shows leave button but not kick buttons', () => { + const players = [ + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + { id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 }, + ]; + + render( + + ); + + expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument(); + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('handles undefined currentPlayerId for client gracefully', () => { + const players = [ + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + ]; + + render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('handles null currentPlayerId for client gracefully', () => { + const players = [ + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + ]; + + render( + + ); + + expect(screen.getByText('Leave Game')).toBeInTheDocument(); + }); + + it('shows correct UI when both kick and leave callbacks provided but role is HOST', () => { + const players = [ + { id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 }, + { id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + ]; + + render( + + ); + + expect(screen.getByTitle('Kick player')).toBeInTheDocument(); + expect(screen.queryByText('Leave Game')).not.toBeInTheDocument(); + expect(screen.getByText('End Game')).toBeInTheDocument(); + }); + }); }); diff --git a/tests/hooks/useGame.kickLeave.test.tsx b/tests/hooks/useGame.kickLeave.test.tsx new file mode 100644 index 0000000..dd68651 --- /dev/null +++ b/tests/hooks/useGame.kickLeave.test.tsx @@ -0,0 +1,913 @@ +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); + }); + }); +}); diff --git a/types.ts b/types.ts index 8c5e41b..47c00d5 100644 --- a/types.ts +++ b/types.ts @@ -129,7 +129,7 @@ export interface QuizListItem { export interface ProcessedDocument { type: 'native' | 'text'; - content: string | Buffer; + content: string | ArrayBuffer; mimeType?: string; } @@ -226,4 +226,7 @@ export type NetworkMessage = | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } } | { type: 'GAME_OVER'; payload: { players: Player[] } } | { type: 'PRESENTER_CHANGED'; payload: { presenterId: string | null } } - | { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } }; \ No newline at end of file + | { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } } + | { type: 'KICK'; payload: { playerId: string } } + | { type: 'KICKED'; payload: { reason?: string } } + | { type: 'PLAYER_LEFT'; payload: { playerId: string } }; \ No newline at end of file