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 => ({ 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(); expect(screen.queryByText('My Library')).not.toBeInTheDocument(); }); it('renders modal when isOpen is true', () => { render(); 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(); expect(screen.getByText('Quiz 1')).toBeInTheDocument(); expect(screen.getByText('Quiz 2')).toBeInTheDocument(); }); it('shows import button in header', () => { render(); expect(screen.getByTitle('Import quizzes')).toBeInTheDocument(); }); it('shows selection mode toggle button', () => { render(); expect(screen.getByTitle('Select for export')).toBeInTheDocument(); }); it('shows empty state when no quizzes', () => { render(); expect(screen.getByText('No saved quizzes yet')).toBeInTheDocument(); }); it('shows import button in empty state', () => { render(); expect(screen.getByText('Import Quizzes')).toBeInTheDocument(); }); it('shows loading state', () => { render(); expect(screen.getByText('Loading your quizzes...')).toBeInTheDocument(); }); it('shows error state with retry button', () => { render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); await user.click(screen.getByTitle('Select for export')); const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; await user.click(quizCard); rerender(); expect(screen.getByText('Exporting...')).toBeInTheDocument(); }); it('disables export button while exporting', async () => { const user = userEvent.setup(); const { rerender } = render(); await user.click(screen.getByTitle('Select for export')); const quizCard = screen.getByText('Test Quiz').closest('[class*="cursor-pointer"]')!; await user.click(quizCard); rerender(); 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(); await user.click(screen.getByTitle('Import quizzes')); expect(defaultProps.onImportClick).toHaveBeenCalled(); }); it('calls onImportClick from empty state', async () => { const user = userEvent.setup(); render(); 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(); 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(); 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(); expect(screen.getByText('AI')).toBeInTheDocument(); }); it('shows Manual badge for manual quizzes', () => { const quizzes = [createMockQuiz({ source: 'manual' })]; render(); expect(screen.getByText('Manual')).toBeInTheDocument(); }); it('shows question count', () => { const quizzes = [createMockQuiz({ questionCount: 10 })]; render(); expect(screen.getByText('10 questions')).toBeInTheDocument(); }); it('shows singular for 1 question', () => { const quizzes = [createMockQuiz({ questionCount: 1 })]; render(); expect(screen.getByText('1 question')).toBeInTheDocument(); }); it('shows aiTopic for AI quizzes', () => { const quizzes = [createMockQuiz({ source: 'ai_generated', aiTopic: 'Space' })]; render(); expect(screen.getByText(/Topic: Space/)).toBeInTheDocument(); }); }); describe('delete functionality', () => { it('shows delete confirmation on delete click', async () => { const user = userEvent.setup(); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.getByText('Shared')).toBeInTheDocument(); }); it('calls onShareQuiz when share button clicked', async () => { const user = userEvent.setup(); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); }); });