- Host can kick players from lobby (removes from game, clears presenter if needed) - Client can voluntarily leave game - Fix browser-compatible base64 decoding for document upload (atob vs Buffer)
1145 lines
33 KiB
TypeScript
1145 lines
33 KiB
TypeScript
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<Record<string, unknown>>) => <div {...props}>{children}</div>,
|
|
button: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <button {...props}>{children}</button>,
|
|
},
|
|
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
|
}));
|
|
|
|
vi.mock('qrcode.react', () => ({
|
|
QRCodeSVG: ({ value, size }: { value: string; size: number }) => (
|
|
<svg data-testid="qr-code" data-value={value} width={size} height={size} />
|
|
),
|
|
}));
|
|
|
|
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(<Lobby {...defaultProps} />);
|
|
|
|
expect(screen.getByTitle('Copy join link')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show copy link button for client', () => {
|
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
|
|
|
expect(screen.queryByTitle('Copy join link')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('copies join URL to clipboard when clicked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
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(<Lobby {...defaultProps} />);
|
|
|
|
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(<Lobby {...defaultProps} gamePin="XYZ789" />);
|
|
|
|
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(<Lobby {...defaultProps} />);
|
|
|
|
const pins = screen.getAllByText('ABC123');
|
|
expect(pins.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('displays quiz title on all screen sizes', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
const titles = screen.getAllByText('Test Quiz');
|
|
expect(titles.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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(<Lobby {...defaultProps} players={players} />);
|
|
|
|
const counts = screen.getAllByText('2');
|
|
expect(counts.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('shows waiting message when no players have joined', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
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(<Lobby {...defaultProps} players={players} />);
|
|
|
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('host controls', () => {
|
|
it('shows Start button for host', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('disables Start button when no players', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
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(<Lobby {...defaultProps} players={players} />);
|
|
|
|
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(<Lobby {...defaultProps} players={players} />);
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Start' }));
|
|
|
|
expect(defaultProps.onStart).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows End Game button when onEndGame provided', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
expect(screen.getByText('End Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onEndGame when End Game clicked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
await user.click(screen.getByText('End Game'));
|
|
|
|
expect(defaultProps.onEndGame).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('client view', () => {
|
|
it('shows waiting message for 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();
|
|
});
|
|
|
|
it('shows player name when currentPlayerId matches', () => {
|
|
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('My Name')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show Start button for client', () => {
|
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
|
|
|
expect(screen.queryByRole('button', { name: 'Start' })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('QR code', () => {
|
|
it('shows QR code for host', () => {
|
|
render(<Lobby {...defaultProps} />);
|
|
|
|
const qrCode = screen.getByTestId('qr-code');
|
|
expect(qrCode).toBeInTheDocument();
|
|
expect(qrCode).toHaveAttribute('data-value', 'http://localhost:5173/play/ABC123');
|
|
});
|
|
|
|
it('does not show QR code for client', () => {
|
|
render(<Lobby {...defaultProps} role="CLIENT" />);
|
|
|
|
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show QR code when gamePin is null', () => {
|
|
render(<Lobby {...defaultProps} gamePin={null} />);
|
|
|
|
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('generates correct QR code URL with game PIN', () => {
|
|
render(<Lobby {...defaultProps} gamePin="XYZ789" />);
|
|
|
|
const qrCode = screen.getByTestId('qr-code');
|
|
expect(qrCode).toHaveAttribute('data-value', 'http://localhost:5173/play/XYZ789');
|
|
});
|
|
});
|
|
|
|
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(<Lobby {...defaultProps} players={players} hostParticipates={true} />);
|
|
|
|
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(<Lobby {...defaultProps} players={players} hostParticipates={true} />);
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('kick player feature - host view', () => {
|
|
const playersWithKick = [
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
|
];
|
|
|
|
it('shows kick button on each player when onKickPlayer provided', () => {
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
expect(kickButtons).toHaveLength(2);
|
|
});
|
|
|
|
it('does not show kick button when onKickPlayer not provided', () => {
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onKickPlayer with correct player id when kick button clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const onKickPlayer = vi.fn();
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={onKickPlayer}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
await user.click(kickButtons[0]);
|
|
|
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
|
});
|
|
|
|
it('kick button click does not trigger presenter selection', async () => {
|
|
const user = userEvent.setup();
|
|
const onKickPlayer = vi.fn();
|
|
const onSetPresenter = vi.fn();
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
hostParticipates={false}
|
|
onKickPlayer={onKickPlayer}
|
|
onSetPresenter={onSetPresenter}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
await user.click(kickButtons[0]);
|
|
|
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
|
expect(onSetPresenter).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show kick button for host player', () => {
|
|
const playersWithHost = [
|
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
|
...playersWithKick,
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithHost}
|
|
hostParticipates={true}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
expect(kickButtons).toHaveLength(2);
|
|
});
|
|
|
|
it('can kick second player without affecting first player kick button', async () => {
|
|
const user = userEvent.setup();
|
|
const onKickPlayer = vi.fn();
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={onKickPlayer}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
await user.click(kickButtons[1]);
|
|
|
|
expect(onKickPlayer).toHaveBeenCalledWith('player-2');
|
|
expect(onKickPlayer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('can kick multiple players in sequence', async () => {
|
|
const user = userEvent.setup();
|
|
const onKickPlayer = vi.fn();
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={onKickPlayer}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
await user.click(kickButtons[0]);
|
|
await user.click(kickButtons[1]);
|
|
|
|
expect(onKickPlayer).toHaveBeenCalledTimes(2);
|
|
expect(onKickPlayer).toHaveBeenNthCalledWith(1, 'player-1');
|
|
expect(onKickPlayer).toHaveBeenNthCalledWith(2, 'player-2');
|
|
});
|
|
|
|
it('shows kick button for presenter player', () => {
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
hostParticipates={false}
|
|
presenterId="player-1"
|
|
onKickPlayer={vi.fn()}
|
|
onSetPresenter={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
expect(kickButtons).toHaveLength(2);
|
|
});
|
|
|
|
it('can kick presenter player', async () => {
|
|
const user = userEvent.setup();
|
|
const onKickPlayer = vi.fn();
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
hostParticipates={false}
|
|
presenterId="player-1"
|
|
onKickPlayer={onKickPlayer}
|
|
onSetPresenter={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
await user.click(kickButtons[0]);
|
|
|
|
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
|
|
});
|
|
|
|
it('shows kick button with single player', () => {
|
|
const singlePlayer = [{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={singlePlayer}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows kick button for many players', () => {
|
|
const manyPlayers = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `player-${i}`,
|
|
name: `Player ${i}`,
|
|
score: 0,
|
|
avatarSeed: i * 0.1,
|
|
}));
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={manyPlayers}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const kickButtons = screen.getAllByTitle('Kick player');
|
|
expect(kickButtons).toHaveLength(10);
|
|
});
|
|
|
|
it('updates kick buttons when players list changes', () => {
|
|
const { rerender } = render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
|
|
|
const newPlayers = [
|
|
...playersWithKick,
|
|
{ id: 'player-3', name: 'Charlie', score: 0, avatarSeed: 0.3 },
|
|
];
|
|
|
|
rerender(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={newPlayers}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(3);
|
|
});
|
|
|
|
it('removes kick buttons when onKickPlayer becomes undefined', () => {
|
|
const { rerender } = render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
|
|
|
rerender(
|
|
<Lobby
|
|
{...defaultProps}
|
|
players={playersWithKick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show kick buttons for client role even if onKickPlayer provided', () => {
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={playersWithKick}
|
|
currentPlayerId="player-1"
|
|
onKickPlayer={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('leave game feature - client view', () => {
|
|
it('shows leave game button when onLeaveGame provided', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show leave game button when onLeaveGame not provided', () => {
|
|
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.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onLeaveGame when leave button clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const onLeaveGame = vi.fn();
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={onLeaveGame}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByText('Leave Game'));
|
|
|
|
expect(onLeaveGame).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show leave game button for host', () => {
|
|
const players = [
|
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="HOST"
|
|
players={players}
|
|
currentPlayerId="host"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows leave game button alongside presenter info', () => {
|
|
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"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('You are the Presenter')).toBeInTheDocument();
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onLeaveGame only once per click', async () => {
|
|
const user = userEvent.setup();
|
|
const onLeaveGame = vi.fn();
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={onLeaveGame}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByText('Leave Game'));
|
|
|
|
expect(onLeaveGame).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leave button remains clickable after multiple clicks', async () => {
|
|
const user = userEvent.setup();
|
|
const onLeaveGame = vi.fn();
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={onLeaveGame}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByText('Leave Game'));
|
|
await user.click(screen.getByText('Leave Game'));
|
|
await user.click(screen.getByText('Leave Game'));
|
|
|
|
expect(onLeaveGame).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('shows leave button when player is not in players list', () => {
|
|
const players = [
|
|
{ id: 'player-2', name: 'Other Player', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows leave button with empty players list', () => {
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={[]}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('removes leave button when onLeaveGame becomes undefined', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
const { rerender } = render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('removes leave button when role changes to HOST', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
|
|
];
|
|
|
|
const { rerender } = render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="HOST"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('kick and leave interaction edge cases', () => {
|
|
it('host view shows kick buttons but not leave button', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="HOST"
|
|
players={players}
|
|
currentPlayerId="host"
|
|
onKickPlayer={vi.fn()}
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
|
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('client view shows leave button but not kick buttons', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId="player-1"
|
|
onKickPlayer={vi.fn()}
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles undefined currentPlayerId for client gracefully', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId={undefined}
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles null currentPlayerId for client gracefully', () => {
|
|
const players = [
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="CLIENT"
|
|
players={players}
|
|
currentPlayerId={null}
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Leave Game')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows correct UI when both kick and leave callbacks provided but role is HOST', () => {
|
|
const players = [
|
|
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
|
|
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
|
|
];
|
|
|
|
render(
|
|
<Lobby
|
|
{...defaultProps}
|
|
role="HOST"
|
|
players={players}
|
|
currentPlayerId="host"
|
|
hostParticipates={true}
|
|
onKickPlayer={vi.fn()}
|
|
onLeaveGame={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
|
|
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
|
|
expect(screen.getByText('End Game')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|