kaboot/tests/hooks/useGame.kickLeave.test.tsx

1058 lines
35 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Player, GameState } from '../../types';
describe('Kick and Leave Feature Logic', () => {
const createPlayer = (overrides: Partial<Player> = {}): 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 <script>alert("xss")</script>' }, 'LOBBY');
expect(result.error).toBe('Kicked for <script>alert("xss")</script>');
});
it('handles very long reason string', () => {
const longReason = 'A'.repeat(1000);
const result = processKickedMessage({ reason: longReason }, 'LOBBY');
expect(result.error).toBe(longReason);
});
it('closes host connection when kicked', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldCloseConnection).toBe(true);
});
it('does not try to close connection when none exists', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: false, hasPeer: true, hasTimer: true }
);
expect(result.shouldCloseConnection).toBe(false);
});
it('destroys peer when kicked', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldDestroyPeer).toBe(true);
});
it('clears timer when kicked during question', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'QUESTION',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldClearTimer).toBe(true);
});
it('handles kick when already in LANDING state', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'LANDING');
expect(result.newGameState).toBe('LANDING');
expect(result.shouldClearSession).toBe(true);
});
it('handles kick during COUNTDOWN', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'COUNTDOWN');
expect(result.newGameState).toBe('LANDING');
});
it('clears all game data when kicked', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'QUESTION');
expect(result.shouldClearGameData).toBe(true);
});
it('handles undefined reason gracefully', () => {
const result = processKickedMessage({ reason: undefined }, 'LOBBY');
expect(result.error).toBe('You were kicked from the game');
});
});
describe('PLAYER_LEFT message handling', () => {
const processPlayerLeft = (
playerId: string,
players: Player[],
presenterId: string | null = null
): { updatedPlayers: Player[]; shouldUpdatePresenter: boolean; newPresenterId: string | null } => {
const updatedPlayers = players.filter(p => p.id !== playerId);
const shouldUpdatePresenter = presenterId === playerId;
const newPresenterId = shouldUpdatePresenter ? null : presenterId;
return { updatedPlayers, shouldUpdatePresenter, newPresenterId };
};
it('removes player from list when they leave', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers).toHaveLength(2);
expect(result.updatedPlayers.find(p => p.id === 'player-2')).toBeUndefined();
expect(result.updatedPlayers.find(p => p.id === 'player-1')).toBeDefined();
expect(result.updatedPlayers.find(p => p.id === 'player-3')).toBeDefined();
});
it('handles player leaving who does not exist', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processPlayerLeft('player-999', players);
expect(result.updatedPlayers).toHaveLength(1);
});
it('handles last player leaving', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(0);
});
it('updates presenter when presenter leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-1', players, 'player-1');
expect(result.shouldUpdatePresenter).toBe(true);
expect(result.newPresenterId).toBeNull();
});
it('does not update presenter when non-presenter leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-2', players, 'player-1');
expect(result.shouldUpdatePresenter).toBe(false);
expect(result.newPresenterId).toBe('player-1');
});
it('preserves player order when someone leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers[0].id).toBe('player-1');
expect(result.updatedPlayers[1].id).toBe('player-3');
});
it('preserves player scores when someone leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice', score: 1000 }),
createPlayer({ id: 'player-2', name: 'Bob', score: 500 }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers[0].score).toBe(1000);
});
it('handles empty players array', () => {
const players: Player[] = [];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(0);
});
it('handles player with complex id leaving', () => {
const players = [
createPlayer({ id: 'peer-abc123-xyz789-def', name: 'Alice' }),
createPlayer({ id: 'peer-111222-333444-555', name: 'Bob' }),
];
const result = processPlayerLeft('peer-abc123-xyz789-def', players);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('peer-111222-333444-555');
});
it('does not modify original players array', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const originalLength = players.length;
processPlayerLeft('player-1', players);
expect(players).toHaveLength(originalLength);
});
it('handles multiple players leaving in sequence', () => {
let players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
createPlayer({ id: 'player-4', name: 'Diana' }),
];
const result1 = processPlayerLeft('player-1', players);
players = result1.updatedPlayers;
const result2 = processPlayerLeft('player-3', players);
players = result2.updatedPlayers;
expect(players).toHaveLength(2);
expect(players.map(p => p.id)).toEqual(['player-2', 'player-4']);
});
it('handles leaving player with active answer state', () => {
const players = [
createPlayer({
id: 'player-1',
name: 'Alice',
lastAnswerCorrect: true,
selectedShape: 'triangle',
pointsBreakdown: { basePoints: 100, streakBonus: 0, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 100 }
}),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('player-2');
});
});
describe('edge cases and race conditions', () => {
it('handles kick request for already-kicked player', () => {
const processKick = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const afterFirstKick = processKick('player-1', players);
const afterSecondKick = processKick('player-1', afterFirstKick);
expect(afterSecondKick).toHaveLength(0);
});
it('handles concurrent kick and leave for same player', () => {
const processRemoval = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const afterKick = processRemoval('player-1', players);
const afterLeave = processRemoval('player-1', afterKick);
expect(afterLeave).toHaveLength(1);
expect(afterLeave[0].id).toBe('player-2');
});
it('handles kick during answer submission', () => {
const player = createPlayer({
id: 'player-1',
name: 'Alice',
lastAnswerCorrect: null,
selectedShape: null,
});
const players = [player];
const updatedPlayers = players.filter(p => p.id !== 'player-1');
expect(updatedPlayers).toHaveLength(0);
});
it('handles multiple rapid kicks', () => {
const processKick = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
let players = Array.from({ length: 10 }, (_, i) =>
createPlayer({ id: `player-${i}`, name: `Player ${i}` })
);
for (let i = 0; i < 10; i++) {
players = processKick(`player-${i}`, players);
}
expect(players).toHaveLength(0);
});
it('validates player id before kick', () => {
const isValidKickTarget = (playerId: string, role: 'HOST' | 'CLIENT') => {
if (role !== 'HOST') return false;
if (playerId === 'host') return false;
if (!playerId || playerId.trim() === '') return false;
return true;
};
expect(isValidKickTarget('player-1', 'HOST')).toBe(true);
expect(isValidKickTarget('player-1', 'CLIENT')).toBe(false);
expect(isValidKickTarget('host', 'HOST')).toBe(false);
expect(isValidKickTarget('', 'HOST')).toBe(false);
expect(isValidKickTarget(' ', 'HOST')).toBe(false);
});
it('handles kick when game is in PODIUM state', () => {
const canKickInState = (gameState: GameState) => {
return gameState !== 'PODIUM';
};
expect(canKickInState('LOBBY')).toBe(true);
expect(canKickInState('QUESTION')).toBe(true);
expect(canKickInState('PODIUM')).toBe(false);
});
it('handles leave when game transitions state', () => {
const canLeaveInState = (gameState: GameState) => {
const nonLeavableStates: GameState[] = ['PODIUM', 'LANDING'];
return !nonLeavableStates.includes(gameState);
};
expect(canLeaveInState('LOBBY')).toBe(true);
expect(canLeaveInState('QUESTION')).toBe(true);
expect(canLeaveInState('SCOREBOARD')).toBe(true);
expect(canLeaveInState('PODIUM')).toBe(false);
expect(canLeaveInState('LANDING')).toBe(false);
});
it('preserves game integrity after player removal', () => {
const validateGameIntegrity = (players: Player[]) => {
const ids = players.map(p => p.id);
const uniqueIds = new Set(ids);
return ids.length === uniqueIds.size;
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const afterRemoval = players.filter(p => p.id !== 'player-1');
expect(validateGameIntegrity(afterRemoval)).toBe(true);
});
});
describe('network message validation', () => {
it('validates KICK message structure', () => {
const isValidKickMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { playerId?: string } };
return msg.type === 'KICK' &&
typeof msg.payload?.playerId === 'string' &&
msg.payload.playerId.length > 0;
};
expect(isValidKickMessage({ type: 'KICK', payload: { playerId: 'player-1' } })).toBe(true);
expect(isValidKickMessage({ type: 'KICK', payload: { playerId: '' } })).toBe(false);
expect(isValidKickMessage({ type: 'KICK', payload: {} })).toBe(false);
expect(isValidKickMessage({ type: 'KICK' })).toBe(false);
expect(isValidKickMessage({ type: 'OTHER', payload: { playerId: 'player-1' } })).toBe(false);
expect(isValidKickMessage(null)).toBe(false);
expect(isValidKickMessage(undefined)).toBe(false);
});
it('validates KICKED message structure', () => {
const isValidKickedMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { reason?: string } };
return msg.type === 'KICKED' && typeof msg.payload === 'object';
};
expect(isValidKickedMessage({ type: 'KICKED', payload: { reason: 'test' } })).toBe(true);
expect(isValidKickedMessage({ type: 'KICKED', payload: {} })).toBe(true);
expect(isValidKickedMessage({ type: 'KICKED' })).toBe(false);
expect(isValidKickedMessage({ type: 'OTHER', payload: {} })).toBe(false);
});
it('validates PLAYER_LEFT message structure', () => {
const isValidPlayerLeftMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { playerId?: string } };
return msg.type === 'PLAYER_LEFT' &&
typeof msg.payload?.playerId === 'string';
};
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: { playerId: 'player-1' } })).toBe(true);
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: {} })).toBe(false);
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT' })).toBe(false);
});
it('validates LEAVE message structure', () => {
const isValidLeaveMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: object };
return msg.type === 'LEAVE' && typeof msg.payload === 'object';
};
expect(isValidLeaveMessage({ type: 'LEAVE', payload: {} })).toBe(true);
expect(isValidLeaveMessage({ type: 'LEAVE' })).toBe(false);
expect(isValidLeaveMessage({ type: 'OTHER', payload: {} })).toBe(false);
expect(isValidLeaveMessage(null)).toBe(false);
});
});
describe('host handling LEAVE message', () => {
const processLeaveMessage = (
leavingPlayerId: string,
players: Player[],
presenterId: string | null
): {
updatedPlayers: Player[];
newPresenterId: string | null;
shouldBroadcastPlayerLeft: boolean;
shouldBroadcastPresenterChanged: boolean;
} => {
const updatedPlayers = players.filter(p => p.id !== leavingPlayerId);
const wasPresenter = presenterId === leavingPlayerId;
let newPresenterId = presenterId;
if (wasPresenter) {
const realPlayers = updatedPlayers.filter(p => p.id !== 'host');
newPresenterId = realPlayers.length > 0 ? realPlayers[0].id : null;
}
return {
updatedPlayers,
newPresenterId,
shouldBroadcastPlayerLeft: true,
shouldBroadcastPresenterChanged: wasPresenter,
};
};
it('removes leaving player from players list', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processLeaveMessage('player-1', players, null);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('player-2');
});
it('broadcasts PLAYER_LEFT to remaining players', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processLeaveMessage('player-1', players, null);
expect(result.shouldBroadcastPlayerLeft).toBe(true);
});
it('reassigns presenter when leaving player was presenter', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processLeaveMessage('player-1', players, 'player-1');
expect(result.newPresenterId).toBe('player-2');
expect(result.shouldBroadcastPresenterChanged).toBe(true);
});
it('preserves presenter when non-presenter leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processLeaveMessage('player-2', players, 'player-1');
expect(result.newPresenterId).toBe('player-1');
expect(result.shouldBroadcastPresenterChanged).toBe(false);
});
it('sets presenter to null when last non-host player leaves', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processLeaveMessage('player-1', players, 'player-1');
expect(result.newPresenterId).toBeNull();
expect(result.shouldBroadcastPresenterChanged).toBe(true);
});
it('handles leave when player does not exist', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processLeaveMessage('player-999', players, null);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.shouldBroadcastPlayerLeft).toBe(true);
});
it('does not modify original players array', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const originalLength = players.length;
processLeaveMessage('player-1', players, null);
expect(players).toHaveLength(originalLength);
});
it('handles multiple sequential leaves', () => {
let players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
let presenterId: string | null = 'player-1';
const result1 = processLeaveMessage('player-1', players, presenterId);
players = result1.updatedPlayers;
presenterId = result1.newPresenterId;
const result2 = processLeaveMessage('player-2', players, presenterId);
players = result2.updatedPlayers;
presenterId = result2.newPresenterId;
expect(players).toHaveLength(1);
expect(players[0].id).toBe('player-3');
expect(presenterId).toBe('player-3');
});
});
});