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,
|
||||
presenterId,
|
||||
setPresenterPlayer,
|
||||
sendAdvance
|
||||
sendAdvance,
|
||||
kickPlayer,
|
||||
leaveGame
|
||||
} = useGame();
|
||||
|
||||
const handleSaveQuiz = async () => {
|
||||
|
|
@ -204,6 +206,8 @@ function App() {
|
|||
hostParticipates={gameConfig.hostParticipates}
|
||||
presenterId={presenterId}
|
||||
onSetPresenter={setPresenterPlayer}
|
||||
onKickPlayer={role === 'HOST' ? kickPlayer : undefined}
|
||||
onLeaveGame={role === 'CLIENT' ? leaveGame : undefined}
|
||||
/>
|
||||
{auth.isAuthenticated && pendingQuizToSave && (
|
||||
<SaveQuizPrompt
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Player } from '../types';
|
||||
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 { PlayerAvatar } from './PlayerAvatar';
|
||||
import toast from 'react-hot-toast';
|
||||
|
|
@ -17,9 +17,11 @@ interface LobbyProps {
|
|||
hostParticipates?: boolean;
|
||||
presenterId?: string | null;
|
||||
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 hostPlayer = players.find(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 && (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,31 @@ export const useGame = () => {
|
|||
if (data.type === 'PRESENTER_CHANGED') {
|
||||
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) => {
|
||||
|
|
@ -1535,6 +1560,54 @@ export const useGame = () => {
|
|||
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') => {
|
||||
if (role !== 'CLIENT' || !hostConnectionRef.current) return;
|
||||
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,
|
||||
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId,
|
||||
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 }> {
|
||||
const buffer = typeof doc.content === 'string'
|
||||
? Buffer.from(doc.content, 'base64')
|
||||
: doc.content;
|
||||
let data: ArrayBuffer;
|
||||
|
||||
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({
|
||||
file: blob,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ vi.mock('react-hot-toast', () => ({
|
|||
vi.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
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}</>,
|
||||
}));
|
||||
|
|
@ -541,4 +542,604 @@ describe('Lobby', () => {
|
|||
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 {
|
||||
type: 'native' | 'text';
|
||||
content: string | Buffer;
|
||||
content: string | ArrayBuffer;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
|
|
@ -226,4 +226,7 @@ export type NetworkMessage =
|
|||
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
|
||||
| { type: 'GAME_OVER'; payload: { players: Player[] } }
|
||||
| { 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