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(); }); }); });