import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /** * Test suite for game reconnection scenarios * * These tests verify the reconnection logic works correctly for: * 1. Player reconnection with score/answer preservation * 2. Host reconnection with game state preservation * 3. State synchronization between host and reconnecting players */ describe('Game Reconnection Logic', () => { describe('Player Matching on Reconnection', () => { const createMockPlayer = (overrides = {}) => ({ id: 'player-123', name: 'TestPlayer', score: 500, previousScore: 0, streak: 2, lastAnswerCorrect: true, selectedShape: 'diamond' as const, pointsBreakdown: { basePoints: 450, streakBonus: 50, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 500 }, isBot: false, avatarSeed: 0.5, color: '#ff0000', ...overrides }); it('should find existing player by previousId', () => { const players = [createMockPlayer({ id: 'old-peer-id' })]; const payload = { name: 'TestPlayer', reconnect: true, previousId: 'old-peer-id' }; const existingByPreviousId = payload.previousId ? players.find(p => p.id === payload.previousId) : null; expect(existingByPreviousId).toBeDefined(); expect(existingByPreviousId?.score).toBe(500); expect(existingByPreviousId?.lastAnswerCorrect).toBe(true); }); it('should find existing player by name when previousId not found', () => { const players = [createMockPlayer({ id: 'different-id' })]; const payload = { name: 'TestPlayer', reconnect: true, previousId: 'non-existent-id' }; const existingByPreviousId = payload.previousId ? players.find(p => p.id === payload.previousId) : null; const existingByName = players.find(p => p.name === payload.name && p.id !== 'host'); const existingPlayer = existingByPreviousId || existingByName; expect(existingByPreviousId).toBeUndefined(); expect(existingByName).toBeDefined(); expect(existingPlayer?.score).toBe(500); }); it('should preserve player score when reconnecting', () => { const players = [createMockPlayer({ score: 1500, streak: 3 })]; const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' }; const existingPlayer = players.find(p => p.id === payload.previousId); const reconnectedPlayer = existingPlayer ? { ...existingPlayer, id: 'new-peer-id' } : null; expect(reconnectedPlayer?.score).toBe(1500); expect(reconnectedPlayer?.streak).toBe(3); expect(reconnectedPlayer?.id).toBe('new-peer-id'); }); it('should preserve lastAnswerCorrect when reconnecting', () => { const players = [createMockPlayer({ lastAnswerCorrect: true, pointsBreakdown: { total: 500 } as any })]; const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' }; const existingPlayer = players.find(p => p.id === payload.previousId); expect(existingPlayer?.lastAnswerCorrect).toBe(true); expect(existingPlayer?.pointsBreakdown?.total).toBe(500); }); it('should NOT create duplicate player when reconnecting by name', () => { const players = [createMockPlayer({ id: 'old-id', name: 'TestPlayer' })]; const payload = { name: 'TestPlayer', reconnect: true, previousId: 'wrong-id' }; const existingByName = players.find(p => p.name === payload.name && p.id !== 'host'); // Should find existing player, not create new one expect(existingByName).toBeDefined(); expect(players.length).toBe(1); }); it('should update player ID when reconnecting', () => { const oldId = 'old-peer-id'; const newId = 'new-peer-id'; const players = [createMockPlayer({ id: oldId })]; const existingPlayer = players.find(p => p.id === oldId); const updatedPlayers = players.map(p => p.id === oldId ? { ...p, id: newId } : p ); expect(updatedPlayers[0].id).toBe(newId); expect(updatedPlayers[0].score).toBe(existingPlayer?.score); }); }); describe('WELCOME Message Construction', () => { it('should include hasAnswered when player already answered', () => { const reconnectedPlayer = { lastAnswerCorrect: true, pointsBreakdown: { total: 500 } }; const welcomePayload: any = {}; if (reconnectedPlayer) { welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null; welcomePayload.lastAnswerCorrect = reconnectedPlayer.lastAnswerCorrect; if (reconnectedPlayer.pointsBreakdown) { welcomePayload.lastPointsEarned = reconnectedPlayer.pointsBreakdown.total; } } expect(welcomePayload.hasAnswered).toBe(true); expect(welcomePayload.lastAnswerCorrect).toBe(true); expect(welcomePayload.lastPointsEarned).toBe(500); }); it('should include hasAnswered=false when player has not answered', () => { const reconnectedPlayer = { lastAnswerCorrect: null, pointsBreakdown: null }; const welcomePayload: any = {}; if (reconnectedPlayer) { welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null; } expect(welcomePayload.hasAnswered).toBe(false); }); it('should include question data for mid-game reconnection', () => { const currentQuestion = { text: 'What is 2+2?', options: [ { text: '3', isCorrect: false, shape: 'triangle', color: 'red' }, { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' }, ] }; const welcomePayload: any = { gameState: 'QUESTION', currentQuestionIndex: 1, totalQuestions: 5, timeLeft: 15000, }; if (currentQuestion) { welcomePayload.questionText = currentQuestion.text; const correctOpt = currentQuestion.options.find(o => o.isCorrect); welcomePayload.correctShape = correctOpt?.shape; // Mask correct answer from client welcomePayload.options = currentQuestion.options.map(o => ({ ...o, isCorrect: false })); } expect(welcomePayload.questionText).toBe('What is 2+2?'); expect(welcomePayload.correctShape).toBe('diamond'); expect(welcomePayload.options.every((o: any) => o.isCorrect === false)).toBe(true); }); it('should include player scores and streak', () => { const reconnectedPlayer = { score: 1500, streak: 3, lastAnswerCorrect: true, pointsBreakdown: { total: 500 } }; const welcomePayload: any = {}; if (reconnectedPlayer) { welcomePayload.score = reconnectedPlayer.score; welcomePayload.streak = reconnectedPlayer.streak; } expect(welcomePayload.score).toBe(1500); expect(welcomePayload.streak).toBe(3); }); }); describe('Client State Sync on WELCOME', () => { it('should set QUESTION state when host is in QUESTION and player answered', () => { const payload = { gameState: 'QUESTION' as const, hasAnswered: true, }; let resultState: string; if (payload.gameState === 'QUESTION' || payload.gameState === 'COUNTDOWN') { resultState = payload.gameState; } else { resultState = 'LOBBY'; } expect(resultState).toBe('QUESTION'); }); it('should set REVEAL state when host is in REVEAL', () => { const payload = { gameState: 'REVEAL' as const, hasAnswered: true, }; let resultState: string; if (payload.gameState === 'REVEAL') { resultState = 'REVEAL'; } else { resultState = 'LOBBY'; } expect(resultState).toBe('REVEAL'); }); it('should set SCOREBOARD state when host is in SCOREBOARD', () => { const payload = { gameState: 'SCOREBOARD' as const, }; let resultState: string; if (payload.gameState === 'SCOREBOARD') { resultState = 'SCOREBOARD'; } else { resultState = 'LOBBY'; } expect(resultState).toBe('SCOREBOARD'); }); it('should reconstruct quiz from WELCOME payload', () => { const payload = { quizTitle: 'Test Quiz', totalQuestions: 5, currentQuestionIndex: 2, questionText: 'What is 2+2?', options: [ { text: '4', shape: 'diamond', color: 'blue', isCorrect: false } ] }; const questions: any[] = []; for (let i = 0; i < payload.totalQuestions; i++) { if (i === payload.currentQuestionIndex) { questions.push({ id: `q-${i}`, text: payload.questionText, options: payload.options, timeLimit: 30 }); } else { questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 }); } } const quiz = { title: payload.quizTitle, questions }; expect(quiz.questions.length).toBe(5); expect(quiz.questions[2].text).toBe('What is 2+2?'); expect(quiz.questions[0].text).toBe(''); }); }); describe('Host Reconnection - Game Resume', () => { it('should set HOST_RECONNECTED state when game was mid-play', () => { const hostData = { gameState: 'QUESTION', currentQuestionIndex: 2, }; let resultState: string; if (hostData.gameState === 'LOBBY') { resultState = 'LOBBY'; } else if (hostData.gameState === 'PODIUM') { resultState = 'PODIUM'; } else { resultState = 'HOST_RECONNECTED'; } expect(resultState).toBe('HOST_RECONNECTED'); }); it('should preserve currentQuestionIndex on host reconnect', () => { const hostData = { currentQuestionIndex: 3, quiz: { questions: [{}, {}, {}, {}, {}] } }; expect(hostData.currentQuestionIndex).toBe(3); }); it('should restore host player score on reconnect', () => { const hostData = { players: [ { id: 'host', name: 'Host', score: 1200, streak: 4 } ] }; const hostPlayer = hostData.players.find(p => p.id === 'host'); expect(hostPlayer?.score).toBe(1200); expect(hostPlayer?.streak).toBe(4); }); it('should clear non-host players on host reconnect (they will rejoin)', () => { const hostData = { players: [ { id: 'host', name: 'Host', score: 1200 }, { id: 'player1', name: 'Player1', score: 800 }, { id: 'player2', name: 'Player2', score: 600 }, ] }; const hostPlayer = hostData.players.find(p => p.id === 'host'); const restoredPlayers = hostPlayer ? [hostPlayer] : []; // Only host should be in players array after reconnect // Other players will rejoin and be matched by name expect(restoredPlayers.length).toBe(1); expect(restoredPlayers[0].id).toBe('host'); }); }); describe('Host Answer State on Resume', () => { it('should preserve hasAnswered=true if host already answered before disconnect', () => { const hostPlayer = { id: 'host', lastAnswerCorrect: true, pointsBreakdown: { total: 500 } }; const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null; expect(hostAlreadyAnswered).toBe(true); }); it('should allow host to answer if they had not answered before disconnect', () => { const hostPlayer = { id: 'host', lastAnswerCorrect: null, pointsBreakdown: null }; const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null; expect(hostAlreadyAnswered).toBe(false); }); it('startQuestion with isResume=true should preserve answer state', () => { const players = [ { id: 'host', lastAnswerCorrect: true, score: 500 }, { id: 'player1', lastAnswerCorrect: false, score: 0 }, { id: 'player2', lastAnswerCorrect: null, score: 300 }, // Didn't answer yet ]; const isResume = true; // When resuming, players who already answered should keep their state const updatedPlayers = isResume ? players // Keep all states on resume : players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null })); expect(updatedPlayers[0].lastAnswerCorrect).toBe(true); expect(updatedPlayers[1].lastAnswerCorrect).toBe(false); }); it('startQuestion with isResume=false should reset all answer states', () => { const players = [ { id: 'host', lastAnswerCorrect: true, score: 500 }, { id: 'player1', lastAnswerCorrect: false, score: 0 }, ]; const isResume = false; const updatedPlayers = isResume ? players : players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null })); expect(updatedPlayers[0].lastAnswerCorrect).toBe(null); expect(updatedPlayers[1].lastAnswerCorrect).toBe(null); }); }); describe('Disconnection State Transitions', () => { it('should transition to DISCONNECTED state on connection close', () => { const currentGameState: string = 'QUESTION'; let newState = currentGameState; // Simulating conn.on('close') handler if (currentGameState !== 'PODIUM') { newState = 'DISCONNECTED'; } expect(newState).toBe('DISCONNECTED'); }); it('should NOT transition to DISCONNECTED if game is at PODIUM', () => { const currentGameState: string = 'PODIUM'; let newState = currentGameState; if (currentGameState !== 'PODIUM') { newState = 'DISCONNECTED'; } expect(newState).toBe('PODIUM'); }); it('should preserve player name and game PIN when disconnected', () => { const gameState = { currentPlayerName: 'TestPlayer', gamePin: '123456', }; // On disconnect, these should NOT be cleared - only gameState changes to DISCONNECTED expect(gameState.currentPlayerName).toBe('TestPlayer'); expect(gameState.gamePin).toBe('123456'); }); }); }); describe('Session Storage Logic', () => { const SESSION_STORAGE_KEY = 'kaboot_session'; const getStoredSession = (storage: Map) => { const stored = storage.get(SESSION_STORAGE_KEY); return stored ? JSON.parse(stored) : null; }; const storeSession = (storage: Map, session: any) => { storage.set(SESSION_STORAGE_KEY, JSON.stringify(session)); }; const clearStoredSession = (storage: Map) => { storage.delete(SESSION_STORAGE_KEY); }; it('should store session with all required fields for client', () => { const storage = new Map(); const session = { pin: '123456', role: 'CLIENT' as const, playerName: 'TestPlayer', playerId: 'peer-123' }; storeSession(storage, session); const stored = getStoredSession(storage); expect(stored.pin).toBe('123456'); expect(stored.role).toBe('CLIENT'); expect(stored.playerName).toBe('TestPlayer'); expect(stored.playerId).toBe('peer-123'); }); it('should store session with hostSecret for host', () => { const storage = new Map(); const session = { pin: '123456', role: 'HOST' as const, hostSecret: 'secret-abc123' }; storeSession(storage, session); const stored = getStoredSession(storage); expect(stored.hostSecret).toBe('secret-abc123'); }); it('should update playerId on reconnection', () => { const storage = new Map(); const oldSession = { pin: '123456', role: 'CLIENT' as const, playerName: 'TestPlayer', playerId: 'old-peer-id' }; storeSession(storage, oldSession); // Simulate reconnection - update playerId while preserving other fields const newSession = { ...oldSession, playerId: 'new-peer-id' }; storeSession(storage, newSession); const stored = getStoredSession(storage); expect(stored.playerId).toBe('new-peer-id'); expect(stored.playerName).toBe('TestPlayer'); // Name should be preserved }); it('should clear session on goHomeFromDisconnected', () => { const storage = new Map(); const session = { pin: '123456', role: 'CLIENT' as const, playerName: 'TestPlayer', }; storeSession(storage, session); expect(getStoredSession(storage)).not.toBeNull(); // Simulate goHomeFromDisconnected clearStoredSession(storage); expect(getStoredSession(storage)).toBeNull(); }); });