kaboot/tests/components/QuizLibrary.test.tsx

641 lines
22 KiB
TypeScript

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QuizLibrary } from '../../components/QuizLibrary';
import type { QuizListItem } from '../../types';
const createMockQuiz = (overrides?: Partial<QuizListItem>): QuizListItem => ({
id: 'quiz-1',
title: 'Test Quiz',
source: 'manual',
questionCount: 5,
isShared: false,
createdAt: '2024-01-15T10:00:00.000Z',
updatedAt: '2024-01-15T10:00:00.000Z',
...overrides,
});
describe('QuizLibrary', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
quizzes: [createMockQuiz()],
loading: false,
loadingQuizId: null,
deletingQuizId: null,
sharingQuizId: null,
exporting: false,
error: null,
onLoadQuiz: vi.fn(),
onDeleteQuiz: vi.fn(),
onExportQuizzes: vi.fn(),
onImportClick: vi.fn(),
onRetry: vi.fn(),
onShareQuiz: vi.fn().mockResolvedValue('mock-token'),
onUnshareQuiz: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders nothing when isOpen is false', () => {
render(<QuizLibrary {...defaultProps} isOpen={false} />);
expect(screen.queryByText('My Library')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByText('My Library')).toBeInTheDocument();
});
it('shows quiz list', () => {
const quizzes = [
createMockQuiz({ id: '1', title: 'Quiz 1' }),
createMockQuiz({ id: '2', title: 'Quiz 2' }),
];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Quiz 1')).toBeInTheDocument();
expect(screen.getByText('Quiz 2')).toBeInTheDocument();
});
it('shows import button in header', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Import quizzes')).toBeInTheDocument();
});
it('shows selection mode toggle button', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Select for export')).toBeInTheDocument();
});
it('shows empty state when no quizzes', () => {
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
expect(screen.getByText('No saved quizzes yet')).toBeInTheDocument();
});
it('shows import button in empty state', () => {
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
expect(screen.getByText('Import Quizzes')).toBeInTheDocument();
});
it('shows loading state', () => {
render(<QuizLibrary {...defaultProps} loading={true} />);
expect(screen.getByText('Loading your quizzes...')).toBeInTheDocument();
});
it('shows error state with retry button', () => {
render(<QuizLibrary {...defaultProps} error="Failed to load quizzes" />);
expect(screen.getByText('Failed to load quizzes')).toBeInTheDocument();
expect(screen.getByText('Try Again')).toBeInTheDocument();
});
});
describe('selection mode', () => {
it('enters selection mode on toggle click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByTitle('Cancel selection')).toBeInTheDocument();
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
});
it('exits selection mode on toggle click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
await user.click(screen.getByTitle('Cancel selection'));
expect(screen.getByText('Select a quiz to play')).toBeInTheDocument();
});
it('shows checkboxes in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const checkboxes = document.querySelectorAll('[class*="rounded-lg border-2"]');
expect(checkboxes.length).toBeGreaterThan(0);
});
it('selects quiz on click in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(screen.getByText('1 of 1 selected')).toBeInTheDocument();
});
it('deselects quiz on second click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(screen.getByText('1 of 1 selected')).toBeInTheDocument();
await user.click(quizCard);
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
});
it('shows Select All button in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('Select All')).toBeInTheDocument();
});
it('selects all quizzes on Select All click', async () => {
const user = userEvent.setup();
const quizzes = [
createMockQuiz({ id: '1', title: 'Quiz 1' }),
createMockQuiz({ id: '2', title: 'Quiz 2' }),
];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('0 of 2 selected')).toBeInTheDocument();
await user.click(screen.getByText('Select All'));
expect(screen.getByText('2 of 2 selected')).toBeInTheDocument();
});
it('shows Deselect All when all selected', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(screen.getByText('Deselect All')).toBeInTheDocument();
});
it('deselects all on Deselect All click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(screen.getByText('1 of 1 selected')).toBeInTheDocument();
await user.click(screen.getByText('Deselect All'));
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
});
it('clears selection when exiting selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(screen.getByText('1 of 1 selected')).toBeInTheDocument();
await user.click(screen.getByTitle('Cancel selection'));
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('0 of 1 selected')).toBeInTheDocument();
});
it('hides play and delete buttons in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Delete quiz')).toBeInTheDocument();
await user.click(screen.getByTitle('Select for export'));
expect(screen.queryByTitle('Delete quiz')).not.toBeInTheDocument();
});
});
describe('export functionality', () => {
it('shows export footer in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText(/Export 0 Quiz/)).toBeInTheDocument();
});
it('export button is disabled when no selection', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const exportButton = screen.getByRole('button', { name: /Export 0 Quiz/ });
expect(exportButton).toBeDisabled();
});
it('export button is enabled with selection', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
const exportButton = screen.getByRole('button', { name: /Export 1 Quiz/ });
expect(exportButton).not.toBeDisabled();
});
it('calls onExportQuizzes with selected IDs', async () => {
const user = userEvent.setup();
const quizzes = [
createMockQuiz({ id: 'id-1', title: 'Quiz 1' }),
createMockQuiz({ id: 'id-2', title: 'Quiz 2' }),
];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Select for export'));
const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!;
await user.click(quiz1Card);
await user.click(screen.getByRole('button', { name: /Export 1 Quiz/ }));
expect(defaultProps.onExportQuizzes).toHaveBeenCalledWith(['id-1']);
});
it('exports multiple selected quizzes', async () => {
const user = userEvent.setup();
const quizzes = [
createMockQuiz({ id: 'id-1', title: 'Quiz 1' }),
createMockQuiz({ id: 'id-2', title: 'Quiz 2' }),
];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Select for export'));
await user.click(screen.getByText('Select All'));
await user.click(screen.getByRole('button', { name: /Export 2 Quizzes/ }));
expect(defaultProps.onExportQuizzes).toHaveBeenCalledWith(['id-1', 'id-2']);
});
it('exits selection mode after export', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
await user.click(screen.getByRole('button', { name: /Export 1 Quiz/ }));
await waitFor(() => {
expect(screen.getByText('Select a quiz to play')).toBeInTheDocument();
});
});
it('cancel button exits selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
expect(screen.getByText('Cancel')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.getByText('Select a quiz to play')).toBeInTheDocument();
});
it('shows exporting state', async () => {
const user = userEvent.setup();
const { rerender } = render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
rerender(<QuizLibrary {...defaultProps} exporting={true} />);
expect(screen.getByText('Exporting...')).toBeInTheDocument();
});
it('disables export button while exporting', async () => {
const user = userEvent.setup();
const { rerender } = render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
rerender(<QuizLibrary {...defaultProps} exporting={true} />);
const exportButton = screen.getByRole('button', { name: /Exporting/ });
expect(exportButton).toBeDisabled();
});
});
describe('import functionality', () => {
it('calls onImportClick when import button clicked', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Import quizzes'));
expect(defaultProps.onImportClick).toHaveBeenCalled();
});
it('calls onImportClick from empty state', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} quizzes={[]} />);
await user.click(screen.getByText('Import Quizzes'));
expect(defaultProps.onImportClick).toHaveBeenCalled();
});
});
describe('normal mode interactions', () => {
it('calls onLoadQuiz when quiz clicked in normal mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(defaultProps.onLoadQuiz).toHaveBeenCalledWith('quiz-1');
});
it('does not call onLoadQuiz in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Select for export'));
const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!;
await user.click(quizCard);
expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled();
});
});
describe('quiz display', () => {
it('shows AI badge for ai_generated quizzes', () => {
const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Science' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('AI')).toBeInTheDocument();
});
it('shows Manual badge for manual quizzes', () => {
const quizzes = [createMockQuiz({ source: 'manual' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Manual')).toBeInTheDocument();
});
it('shows question count', () => {
const quizzes = [createMockQuiz({ questionCount: 10 })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('10 questions')).toBeInTheDocument();
});
it('shows singular for 1 question', () => {
const quizzes = [createMockQuiz({ questionCount: 1 })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('1 question')).toBeInTheDocument();
});
it('shows aiTopic for AI quizzes', () => {
const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Space' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText(/Topic: Space/)).toBeInTheDocument();
});
});
describe('delete functionality', () => {
it('shows delete confirmation on delete click', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Delete quiz'));
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('calls onDeleteQuiz on confirm', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Delete quiz'));
await user.click(screen.getByText('Confirm'));
expect(defaultProps.onDeleteQuiz).toHaveBeenCalledWith('quiz-1');
});
it('hides confirmation on cancel', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Delete quiz'));
expect(screen.getByText('Confirm')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByText('Confirm')).not.toBeInTheDocument();
});
});
describe('disabled states', () => {
it('hides export/import buttons when loading', () => {
render(<QuizLibrary {...defaultProps} loading={true} />);
expect(screen.queryByTitle('Import quizzes')).not.toBeInTheDocument();
expect(screen.queryByTitle('Select for export')).not.toBeInTheDocument();
});
it('shows quiz cards with reduced opacity when loadingQuizId is set', async () => {
render(<QuizLibrary {...defaultProps} loadingQuizId="quiz-1" />);
const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!;
expect(quizCard).toHaveClass('opacity-70');
});
it('shows quiz cards with reduced opacity when deletingQuizId is set', async () => {
render(<QuizLibrary {...defaultProps} deletingQuizId="quiz-1" />);
const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!;
expect(quizCard).toHaveClass('opacity-70');
});
});
describe('modal interactions', () => {
it('calls onClose when backdrop clicked', async () => {
render(<QuizLibrary {...defaultProps} />);
const backdrop = document.querySelector('.fixed.inset-0');
expect(backdrop).toBeInTheDocument();
fireEvent.click(backdrop!);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('does not close when modal content clicked', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByText('My Library'));
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it('calls onRetry when Try Again clicked', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} error="Failed to load" />);
await user.click(screen.getByText('Try Again'));
expect(defaultProps.onRetry).toHaveBeenCalled();
});
});
describe('share functionality', () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: mockWriteText,
},
writable: true,
configurable: true,
});
mockWriteText.mockClear();
});
it('shows share button for non-shared quiz', () => {
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Share quiz')).toBeInTheDocument();
});
it('shows copy link button and stop sharing button for shared quiz', () => {
const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByTitle('Copy share link')).toBeInTheDocument();
expect(screen.getByTitle('Stop sharing')).toBeInTheDocument();
});
it('shows Shared badge for shared quiz', () => {
const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Shared')).toBeInTheDocument();
});
it('calls onShareQuiz when share button clicked', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Share quiz'));
expect(defaultProps.onShareQuiz).toHaveBeenCalledWith('quiz-1');
});
it('does not call onShareQuiz for already shared quiz', async () => {
const user = userEvent.setup();
const quizzes = [createMockQuiz({ isShared: true, shareToken: 'existing-token' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Copy share link'));
expect(defaultProps.onShareQuiz).not.toHaveBeenCalled();
});
it('calls onUnshareQuiz when stop sharing button clicked', async () => {
const user = userEvent.setup();
const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Stop sharing'));
expect(defaultProps.onUnshareQuiz).toHaveBeenCalledWith('quiz-1');
});
it('hides share buttons in selection mode', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
expect(screen.getByTitle('Share quiz')).toBeInTheDocument();
await user.click(screen.getByTitle('Select for export'));
expect(screen.queryByTitle('Share quiz')).not.toBeInTheDocument();
});
it('shows loading state when sharing quiz', () => {
const quizzes = [createMockQuiz({ id: 'quiz-1' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} sharingQuizId="quiz-1" />);
const quizCard = screen.getByText('Test Quiz').closest('[class*="rounded-2xl"]')!;
expect(quizCard).toHaveClass('opacity-70');
});
it('share click does not trigger quiz load', async () => {
const user = userEvent.setup();
render(<QuizLibrary {...defaultProps} />);
await user.click(screen.getByTitle('Share quiz'));
expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled();
});
it('unshare click does not trigger quiz load', async () => {
const user = userEvent.setup();
const quizzes = [createMockQuiz({ isShared: true, shareToken: 'test-token' })];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
await user.click(screen.getByTitle('Stop sharing'));
expect(defaultProps.onLoadQuiz).not.toHaveBeenCalled();
});
it('displays multiple shared and non-shared quizzes correctly', () => {
const quizzes = [
createMockQuiz({ id: '1', title: 'Shared Quiz', isShared: true, shareToken: 'token1' }),
createMockQuiz({ id: '2', title: 'Private Quiz', isShared: false }),
];
render(<QuizLibrary {...defaultProps} quizzes={quizzes} />);
expect(screen.getByText('Shared')).toBeInTheDocument();
expect(screen.getAllByTitle('Share quiz')).toHaveLength(1);
expect(screen.getAllByTitle('Copy share link')).toHaveLength(1);
expect(screen.getAllByTitle('Stop sharing')).toHaveLength(1);
});
});
});