diff --git a/components/Landing.tsx b/components/Landing.tsx index c37805c..c2801b8 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -67,6 +67,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [pin, setPin] = useState(initialPin || ''); const [name, setName] = useState(''); + useEffect(() => { + if (initialPin) { + setPin(initialPin); + } + }, [initialPin]); + const modalParam = searchParams.get('modal'); const libraryOpen = modalParam === 'library'; const importOpen = modalParam === 'import'; diff --git a/components/Lobby.tsx b/components/Lobby.tsx index 792754e..75288ff 100644 --- a/components/Lobby.tsx +++ b/components/Lobby.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Player } from '../types'; import { motion, AnimatePresence } from 'framer-motion'; -import { Sparkles, User, X } from 'lucide-react'; +import { Sparkles, User, X, Link, Check } from 'lucide-react'; import { PlayerAvatar } from './PlayerAvatar'; +import toast from 'react-hot-toast'; interface LobbyProps { quizTitle: string; @@ -20,14 +21,35 @@ export const Lobby: React.FC = ({ quizTitle, players, gamePin, role, 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 copyJoinLink = async () => { + if (!gamePin) return; + const joinUrl = `${window.location.origin}/play/${gamePin}`; + await navigator.clipboard.writeText(joinUrl); + setLinkCopied(true); + toast.success('Join link copied!'); + setTimeout(() => setLinkCopied(false), 2000); + }; return (
Game PIN -
- {gamePin} +
+
+ {gamePin} +
+ {isHost && ( + + )}
diff --git a/tests/components/Lobby.test.tsx b/tests/components/Lobby.test.tsx new file mode 100644 index 0000000..7aefac0 --- /dev/null +++ b/tests/components/Lobby.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Lobby } from '../../components/Lobby'; + +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: React.PropsWithChildren>) =>
{children}
, + }, + AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, +})); + +describe('Lobby', () => { + const defaultProps = { + quizTitle: 'Test Quiz', + players: [], + gamePin: 'ABC123', + role: 'HOST' as const, + onStart: vi.fn(), + onEndGame: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:5173' }, + writable: true, + }); + }); + + describe('copy join link', () => { + it('shows copy link button for host', () => { + render(); + + expect(screen.getByTitle('Copy join link')).toBeInTheDocument(); + }); + + it('does not show copy link button for client', () => { + render(); + + expect(screen.queryByTitle('Copy join link')).not.toBeInTheDocument(); + }); + + it('copies join URL to clipboard when clicked', async () => { + const user = userEvent.setup(); + render(); + + const copyButton = screen.getByTitle('Copy join link'); + expect(copyButton.querySelector('.lucide-link')).toBeInTheDocument(); + + await user.click(copyButton); + + await waitFor(() => { + expect(copyButton.querySelector('.lucide-check')).toBeInTheDocument(); + }); + }); + + it('shows success toast when link is copied', async () => { + const toast = await import('react-hot-toast'); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle('Copy join link')); + + await waitFor(() => { + expect(toast.default.success).toHaveBeenCalledWith('Join link copied!'); + }); + }); + + it('shows checkmark after copying link', async () => { + const user = userEvent.setup(); + render(); + + const copyButton = screen.getByTitle('Copy join link'); + + await user.click(copyButton); + + await waitFor(() => { + expect(copyButton.querySelector('.lucide-check')).toBeInTheDocument(); + }); + }); + }); + + describe('display', () => { + it('displays game PIN', () => { + render(); + + expect(screen.getByText('ABC123')).toBeInTheDocument(); + }); + + it('displays quiz title on larger screens', () => { + render(); + + expect(screen.getByText('Test Quiz')).toBeInTheDocument(); + }); + + it('displays player count', () => { + const players = [ + { id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 }, + { id: '2', name: 'Player 2', score: 0, avatarSeed: 0.2 }, + ]; + render(); + + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('shows waiting message when no players have joined', () => { + render(); + + expect(screen.getByText('Waiting for players to join...')).toBeInTheDocument(); + }); + + it('displays player names when players join', () => { + const players = [ + { id: '1', name: 'Alice', score: 0, avatarSeed: 0.1 }, + { id: '2', name: 'Bob', score: 0, avatarSeed: 0.2 }, + ]; + render(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('host controls', () => { + it('shows Start button for host', () => { + render(); + + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); + }); + + it('disables Start button when no players', () => { + render(); + + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); + }); + + it('enables Start button when players joined', () => { + const players = [{ id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 }]; + render(); + + expect(screen.getByRole('button', { name: 'Start' })).not.toBeDisabled(); + }); + + it('calls onStart when Start clicked', async () => { + const user = userEvent.setup(); + const players = [{ id: '1', name: 'Player 1', score: 0, avatarSeed: 0.1 }]; + render(); + + await user.click(screen.getByRole('button', { name: 'Start' })); + + expect(defaultProps.onStart).toHaveBeenCalled(); + }); + + it('shows End Game button when onEndGame provided', () => { + render(); + + expect(screen.getByText('End Game')).toBeInTheDocument(); + }); + + it('calls onEndGame when End Game clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('End Game')); + + expect(defaultProps.onEndGame).toHaveBeenCalled(); + }); + }); + + describe('client view', () => { + it('shows waiting message for client', () => { + render(); + + expect(screen.getByText('Waiting for the host to start...')).toBeInTheDocument(); + }); + + it('shows player name when currentPlayerId matches', () => { + const players = [ + { id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 }, + ]; + render( + + ); + + expect(screen.getByText('My Name')).toBeInTheDocument(); + }); + + it('does not show Start button for client', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Start' })).not.toBeInTheDocument(); + }); + }); + + describe('host participates mode', () => { + it('shows host in player list when hostParticipates is true', () => { + const players = [ + { id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 }, + ]; + render(); + + expect(screen.getByText('Host Player')).toBeInTheDocument(); + expect(screen.getByText('HOST')).toBeInTheDocument(); + }); + + it('does not show waiting message when host participates and no other players', () => { + const players = [ + { id: 'host', name: 'Host Player', score: 0, avatarSeed: 0.99 }, + ]; + render(); + + expect(screen.queryByText('Waiting for players to join...')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/tests/hooks/useGame.navigation.test.tsx b/tests/hooks/useGame.navigation.test.tsx index 07d97b0..fe7a2b6 100644 --- a/tests/hooks/useGame.navigation.test.tsx +++ b/tests/hooks/useGame.navigation.test.tsx @@ -1279,6 +1279,51 @@ describe('OAuth Callback Handling', () => { }); }); +describe('/play/:pin URL Join Flow', () => { + describe('Landing PIN sync (regression: PIN field empty on /play/:pin)', () => { + it('should sync local PIN state when initialPin prop changes after async load', () => { + let initialPin: string | null = null; + let localPin = initialPin || ''; + + const syncPin = (newInitialPin: string | null) => { + if (newInitialPin) { + localPin = newInitialPin; + } + }; + + initialPin = 'TESTPIN'; + syncPin(initialPin); + + expect(localPin).toBe('TESTPIN'); + }); + + it('should preserve user-typed PIN when initialPin becomes null', () => { + let localPin = 'USER_TYPED'; + + const syncPin = (newInitialPin: string | null) => { + if (newInitialPin) { + localPin = newInitialPin; + } + }; + + syncPin(null); + + expect(localPin).toBe('USER_TYPED'); + }); + + it('should force JOIN mode when initialPin is provided via URL', () => { + const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => { + if (initialPin) return 'JOIN'; + const modeParam = searchParams.get('mode'); + return modeParam === 'host' ? 'HOST' : 'JOIN'; + }; + + const searchParams = new URLSearchParams('?mode=host'); + expect(getModeFromUrl(searchParams, 'TESTPIN')).toBe('JOIN'); + }); + }); +}); + describe('State Sync with Async Data Loading', () => { describe('Settings modal data sync', () => { it('should update editingDefaultConfig when defaultConfig loads', () => { diff --git a/tests/setup.tsx b/tests/setup.tsx index bf5ea33..5f12356 100644 --- a/tests/setup.tsx +++ b/tests/setup.tsx @@ -1,7 +1,18 @@ import React from 'react'; import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; -import { afterEach, vi } from 'vitest'; +import { afterEach, vi, beforeAll } from 'vitest'; + +beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue(''), + }, + writable: true, + configurable: true, + }); +}); afterEach(() => { cleanup();