diff --git a/apps/frontend/src/screens/LiveQuiz.test.tsx b/apps/frontend/src/screens/LiveQuiz.test.tsx index f75fef0..8cdd955 100644 --- a/apps/frontend/src/screens/LiveQuiz.test.tsx +++ b/apps/frontend/src/screens/LiveQuiz.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import LiveQuiz from './LiveQuiz'; @@ -102,3 +103,110 @@ describe('LiveQuiz join message', () => { expect(JSON.stringify(join)).not.toContain('login-token'); }); }); + +function deliver(type: string, payload: Record) { + act(() => sockets[0]!.onmessage?.({ data: JSON.stringify({ type, payload }) })); +} + +function dropConnection() { + act(() => { + sockets[0]!.readyState = 3; + sockets[0]!.onclose?.(); + }); +} + +function sentOfType(type: string) { + return sockets[0]!.sent.filter((message) => message.type === type); +} + +const question = { + id: 'q1', + text: 'Capital of France?', + timeLimit: 20, + answers: [ + { id: 'a1', text: 'Paris' }, + { id: 'a2', text: 'Rome' }, + ], +}; + +describe('LiveQuiz on a dropped connection', () => { + async function joinAsParticipant() { + localStorage.setItem('rexial:participant:session-1', 'participant-1'); + renderLive('/live/session-1', false); + await firstJoinMessage(); + deliver('quiz:question', { question }); + } + + it('sends the answer and marks it chosen while connected', async () => { + const user = userEvent.setup(); + await joinAsParticipant(); + + await user.click(screen.getByRole('button', { name: 'Paris' })); + + expect(sentOfType('quiz:submit-answer')).toHaveLength(1); + expect(sentOfType('quiz:submit-answer')[0]!.payload).toMatchObject({ answerId: 'a1' }); + expect(screen.getByRole('button', { name: 'Paris' })).toBeDisabled(); + }); + + it('refuses an answer the socket cannot send and says why', async () => { + const user = userEvent.setup(); + await joinAsParticipant(); + // Closed, but onclose has not fired yet: the banner still thinks we + // are connected, so only the send itself can catch it. + sockets[0]!.readyState = 3; + + await user.click(screen.getByRole('button', { name: 'Paris' })); + + expect(sentOfType('quiz:submit-answer')).toHaveLength(0); + expect(screen.getByRole('button', { name: 'Paris' })).toBeEnabled(); + expect(screen.getByRole('alert')).toHaveTextContent('your answer was not sent'); + }); + + it('disables the answers with a reason once the connection is lost', async () => { + await joinAsParticipant(); + + dropConnection(); + + expect(screen.getByRole('button', { name: 'Paris' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Rome' })).toBeDisabled(); + expect(screen.getByRole('alert')).toHaveTextContent('answers cannot be sent'); + }); +}); + +describe('LiveQuiz host controls on a dropped connection', () => { + async function joinAsHost() { + renderLive('/live/session-1', true); + await firstJoinMessage(); + deliver('participants:sync', { participants: [{ id: 'p1', name: 'Sam' }] }); + } + + it('disables Start Game with a reason once the connection is lost', async () => { + await joinAsHost(); + expect(screen.getByRole('button', { name: 'Start Game' })).toBeEnabled(); + + dropConnection(); + + expect(screen.getByRole('button', { name: 'Start Game' })).toBeDisabled(); + expect(screen.getByRole('alert')).toHaveTextContent('the game can be started once the connection is back'); + }); + + it('does not skip a question when Next could not be sent', async () => { + const user = userEvent.setup(); + await joinAsHost(); + deliver('quiz:leaderboard', { leaderboard: [] }); + const next = () => screen.getByRole('button', { name: 'Next Question' }); + + await user.click(next()); + sockets[0]!.readyState = 3; + await user.click(next()); + + expect(sentOfType('quiz:next-question')).toHaveLength(1); + expect(screen.getByRole('alert')).toHaveTextContent('the next question was not sent'); + + sockets[0]!.readyState = 1; + await user.click(next()); + + expect(sentOfType('quiz:next-question').map((m) => m.payload.questionIndex)).toEqual([1, 2]); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/apps/frontend/src/screens/LiveQuiz.tsx b/apps/frontend/src/screens/LiveQuiz.tsx index 7bf505e..4de5c29 100644 --- a/apps/frontend/src/screens/LiveQuiz.tsx +++ b/apps/frontend/src/screens/LiveQuiz.tsx @@ -52,6 +52,10 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { const [ws, setWs] = useState(null); const [connectionState, setConnectionState] = useState<'connected' | 'reconnecting' | 'failed'>('connected'); + // Set when a send was refused because the socket was not open, so the + // user is told at the moment they tap rather than only by the banner. + const [sendFailed, setSendFailed] = useState(false); + const isConnected = connectionState === 'connected'; const [gameState, setGameState] = useState('WAITING'); const [participants, setParticipants] = useState([]); const [currentQuestion, setCurrentQuestion] = useState(null); @@ -212,6 +216,7 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { reconnectAttempts = 0; setConnectionState('connected'); + setSendFailed(false); if (!sessionId) { console.log("Session is not initiated. Try again...") @@ -414,30 +419,44 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { }, [sessionId, isOrganizer]); + // `ws?.send` on a closed socket does nothing and throws nothing, so every + // send goes through here and reports whether the message actually left. + const sendMessage = (message: { type: string; payload: Record }) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + setSendFailed(true); + return false; + } + ws.send(JSON.stringify(message)); + setSendFailed(false); + return true; + }; + const handleStart = () => { - ws?.send(JSON.stringify({ type: 'quiz:start', payload: { sessionId } })); + sendMessage({ type: 'quiz:start', payload: { sessionId } }); }; const handleNext = () => { const nextIdx = qIndex + 1; - setQIndex(nextIdx); - ws?.send(JSON.stringify({ + const sent = sendMessage({ type: 'quiz:next-question', payload: { sessionId, questionIndex: nextIdx } - })) + }); + if (sent) setQIndex(nextIdx); }; const handleAnswer = (answerId: string) => { if (selectedAnswer || gameState !== GameState.QUESTION) return; - setSelectedAnswer(answerId); const timeMs = Date.now() - questionStartTime; - ws?.send(JSON.stringify({ + // Refuse the tap rather than queue it for the reconnect: a queued answer + // could land after the question closed, with a timeMs that no longer + // means anything. + const sent = sendMessage({ type: 'quiz:submit-answer', payload: { sessionId, @@ -446,9 +465,21 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { answerId, timeMs } - })) + }); + if (sent) setSelectedAnswer(answerId); } + // Shown beside the answers and the host controls, so the reason they are + // disabled or refused is where the user is looking, not only in the banner. + const notSentNotice = (refused: string, waiting: string) => { + if (!sendFailed && isConnected) return null; + return ( +

+ {sendFailed ? `Not connected — ${refused}` : `Not connected — ${waiting}`} +

+ ); + }; + // Rendered above every game state, so a participant always knows the live // connection dropped rather than silently missing questions. @@ -519,11 +550,13 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { + {notSentNotice('the game was not started.', + 'the game can be started once the connection is back.')} ) : ( <> @@ -760,7 +793,7 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { +
+ + {notSentNotice('the next step was not sent.', 'wait for the connection to come back.')}
)} @@ -876,8 +914,9 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) { {isOrganizer && ( -
- +
+ + {notSentNotice('the next question was not sent.', 'wait for the connection to come back.')}
)}