kaboot/tests/components/QuizEditorConfig.test.tsx

423 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();
});
describe('config initialization', () => {
it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => {
render(<QuizEditor {...defaultProps} />);
expect(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game 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('renders collapsed Game Settings by default (compact mode)', () => {
render(<QuizEditor {...defaultProps} />);
expect(screen.getByText('Game Settings')).toBeInTheDocument();
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
it('expands config panel when Game Settings is clicked', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
expect(screen.getByText('Host Participates')).toBeInTheDocument();
});
it('collapses config panel when Game Settings is clicked again', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
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();
});
it('calls onConfigChange when config is modified', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game Settings'));
await user.click(screen.getByText('Host Participates'));
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,
},
});
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(screen.getByText('Game 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(screen.getByText('Game 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(screen.getByText('Game Settings'));
const thresholdInput = screen.getByDisplayValue('3');
await user.clear(thresholdInput);
await user.type(thresholdInput, '5');
expect(mockOnConfigChange).toHaveBeenCalled();
});
});
});