feat: add comprehensive game configuration system
Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
This commit is contained in:
parent
90fba17a1e
commit
af21f2bcdc
23 changed files with 2925 additions and 133 deletions
330
tests/components/DefaultConfigModal.test.tsx
Normal file
330
tests/components/DefaultConfigModal.test.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
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 { DefaultConfigModal } from '../../components/DefaultConfigModal';
|
||||
import { DEFAULT_GAME_CONFIG } from '../../types';
|
||||
import type { GameConfig } from '../../types';
|
||||
|
||||
describe('DefaultConfigModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnChange = vi.fn();
|
||||
const mockOnSave = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
config: { ...DEFAULT_GAME_CONFIG },
|
||||
onChange: mockOnChange,
|
||||
onSave: mockOnSave,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Default Game Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays subtitle explaining the settings', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Applied to all new quizzes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GameConfigPanel with config', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host Participates')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Cancel button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Save Defaults button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close X button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
|
||||
expect(xButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions - happy path', () => {
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
|
||||
await user.click(xButton!);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when backdrop is clicked', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const backdrop = document.querySelector('.fixed.inset-0');
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not close when modal content is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Default Game Settings'));
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave when Save Defaults is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onChange when config is modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Shuffle Questions'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions - unhappy path / edge cases', () => {
|
||||
it('disables Save button when saving is true', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
const saveButton = screen.getByText('Saving...').closest('button');
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Saving... text when saving', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save Defaults')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call onSave when button is disabled and clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
const saveButton = screen.getByText('Saving...').closest('button')!;
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Cancel button is still clickable when saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles rapid clicks on Save button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const saveButton = screen.getByText('Save Defaults');
|
||||
await user.click(saveButton);
|
||||
await user.click(saveButton);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('remains functional after re-opening', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} isOpen={true} />);
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config propagation', () => {
|
||||
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('displays provided config values', () => {
|
||||
const customConfig: GameConfig = {
|
||||
...DEFAULT_GAME_CONFIG,
|
||||
shuffleQuestions: true,
|
||||
streakBonusEnabled: true,
|
||||
streakThreshold: 5,
|
||||
};
|
||||
|
||||
render(<DefaultConfigModal {...defaultProps} config={customConfig} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
|
||||
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates display when config prop changes', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(false);
|
||||
|
||||
const newConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
|
||||
rerender(<DefaultConfigModal {...defaultProps} config={newConfig} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions', () => {
|
||||
it('transitions from not saving to saving correctly', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={false} />);
|
||||
|
||||
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('transitions from saving back to not saving', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} saving={false} />);
|
||||
|
||||
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('transitions from open to closed preserves state for re-open', () => {
|
||||
const customConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} config={customConfig} />);
|
||||
|
||||
const getCheckbox = () => {
|
||||
const label = screen.getByText('Shuffle Questions');
|
||||
const row = label.closest('[class*="bg-white rounded-xl"]')!;
|
||||
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
};
|
||||
|
||||
expect(getCheckbox().checked).toBe(true);
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={false} />);
|
||||
rerender(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={true} />);
|
||||
|
||||
expect(getCheckbox().checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('all interactive elements are focusable', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).not.toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('modal traps focus within when open', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const modal = document.querySelector('[class*="bg-white rounded-2xl"]');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with all config options enabled', () => {
|
||||
it('renders all nested settings when all options are enabled', () => {
|
||||
const allEnabledConfig: GameConfig = {
|
||||
shuffleQuestions: true,
|
||||
shuffleAnswers: true,
|
||||
hostParticipates: true,
|
||||
streakBonusEnabled: true,
|
||||
streakThreshold: 3,
|
||||
streakMultiplier: 1.2,
|
||||
comebackBonusEnabled: true,
|
||||
comebackBonusPoints: 100,
|
||||
penaltyForWrongAnswer: true,
|
||||
penaltyPercent: 30,
|
||||
firstCorrectBonusEnabled: true,
|
||||
firstCorrectBonusPoints: 75,
|
||||
};
|
||||
|
||||
render(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />);
|
||||
|
||||
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
|
||||
expect(screen.getByText('Multiplier')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Bonus points').length).toBe(2);
|
||||
expect(screen.getByText('Penalty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('async save behavior', () => {
|
||||
it('handles async onSave that resolves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const asyncOnSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
|
||||
expect(asyncOnSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles async onSave that rejects', async () => {
|
||||
const user = userEvent.setup();
|
||||
const asyncOnSave = vi.fn().mockRejectedValue(new Error('Save failed'));
|
||||
|
||||
render(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
|
||||
expect(asyncOnSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
449
tests/components/GameConfigPanel.test.tsx
Normal file
449
tests/components/GameConfigPanel.test.tsx
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
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,
|
||||
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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
422
tests/components/QuizEditorConfig.test.tsx
Normal file
422
tests/components/QuizEditorConfig.test.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue