import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GameConfigPanel } from '../../components/GameConfigPanel';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { GameConfig } from '../../types';
describe('GameConfigPanel', () => {
const mockOnChange = vi.fn();
const defaultProps = {
config: { ...DEFAULT_GAME_CONFIG },
onChange: mockOnChange,
questionCount: 10,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders all config toggles', () => {
render();
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
expect(screen.getByText('Shuffle Answers')).toBeInTheDocument();
expect(screen.getByText('Host Participates')).toBeInTheDocument();
expect(screen.getByText('Streak Bonus')).toBeInTheDocument();
expect(screen.getByText('Comeback Bonus')).toBeInTheDocument();
expect(screen.getByText('Wrong Answer Penalty')).toBeInTheDocument();
expect(screen.getByText('First Correct Bonus')).toBeInTheDocument();
});
it('renders descriptions for each toggle', () => {
render();
expect(screen.getByText('Randomize question order when starting')).toBeInTheDocument();
expect(screen.getByText('Randomize answer positions for each question')).toBeInTheDocument();
expect(screen.getByText('Join as a player and answer questions')).toBeInTheDocument();
});
it('renders compact collapsed state when compact=true', () => {
render();
expect(screen.getByText('Game Settings')).toBeInTheDocument();
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
it('expands when clicking collapsed compact view', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
});
it('collapses when clicking expanded compact view header', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
await user.click(screen.getByText('Game Settings'));
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
});
describe('toggle interactions - happy path', () => {
const getCheckboxForLabel = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]')!;
};
it('calls onChange when toggling shuffleQuestions', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Shuffle Questions'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
});
});
it('calls onChange when toggling shuffleAnswers', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Shuffle Answers'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
});
});
it('calls onChange when toggling hostParticipates off', async () => {
const user = userEvent.setup();
const config = { ...DEFAULT_GAME_CONFIG, hostParticipates: true };
render();
await user.click(screen.getByText('Host Participates'));
expect(mockOnChange).toHaveBeenCalledWith({
...config,
hostParticipates: false,
});
});
it('calls onChange when enabling streakBonus', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Streak Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
streakBonusEnabled: true,
});
});
it('calls onChange when enabling comebackBonus', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Comeback Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
comebackBonusEnabled: true,
});
});
it('calls onChange when enabling penalty', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('Wrong Answer Penalty'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
penaltyForWrongAnswer: true,
});
});
it('calls onChange when enabling firstCorrectBonus', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByText('First Correct Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
firstCorrectBonusEnabled: true,
});
});
});
describe('nested number inputs - happy path', () => {
it('shows streak settings when streakBonusEnabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render();
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
expect(screen.getByText('Multiplier')).toBeInTheDocument();
});
it('shows comeback settings when comebackBonusEnabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render();
expect(screen.getByText('Bonus points')).toBeInTheDocument();
});
it('shows penalty settings when penaltyForWrongAnswer enabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
render();
expect(screen.getByText('Penalty')).toBeInTheDocument();
});
it('updates streakThreshold value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render();
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: '5' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ streakThreshold: 5 })
);
});
it('updates streakMultiplier value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render();
const multiplierInput = screen.getByDisplayValue('1.1') as HTMLInputElement;
fireEvent.change(multiplierInput, { target: { value: '1.5' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ streakMultiplier: 1.5 })
);
});
it('updates comebackBonusPoints value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render();
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '100' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ comebackBonusPoints: 100 })
);
});
it('updates penaltyPercent value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
render();
const penaltyInput = screen.getByDisplayValue('25') as HTMLInputElement;
fireEvent.change(penaltyInput, { target: { value: '50' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ penaltyPercent: 50 })
);
});
it('updates firstCorrectBonusPoints value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true };
render();
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '75' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ firstCorrectBonusPoints: 75 })
);
});
});
describe('suggested values', () => {
it('displays suggested comeback bonus based on question count', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render();
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
});
it('displays suggested first correct bonus based on question count', () => {
const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true };
render();
expect(screen.getByText(/Suggested for 10 questions: 50 pts/)).toBeInTheDocument();
});
it('updates suggested values when question count changes', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
const { rerender } = render(
);
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
rerender();
expect(screen.getByText(/Suggested for 20 questions: 150 pts/)).toBeInTheDocument();
});
});
describe('interactions - unhappy path / edge cases', () => {
it('handles rapid toggle clicks', async () => {
const user = userEvent.setup();
render();
const label = screen.getByText('Shuffle Questions');
await user.click(label);
await user.click(label);
await user.click(label);
expect(mockOnChange).toHaveBeenCalledTimes(3);
});
it('handles number input with invalid value (converts to 0)', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render();
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: 'abc' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ streakThreshold: 0 })
);
});
it('handles number input with negative value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render();
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '-10' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ comebackBonusPoints: -10 })
);
});
it('handles empty number input (converts to 0)', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render();
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ streakThreshold: 0 })
);
});
it('does not show nested settings when toggle is off', () => {
render();
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
expect(screen.queryByText('Multiplier')).not.toBeInTheDocument();
});
it('hides nested settings when toggle is turned off', async () => {
const user = userEvent.setup();
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
const { rerender } = render();
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
rerender();
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
});
});
describe('tooltip interactions', () => {
it('shows tooltip on hover', async () => {
const user = userEvent.setup();
render();
const infoButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg')
);
if (infoButtons.length > 0) {
await user.hover(infoButtons[0]);
}
});
it('shows tooltip on click', async () => {
const user = userEvent.setup();
render();
const infoButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg')
);
if (infoButtons.length > 0) {
await user.click(infoButtons[0]);
}
});
});
describe('state management', () => {
const getCheckboxForRow = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
it('reflects updated config values correctly', () => {
const customConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 5,
streakMultiplier: 1.5,
};
render();
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
expect(getCheckboxForRow('Shuffle Answers').checked).toBe(true);
expect(getCheckboxForRow('Host Participates').checked).toBe(false);
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
expect(screen.getByDisplayValue('1.5')).toBeInTheDocument();
});
it('handles config with all options enabled', () => {
const allEnabledConfig: GameConfig = {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
randomNamesEnabled: false,
streakBonusEnabled: true,
streakThreshold: 3,
streakMultiplier: 1.2,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
timerEnabled: true,
maxPlayers: 10,
};
render();
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
expect(screen.getAllByText('Bonus points').length).toBe(2);
expect(screen.getByText('Penalty')).toBeInTheDocument();
});
it('handles config with all options disabled', () => {
const allDisabledConfig: GameConfig = {
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: false,
randomNamesEnabled: false,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
timerEnabled: true,
maxPlayers: 10,
};
render();
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
expect(screen.queryByText('Bonus points')).not.toBeInTheDocument();
expect(screen.queryByText('Penalty')).not.toBeInTheDocument();
});
});
});