Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion apps/frontend/src/screens/LiveQuiz.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -102,3 +103,110 @@ describe('LiveQuiz join message', () => {
expect(JSON.stringify(join)).not.toContain('login-token');
});
});

function deliver(type: string, payload: Record<string, unknown>) {
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();
});
});
65 changes: 52 additions & 13 deletions apps/frontend/src/screens/LiveQuiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,16 @@
const [ws, setWs] = useState<WebSocket | null>(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<GameState>('WAITING');
const [participants, setParticipants] = useState<any[]>([]);

Check failure on line 60 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type
const [currentQuestion, setCurrentQuestion] = useState<any>(null);

Check failure on line 61 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type
const [timeLeft, setTimeLeft] = useState(0);
const [correctAnswers, setCorrectAnswers] = useState<string[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);

Check failure on line 64 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type

const [joinCode, setJoinCode] = useState('');
// Set by the server on join: the host opened this lobby with live video.
Expand Down Expand Up @@ -129,7 +133,7 @@
{ rank: 2, player: sortedLeaderboard[1], order: 'order-2 sm:order-1' },
{ rank: 1, player: sortedLeaderboard[0], order: 'order-1 sm:order-2' },
{ rank: 3, player: sortedLeaderboard[2], order: 'order-3 sm:order-3' },
].filter((entry): entry is { rank: number; player: any; order: string } => Boolean(entry.player));

Check failure on line 136 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type

// Everyone past the podium, capped: a 500-player session rendered the whole
// board, so the end of a quiz was a page of names nobody reads.
Expand Down Expand Up @@ -212,6 +216,7 @@

reconnectAttempts = 0;
setConnectionState('connected');
setSendFailed(false);

if (!sessionId) {
console.log("Session is not initiated. Try again...")
Expand Down Expand Up @@ -411,33 +416,47 @@
}
};

}, [sessionId, isOrganizer]);

Check warning on line 419 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

React Hook useEffect has missing dependencies: 'navigate', 'participantId', 'token', and 'user?.id'. Either include them or remove the dependency array


// `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<string, unknown> }) => {
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,
Expand All @@ -446,9 +465,21 @@
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 (
<p role="alert" className="mt-6 text-center text-sm sm:text-base text-red-400 font-secondary tracking-wide">
{sendFailed ? `Not connected — ${refused}` : `Not connected — ${waiting}`}
</p>
);
};


// Rendered above every game state, so a participant always knows the live
// connection dropped rather than silently missing questions.
Expand Down Expand Up @@ -519,11 +550,13 @@

<button
onClick={handleStart}
disabled={participants.length === 0}
disabled={participants.length === 0 || !isConnected}
className="btn-primary py-4 px-12 rounded-lg text-xl bg-linear-to-t from-pink-600 to-pink-500 hover:from-pink-800/90 font-secondary transition duration-200 cursor-pointer font-extrabold uppercase tracking-wider disabled:opacity-50"
>
Start Game
</button>
{notSentNotice('the game was not started.',
'the game can be started once the connection is back.')}
</>
) : (
<>
Expand Down Expand Up @@ -760,7 +793,7 @@
<button
key={answer.id}
onClick={() => handleAnswer(answer.id)}
disabled={Boolean(selectedAnswer) || showResults}
disabled={Boolean(selectedAnswer) || showResults || !isConnected}
className={`relative w-full px-4 sm:px-6 md:px-12 py-3 sm:py-4 rounded-full border-2 text-base sm:text-lg md:text-xl font-bold flex items-center justify-center text-center transition-all ${bgClass} ${opacityClass}`}
>
{answer.text}
Expand All @@ -777,6 +810,10 @@
})}
</div>

{gameState === GameState.QUESTION && !isOrganizer && !selectedAnswer &&
notSentNotice('your answer was not sent.',
'answers cannot be sent until the connection is back.')}

</div>
</div>
</div>
Expand All @@ -786,8 +823,9 @@


{gameState === GameState.RESULTS && isOrganizer && (
<div className="fixed bottom-0 left-0 w-full p-6 bg-background/80 backdrop-blur-md border-t border-white/10 flex justify-end">
<button onClick={handleNext} className="btn-primary py-3 px-8 text-lg font-bold">Show Standings →</button>
<div className="fixed bottom-0 left-0 w-full p-6 bg-background/80 backdrop-blur-md border-t border-white/10 flex flex-col items-end">
<button onClick={handleNext} disabled={!isConnected} className="btn-primary py-3 px-8 text-lg font-bold disabled:opacity-50">Show Standings →</button>
{notSentNotice('the next step was not sent.', 'wait for the connection to come back.')}
</div>
)}
</div>
Expand Down Expand Up @@ -876,8 +914,9 @@
</div>

{isOrganizer && (
<div className="fixed bottom-0 sm:right-6 z-50 p-6 bg-black border-t border-white/20 flex justify-center">
<button onClick={handleNext} className="cursor-pointer active:scale-95 py-4 px-12 text-lg uppercase tracking-widest shadow-xl font-secondary bg-linear-to-b from-pink-500 to-pink-700/70 hover:to-pink-700/60 transition duration-200 text-gray-900 font-extrabold border border-pink-800 rounded-2xl">Next Question</button>
<div className="fixed bottom-0 sm:right-6 z-50 p-6 bg-black border-t border-white/20 flex flex-col items-center">
<button onClick={handleNext} disabled={!isConnected} className="disabled:opacity-50 cursor-pointer active:scale-95 py-4 px-12 text-lg uppercase tracking-widest shadow-xl font-secondary bg-linear-to-b from-pink-500 to-pink-700/70 hover:to-pink-700/60 transition duration-200 text-gray-900 font-extrabold border border-pink-800 rounded-2xl">Next Question</button>
{notSentNotice('the next question was not sent.', 'wait for the connection to come back.')}
</div>
)}
</div>
Expand Down
Loading