Add presenter role for game flow control

This commit is contained in:
Joey Yakimowich-Payne 2026-01-19 14:02:28 -07:00
commit 9ef8f7343d
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
10 changed files with 1412 additions and 17 deletions

11
App.tsx
View file

@ -91,7 +91,10 @@ function App() {
attemptReconnect, attemptReconnect,
goHomeFromDisconnected, goHomeFromDisconnected,
endGame, endGame,
resumeGame resumeGame,
presenterId,
setPresenterPlayer,
sendAdvance
} = useGame(); } = useGame();
const handleSaveQuiz = async () => { const handleSaveQuiz = async () => {
@ -199,6 +202,8 @@ function App() {
onEndGame={role === 'HOST' ? endGame : undefined} onEndGame={role === 'HOST' ? endGame : undefined}
currentPlayerId={currentPlayerId} currentPlayerId={currentPlayerId}
hostParticipates={gameConfig.hostParticipates} hostParticipates={gameConfig.hostParticipates}
presenterId={presenterId}
onSetPresenter={setPresenterPlayer}
/> />
{auth.isAuthenticated && pendingQuizToSave && ( {auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt <SaveQuizPrompt
@ -259,6 +264,8 @@ function App() {
selectedOption={selectedOption} selectedOption={selectedOption}
role={role} role={role}
onNext={showScoreboard} onNext={showScoreboard}
isPresenter={currentPlayerId === presenterId}
onPresenterAdvance={() => sendAdvance('SCOREBOARD')}
/> />
) : currentPlayerName ? ( ) : currentPlayerName ? (
<WaitingToRejoin <WaitingToRejoin
@ -274,6 +281,8 @@ function App() {
onNext={nextQuestion} onNext={nextQuestion}
isHost={role === 'HOST'} isHost={role === 'HOST'}
currentPlayerId={currentPlayerId} currentPlayerId={currentPlayerId}
isPresenter={currentPlayerId === presenterId}
onPresenterAdvance={() => sendAdvance('NEXT')}
/> />
) : null} ) : null}

View file

@ -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 } from 'lucide-react'; import { Sparkles, User, X, Link, Check, QrCode, Crown } 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';
@ -15,15 +15,19 @@ interface LobbyProps {
onEndGame?: () => void; onEndGame?: () => void;
currentPlayerId?: string | null; currentPlayerId?: string | null;
hostParticipates?: boolean; hostParticipates?: boolean;
presenterId?: string | null;
onSetPresenter?: (playerId: string | null) => void;
} }
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false }) => { export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => {
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');
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null; const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [isQrModalOpen, setIsQrModalOpen] = useState(false); const [isQrModalOpen, setIsQrModalOpen] = useState(false);
const isPresenter = currentPlayerId === presenterId;
const canSelectPresenter = isHost && !hostParticipates && onSetPresenter;
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -165,6 +169,12 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
<div className="text-xl md:text-3xl font-bold font-display text-center px-4">Waiting for players to join...</div> <div className="text-xl md:text-3xl font-bold font-display text-center px-4">Waiting for players to join...</div>
</div> </div>
)} )}
{realPlayers.length > 0 && !hostParticipates && canSelectPresenter && (
<div className="w-full text-center text-white/60 text-sm mb-2">
<Crown size={14} className="inline mr-1 text-yellow-400" />
Click a player to make them presenter (can advance screens)
</div>
)}
{hostParticipates && hostPlayer && ( {hostParticipates && hostPlayer && (
<motion.div <motion.div
key="host" key="host"
@ -178,18 +188,30 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
<span className="text-xs bg-black/20 px-2 py-0.5 rounded-full">HOST</span> <span className="text-xs bg-black/20 px-2 py-0.5 rounded-full">HOST</span>
</motion.div> </motion.div>
)} )}
{realPlayers.map((player) => ( {realPlayers.map((player) => {
const isPlayerPresenter = player.id === presenterId;
return (
<motion.div <motion.div
key={player.id} key={player.id}
initial={{ scale: 0, rotate: -10 }} initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }} animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }} exit={{ scale: 0, opacity: 0 }}
className="bg-white text-black px-4 md:px-6 py-2 md:py-3 rounded-full font-black text-base md:text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-2 md:gap-3 border-b-4 border-gray-200" onClick={() => canSelectPresenter && onSetPresenter(player.id)}
className={`bg-white text-black px-4 md:px-6 py-2 md:py-3 rounded-full font-black text-base md:text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-2 md:gap-3 border-b-4 ${
isPlayerPresenter ? 'border-yellow-400 ring-2 ring-yellow-400' : 'border-gray-200'
} ${canSelectPresenter ? 'cursor-pointer hover:scale-105 transition-transform' : ''}`}
> >
{isPlayerPresenter && (
<Crown size={18} className="text-yellow-500 -ml-1" />
)}
<PlayerAvatar seed={player.avatarSeed} size={20} /> <PlayerAvatar seed={player.avatarSeed} size={20} />
{player.name} {player.name}
{isPlayerPresenter && (
<span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full">PRESENTER</span>
)}
</motion.div> </motion.div>
))} );
})}
</AnimatePresence> </AnimatePresence>
</div> </div>
@ -223,8 +245,13 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
initial={{ scale: 0.5 }} initial={{ scale: 0.5 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.6 }} transition={{ type: 'spring', bounce: 0.6 }}
className="bg-white p-6 md:p-8 rounded-2xl md:rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-4 md:mb-8" className={`bg-white p-6 md:p-8 rounded-2xl md:rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-4 md:mb-8 relative ${isPresenter ? 'ring-4 ring-yellow-400' : ''}`}
> >
{isPresenter && (
<div className="absolute -top-3 -right-3 bg-yellow-400 p-2 rounded-full shadow-lg">
<Crown size={24} className="text-yellow-900" />
</div>
)}
{currentPlayer ? ( {currentPlayer ? (
<PlayerAvatar seed={currentPlayer.avatarSeed} size={60} className="md:w-20 md:h-20" /> <PlayerAvatar seed={currentPlayer.avatarSeed} size={60} className="md:w-20 md:h-20" />
) : ( ) : (
@ -234,7 +261,17 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
<h2 className="text-3xl md:text-5xl font-black mb-2 md:mb-4 font-display"> <h2 className="text-3xl md:text-5xl font-black mb-2 md:mb-4 font-display">
{currentPlayer?.name || "You're in!"} {currentPlayer?.name || "You're in!"}
</h2> </h2>
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p> {isPresenter ? (
<div className="flex flex-col items-center gap-2">
<span className="bg-yellow-400 text-yellow-900 px-4 py-1 rounded-full font-bold text-sm flex items-center gap-2">
<Crown size={16} />
You are the Presenter
</span>
<p className="text-lg md:text-xl font-bold opacity-80">You can advance screens during the game</p>
</div>
) : (
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
)}
</div> </div>
)} )}
</main> </main>

View file

@ -35,6 +35,8 @@ interface RevealScreenProps {
selectedOption?: AnswerOption | null; selectedOption?: AnswerOption | null;
role: GameRole; role: GameRole;
onNext?: () => void; onNext?: () => void;
isPresenter?: boolean;
onPresenterAdvance?: () => void;
} }
export const RevealScreen: React.FC<RevealScreenProps> = ({ export const RevealScreen: React.FC<RevealScreenProps> = ({
@ -45,9 +47,12 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
correctOption, correctOption,
selectedOption, selectedOption,
role, role,
onNext onNext,
isPresenter = false,
onPresenterAdvance
}) => { }) => {
const isHost = role === 'HOST'; const isHost = role === 'HOST';
const canAdvance = isHost || isPresenter;
// Trigger confetti for correct answers // Trigger confetti for correct answers
useEffect(() => { useEffect(() => {
@ -191,7 +196,7 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
</motion.div> </motion.div>
{!isCorrect && ( {!isCorrect && (
<div className="absolute bottom-0 left-0 right-0 overflow-hidden z-20"> <div className={`absolute bottom-0 left-0 right-0 overflow-hidden z-20 ${isPresenter ? 'pb-20' : ''}`}>
<motion.div <motion.div
initial={{ y: "100%" }} initial={{ y: "100%" }}
animate={{ y: 0 }} animate={{ y: 0 }}
@ -223,6 +228,19 @@ export const RevealScreen: React.FC<RevealScreenProps> = ({
</motion.div> </motion.div>
</div> </div>
)} )}
{isPresenter && onPresenterAdvance && (
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
onClick={onPresenterAdvance}
className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-white text-gray-900 px-8 py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_rgba(0,0,0,0.3)] active:shadow-none active:translate-y-[6px] transition-all flex items-center gap-2 hover:bg-gray-100 z-30 cursor-pointer"
>
Continue to Scoreboard
<ChevronRight size={28} strokeWidth={3} />
</motion.button>
)}
</div> </div>
); );
}; };

View file

@ -210,6 +210,8 @@ interface ScoreboardProps {
onNext: () => void; onNext: () => void;
isHost: boolean; isHost: boolean;
currentPlayerId: string | null; currentPlayerId: string | null;
isPresenter?: boolean;
onPresenterAdvance?: () => void;
} }
interface AnimatedPlayerState extends Player { interface AnimatedPlayerState extends Player {
@ -219,7 +221,8 @@ interface AnimatedPlayerState extends Player {
initialIndex: number; initialIndex: number;
} }
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => { export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId, isPresenter = false, onPresenterAdvance }) => {
const canAdvance = isHost || isPresenter;
// Initialize players sorted by previousScore to start // Initialize players sorted by previousScore to start
const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => { const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
const playersWithMeta = players.map(p => ({ const playersWithMeta = players.map(p => ({
@ -341,9 +344,9 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
</div> </div>
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20"> <div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20">
{isHost ? ( {canAdvance ? (
<button <button
onClick={onNext} onClick={isHost ? onNext : onPresenterAdvance}
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all flex items-center gap-2" className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all flex items-center gap-2"
> >
Next <Trophy size={24} /> Next <Trophy size={24} />

View file

@ -124,6 +124,7 @@ export const useGame = () => {
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null); const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
const [hostSecret, setHostSecret] = useState<string | null>(null); const [hostSecret, setHostSecret] = useState<string | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
const [presenterId, setPresenterId] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const syncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const syncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -141,6 +142,7 @@ export const useGame = () => {
const gameStateRef = useRef<GameState>("LANDING"); const gameStateRef = useRef<GameState>("LANDING");
const firstCorrectPlayerIdRef = useRef<string | null>(null); const firstCorrectPlayerIdRef = useRef<string | null>(null);
const currentCorrectShapeRef = useRef<string | null>(null); const currentCorrectShapeRef = useRef<string | null>(null);
const presenterIdRef = useRef<string | null>(null);
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]); useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
useEffect(() => { playersRef.current = players; }, [players]); useEffect(() => { playersRef.current = players; }, [players]);
@ -152,6 +154,7 @@ export const useGame = () => {
useEffect(() => { gameStateRef.current = gameState; }, [gameState]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]); useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]); useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
useEffect(() => { presenterIdRef.current = presenterId; }, [presenterId]);
const isInitializingFromUrl = useRef(false); const isInitializingFromUrl = useRef(false);
@ -817,6 +820,9 @@ export const useGame = () => {
updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p); updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p);
setPlayers(updatedPlayers); setPlayers(updatedPlayers);
assignedName = reconnectedPlayer.name; assignedName = reconnectedPlayer.name;
if (presenterIdRef.current === reconnectedPlayer.id) {
setPresenterId(conn.peer);
}
} else if (!playersRef.current.find(p => p.id === conn.peer)) { } else if (!playersRef.current.find(p => p.id === conn.peer)) {
const colorIndex = playersRef.current.length % PLAYER_COLORS.length; const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
newPlayer = { newPlayer = {
@ -834,6 +840,12 @@ export const useGame = () => {
}; };
updatedPlayers = [...playersRef.current, newPlayer]; updatedPlayers = [...playersRef.current, newPlayer];
setPlayers(updatedPlayers); setPlayers(updatedPlayers);
const realPlayers = updatedPlayers.filter(p => p.id !== 'host');
if (!gameConfigRef.current.hostParticipates && realPlayers.length === 1 && !presenterIdRef.current) {
setPresenterId(conn.peer);
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: conn.peer } });
}
} }
const currentState = gameStateRef.current; const currentState = gameStateRef.current;
@ -855,6 +867,7 @@ export const useGame = () => {
lastAnswerCorrect: null, lastAnswerCorrect: null,
selectedShape: null, selectedShape: null,
assignedName, assignedName,
presenterId: presenterIdRef.current,
}; };
if (currentQuestion) { if (currentQuestion) {
@ -933,6 +946,26 @@ export const useGame = () => {
endQuestion(); endQuestion();
} }
} }
if (data.type === 'ADVANCE') {
const { action } = data.payload;
if (conn.peer !== presenterIdRef.current) {
console.log('[HOST] ADVANCE rejected - not from presenter');
return;
}
if (action === 'START' && gameStateRef.current === 'LOBBY') {
startHostGame();
} else if (action === 'NEXT') {
if (gameStateRef.current === 'REVEAL') {
showScoreboard();
} else if (gameStateRef.current === 'SCOREBOARD') {
nextQuestion();
}
} else if (action === 'SCOREBOARD' && gameStateRef.current === 'REVEAL') {
showScoreboard();
}
}
}; };
useEffect(() => { useEffect(() => {
@ -1287,6 +1320,9 @@ export const useGame = () => {
setSelectedOption(matchedOption); setSelectedOption(matchedOption);
} }
} }
if (payload.presenterId !== undefined) {
setPresenterId(payload.presenterId);
}
if (payload.questionText && payload.options && payload.totalQuestions !== undefined) { if (payload.questionText && payload.options && payload.totalQuestions !== undefined) {
const questions: Question[] = []; const questions: Question[] = [];
@ -1406,6 +1442,10 @@ export const useGame = () => {
setPlayers(data.payload.players); setPlayers(data.payload.players);
clearStoredSession(); clearStoredSession();
} }
if (data.type === 'PRESENTER_CHANGED') {
setPresenterId(data.payload.presenterId);
}
}; };
const handleAnswer = (arg: boolean | AnswerOption) => { const handleAnswer = (arg: boolean | AnswerOption) => {
@ -1489,10 +1529,21 @@ export const useGame = () => {
} }
}, [gameState, players, role]); }, [gameState, players, role]);
const setPresenterPlayer = (playerId: string | null) => {
if (role !== 'HOST') return;
setPresenterId(playerId);
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } });
};
const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => {
if (role !== 'CLIENT' || !hostConnectionRef.current) return;
hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } });
};
return { return {
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, 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 updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance
}; };
}; };

View file

@ -187,7 +187,10 @@ describe('Lobby', () => {
describe('client view', () => { describe('client view', () => {
it('shows waiting message for client', () => { it('shows waiting message for client', () => {
render(<Lobby {...defaultProps} role="CLIENT" />); 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.getByText('Waiting for the host to start...')).toBeInTheDocument(); expect(screen.getByText('Waiting for the host to start...')).toBeInTheDocument();
}); });
@ -264,4 +267,278 @@ describe('Lobby', () => {
expect(screen.queryByText('Waiting for players to join...')).not.toBeInTheDocument(); expect(screen.queryByText('Waiting for players to join...')).not.toBeInTheDocument();
}); });
}); });
describe('presenter feature - host view', () => {
const playersWithPresenter = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
];
it('shows presenter selection hint when host does not participate', () => {
render(
<Lobby
{...defaultProps}
players={playersWithPresenter}
hostParticipates={false}
onSetPresenter={vi.fn()}
/>
);
expect(screen.getByText(/Click a player to make them presenter/)).toBeInTheDocument();
});
it('does not show presenter selection hint when host participates', () => {
const players = [
{ id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 },
...playersWithPresenter,
];
render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={true}
onSetPresenter={vi.fn()}
/>
);
expect(screen.queryByText(/Click a player to make them presenter/)).not.toBeInTheDocument();
});
it('shows PRESENTER badge on the presenter player', () => {
render(
<Lobby
{...defaultProps}
players={playersWithPresenter}
hostParticipates={false}
presenterId="player-1"
onSetPresenter={vi.fn()}
/>
);
expect(screen.getByText('PRESENTER')).toBeInTheDocument();
});
it('calls onSetPresenter when clicking a player', async () => {
const user = userEvent.setup();
const onSetPresenter = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithPresenter}
hostParticipates={false}
onSetPresenter={onSetPresenter}
/>
);
await user.click(screen.getByText('Bob'));
expect(onSetPresenter).toHaveBeenCalledWith('player-2');
});
it('does not call onSetPresenter when clicking player if host participates', async () => {
const user = userEvent.setup();
const onSetPresenter = vi.fn();
const players = [
{ id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 },
...playersWithPresenter,
];
render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={true}
onSetPresenter={onSetPresenter}
/>
);
await user.click(screen.getByText('Alice'));
expect(onSetPresenter).not.toHaveBeenCalled();
});
it('does not show presenter hint when no players have joined', () => {
render(
<Lobby
{...defaultProps}
players={[]}
hostParticipates={false}
onSetPresenter={vi.fn()}
/>
);
expect(screen.queryByText(/Click a player to make them presenter/)).not.toBeInTheDocument();
});
it('applies visual highlight to presenter player', () => {
render(
<Lobby
{...defaultProps}
players={playersWithPresenter}
hostParticipates={false}
presenterId="player-1"
onSetPresenter={vi.fn()}
/>
);
// The presenter badge exists, confirming visual distinction
const presenterBadge = screen.getByText('PRESENTER');
expect(presenterBadge).toHaveClass('bg-yellow-400');
});
});
describe('presenter feature - client view', () => {
it('shows presenter crown and badge when current player is presenter', () => {
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"
/>
);
expect(screen.getByText('You are the Presenter')).toBeInTheDocument();
expect(screen.getByText('You can advance screens during the game')).toBeInTheDocument();
});
it('shows waiting message when not presenter', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
{ id: 'player-2', name: 'Other', score: 0, avatarSeed: 0.6 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
presenterId="player-2"
/>
);
expect(screen.queryByText('You are the Presenter')).not.toBeInTheDocument();
expect(screen.getByText('Waiting for the host to start...')).toBeInTheDocument();
});
it('shows waiting message when no presenter assigned', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
presenterId={null}
/>
);
expect(screen.queryByText('You are the Presenter')).not.toBeInTheDocument();
expect(screen.getByText('Waiting for the host to start...')).toBeInTheDocument();
});
});
describe('presenter feature - edge cases', () => {
it('handles undefined presenterId gracefully', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
];
// Should not throw
render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={false}
presenterId={undefined}
onSetPresenter={vi.fn()}
/>
);
expect(screen.queryByText('PRESENTER')).not.toBeInTheDocument();
});
it('handles onSetPresenter being undefined', async () => {
const user = userEvent.setup();
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
];
// Should not throw when clicking
render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={false}
/>
);
// Click should not cause error
await user.click(screen.getByText('Alice'));
// No assertion needed - just testing it doesn't crash
});
it('presenter badge only appears once even with multiple players', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
{ id: 'player-3', name: 'Charlie', score: 0, avatarSeed: 0.3 },
];
render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={false}
presenterId="player-2"
onSetPresenter={vi.fn()}
/>
);
const presenterBadges = screen.getAllByText('PRESENTER');
expect(presenterBadges).toHaveLength(1);
});
it('updates presenter display when presenterId changes', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
];
const { rerender } = render(
<Lobby
{...defaultProps}
players={players}
hostParticipates={false}
presenterId="player-1"
onSetPresenter={vi.fn()}
/>
);
// Initially Alice is presenter
expect(screen.getByText('PRESENTER')).toBeInTheDocument();
// Change presenter to Bob
rerender(
<Lobby
{...defaultProps}
players={players}
hostParticipates={false}
presenterId="player-2"
onSetPresenter={vi.fn()}
/>
);
// Still exactly one presenter badge
const presenterBadges = screen.getAllByText('PRESENTER');
expect(presenterBadges).toHaveLength(1);
});
});
}); });

View file

@ -0,0 +1,283 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RevealScreen } from '../../components/RevealScreen';
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>,
p: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <p {...props}>{children}</p>,
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
useSpring: () => ({ set: vi.fn(), on: () => vi.fn() }),
useTransform: () => ({ on: () => vi.fn() }),
}));
vi.mock('canvas-confetti', () => ({
default: vi.fn(),
}));
vi.mock('lucide-react', async () => {
const React = await import('react');
const createMockIcon = (name: string) => {
return function MockIcon(props: { size?: number; fill?: string; className?: string; strokeWidth?: number }) {
return React.createElement('svg', {
'data-testid': `icon-${name}`,
width: props.size || 24,
height: props.size || 24,
className: props.className
});
};
};
return {
Check: createMockIcon('check'),
X: createMockIcon('x'),
Flame: createMockIcon('flame'),
ChevronRight: createMockIcon('chevron-right'),
Triangle: createMockIcon('triangle'),
Diamond: createMockIcon('diamond'),
Circle: createMockIcon('circle'),
Square: createMockIcon('square'),
};
});
describe('RevealScreen', () => {
const correctOption = {
text: 'Correct Answer',
isCorrect: true,
shape: 'circle' as const,
color: 'green' as const,
reason: 'This is why it is correct',
};
const defaultProps = {
isCorrect: true,
pointsEarned: 100,
newScore: 500,
streak: 3,
correctOption,
selectedOption: correctOption,
role: 'CLIENT' as const,
onNext: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('host view', () => {
it('shows Continue to Scoreboard button for host', () => {
render(<RevealScreen {...defaultProps} role="HOST" onNext={vi.fn()} />);
expect(screen.getByRole('button', { name: /Continue to Scoreboard/i })).toBeInTheDocument();
});
it('calls onNext when host clicks Continue', async () => {
const user = userEvent.setup();
const onNext = vi.fn();
render(<RevealScreen {...defaultProps} role="HOST" onNext={onNext} />);
await user.click(screen.getByRole('button', { name: /Continue to Scoreboard/i }));
expect(onNext).toHaveBeenCalled();
});
it('does not show button when onNext is undefined for host', () => {
render(<RevealScreen {...defaultProps} role="HOST" onNext={undefined} />);
expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument();
});
});
describe('client view - non-presenter', () => {
it('does not show advance button for regular client', () => {
render(<RevealScreen {...defaultProps} role="CLIENT" />);
expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument();
});
it('shows correct/incorrect feedback', () => {
render(<RevealScreen {...defaultProps} role="CLIENT" isCorrect={true} />);
expect(screen.getByText('Correct!')).toBeInTheDocument();
});
it('shows incorrect feedback when wrong', () => {
const wrongOption = { ...correctOption, isCorrect: false };
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isCorrect={false}
selectedOption={wrongOption}
/>
);
expect(screen.getByText('Incorrect')).toBeInTheDocument();
});
});
describe('presenter controls', () => {
it('shows Continue button for presenter client', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isPresenter={true}
onPresenterAdvance={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /Continue to Scoreboard/i })).toBeInTheDocument();
});
it('calls onPresenterAdvance when presenter clicks Continue', async () => {
const user = userEvent.setup();
const onPresenterAdvance = vi.fn();
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isPresenter={true}
onPresenterAdvance={onPresenterAdvance}
/>
);
await user.click(screen.getByRole('button', { name: /Continue to Scoreboard/i }));
expect(onPresenterAdvance).toHaveBeenCalled();
});
it('does not show button when isPresenter is true but onPresenterAdvance is undefined', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isPresenter={true}
/>
);
expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument();
});
it('presenter button is fixed at bottom of screen', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isPresenter={true}
onPresenterAdvance={vi.fn()}
/>
);
const button = screen.getByRole('button', { name: /Continue to Scoreboard/i });
expect(button).toHaveClass('fixed');
});
});
describe('presenter edge cases', () => {
it('handles isPresenter false gracefully', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isPresenter={false}
onPresenterAdvance={vi.fn()}
/>
);
expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument();
});
it('handles both isPresenter and onPresenterAdvance being undefined', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
/>
);
expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument();
});
it('presenter sees button on incorrect answer screen too', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isCorrect={false}
isPresenter={true}
onPresenterAdvance={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /Continue to Scoreboard/i })).toBeInTheDocument();
});
it('adds padding to incorrect screen when presenter', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isCorrect={false}
isPresenter={true}
onPresenterAdvance={vi.fn()}
/>
);
const incorrectContainer = document.querySelector('.absolute.bottom-0');
expect(incorrectContainer).toHaveClass('pb-20');
});
it('does not add padding when not presenter on incorrect screen', () => {
render(
<RevealScreen
{...defaultProps}
role="CLIENT"
isCorrect={false}
isPresenter={false}
/>
);
const incorrectContainer = document.querySelector('.absolute.bottom-0');
expect(incorrectContainer).not.toHaveClass('pb-20');
});
});
describe('display elements', () => {
it('shows points earned on correct answer', () => {
render(<RevealScreen {...defaultProps} role="CLIENT" isCorrect={true} pointsEarned={150} />);
expect(screen.getByText('+150')).toBeInTheDocument();
});
it('shows streak indicator when streak > 1', () => {
render(<RevealScreen {...defaultProps} role="CLIENT" isCorrect={true} streak={5} />);
expect(screen.getByText(/Answer Streak: 5/)).toBeInTheDocument();
});
it('does not show streak indicator when streak is 1', () => {
render(<RevealScreen {...defaultProps} role="CLIENT" isCorrect={true} streak={1} />);
expect(screen.queryByText(/Answer Streak/)).not.toBeInTheDocument();
});
it('shows correct answer on host view', () => {
render(<RevealScreen {...defaultProps} role="HOST" />);
expect(screen.getByText('The correct answer is')).toBeInTheDocument();
expect(screen.getByText(correctOption.text)).toBeInTheDocument();
});
it('shows reason on host view when available', () => {
render(<RevealScreen {...defaultProps} role="HOST" />);
expect(screen.getByText(correctOption.reason!)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,242 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Scoreboard } from '../../components/Scoreboard';
import { Player } from '../../types';
vi.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
useSpring: () => ({ set: vi.fn(), on: () => vi.fn() }),
useTransform: () => ({ on: () => vi.fn() }),
LayoutGroup: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
const createPlayer = (overrides: Partial<Player> = {}): Player => ({
id: 'player-1',
name: 'Test Player',
score: 100,
previousScore: 50,
streak: 2,
lastAnswerCorrect: true,
selectedShape: 'circle',
pointsBreakdown: {
basePoints: 80,
streakBonus: 10,
comebackBonus: 0,
firstCorrectBonus: 0,
penalty: 0,
total: 90,
},
isBot: false,
avatarSeed: 0.5,
color: '#ff0000',
...overrides,
});
describe('Scoreboard', () => {
const defaultProps = {
players: [
createPlayer({ id: 'player-1', name: 'Alice', score: 200 }),
createPlayer({ id: 'player-2', name: 'Bob', score: 150 }),
],
onNext: vi.fn(),
isHost: true,
currentPlayerId: 'player-1',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('host controls', () => {
it('shows Next button for host', () => {
render(<Scoreboard {...defaultProps} />);
expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument();
});
it('calls onNext when host clicks Next', async () => {
const user = userEvent.setup();
render(<Scoreboard {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(defaultProps.onNext).toHaveBeenCalled();
});
it('shows waiting message for non-host non-presenter', () => {
render(<Scoreboard {...defaultProps} isHost={false} currentPlayerId="player-2" />);
expect(screen.getByText('Waiting for host...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Next/i })).not.toBeInTheDocument();
});
});
describe('presenter controls', () => {
it('shows Next button for presenter (non-host)', () => {
render(
<Scoreboard
{...defaultProps}
isHost={false}
currentPlayerId="player-2"
isPresenter={true}
onPresenterAdvance={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument();
});
it('calls onPresenterAdvance when presenter clicks Next', async () => {
const user = userEvent.setup();
const onPresenterAdvance = vi.fn();
render(
<Scoreboard
{...defaultProps}
isHost={false}
currentPlayerId="player-2"
isPresenter={true}
onPresenterAdvance={onPresenterAdvance}
/>
);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(onPresenterAdvance).toHaveBeenCalled();
});
it('does not call onNext when presenter clicks (uses onPresenterAdvance)', async () => {
const user = userEvent.setup();
const onNext = vi.fn();
const onPresenterAdvance = vi.fn();
render(
<Scoreboard
{...defaultProps}
onNext={onNext}
isHost={false}
currentPlayerId="player-2"
isPresenter={true}
onPresenterAdvance={onPresenterAdvance}
/>
);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(onPresenterAdvance).toHaveBeenCalled();
expect(onNext).not.toHaveBeenCalled();
});
it('host uses onNext, not onPresenterAdvance', async () => {
const user = userEvent.setup();
const onNext = vi.fn();
const onPresenterAdvance = vi.fn();
render(
<Scoreboard
{...defaultProps}
onNext={onNext}
isHost={true}
isPresenter={false}
onPresenterAdvance={onPresenterAdvance}
/>
);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(onNext).toHaveBeenCalled();
expect(onPresenterAdvance).not.toHaveBeenCalled();
});
});
describe('presenter edge cases', () => {
it('shows waiting message when isPresenter is false', () => {
render(
<Scoreboard
{...defaultProps}
isHost={false}
currentPlayerId="player-2"
isPresenter={false}
/>
);
expect(screen.getByText('Waiting for host...')).toBeInTheDocument();
});
it('shows waiting message when isPresenter is undefined', () => {
render(
<Scoreboard
{...defaultProps}
isHost={false}
currentPlayerId="player-2"
/>
);
expect(screen.getByText('Waiting for host...')).toBeInTheDocument();
});
it('handles missing onPresenterAdvance gracefully', async () => {
const user = userEvent.setup();
render(
<Scoreboard
{...defaultProps}
isHost={false}
currentPlayerId="player-2"
isPresenter={true}
/>
);
const button = screen.getByRole('button', { name: /Next/i });
await user.click(button);
});
it('both host and presenter see button, but only host uses onNext', async () => {
const user = userEvent.setup();
const onNext = vi.fn();
const onPresenterAdvance = vi.fn();
render(
<Scoreboard
{...defaultProps}
onNext={onNext}
isHost={true}
isPresenter={true}
onPresenterAdvance={onPresenterAdvance}
/>
);
await user.click(screen.getByRole('button', { name: /Next/i }));
expect(onNext).toHaveBeenCalled();
expect(onPresenterAdvance).not.toHaveBeenCalled();
});
});
describe('display', () => {
it('shows Scoreboard title', () => {
render(<Scoreboard {...defaultProps} />);
expect(screen.getByText('Scoreboard')).toBeInTheDocument();
});
it('shows player names', () => {
render(<Scoreboard {...defaultProps} />);
expect(screen.getByText('Alice (You)')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('marks current player with (You) suffix', () => {
render(<Scoreboard {...defaultProps} currentPlayerId="player-2" />);
expect(screen.getByText('Bob (You)')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,472 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Player, GameConfig, DEFAULT_GAME_CONFIG } from '../../types';
describe('Presenter 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('auto-assignment of presenter', () => {
const shouldAutoAssignPresenter = (
players: Player[],
newPlayerId: string,
hostParticipates: boolean,
currentPresenterId: string | null
): string | null => {
if (hostParticipates) return null;
if (currentPresenterId) return null;
const realPlayers = players.filter(p => p.id !== 'host');
const isFirstRealPlayer = realPlayers.length === 1 && realPlayers[0].id === newPlayerId;
return isFirstRealPlayer ? newPlayerId : null;
};
it('assigns first joiner as presenter when host does not participate', () => {
const players = [createPlayer({ id: 'player-1', name: 'Alice' })];
const result = shouldAutoAssignPresenter(players, 'player-1', false, null);
expect(result).toBe('player-1');
});
it('does not assign presenter when host participates', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = shouldAutoAssignPresenter(players, 'player-1', true, null);
expect(result).toBeNull();
});
it('does not reassign presenter when one already exists', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = shouldAutoAssignPresenter(players, 'player-2', false, 'player-1');
expect(result).toBeNull();
});
it('does not assign second joiner as presenter', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = shouldAutoAssignPresenter(players, 'player-2', false, null);
expect(result).toBeNull();
});
it('ignores host player when counting real players', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = shouldAutoAssignPresenter(players, 'player-1', false, null);
expect(result).toBe('player-1');
});
});
describe('ADVANCE message validation', () => {
type AdvanceAction = 'START' | 'NEXT' | 'SCOREBOARD';
const validateAdvanceMessage = (
senderPeerId: string,
presenterId: string | null,
action: AdvanceAction,
currentGameState: string
): { valid: boolean; reason?: string } => {
if (senderPeerId !== presenterId) {
return { valid: false, reason: 'Sender is not the presenter' };
}
const validTransitions: Record<string, AdvanceAction[]> = {
'LOBBY': ['START'],
'REVEAL': ['NEXT', 'SCOREBOARD'],
'SCOREBOARD': ['NEXT'],
};
const allowedActions = validTransitions[currentGameState] || [];
if (!allowedActions.includes(action)) {
return { valid: false, reason: `Invalid action ${action} for state ${currentGameState}` };
}
return { valid: true };
};
it('accepts ADVANCE from presenter', () => {
const result = validateAdvanceMessage('player-1', 'player-1', 'START', 'LOBBY');
expect(result.valid).toBe(true);
});
it('rejects ADVANCE from non-presenter', () => {
const result = validateAdvanceMessage('player-2', 'player-1', 'START', 'LOBBY');
expect(result.valid).toBe(false);
expect(result.reason).toBe('Sender is not the presenter');
});
it('rejects ADVANCE when no presenter is set', () => {
const result = validateAdvanceMessage('player-1', null, 'START', 'LOBBY');
expect(result.valid).toBe(false);
expect(result.reason).toBe('Sender is not the presenter');
});
it('accepts START action only in LOBBY state', () => {
expect(validateAdvanceMessage('p1', 'p1', 'START', 'LOBBY').valid).toBe(true);
expect(validateAdvanceMessage('p1', 'p1', 'START', 'QUESTION').valid).toBe(false);
expect(validateAdvanceMessage('p1', 'p1', 'START', 'REVEAL').valid).toBe(false);
});
it('accepts SCOREBOARD action only in REVEAL state', () => {
expect(validateAdvanceMessage('p1', 'p1', 'SCOREBOARD', 'REVEAL').valid).toBe(true);
expect(validateAdvanceMessage('p1', 'p1', 'SCOREBOARD', 'LOBBY').valid).toBe(false);
expect(validateAdvanceMessage('p1', 'p1', 'SCOREBOARD', 'SCOREBOARD').valid).toBe(false);
});
it('accepts NEXT action in REVEAL and SCOREBOARD states', () => {
expect(validateAdvanceMessage('p1', 'p1', 'NEXT', 'REVEAL').valid).toBe(true);
expect(validateAdvanceMessage('p1', 'p1', 'NEXT', 'SCOREBOARD').valid).toBe(true);
expect(validateAdvanceMessage('p1', 'p1', 'NEXT', 'LOBBY').valid).toBe(false);
});
it('rejects all actions in QUESTION state (no advancing mid-question)', () => {
expect(validateAdvanceMessage('p1', 'p1', 'START', 'QUESTION').valid).toBe(false);
expect(validateAdvanceMessage('p1', 'p1', 'NEXT', 'QUESTION').valid).toBe(false);
expect(validateAdvanceMessage('p1', 'p1', 'SCOREBOARD', 'QUESTION').valid).toBe(false);
});
});
describe('presenter reconnection handling', () => {
const handlePresenterReconnection = (
reconnectedPlayerId: string,
previousPlayerId: string | null,
currentPresenterId: string | null
): string | null => {
if (!previousPlayerId) return currentPresenterId;
if (previousPlayerId === currentPresenterId) {
return reconnectedPlayerId;
}
return currentPresenterId;
};
it('updates presenter ID when presenter reconnects with new peer ID', () => {
const result = handlePresenterReconnection('new-peer-id', 'old-peer-id', 'old-peer-id');
expect(result).toBe('new-peer-id');
});
it('keeps presenter ID unchanged when non-presenter reconnects', () => {
const result = handlePresenterReconnection('new-peer-id', 'old-peer-id', 'presenter-id');
expect(result).toBe('presenter-id');
});
it('keeps presenter ID unchanged when no previous ID provided', () => {
const result = handlePresenterReconnection('new-peer-id', null, 'presenter-id');
expect(result).toBe('presenter-id');
});
it('handles null current presenter', () => {
const result = handlePresenterReconnection('new-peer-id', 'old-peer-id', null);
expect(result).toBeNull();
});
});
describe('setPresenterPlayer validation', () => {
const canSetPresenter = (
role: 'HOST' | 'CLIENT',
hostParticipates: boolean,
targetPlayerId: string | null,
players: Player[]
): { allowed: boolean; reason?: string } => {
if (role !== 'HOST') {
return { allowed: false, reason: 'Only host can set presenter' };
}
if (hostParticipates) {
return { allowed: false, reason: 'Cannot set presenter when host participates' };
}
if (targetPlayerId === null) {
return { allowed: true };
}
const playerExists = players.some(p => p.id === targetPlayerId && p.id !== 'host');
if (!playerExists) {
return { allowed: false, reason: 'Target player not found' };
}
return { allowed: true };
};
it('allows host to set presenter when not participating', () => {
const players = [createPlayer({ id: 'player-1' })];
const result = canSetPresenter('HOST', false, 'player-1', players);
expect(result.allowed).toBe(true);
});
it('disallows client from setting presenter', () => {
const players = [createPlayer({ id: 'player-1' })];
const result = canSetPresenter('CLIENT', false, 'player-1', players);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('Only host can set presenter');
});
it('disallows setting presenter when host participates', () => {
const players = [createPlayer({ id: 'player-1' })];
const result = canSetPresenter('HOST', true, 'player-1', players);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('Cannot set presenter when host participates');
});
it('allows setting presenter to null (removing presenter)', () => {
const players = [createPlayer({ id: 'player-1' })];
const result = canSetPresenter('HOST', false, null, players);
expect(result.allowed).toBe(true);
});
it('disallows setting non-existent player as presenter', () => {
const players = [createPlayer({ id: 'player-1' })];
const result = canSetPresenter('HOST', false, 'player-999', players);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('Target player not found');
});
it('disallows setting host as presenter', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1' }),
];
const result = canSetPresenter('HOST', false, 'host', players);
expect(result.allowed).toBe(false);
expect(result.reason).toBe('Target player not found');
});
});
describe('sendAdvance client behavior', () => {
const canSendAdvance = (
role: 'HOST' | 'CLIENT',
isConnectedToHost: boolean
): boolean => {
return role === 'CLIENT' && isConnectedToHost;
};
it('allows client to send advance when connected', () => {
expect(canSendAdvance('CLIENT', true)).toBe(true);
});
it('disallows client from sending advance when disconnected', () => {
expect(canSendAdvance('CLIENT', false)).toBe(false);
});
it('disallows host from sending advance (host controls directly)', () => {
expect(canSendAdvance('HOST', true)).toBe(false);
});
});
describe('PRESENTER_CHANGED message handling', () => {
type PresenterChangedHandler = (
currentPlayerId: string | null,
newPresenterId: string | null
) => { isPresenter: boolean; shouldShowNotification: boolean };
const handlePresenterChanged: PresenterChangedHandler = (currentPlayerId, newPresenterId) => {
const isPresenter = currentPlayerId !== null && currentPlayerId === newPresenterId;
const shouldShowNotification = isPresenter;
return { isPresenter, shouldShowNotification };
};
it('detects when current player becomes presenter', () => {
const result = handlePresenterChanged('player-1', 'player-1');
expect(result.isPresenter).toBe(true);
expect(result.shouldShowNotification).toBe(true);
});
it('detects when current player is not presenter', () => {
const result = handlePresenterChanged('player-1', 'player-2');
expect(result.isPresenter).toBe(false);
expect(result.shouldShowNotification).toBe(false);
});
it('handles null current player ID', () => {
const result = handlePresenterChanged(null, 'player-1');
expect(result.isPresenter).toBe(false);
});
it('handles null presenter ID', () => {
const result = handlePresenterChanged('player-1', null);
expect(result.isPresenter).toBe(false);
});
it('handles both null', () => {
const result = handlePresenterChanged(null, null);
expect(result.isPresenter).toBe(false);
});
});
describe('WELCOME message presenter payload', () => {
const buildWelcomePayload = (
playerId: string,
presenterId: string | null,
isReconnect: boolean,
reconnectedPlayerWasPresenter: boolean
): { playerId: string; presenterId: string | null } => {
let finalPresenterId = presenterId;
if (isReconnect && reconnectedPlayerWasPresenter) {
finalPresenterId = playerId;
}
return {
playerId,
presenterId: finalPresenterId,
};
};
it('includes presenter ID in welcome payload', () => {
const result = buildWelcomePayload('player-1', 'player-2', false, false);
expect(result.presenterId).toBe('player-2');
});
it('updates presenter ID when presenter reconnects', () => {
const result = buildWelcomePayload('new-peer-id', 'old-peer-id', true, true);
expect(result.presenterId).toBe('new-peer-id');
});
it('keeps presenter ID when non-presenter reconnects', () => {
const result = buildWelcomePayload('player-1', 'player-2', true, false);
expect(result.presenterId).toBe('player-2');
});
it('handles null presenter', () => {
const result = buildWelcomePayload('player-1', null, false, false);
expect(result.presenterId).toBeNull();
});
});
describe('edge cases', () => {
it('handles rapid presenter changes', () => {
let presenterId: string | null = null;
const changes: string[] = [];
const setPresenterId = (id: string | null) => {
presenterId = id;
changes.push(id ?? 'null');
};
setPresenterId('player-1');
setPresenterId('player-2');
setPresenterId('player-3');
setPresenterId('player-1');
expect(presenterId).toBe('player-1');
expect(changes).toEqual(['player-1', 'player-2', 'player-3', 'player-1']);
});
it('handles player leaving who was presenter', () => {
const handlePlayerLeave = (
leavingPlayerId: string,
currentPresenterId: string | null,
remainingPlayers: Player[]
): string | null => {
if (leavingPlayerId !== currentPresenterId) {
return currentPresenterId;
}
const realPlayers = remainingPlayers.filter(p => p.id !== 'host');
return realPlayers.length > 0 ? realPlayers[0].id : null;
};
const players = [
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result = handlePlayerLeave('player-1', 'player-1', players);
expect(result).toBe('player-2');
});
it('handles presenter leaving when no other players', () => {
const handlePlayerLeave = (
leavingPlayerId: string,
currentPresenterId: string | null,
remainingPlayers: Player[]
): string | null => {
if (leavingPlayerId !== currentPresenterId) {
return currentPresenterId;
}
const realPlayers = remainingPlayers.filter(p => p.id !== 'host');
return realPlayers.length > 0 ? realPlayers[0].id : null;
};
const result = handlePlayerLeave('player-1', 'player-1', []);
expect(result).toBeNull();
});
it('handles game ending with presenter', () => {
const handleGameEnd = (presenterId: string | null): string | null => {
return null;
};
expect(handleGameEnd('player-1')).toBeNull();
expect(handleGameEnd(null)).toBeNull();
});
it('handles host joining their own game when not participating', () => {
const shouldAssignHostAsPresenter = (
joiningPlayerId: string,
hostParticipates: boolean
): boolean => {
if (joiningPlayerId === 'host') return false;
return !hostParticipates;
};
expect(shouldAssignHostAsPresenter('host', false)).toBe(false);
expect(shouldAssignHostAsPresenter('player-1', false)).toBe(true);
expect(shouldAssignHostAsPresenter('player-1', true)).toBe(false);
});
});
});

View file

@ -203,6 +203,7 @@ export type NetworkMessage =
correctShape?: string; correctShape?: string;
timeLeft?: number; timeLeft?: number;
assignedName?: string; assignedName?: string;
presenterId?: string | null;
} } } }
| { type: 'PLAYER_JOINED'; payload: { player: Player } } | { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} } | { type: 'GAME_START'; payload: {} }
@ -223,4 +224,6 @@ export type NetworkMessage =
| { type: 'TIME_SYNC'; payload: { timeLeft: number } } | { type: 'TIME_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} } | { type: 'TIME_UP'; payload: {} }
| { 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: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } };