- Host can kick players from lobby (removes from game, clears presenter if needed) - Client can voluntarily leave game - Fix browser-compatible base64 decoding for document upload (atob vs Buffer)
913 lines
30 KiB
TypeScript
913 lines
30 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);
|
|
});
|
|
});
|
|
});
|