Fix stuff
This commit is contained in:
parent
fc270d437f
commit
32696ad33d
13 changed files with 2194 additions and 110 deletions
521
tests/hooks/useGame.reconnection.test.tsx
Normal file
521
tests/hooks/useGame.reconnection.test.tsx
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue