diff --git a/App.tsx b/App.tsx index f28cff7..676e173 100644 --- a/App.tsx +++ b/App.tsx @@ -91,7 +91,10 @@ function App() { attemptReconnect, goHomeFromDisconnected, endGame, - resumeGame + resumeGame, + presenterId, + setPresenterPlayer, + sendAdvance } = useGame(); const handleSaveQuiz = async () => { @@ -199,6 +202,8 @@ function App() { onEndGame={role === 'HOST' ? endGame : undefined} currentPlayerId={currentPlayerId} hostParticipates={gameConfig.hostParticipates} + presenterId={presenterId} + onSetPresenter={setPresenterPlayer} /> {auth.isAuthenticated && pendingQuizToSave && ( sendAdvance('SCOREBOARD')} /> ) : currentPlayerName ? ( sendAdvance('NEXT')} /> ) : null} diff --git a/components/Lobby.tsx b/components/Lobby.tsx index a15914d..ca1f79a 100644 --- a/components/Lobby.tsx +++ b/components/Lobby.tsx @@ -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 } from 'lucide-react'; +import { Sparkles, User, X, Link, Check, QrCode, Crown } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { PlayerAvatar } from './PlayerAvatar'; import toast from 'react-hot-toast'; @@ -15,15 +15,19 @@ interface LobbyProps { onEndGame?: () => void; currentPlayerId?: string | null; hostParticipates?: boolean; + presenterId?: string | null; + onSetPresenter?: (playerId: string | null) => void; } -export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false }) => { +export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => { const isHost = role === 'HOST'; const hostPlayer = players.find(p => p.id === 'host'); const realPlayers = players.filter(p => p.id !== 'host'); const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null; const [linkCopied, setLinkCopied] = useState(false); const [isQrModalOpen, setIsQrModalOpen] = useState(false); + const isPresenter = currentPlayerId === presenterId; + const canSelectPresenter = isHost && !hostParticipates && onSetPresenter; React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -165,6 +169,12 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role,
Waiting for players to join...
)} + {realPlayers.length > 0 && !hostParticipates && canSelectPresenter && ( +
+ + Click a player to make them presenter (can advance screens) +
+ )} {hostParticipates && hostPlayer && ( = ({ quizTitle, players, gamePin, role, HOST )} - {realPlayers.map((player) => ( + {realPlayers.map((player) => { + const isPlayerPresenter = player.id === presenterId; + return ( 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 && ( + + )} {player.name} + {isPlayerPresenter && ( + PRESENTER + )} - ))} + ); + })} @@ -223,8 +245,13 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, initial={{ scale: 0.5 }} animate={{ scale: 1 }} 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 && ( +
+ +
+ )} {currentPlayer ? ( ) : ( @@ -234,7 +261,17 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role,

{currentPlayer?.name || "You're in!"}

-

Waiting for the host to start...

+ {isPresenter ? ( +
+ + + You are the Presenter + +

You can advance screens during the game

+
+ ) : ( +

Waiting for the host to start...

+ )} )} diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index d1cd804..718da13 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle, List, Settings } from 'lucide-react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types'; @@ -40,6 +40,7 @@ export const QuizEditor: React.FC = ({ initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG ); const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config); + const [activeTab, setActiveTab] = useState<'questions' | 'settings'>('questions'); useEffect(() => { if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) { @@ -215,60 +216,96 @@ export const QuizEditor: React.FC = ({
-
-
-

Questions

- -
- - + + +
- {quiz.questions.length === 0 && ( -
-

No questions yet

-

Click "Add Question" to get started

+
+ {activeTab === 'questions' ? ( + <> +
+

Questions

+ +
+ + + q.id)} + strategy={verticalListSortingStrategy} + > +
+ + {quiz.questions.map((question, index) => ( + setExpandedId(expandedId === question.id ? null : question.id)} + onEdit={() => setEditingQuestion(question)} + onDelete={() => quiz.questions.length > 1 ? setShowDeleteConfirm(question.id) : null} + /> + ))} + +
+
+
+ + {quiz.questions.length === 0 && ( +
+

No questions yet

+

Click "Add Question" to get started

+
+ )} + + ) : ( +
+

Game Settings

+
)}
- -
- {isHost ? ( + {canAdvance ? ( , + p: ({ children, ...props }: React.PropsWithChildren>) =>

{children}

, + }, + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument(); + }); + + it('shows correct/incorrect feedback', () => { + render(); + + expect(screen.getByText('Correct!')).toBeInTheDocument(); + }); + + it('shows incorrect feedback when wrong', () => { + const wrongOption = { ...correctOption, isCorrect: false }; + render( + + ); + + expect(screen.getByText('Incorrect')).toBeInTheDocument(); + }); + }); + + describe('presenter controls', () => { + it('shows Continue button for presenter client', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument(); + }); + + it('presenter button is fixed at bottom of screen', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /Continue to Scoreboard/i }); + expect(button).toHaveClass('fixed'); + }); + }); + + describe('presenter edge cases', () => { + it('handles isPresenter false gracefully', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument(); + }); + + it('handles both isPresenter and onPresenterAdvance being undefined', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /Continue to Scoreboard/i })).not.toBeInTheDocument(); + }); + + it('presenter sees button on incorrect answer screen too', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /Continue to Scoreboard/i })).toBeInTheDocument(); + }); + + it('adds padding to incorrect screen when presenter', () => { + render( + + ); + + const incorrectContainer = document.querySelector('.absolute.bottom-0'); + expect(incorrectContainer).toHaveClass('pb-20'); + }); + + it('does not add padding when not presenter on incorrect screen', () => { + render( + + ); + + const incorrectContainer = document.querySelector('.absolute.bottom-0'); + expect(incorrectContainer).not.toHaveClass('pb-20'); + }); + }); + + describe('display elements', () => { + it('shows points earned on correct answer', () => { + render(); + + expect(screen.getByText('+150')).toBeInTheDocument(); + }); + + it('shows streak indicator when streak > 1', () => { + render(); + + expect(screen.getByText(/Answer Streak: 5/)).toBeInTheDocument(); + }); + + it('does not show streak indicator when streak is 1', () => { + render(); + + expect(screen.queryByText(/Answer Streak/)).not.toBeInTheDocument(); + }); + + it('shows correct answer on host view', () => { + render(); + + expect(screen.getByText('The correct answer is')).toBeInTheDocument(); + expect(screen.getByText(correctOption.text)).toBeInTheDocument(); + }); + + it('shows reason on host view when available', () => { + render(); + + expect(screen.getByText(correctOption.reason!)).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/components/Scoreboard.test.tsx b/tests/components/Scoreboard.test.tsx new file mode 100644 index 0000000..a710644 --- /dev/null +++ b/tests/components/Scoreboard.test.tsx @@ -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>) =>
{children}
, + }, + 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 => ({ + 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(); + + expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument(); + }); + + it('calls onNext when host clicks Next', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Next/i })); + + expect(defaultProps.onNext).toHaveBeenCalled(); + }); + + it('shows waiting message for non-host non-presenter', () => { + render(); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByText('Waiting for host...')).toBeInTheDocument(); + }); + + it('shows waiting message when isPresenter is undefined', () => { + render( + + ); + + expect(screen.getByText('Waiting for host...')).toBeInTheDocument(); + }); + + it('handles missing onPresenterAdvance gracefully', async () => { + const user = userEvent.setup(); + + render( + + ); + + 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( + + ); + + await user.click(screen.getByRole('button', { name: /Next/i })); + + expect(onNext).toHaveBeenCalled(); + expect(onPresenterAdvance).not.toHaveBeenCalled(); + }); + }); + + describe('display', () => { + it('shows Scoreboard title', () => { + render(); + + expect(screen.getByText('Scoreboard')).toBeInTheDocument(); + }); + + it('shows player names', () => { + render(); + + expect(screen.getByText('Alice (You)')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + + it('marks current player with (You) suffix', () => { + render(); + + expect(screen.getByText('Bob (You)')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/hooks/useGame.presenter.test.tsx b/tests/hooks/useGame.presenter.test.tsx new file mode 100644 index 0000000..7a20536 --- /dev/null +++ b/tests/hooks/useGame.presenter.test.tsx @@ -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 => ({ + 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 = { + '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); + }); + }); +}); diff --git a/types.ts b/types.ts index 44b91df..8c5e41b 100644 --- a/types.ts +++ b/types.ts @@ -203,6 +203,7 @@ export type NetworkMessage = correctShape?: string; timeLeft?: number; assignedName?: string; + presenterId?: string | null; } } | { type: 'PLAYER_JOINED'; payload: { player: Player } } | { type: 'GAME_START'; payload: {} } @@ -223,4 +224,6 @@ export type NetworkMessage = | { type: 'TIME_SYNC'; payload: { timeLeft: number } } | { type: 'TIME_UP'; payload: {} } | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } } - | { type: 'GAME_OVER'; payload: { players: Player[] } }; \ No newline at end of file + | { type: 'GAME_OVER'; payload: { players: Player[] } } + | { type: 'PRESENTER_CHANGED'; payload: { presenterId: string | null } } + | { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } }; \ No newline at end of file