451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} compact />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} compact />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} compact />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
expect(screen.getByText('Bonus points')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows penalty settings when penaltyForWrongAnswer enabled', () => {
|
|
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
|
|
render(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
expect(screen.getByText('Penalty')).toBeInTheDocument();
|
|
});
|
|
|
|
it('updates streakThreshold value', async () => {
|
|
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
|
|
render(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
|
|
|
|
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(
|
|
<GameConfigPanel {...defaultProps} config={config} questionCount={10} />
|
|
);
|
|
|
|
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
|
|
|
|
rerender(<GameConfigPanel {...defaultProps} config={config} questionCount={20} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={config} />);
|
|
|
|
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
|
|
|
|
rerender(<GameConfigPanel {...defaultProps} config={{ ...config, streakBonusEnabled: false }} />);
|
|
|
|
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('tooltip interactions', () => {
|
|
it('shows tooltip on hover', async () => {
|
|
const user = userEvent.setup();
|
|
render(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} />);
|
|
|
|
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(<GameConfigPanel {...defaultProps} config={customConfig} />);
|
|
|
|
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,
|
|
};
|
|
|
|
render(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />);
|
|
|
|
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,
|
|
};
|
|
|
|
render(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />);
|
|
|
|
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Bonus points')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Penalty')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|