331 lines
12 KiB
TypeScript
331 lines
12 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 { 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,
|
|
randomNamesEnabled: false,
|
|
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);
|
|
});
|
|
});
|
|
});
|