Add a 'Question Timer' toggle to game settings that lets the host disable the countdown timer. When disabled, questions show ∞ instead of a countdown, the host gets an 'End Question' button to manually advance, and all correct answers receive maximum points. Also fix a bug where per-question time limits were ignored — the timer and scoring always used the hardcoded 20-second default instead of each question's individual timeLimit.
438 lines
14 KiB
TypeScript
438 lines
14 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 { QuizEditor } from '../../components/QuizEditor';
|
|
import { DEFAULT_GAME_CONFIG } from '../../types';
|
|
import type { Quiz, GameConfig } from '../../types';
|
|
|
|
vi.mock('uuid', () => ({
|
|
v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9),
|
|
}));
|
|
|
|
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
|
title: 'Test Quiz',
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
text: 'What is 2+2?',
|
|
timeLimit: 20,
|
|
options: [
|
|
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
|
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
|
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
|
|
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
|
|
],
|
|
},
|
|
],
|
|
...overrides,
|
|
});
|
|
|
|
describe('QuizEditor - Game Config Integration', () => {
|
|
const mockOnSave = vi.fn();
|
|
const mockOnStartGame = vi.fn();
|
|
const mockOnConfigChange = vi.fn();
|
|
const mockOnBack = vi.fn();
|
|
|
|
const defaultProps = {
|
|
quiz: createMockQuiz(),
|
|
onSave: mockOnSave,
|
|
onStartGame: mockOnStartGame,
|
|
onConfigChange: mockOnConfigChange,
|
|
onBack: mockOnBack,
|
|
showSaveButton: true,
|
|
isSaving: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const findTab = (name: 'Questions' | 'Settings') => {
|
|
const tabButtons = screen.getAllByRole('button');
|
|
return tabButtons.find(btn => {
|
|
const text = btn.textContent || '';
|
|
if (name === 'Questions') {
|
|
return text.includes('Questions') && !text.includes('Add');
|
|
}
|
|
return text.includes('Settings');
|
|
})!;
|
|
};
|
|
|
|
describe('config initialization', () => {
|
|
it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => {
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
expect(findTab('Settings')).toBeInTheDocument();
|
|
});
|
|
|
|
it('uses quiz config when provided', async () => {
|
|
const user = userEvent.setup();
|
|
const quizWithConfig = createMockQuiz({
|
|
config: {
|
|
...DEFAULT_GAME_CONFIG,
|
|
shuffleQuestions: true,
|
|
streakBonusEnabled: true,
|
|
},
|
|
});
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
|
|
const getCheckbox = (labelText: string) => {
|
|
const label = screen.getByText(labelText);
|
|
const row = label.closest('[class*="bg-white rounded-xl"]')!;
|
|
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
};
|
|
|
|
expect(getCheckbox('Shuffle Questions').checked).toBe(true);
|
|
});
|
|
|
|
it('uses defaultConfig prop when quiz has no config', async () => {
|
|
const user = userEvent.setup();
|
|
const customDefault: GameConfig = {
|
|
...DEFAULT_GAME_CONFIG,
|
|
hostParticipates: false,
|
|
penaltyForWrongAnswer: true,
|
|
};
|
|
|
|
render(<QuizEditor {...defaultProps} defaultConfig={customDefault} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
|
|
const getCheckbox = (labelText: string) => {
|
|
const label = screen.getByText(labelText);
|
|
const row = label.closest('[class*="bg-white rounded-xl"]')!;
|
|
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
};
|
|
|
|
expect(getCheckbox('Host Participates').checked).toBe(false);
|
|
expect(getCheckbox('Wrong Answer Penalty').checked).toBe(true);
|
|
});
|
|
|
|
it('prioritizes quiz config over defaultConfig prop', async () => {
|
|
const user = userEvent.setup();
|
|
const quizWithConfig = createMockQuiz({
|
|
config: {
|
|
...DEFAULT_GAME_CONFIG,
|
|
shuffleQuestions: true,
|
|
},
|
|
});
|
|
const customDefault: GameConfig = {
|
|
...DEFAULT_GAME_CONFIG,
|
|
shuffleQuestions: false,
|
|
shuffleAnswers: true,
|
|
};
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} defaultConfig={customDefault} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
|
|
const getCheckbox = (labelText: string) => {
|
|
const label = screen.getByText(labelText);
|
|
const row = label.closest('[class*="bg-white rounded-xl"]')!;
|
|
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
};
|
|
|
|
expect(getCheckbox('Shuffle Questions').checked).toBe(true);
|
|
expect(getCheckbox('Shuffle Answers').checked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('config panel interactions', () => {
|
|
it('shows Questions tab by default', () => {
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
expect(findTab('Questions')).toBeInTheDocument();
|
|
expect(findTab('Settings')).toBeInTheDocument();
|
|
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows config panel when Settings tab is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
|
|
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
|
expect(screen.getByText('Host Participates')).toBeInTheDocument();
|
|
});
|
|
|
|
it('switches back to Questions tab when clicked', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
|
|
|
await user.click(findTab('Questions'));
|
|
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onConfigChange when config is modified', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
|
|
expect(mockOnConfigChange).toHaveBeenCalledWith({
|
|
...DEFAULT_GAME_CONFIG,
|
|
shuffleQuestions: true,
|
|
});
|
|
});
|
|
|
|
it('calls onConfigChange multiple times for multiple changes', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
await user.click(screen.getByText('Shuffle Answers'));
|
|
|
|
expect(mockOnConfigChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('start game with config', () => {
|
|
it('passes config to onStartGame', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Test Quiz',
|
|
config: DEFAULT_GAME_CONFIG,
|
|
}),
|
|
DEFAULT_GAME_CONFIG
|
|
);
|
|
});
|
|
|
|
it('passes modified config to onStartGame', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('shuffles questions when shuffleQuestions is enabled', async () => {
|
|
const user = userEvent.setup();
|
|
const multiQuestionQuiz = createMockQuiz({
|
|
questions: [
|
|
{ id: 'q1', text: 'Q1', timeLimit: 20, options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] },
|
|
{ id: 'q2', text: 'Q2', timeLimit: 20, options: [{ text: 'C', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }] },
|
|
{ id: 'q3', text: 'Q3', timeLimit: 20, options: [{ text: 'E', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'F', isCorrect: false, shape: 'diamond', color: 'blue' }] },
|
|
],
|
|
config: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true },
|
|
});
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={multiQuestionQuiz} />);
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
questions: expect.any(Array),
|
|
}),
|
|
expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
})
|
|
);
|
|
|
|
const calledQuiz = mockOnStartGame.mock.calls[0][0];
|
|
expect(calledQuiz.questions).toHaveLength(3);
|
|
});
|
|
|
|
it('shuffles answers when shuffleAnswers is enabled', async () => {
|
|
const user = userEvent.setup();
|
|
const quizWithConfig = createMockQuiz({
|
|
config: { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true },
|
|
});
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
questions: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
options: expect.any(Array),
|
|
}),
|
|
]),
|
|
}),
|
|
expect.objectContaining({
|
|
shuffleAnswers: true,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('config persistence', () => {
|
|
it('maintains config state across quiz title edits', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
|
|
await user.click(screen.getByText('Test Quiz'));
|
|
const titleInput = screen.getByDisplayValue('Test Quiz');
|
|
await user.clear(titleInput);
|
|
await user.type(titleInput, 'New Title');
|
|
fireEvent.blur(titleInput);
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
}),
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('maintains config state across question additions', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Host Participates'));
|
|
|
|
await user.click(findTab('Questions'));
|
|
await user.click(screen.getByText('Add Question'));
|
|
|
|
expect(mockOnConfigChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
hostParticipates: false,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles quiz with all config options enabled', async () => {
|
|
const user = userEvent.setup();
|
|
const fullConfigQuiz = createMockQuiz({
|
|
config: {
|
|
shuffleQuestions: true,
|
|
shuffleAnswers: true,
|
|
hostParticipates: true,
|
|
randomNamesEnabled: false,
|
|
streakBonusEnabled: true,
|
|
streakThreshold: 5,
|
|
streakMultiplier: 1.5,
|
|
comebackBonusEnabled: true,
|
|
comebackBonusPoints: 100,
|
|
penaltyForWrongAnswer: true,
|
|
penaltyPercent: 30,
|
|
firstCorrectBonusEnabled: true,
|
|
firstCorrectBonusPoints: 75,
|
|
timerEnabled: true,
|
|
maxPlayers: 10,
|
|
},
|
|
});
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={fullConfigQuiz} />);
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
streakBonusEnabled: true,
|
|
comebackBonusEnabled: true,
|
|
penaltyForWrongAnswer: true,
|
|
firstCorrectBonusEnabled: true,
|
|
}),
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('handles config without onConfigChange callback', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} onConfigChange={undefined} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
|
|
await user.click(screen.getByText(/Start Game/));
|
|
|
|
expect(mockOnStartGame).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({
|
|
shuffleQuestions: true,
|
|
}),
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('handles empty questions array', () => {
|
|
const emptyQuiz = createMockQuiz({ questions: [] });
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={emptyQuiz} />);
|
|
|
|
const startButton = screen.getByText(/Start Game/).closest('button');
|
|
expect(startButton).toBeDisabled();
|
|
});
|
|
|
|
it('handles quiz without title', () => {
|
|
const noTitleQuiz = createMockQuiz({ title: '' });
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={noTitleQuiz} />);
|
|
|
|
expect(screen.getByText('Untitled Quiz')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('onConfigChange callback timing', () => {
|
|
it('calls onConfigChange immediately when toggle changes', async () => {
|
|
const user = userEvent.setup();
|
|
render(<QuizEditor {...defaultProps} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
await user.click(screen.getByText('Shuffle Questions'));
|
|
|
|
expect(mockOnConfigChange).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('calls onConfigChange for nested number input changes', async () => {
|
|
const user = userEvent.setup();
|
|
const quizWithStreak = createMockQuiz({
|
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true },
|
|
});
|
|
|
|
render(<QuizEditor {...defaultProps} quiz={quizWithStreak} />);
|
|
|
|
await user.click(findTab('Settings'));
|
|
|
|
const thresholdInput = screen.getByDisplayValue('3');
|
|
await user.clear(thresholdInput);
|
|
await user.type(thresholdInput, '5');
|
|
|
|
expect(mockOnConfigChange).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|