521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
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<string, string>) => {
|
|
const stored = storage.get(SESSION_STORAGE_KEY);
|
|
return stored ? JSON.parse(stored) : null;
|
|
};
|
|
|
|
const storeSession = (storage: Map<string, string>, session: any) => {
|
|
storage.set(SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
};
|
|
|
|
const clearStoredSession = (storage: Map<string, string>) => {
|
|
storage.delete(SESSION_STORAGE_KEY);
|
|
};
|
|
|
|
it('should store session with all required fields for client', () => {
|
|
const storage = new Map<string, string>();
|
|
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<string, string>();
|
|
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<string, string>();
|
|
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<string, string>();
|
|
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();
|
|
});
|
|
});
|