Add kick player and leave game functionality

- 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)
This commit is contained in:
Joey Yakimowich-Payne 2026-01-19 14:52:57 -07:00
commit 79820f5298
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
7 changed files with 1640 additions and 10 deletions

View file

@ -14,6 +14,7 @@ vi.mock('react-hot-toast', () => ({
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}</>,
}));
@ -541,4 +542,604 @@ describe('Lobby', () => {
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();
});
});
});