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:
parent
3122748bae
commit
79820f5298
7 changed files with 1640 additions and 10 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue