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 { ImportQuizzesModal } from '../../components/ImportQuizzesModal'; import type { ExportedQuiz, QuizExportFile } from '../../types'; const createMockExportFile = (quizzes: ExportedQuiz[] = []): QuizExportFile => ({ version: 1, exportedAt: '2024-01-15T10:00:00.000Z', quizzes, }); const createMockQuiz = (overrides?: Partial): ExportedQuiz => ({ title: 'Test Quiz', source: 'manual', 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' }, ], }, ], ...overrides, }); describe('ImportQuizzesModal', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onImport: vi.fn(), parseFile: vi.fn(), importing: false, }; beforeEach(() => { vi.clearAllMocks(); }); describe('rendering', () => { it('renders nothing when isOpen is false', () => { render(); expect(screen.queryByText('Import Quizzes')).not.toBeInTheDocument(); }); it('renders modal when isOpen is true', () => { render(); expect(screen.getByText('Import Quizzes')).toBeInTheDocument(); }); it('shows upload step by default', () => { render(); expect(screen.getByText(/Select a Kaboot export file/)).toBeInTheDocument(); expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); }); it('shows file type hint', () => { render(); expect(screen.getByText(/Accepts .json export files/)).toBeInTheDocument(); }); }); describe('file upload - happy path', () => { it('parses file on drop and shows quiz selection', async () => { const mockExport = createMockExportFile([createMockQuiz()]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] }, }); await waitFor(() => { expect(screen.getByText('Test Quiz')).toBeInTheDocument(); }); }); it('shows all quizzes from export file', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), createMockQuiz({ title: 'Quiz 3' }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Quiz 1')).toBeInTheDocument(); expect(screen.getByText('Quiz 2')).toBeInTheDocument(); expect(screen.getByText('Quiz 3')).toBeInTheDocument(); }); }); it('selects all quizzes by default', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); }); }); it('shows export date', async () => { const mockExport = createMockExportFile([createMockQuiz()]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText(/Exported/)).toBeInTheDocument(); }); }); it('shows AI badge for ai_generated quizzes', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Science' }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('AI')).toBeInTheDocument(); expect(screen.getByText(/Science/)).toBeInTheDocument(); }); }); it('shows Manual badge for manual quizzes', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'Manual Quiz', source: 'manual' }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Manual')).toBeInTheDocument(); }); }); }); describe('file upload - unhappy path', () => { it('shows error for non-JSON files', async () => { render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File(['content'], 'export.txt', { type: 'text/plain' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Please select a JSON file')).toBeInTheDocument(); }); }); it('shows error when parseFile throws', async () => { defaultProps.parseFile.mockRejectedValueOnce(new Error('Invalid export file format')); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File(['{}'], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Invalid export file format')).toBeInTheDocument(); }); }); it('shows generic error for unknown parse failures', async () => { defaultProps.parseFile.mockRejectedValueOnce('unknown error'); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File(['{}'], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Failed to parse file')).toBeInTheDocument(); }); }); it('handles empty file drop event', async () => { render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; await fireEvent.drop(dropZone, { dataTransfer: { files: [] } }); expect(defaultProps.parseFile).not.toHaveBeenCalled(); }); }); describe('quiz selection', () => { const setupWithQuizzes = async (quizzes: ExportedQuiz[] = [createMockQuiz()]) => { const mockExport = createMockExportFile(quizzes); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); }); }; it('toggles quiz selection on click', async () => { await setupWithQuizzes([createMockQuiz({ title: 'Toggle Test' })]); const quizCard = screen.getByText('Toggle Test').closest('[class*="cursor-pointer"]')!; expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); await fireEvent.click(quizCard); expect(screen.getByText('0 of 1 selected')).toBeInTheDocument(); await fireEvent.click(quizCard); expect(screen.getByText('1 of 1 selected')).toBeInTheDocument(); }); it('select all button selects all quizzes', async () => { await setupWithQuizzes([ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), ]); const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; await fireEvent.click(quiz1Card); expect(screen.getByText('1 of 2 selected')).toBeInTheDocument(); await fireEvent.click(screen.getByText('Select All')); expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); }); it('deselect all when all selected', async () => { await setupWithQuizzes([ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), ]); expect(screen.getByText('2 of 2 selected')).toBeInTheDocument(); expect(screen.getByText('Deselect All')).toBeInTheDocument(); await fireEvent.click(screen.getByText('Deselect All')); expect(screen.getByText('0 of 2 selected')).toBeInTheDocument(); }); it('back button returns to upload step', async () => { await setupWithQuizzes(); await fireEvent.click(screen.getByText('Back')); expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); }); }); describe('import action', () => { const setupWithQuizzes = async (quizzes: ExportedQuiz[] = [createMockQuiz()]) => { const mockExport = createMockExportFile(quizzes); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); }); }; it('calls onImport with selected quizzes', async () => { const quizzes = [ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), ]; await setupWithQuizzes(quizzes); const quiz1Card = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; await fireEvent.click(quiz1Card); await fireEvent.click(screen.getByText(/Import 1 Quiz/)); expect(defaultProps.onImport).toHaveBeenCalledWith([quizzes[1]]); }); it('calls onImport with all selected quizzes', async () => { const quizzes = [ createMockQuiz({ title: 'Quiz 1' }), createMockQuiz({ title: 'Quiz 2' }), ]; await setupWithQuizzes(quizzes); await fireEvent.click(screen.getByText(/Import 2 Quizzes/)); expect(defaultProps.onImport).toHaveBeenCalledWith(quizzes); }); it('disables import button when no quizzes selected', async () => { await setupWithQuizzes([createMockQuiz({ title: 'Quiz 1' })]); const quizCard = screen.getByText('Quiz 1').closest('[class*="cursor-pointer"]')!; await fireEvent.click(quizCard); const importButton = screen.getByRole('button', { name: /Import 0 Quiz/ }); expect(importButton).toBeDisabled(); }); it('closes modal after successful import', async () => { defaultProps.onImport.mockResolvedValueOnce(undefined); await setupWithQuizzes(); await fireEvent.click(screen.getByText(/Import 1 Quiz/)); await waitFor(() => { expect(defaultProps.onClose).toHaveBeenCalled(); }); }); it('shows importing state', async () => { const quizzes = [createMockQuiz()]; const mockExport = createMockExportFile(quizzes); defaultProps.parseFile.mockResolvedValueOnce(mockExport); const { rerender } = render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); }); rerender(); expect(screen.getByText('Importing...')).toBeInTheDocument(); }); it('disables quiz selection while importing', async () => { const quizzes = [createMockQuiz()]; const mockExport = createMockExportFile(quizzes); defaultProps.parseFile.mockResolvedValueOnce(mockExport); const { rerender } = render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText(quizzes[0].title)).toBeInTheDocument(); }); rerender(); const quizCard = screen.getByText(quizzes[0].title).closest('[class*="cursor-pointer"]')!; expect(quizCard).toHaveClass('cursor-not-allowed'); }); }); describe('modal interactions', () => { it('calls onClose when X button clicked', async () => { const user = userEvent.setup(); render(); const closeButtons = screen.getAllByRole('button'); const xButton = closeButtons.find(btn => btn.querySelector('svg')); if (xButton) { await user.click(xButton); expect(defaultProps.onClose).toHaveBeenCalled(); } }); 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('Import Quizzes')); expect(defaultProps.onClose).not.toHaveBeenCalled(); }); it('resets state when closed via onClose', async () => { const mockExport = createMockExportFile([createMockQuiz()]); const mockOnClose = vi.fn(); defaultProps.parseFile.mockResolvedValue(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('Test Quiz')).toBeInTheDocument(); }); await fireEvent.click(screen.getByText('Back')); expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); }); }); describe('drag and drop visual feedback', () => { it('shows visual feedback on drag over', async () => { render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; fireEvent.dragOver(dropZone); expect(screen.getByText('Drop file here')).toBeInTheDocument(); }); it('removes visual feedback on drag leave', async () => { render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; fireEvent.dragOver(dropZone); expect(screen.getByText('Drop file here')).toBeInTheDocument(); fireEvent.dragLeave(dropZone); expect(screen.getByText(/Drag & drop or click to select/)).toBeInTheDocument(); }); }); describe('question count display', () => { it('shows singular for 1 question', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'Single Q Quiz' }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('1 question')).toBeInTheDocument(); }); }); it('shows plural for multiple questions', async () => { const mockExport = createMockExportFile([ createMockQuiz({ title: 'Multi Q Quiz', 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: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] }, ], }), ]); defaultProps.parseFile.mockResolvedValueOnce(mockExport); render(); const dropZone = screen.getByText(/Drag & drop or click to select/).closest('div')!; const file = new File([JSON.stringify(mockExport)], 'export.json', { type: 'application/json' }); await fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); await waitFor(() => { expect(screen.getByText('2 questions')).toBeInTheDocument(); }); }); }); });