Add kick player and leave game functionality
- 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)
This commit is contained in:
parent
3122748bae
commit
79820f5298
7 changed files with 1640 additions and 10 deletions
6
App.tsx
6
App.tsx
|
|
@ -94,7 +94,9 @@ function App() {
|
||||||
resumeGame,
|
resumeGame,
|
||||||
presenterId,
|
presenterId,
|
||||||
setPresenterPlayer,
|
setPresenterPlayer,
|
||||||
sendAdvance
|
sendAdvance,
|
||||||
|
kickPlayer,
|
||||||
|
leaveGame
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
|
||||||
const handleSaveQuiz = async () => {
|
const handleSaveQuiz = async () => {
|
||||||
|
|
@ -204,6 +206,8 @@ function App() {
|
||||||
hostParticipates={gameConfig.hostParticipates}
|
hostParticipates={gameConfig.hostParticipates}
|
||||||
presenterId={presenterId}
|
presenterId={presenterId}
|
||||||
onSetPresenter={setPresenterPlayer}
|
onSetPresenter={setPresenterPlayer}
|
||||||
|
onKickPlayer={role === 'HOST' ? kickPlayer : undefined}
|
||||||
|
onLeaveGame={role === 'CLIENT' ? leaveGame : undefined}
|
||||||
/>
|
/>
|
||||||
{auth.isAuthenticated && pendingQuizToSave && (
|
{auth.isAuthenticated && pendingQuizToSave && (
|
||||||
<SaveQuizPrompt
|
<SaveQuizPrompt
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Player } from '../types';
|
import { Player } from '../types';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Sparkles, User, X, Link, Check, QrCode, Crown } from 'lucide-react';
|
import { Sparkles, User, X, Link, Check, QrCode, Crown, LogOut, UserX } from 'lucide-react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { PlayerAvatar } from './PlayerAvatar';
|
import { PlayerAvatar } from './PlayerAvatar';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
@ -17,9 +17,11 @@ interface LobbyProps {
|
||||||
hostParticipates?: boolean;
|
hostParticipates?: boolean;
|
||||||
presenterId?: string | null;
|
presenterId?: string | null;
|
||||||
onSetPresenter?: (playerId: string | null) => void;
|
onSetPresenter?: (playerId: string | null) => void;
|
||||||
|
onKickPlayer?: (playerId: string) => void;
|
||||||
|
onLeaveGame?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => {
|
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter, onKickPlayer, onLeaveGame }) => {
|
||||||
const isHost = role === 'HOST';
|
const isHost = role === 'HOST';
|
||||||
const hostPlayer = players.find(p => p.id === 'host');
|
const hostPlayer = players.find(p => p.id === 'host');
|
||||||
const realPlayers = players.filter(p => p.id !== 'host');
|
const realPlayers = players.filter(p => p.id !== 'host');
|
||||||
|
|
@ -209,6 +211,18 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
{isPlayerPresenter && (
|
{isPlayerPresenter && (
|
||||||
<span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full">PRESENTER</span>
|
<span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full">PRESENTER</span>
|
||||||
)}
|
)}
|
||||||
|
{onKickPlayer && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onKickPlayer(player.id);
|
||||||
|
}}
|
||||||
|
className="ml-1 p-1 rounded-full hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Kick player"
|
||||||
|
>
|
||||||
|
<UserX size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -272,6 +286,19 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
) : (
|
) : (
|
||||||
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
|
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onLeaveGame && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
onClick={onLeaveGame}
|
||||||
|
className="mt-8 bg-white/20 hover:bg-white/30 text-white px-6 py-3 rounded-full font-bold flex items-center gap-2 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
Leave Game
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1446,6 +1446,31 @@ export const useGame = () => {
|
||||||
if (data.type === 'PRESENTER_CHANGED') {
|
if (data.type === 'PRESENTER_CHANGED') {
|
||||||
setPresenterId(data.payload.presenterId);
|
setPresenterId(data.payload.presenterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === 'KICKED') {
|
||||||
|
if (hostConnectionRef.current) {
|
||||||
|
hostConnectionRef.current.close();
|
||||||
|
hostConnectionRef.current = null;
|
||||||
|
}
|
||||||
|
if (peerRef.current) {
|
||||||
|
peerRef.current.destroy();
|
||||||
|
peerRef.current = null;
|
||||||
|
}
|
||||||
|
clearStoredSession();
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
setError(data.payload.reason || 'You were kicked from the game');
|
||||||
|
setGamePin(null);
|
||||||
|
setQuiz(null);
|
||||||
|
setPlayers([]);
|
||||||
|
setCurrentPlayerId(null);
|
||||||
|
setCurrentPlayerName(null);
|
||||||
|
setGameState('LANDING');
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'PLAYER_LEFT') {
|
||||||
|
setPlayers(prev => prev.filter(p => p.id !== data.payload.playerId));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAnswer = (arg: boolean | AnswerOption) => {
|
const handleAnswer = (arg: boolean | AnswerOption) => {
|
||||||
|
|
@ -1535,6 +1560,54 @@ export const useGame = () => {
|
||||||
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } });
|
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const kickPlayer = (playerId: string) => {
|
||||||
|
if (role !== 'HOST') return;
|
||||||
|
if (playerId === 'host') return;
|
||||||
|
|
||||||
|
const conn = connectionsRef.current.get(playerId);
|
||||||
|
if (conn?.open) {
|
||||||
|
conn.send({ type: 'KICKED', payload: { reason: 'You were kicked by the host' } });
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
connectionsRef.current.delete(playerId);
|
||||||
|
|
||||||
|
const updatedPlayers = playersRef.current.filter(p => p.id !== playerId);
|
||||||
|
playersRef.current = updatedPlayers;
|
||||||
|
setPlayers(updatedPlayers);
|
||||||
|
|
||||||
|
if (presenterIdRef.current === playerId) {
|
||||||
|
setPresenterId(null);
|
||||||
|
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: null } });
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'PLAYER_LEFT', payload: { playerId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveGame = () => {
|
||||||
|
if (role !== 'CLIENT') return;
|
||||||
|
|
||||||
|
if (hostConnectionRef.current?.open) {
|
||||||
|
hostConnectionRef.current.close();
|
||||||
|
}
|
||||||
|
hostConnectionRef.current = null;
|
||||||
|
|
||||||
|
if (peerRef.current) {
|
||||||
|
peerRef.current.destroy();
|
||||||
|
peerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredSession();
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
|
||||||
|
setGamePin(null);
|
||||||
|
setQuiz(null);
|
||||||
|
setPlayers([]);
|
||||||
|
setCurrentPlayerId(null);
|
||||||
|
setCurrentPlayerName(null);
|
||||||
|
setGameState('LANDING');
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => {
|
const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => {
|
||||||
if (role !== 'CLIENT' || !hostConnectionRef.current) return;
|
if (role !== 'CLIENT' || !hostConnectionRef.current) return;
|
||||||
hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } });
|
hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } });
|
||||||
|
|
@ -1544,6 +1617,6 @@ export const useGame = () => {
|
||||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
||||||
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId,
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId,
|
||||||
startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||||
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance
|
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,20 @@ function transformToQuiz(data: any): Quiz {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> {
|
async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> {
|
||||||
const buffer = typeof doc.content === 'string'
|
let data: ArrayBuffer;
|
||||||
? Buffer.from(doc.content, 'base64')
|
|
||||||
: doc.content;
|
|
||||||
|
|
||||||
const blob = new Blob([buffer], { type: doc.mimeType });
|
if (typeof doc.content === 'string') {
|
||||||
|
const binaryString = atob(doc.content);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
data = bytes.buffer as ArrayBuffer;
|
||||||
|
} else {
|
||||||
|
data = doc.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([data], { type: doc.mimeType });
|
||||||
|
|
||||||
const uploadedFile = await ai.files.upload({
|
const uploadedFile = await ai.files.upload({
|
||||||
file: blob,
|
file: blob,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ vi.mock('react-hot-toast', () => ({
|
||||||
vi.mock('framer-motion', () => ({
|
vi.mock('framer-motion', () => ({
|
||||||
motion: {
|
motion: {
|
||||||
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
|
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
|
||||||
|
button: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <button {...props}>{children}</button>,
|
||||||
},
|
},
|
||||||
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
@ -541,4 +542,604 @@ describe('Lobby', () => {
|
||||||
expect(presenterBadges).toHaveLength(1);
|
expect(presenterBadges).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('kick player feature - host view', () => {
|
||||||
|
const playersWithKick = [
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('shows kick button on each player when onKickPlayer provided', () => {
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
expect(kickButtons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kick button when onKickPlayer not provided', () => {
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onKickPlayer with correct player id when kick button clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onKickPlayer = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={onKickPlayer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
await user.click(kickButtons[0]);
|
||||||
|
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kick button click does not trigger presenter selection', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onKickPlayer = vi.fn();
|
||||||
|
const onSetPresenter = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
hostParticipates={false}
|
||||||
|
onKickPlayer={onKickPlayer}
|
||||||
|
onSetPresenter={onSetPresenter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
await user.click(kickButtons[0]);
|
||||||
|
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
||||||
|
expect(onSetPresenter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kick button for host player', () => {
|
||||||
|
const playersWithHost = [
|
||||||
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
||||||
|
...playersWithKick,
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithHost}
|
||||||
|
hostParticipates={true}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
expect(kickButtons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can kick second player without affecting first player kick button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onKickPlayer = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={onKickPlayer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
await user.click(kickButtons[1]);
|
||||||
|
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledWith('player-2');
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can kick multiple players in sequence', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onKickPlayer = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={onKickPlayer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
await user.click(kickButtons[0]);
|
||||||
|
await user.click(kickButtons[1]);
|
||||||
|
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onKickPlayer).toHaveBeenNthCalledWith(1, 'player-1');
|
||||||
|
expect(onKickPlayer).toHaveBeenNthCalledWith(2, 'player-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kick button for presenter player', () => {
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
hostParticipates={false}
|
||||||
|
presenterId="player-1"
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
onSetPresenter={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
expect(kickButtons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can kick presenter player', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onKickPlayer = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
hostParticipates={false}
|
||||||
|
presenterId="player-1"
|
||||||
|
onKickPlayer={onKickPlayer}
|
||||||
|
onSetPresenter={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
await user.click(kickButtons[0]);
|
||||||
|
|
||||||
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kick button with single player', () => {
|
||||||
|
const singlePlayer = [{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={singlePlayer}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows kick button for many players', () => {
|
||||||
|
const manyPlayers = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
id: `player-${i}`,
|
||||||
|
name: `Player ${i}`,
|
||||||
|
score: 0,
|
||||||
|
avatarSeed: i * 0.1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={manyPlayers}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kickButtons = screen.getAllByTitle('Kick player');
|
||||||
|
expect(kickButtons).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates kick buttons when players list changes', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
||||||
|
|
||||||
|
const newPlayers = [
|
||||||
|
...playersWithKick,
|
||||||
|
{ id: 'player-3', name: 'Charlie', score: 0, avatarSeed: 0.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={newPlayers}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes kick buttons when onKickPlayer becomes undefined', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
players={playersWithKick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show kick buttons for client role even if onKickPlayer provided', () => {
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={playersWithKick}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leave game feature - client view', () => {
|
||||||
|
it('shows leave game button when onLeaveGame provided', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show leave game button when onLeaveGame not provided', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onLeaveGame when leave button clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onLeaveGame = vi.fn();
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={onLeaveGame}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Leave Game'));
|
||||||
|
|
||||||
|
expect(onLeaveGame).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show leave game button for host', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="HOST"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="host"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows leave game button alongside presenter info', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
presenterId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('You are the Presenter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onLeaveGame only once per click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onLeaveGame = vi.fn();
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={onLeaveGame}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Leave Game'));
|
||||||
|
|
||||||
|
expect(onLeaveGame).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leave button remains clickable after multiple clicks', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onLeaveGame = vi.fn();
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={onLeaveGame}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Leave Game'));
|
||||||
|
await user.click(screen.getByText('Leave Game'));
|
||||||
|
await user.click(screen.getByText('Leave Game'));
|
||||||
|
|
||||||
|
expect(onLeaveGame).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows leave button when player is not in players list', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-2', name: 'Other Player', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows leave button with empty players list', () => {
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={[]}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes leave button when onLeaveGame becomes undefined', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes leave button when role changes to HOST', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="HOST"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('kick and leave interaction edge cases', () => {
|
||||||
|
it('host view shows kick buttons but not leave button', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="HOST"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="host"
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('client view shows leave button but not kick buttons', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="player-1"
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined currentPlayerId for client gracefully', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId={undefined}
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null currentPlayerId for client gracefully', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="CLIENT"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId={null}
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct UI when both kick and leave callbacks provided but role is HOST', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
||||||
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Lobby
|
||||||
|
{...defaultProps}
|
||||||
|
role="HOST"
|
||||||
|
players={players}
|
||||||
|
currentPlayerId="host"
|
||||||
|
hostParticipates={true}
|
||||||
|
onKickPlayer={vi.fn()}
|
||||||
|
onLeaveGame={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('End Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
913
tests/hooks/useGame.kickLeave.test.tsx
Normal file
913
tests/hooks/useGame.kickLeave.test.tsx
Normal file
|
|
@ -0,0 +1,913 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
7
types.ts
7
types.ts
|
|
@ -129,7 +129,7 @@ export interface QuizListItem {
|
||||||
|
|
||||||
export interface ProcessedDocument {
|
export interface ProcessedDocument {
|
||||||
type: 'native' | 'text';
|
type: 'native' | 'text';
|
||||||
content: string | Buffer;
|
content: string | ArrayBuffer;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,4 +226,7 @@ export type NetworkMessage =
|
||||||
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
|
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
|
||||||
| { type: 'GAME_OVER'; payload: { players: Player[] } }
|
| { type: 'GAME_OVER'; payload: { players: Player[] } }
|
||||||
| { type: 'PRESENTER_CHANGED'; payload: { presenterId: string | null } }
|
| { type: 'PRESENTER_CHANGED'; payload: { presenterId: string | null } }
|
||||||
| { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } };
|
| { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } }
|
||||||
|
| { type: 'KICK'; payload: { playerId: string } }
|
||||||
|
| { type: 'KICKED'; payload: { reason?: string } }
|
||||||
|
| { type: 'PLAYER_LEFT'; payload: { playerId: string } };
|
||||||
Loading…
Add table
Add a link
Reference in a new issue