diff --git a/components/ImportQuizzesModal.tsx b/components/ImportQuizzesModal.tsx new file mode 100644 index 0000000..b3700ae --- /dev/null +++ b/components/ImportQuizzesModal.tsx @@ -0,0 +1,284 @@ +import React, { useState, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, FileUp, FileJson, Check, AlertCircle, Loader2, BrainCircuit, PenTool } from 'lucide-react'; +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import type { ExportedQuiz, QuizExportFile } from '../types'; + +interface ImportQuizzesModalProps { + isOpen: boolean; + onClose: () => void; + onImport: (quizzes: ExportedQuiz[]) => Promise; + parseFile: (file: File) => Promise; + importing: boolean; +} + +type ImportStep = 'upload' | 'select'; + +export const ImportQuizzesModal: React.FC = ({ + isOpen, + onClose, + onImport, + parseFile, + importing, +}) => { + const [step, setStep] = useState('upload'); + const [parsedFile, setParsedFile] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [parseError, setParseError] = useState(null); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + + useBodyScrollLock(isOpen); + + const resetState = () => { + setStep('upload'); + setParsedFile(null); + setSelectedIds(new Set()); + setParseError(null); + setDragActive(false); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const handleFileSelect = async (file: File) => { + setParseError(null); + + if (!file.name.endsWith('.json')) { + setParseError('Please select a JSON file'); + return; + } + + try { + const parsed = await parseFile(file); + setParsedFile(parsed); + setSelectedIds(new Set(parsed.quizzes.map((_, i) => i))); + setStep('select'); + } catch (err) { + setParseError(err instanceof Error ? err.message : 'Failed to parse file'); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(true); + }; + + const handleDragLeave = () => { + setDragActive(false); + }; + + const toggleQuiz = (index: number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedIds(newSelected); + }; + + const toggleAll = () => { + if (!parsedFile) return; + if (selectedIds.size === parsedFile.quizzes.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(parsedFile.quizzes.map((_, i) => i))); + } + }; + + const handleImport = async () => { + if (!parsedFile || selectedIds.size === 0) return; + + const quizzesToImport = parsedFile.quizzes.filter((_, i) => selectedIds.has(i)); + await onImport(quizzesToImport); + handleClose(); + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden" + > +
+
+

Import Quizzes

+

+ {step === 'upload' ? 'Select a Kaboot export file' : `${selectedIds.size} of ${parsedFile?.quizzes.length} selected`} +

+
+ +
+ +
+ {step === 'upload' && ( +
+
fileInputRef.current?.click()} + className={`border-3 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${ + dragActive + ? 'border-theme-primary bg-theme-primary/5' + : 'border-gray-200 hover:border-gray-300 hover:bg-gray-100' + }`} + > + e.target.files?.[0] && handleFileSelect(e.target.files[0])} + className="hidden" + /> +
+
+ +
+
+

+ {dragActive ? 'Drop file here' : 'Drag & drop or click to select'} +

+

+ Accepts .json export files +

+
+
+
+ + {parseError && ( +
+ +

{parseError}

+
+ )} +
+ )} + + {step === 'select' && parsedFile && ( +
+
+ +

+ Exported {new Date(parsedFile.exportedAt).toLocaleDateString()} +

+
+ + {parsedFile.quizzes.map((quiz, index) => ( + !importing && toggleQuiz(index)} + className={`group bg-white p-4 rounded-2xl border-2 transition-all cursor-pointer ${ + selectedIds.has(index) + ? 'border-theme-primary shadow-md' + : 'border-gray-100 hover:border-gray-200' + } ${importing ? 'opacity-70 cursor-not-allowed' : ''}`} + > +
+
+ {selectedIds.has(index) && ( + + )} +
+ +
+
+ {quiz.source === 'ai_generated' ? ( + + AI + + ) : ( + + Manual + + )} +
+

{quiz.title}

+

+ {quiz.questions.length} question{quiz.questions.length !== 1 ? 's' : ''} + {quiz.aiTopic && ยท {quiz.aiTopic}} +

+
+
+
+ ))} +
+ )} +
+ + {step === 'select' && ( +
+ + +
+ )} +
+
+ )} +
+ ); +}; diff --git a/components/Landing.tsx b/components/Landing.tsx index d6ba50a..54b31e6 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -5,6 +5,7 @@ import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Im import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; import { QuizLibrary } from './QuizLibrary'; +import { ImportQuizzesModal } from './ImportQuizzesModal'; import { DefaultConfigModal } from './DefaultConfigModal'; import { PreferencesModal } from './PreferencesModal'; import { ApiKeyModal } from './ApiKeyModal'; @@ -68,6 +69,7 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const modalParam = searchParams.get('modal'); const libraryOpen = modalParam === 'library'; + const importOpen = modalParam === 'import'; const preferencesOpen = modalParam === 'preferences'; const defaultConfigOpen = modalParam === 'settings'; const accountSettingsOpen = modalParam === 'account'; @@ -89,6 +91,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setSearchParams(buildCleanParams({ modal: open ? 'library' : null })); }; + const setImportOpen = (open: boolean) => { + setSearchParams(buildCleanParams({ modal: open ? 'import' : null })); + }; + const setPreferencesOpen = (open: boolean) => { setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null })); }; @@ -127,10 +133,15 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading: libraryLoading, loadingQuizId, deletingQuizId, + exporting, + importing, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz, + exportQuizzes, + importQuizzes, + parseImportFile, retry: retryLibrary } = useQuizLibrary(); @@ -610,12 +621,23 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on loading={libraryLoading} loadingQuizId={loadingQuizId} deletingQuizId={deletingQuizId} + exporting={exporting} error={libraryError} onLoadQuiz={handleLoadQuiz} onDeleteQuiz={deleteQuiz} + onExportQuizzes={exportQuizzes} + onImportClick={() => setImportOpen(true)} onRetry={retryLibrary} /> + setImportOpen(false)} + onImport={importQuizzes} + parseFile={parseImportFile} + importing={importing} + /> + { diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx index bd25caa..7f1bf00 100644 --- a/components/QuizLibrary.tsx +++ b/components/QuizLibrary.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react'; +import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar, FileDown, FileUp, Check, ListChecks } from 'lucide-react'; import { QuizListItem } from '../types'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; @@ -11,9 +11,12 @@ interface QuizLibraryProps { loading: boolean; loadingQuizId: string | null; deletingQuizId: string | null; + exporting: boolean; error: string | null; onLoadQuiz: (id: string) => void; onDeleteQuiz: (id: string) => void; + onExportQuizzes: (ids: string[]) => Promise; + onImportClick: () => void; onRetry: () => void; } @@ -24,16 +27,53 @@ export const QuizLibrary: React.FC = ({ loading, loadingQuizId, deletingQuizId, + exporting, error, onLoadQuiz, onDeleteQuiz, + onExportQuizzes, + onImportClick, onRetry, }) => { const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId; + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const isAnyOperationInProgress = loading || !!loadingQuizId || !!deletingQuizId || exporting; useBodyScrollLock(isOpen); + const toggleSelectMode = () => { + if (selectMode) { + setSelectedIds(new Set()); + } + setSelectMode(!selectMode); + }; + + const toggleQuizSelection = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === quizzes.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(quizzes.map(q => q.id))); + } + }; + + const handleExport = async () => { + await onExportQuizzes(Array.from(selectedIds)); + setSelectMode(false); + setSelectedIds(new Set()); + }; + const handleDeleteClick = (e: React.MouseEvent, id: string) => { e.stopPropagation(); setConfirmDeleteId(id); @@ -90,14 +130,45 @@ export const QuizLibrary: React.FC = ({

My Library

-

Select a quiz to play

+

+ {selectMode + ? `${selectedIds.size} of ${quizzes.length} selected` + : 'Select a quiz to play' + } +

+
+
+ {!loading && quizzes.length > 0 && ( + <> + + + + )} +
-
@@ -127,6 +198,24 @@ export const QuizLibrary: React.FC = ({

No saved quizzes yet

Create or generate a quiz to save it here!

+ + + )} + + {selectMode && quizzes.length > 0 && ( +
+
)} @@ -136,10 +225,34 @@ export const QuizLibrary: React.FC = ({ layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} - className={`group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all relative overflow-hidden ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} - onClick={() => !isAnyOperationInProgress && onLoadQuiz(quiz.id)} + className={`group bg-white p-4 rounded-2xl border-2 transition-all relative overflow-hidden ${ + selectMode && selectedIds.has(quiz.id) + ? 'border-theme-primary shadow-md' + : 'border-gray-100 hover:border-theme-primary hover:shadow-md' + } ${isAnyOperationInProgress ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} + onClick={(e) => { + if (isAnyOperationInProgress) return; + if (selectMode) { + toggleQuizSelection(e, quiz.id); + } else { + onLoadQuiz(quiz.id); + } + }} >
+ {selectMode && ( +
+
+ {selectedIds.has(quiz.id) && ( + + )} +
+
+ )}
{quiz.source === 'ai_generated' ? ( @@ -166,52 +279,83 @@ export const QuizLibrary: React.FC = ({

-
- {loadingQuizId === quiz.id ? ( -
- -
- ) : deletingQuizId === quiz.id ? ( -
- -
- ) : confirmDeleteId === quiz.id ? ( -
e.stopPropagation()}> - - -
- ) : ( - <> - -
- + {!selectMode && ( +
+ {loadingQuizId === quiz.id ? ( +
+
- - )} -
+ ) : deletingQuizId === quiz.id ? ( +
+ +
+ ) : confirmDeleteId === quiz.id ? ( +
e.stopPropagation()}> + + +
+ ) : ( + <> + +
+ +
+ + )} +
+ )}
))}
+ + {selectMode && ( +
+ + +
+ )} diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts index f9c91c1..4f361b1 100644 --- a/hooks/useQuizLibrary.ts +++ b/hooks/useQuizLibrary.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef } from 'react'; import toast from 'react-hot-toast'; import { useAuthenticatedFetch } from './useAuthenticatedFetch'; -import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig, ExportedQuiz, QuizExportFile } from '../types'; interface UseQuizLibraryReturn { quizzes: QuizListItem[]; @@ -9,6 +9,8 @@ interface UseQuizLibraryReturn { loadingQuizId: string | null; deletingQuizId: string | null; saving: boolean; + exporting: boolean; + importing: boolean; error: string | null; fetchQuizzes: () => Promise; loadQuiz: (id: string) => Promise; @@ -16,6 +18,9 @@ interface UseQuizLibraryReturn { updateQuiz: (id: string, quiz: Quiz) => Promise; updateQuizConfig: (id: string, config: GameConfig) => Promise; deleteQuiz: (id: string) => Promise; + exportQuizzes: (quizIds: string[]) => Promise; + importQuizzes: (quizzes: ExportedQuiz[]) => Promise; + parseImportFile: (file: File) => Promise; retry: () => Promise; clearError: () => void; } @@ -27,6 +32,8 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { const [loadingQuizId, setLoadingQuizId] = useState(null); const [deletingQuizId, setDeletingQuizId] = useState(null); const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); const [error, setError] = useState(null); const lastOperationRef = useRef<(() => Promise) | null>(null); const savingRef = useRef(false); @@ -165,6 +172,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } throw err; } finally { + savingRef.current = false; setSaving(false); } }, [authFetch]); @@ -260,6 +268,102 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } }, [authFetch]); + const exportQuizzes = useCallback(async (quizIds: string[]): Promise => { + if (quizIds.length === 0) { + toast.error('No quizzes selected'); + return; + } + + setExporting(true); + try { + const quizzesToExport: ExportedQuiz[] = []; + + for (const id of quizIds) { + const response = await authFetch(`/api/quizzes/${id}`); + if (!response.ok) continue; + + const data = await response.json(); + quizzesToExport.push({ + title: data.title, + source: data.source, + aiTopic: data.aiTopic, + config: data.gameConfig, + questions: data.questions.map((q: { id: string; text: string; timeLimit: number; options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }) => ({ + id: q.id, + text: q.text, + timeLimit: q.timeLimit, + options: q.options, + })), + }); + } + + const exportData: QuizExportFile = { + version: 1, + exportedAt: new Date().toISOString(), + quizzes: quizzesToExport, + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kaboot-quizzes-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success(`Exported ${quizzesToExport.length} quiz${quizzesToExport.length !== 1 ? 'zes' : ''}`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to export quizzes'; + toast.error(message); + throw err; + } finally { + setExporting(false); + } + }, [authFetch]); + + const parseImportFile = useCallback(async (file: File): Promise => { + const text = await file.text(); + const data = JSON.parse(text); + + if (!data.version || !data.quizzes || !Array.isArray(data.quizzes)) { + throw new Error('Invalid export file format'); + } + + return data as QuizExportFile; + }, []); + + const importQuizzes = useCallback(async (quizzesToImport: ExportedQuiz[]): Promise => { + if (quizzesToImport.length === 0) { + toast.error('No quizzes selected'); + return; + } + + setImporting(true); + let successCount = 0; + + try { + for (const quiz of quizzesToImport) { + await saveQuiz( + { title: quiz.title, questions: quiz.questions, config: quiz.config }, + quiz.source, + quiz.aiTopic + ); + successCount++; + } + + toast.success(`Imported ${successCount} quiz${successCount !== 1 ? 'zes' : ''}`); + await fetchQuizzes(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to import quizzes'; + toast.error(`Imported ${successCount} of ${quizzesToImport.length}. ${message}`); + throw err; + } finally { + setImporting(false); + } + }, [saveQuiz, fetchQuizzes]); + const retry = useCallback(async () => { if (lastOperationRef.current) { await lastOperationRef.current(); @@ -276,6 +380,8 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { loadingQuizId, deletingQuizId, saving, + exporting, + importing, error, fetchQuizzes, loadQuiz, @@ -283,6 +389,9 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { updateQuiz, updateQuizConfig, deleteQuiz, + exportQuizzes, + importQuizzes, + parseImportFile, retry, clearError, }; diff --git a/tests/components/ImportQuizzesModal.test.tsx b/tests/components/ImportQuizzesModal.test.tsx new file mode 100644 index 0000000..69dfcb4 --- /dev/null +++ b/tests/components/ImportQuizzesModal.test.tsx @@ -0,0 +1,539 @@ +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(); + }); + }); + }); +}); diff --git a/tests/components/QuizLibrary.test.tsx b/tests/components/QuizLibrary.test.tsx new file mode 100644 index 0000000..850f411 --- /dev/null +++ b/tests/components/QuizLibrary.test.tsx @@ -0,0 +1,522 @@ +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, + 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, + exporting: false, + error: null, + onLoadQuiz: vi.fn(), + onDeleteQuiz: vi.fn(), + onExportQuizzes: vi.fn(), + onImportClick: vi.fn(), + onRetry: vi.fn(), + }; + + 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(); + }); + }); +}); diff --git a/tests/hooks/useAuthenticatedFetch.test.tsx b/tests/hooks/useAuthenticatedFetch.test.tsx index 041697e..f6fed68 100644 --- a/tests/hooks/useAuthenticatedFetch.test.tsx +++ b/tests/hooks/useAuthenticatedFetch.test.tsx @@ -18,6 +18,9 @@ vi.mock('react-oidc-context', () => ({ useAuth: () => mockAuth, })); +// Get the API URL that the hook will actually use +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + const originalFetch = global.fetch; describe('useAuthenticatedFetch', () => { @@ -61,7 +64,7 @@ describe('useAuthenticatedFetch', () => { }); expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost:3001/api/test', + `${API_URL}/api/test`, expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer valid-token', diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx index 430bc70..7a556a7 100644 --- a/tests/hooks/useQuizLibrary.test.tsx +++ b/tests/hooks/useQuizLibrary.test.tsx @@ -597,4 +597,574 @@ describe('useQuizLibrary', () => { } }); }); + + describe('exportQuizzes', () => { + let mockCreateObjectURL: ReturnType; + let mockRevokeObjectURL: ReturnType; + let mockClick: ReturnType; + let capturedBlob: Blob | null = null; + let originalCreateElement: typeof document.createElement; + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + capturedBlob = null; + mockCreateObjectURL = vi.fn((blob: Blob) => { + capturedBlob = blob; + return 'blob:test-url'; + }); + mockRevokeObjectURL = vi.fn(); + mockClick = vi.fn(); + + originalCreateObjectURL = global.URL.createObjectURL; + originalRevokeObjectURL = global.URL.revokeObjectURL; + global.URL.createObjectURL = mockCreateObjectURL as typeof URL.createObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL as typeof URL.revokeObjectURL; + + originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') { + return { + href: '', + download: '', + click: mockClick, + } as unknown as HTMLAnchorElement; + } + return originalCreateElement(tag); + }); + + vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node); + vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => node); + }); + + afterEach(() => { + global.URL.createObjectURL = originalCreateObjectURL; + global.URL.revokeObjectURL = originalRevokeObjectURL; + vi.restoreAllMocks(); + }); + + it('exports selected quizzes as JSON file', async () => { + const mockQuizData = { + id: 'quiz-1', + title: 'Test Quiz', + source: 'manual', + questions: [ + { id: 'q1', text: 'Question 1', timeLimit: 20, options: [] }, + ], + }; + + mockAuthFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockQuizData), + }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.exportQuizzes(['quiz-1']); + }); + + expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-1'); + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(mockClick).toHaveBeenCalled(); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url'); + expect(result.current.exporting).toBe(false); + }); + + it('exports multiple quizzes', async () => { + mockAuthFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'quiz-1', title: 'Quiz 1', source: 'manual', questions: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'ai_generated', aiTopic: 'Science', questions: [] }), + }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.exportQuizzes(['quiz-1', 'quiz-2']); + }); + + expect(mockAuthFetch).toHaveBeenCalledTimes(2); + expect(mockCreateObjectURL).toHaveBeenCalled(); + }); + + it('sets exporting to true during export', async () => { + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockAuthFetch.mockReturnValueOnce(pendingPromise); + + const { result } = renderHook(() => useQuizLibrary()); + + act(() => { + result.current.exportQuizzes(['quiz-1']); + }); + + await waitFor(() => { + expect(result.current.exporting).toBe(true); + }); + + await act(async () => { + resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'quiz-1', title: 'Q', source: 'manual', questions: [] }) }); + }); + + await waitFor(() => { + expect(result.current.exporting).toBe(false); + }); + }); + + it('handles empty quiz IDs array', async () => { + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.exportQuizzes([]); + }); + + expect(mockAuthFetch).not.toHaveBeenCalled(); + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + }); + + it('skips quizzes that fail to load', async () => { + mockAuthFetch + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'quiz-2', title: 'Quiz 2', source: 'manual', questions: [] }), + }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.exportQuizzes(['quiz-1', 'quiz-2']); + }); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(capturedBlob).toBeInstanceOf(Blob); + }); + + it('handles network error during export', async () => { + mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.exportQuizzes(['quiz-1']); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Network error'); + } + + expect(result.current.exporting).toBe(false); + }); + +it('creates blob with correct mime type', async () => { + const mockQuizData = { + id: 'quiz-1', + title: 'Test Quiz', + source: 'ai_generated', + aiTopic: 'History', + gameConfig: { shuffleQuestions: true }, + questions: [ + { + id: 'q1', + text: 'What year?', + timeLimit: 30, + options: [ + { text: '1990', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '2000', isCorrect: true, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + mockAuthFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockQuizData), + }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.exportQuizzes(['quiz-1']); + }); + + expect(capturedBlob).not.toBeNull(); + expect(capturedBlob!.type).toBe('application/json'); + }); + }); + + describe('parseImportFile', () => { + const originalText = File.prototype.text; + + beforeEach(() => { + // JSDOM doesn't implement File.prototype.text, so define it using FileReader + File.prototype.text = function() { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(this); + }); + }; + }); + + afterEach(() => { + File.prototype.text = originalText; + }); + + it('parses valid export file', async () => { + const validExportData = { + version: 1, + exportedAt: '2024-01-01T00:00:00.000Z', + quizzes: [ + { title: 'Quiz 1', source: 'manual', questions: [] }, + ], + }; + + const file = new File( + [JSON.stringify(validExportData)], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + let parsed; + await act(async () => { + parsed = await result.current.parseImportFile(file); + }); + + expect(parsed).toEqual(validExportData); + }); + + it('rejects file without version field', async () => { + const invalidData = { + exportedAt: '2024-01-01T00:00:00.000Z', + quizzes: [], + }; + + const file = new File( + [JSON.stringify(invalidData)], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.parseImportFile(file); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Invalid export file format'); + } + }); + + it('rejects file without quizzes array', async () => { + const invalidData = { + version: 1, + exportedAt: '2024-01-01T00:00:00.000Z', + }; + + const file = new File( + [JSON.stringify(invalidData)], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.parseImportFile(file); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Invalid export file format'); + } + }); + + it('rejects file with quizzes as non-array', async () => { + const invalidData = { + version: 1, + exportedAt: '2024-01-01T00:00:00.000Z', + quizzes: 'not-an-array', + }; + + const file = new File( + [JSON.stringify(invalidData)], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.parseImportFile(file); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Invalid export file format'); + } + }); + + it('rejects invalid JSON', async () => { + const file = new File( + ['not valid json {'], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.parseImportFile(file); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(SyntaxError); + } + }); + + it('handles empty file', async () => { + const file = new File( + [''], + 'export.json', + { type: 'application/json' } + ); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.parseImportFile(file); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(SyntaxError); + } + }); + }); + + describe('importQuizzes', () => { + it('imports single quiz successfully', async () => { + mockAuthFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'new-quiz-1' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const { result } = renderHook(() => useQuizLibrary()); + + const quizzesToImport = [ + { + title: 'Imported Quiz', + source: 'manual' as const, + questions: [ + { + id: 'q1', + text: 'Question?', + timeLimit: 20, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle' as const, color: 'red' as const }, + { text: 'B', isCorrect: false, shape: 'diamond' as const, color: 'blue' as const }, + ], + }, + ], + }, + ]; + + await act(async () => { + await result.current.importQuizzes(quizzesToImport); + }); + + expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes', expect.objectContaining({ + method: 'POST', + })); + expect(result.current.importing).toBe(false); + }); + + it('imports multiple quizzes sequentially', async () => { + mockAuthFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q2' }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const { result } = renderHook(() => useQuizLibrary()); + + const quizzesToImport = [ + { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, + { title: 'Quiz 2', source: 'ai_generated' as const, aiTopic: 'Science', questions: createMockQuiz().questions }, + ]; + + await act(async () => { + await result.current.importQuizzes(quizzesToImport); + }); + + expect(mockAuthFetch).toHaveBeenCalledTimes(3); + }); + + it('sets importing to true during import', async () => { + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockAuthFetch.mockReturnValueOnce(pendingPromise); + + const { result } = renderHook(() => useQuizLibrary()); + + act(() => { + result.current.importQuizzes([ + { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, + ]); + }); + + await waitFor(() => { + expect(result.current.importing).toBe(true); + }); + + await act(async () => { + resolvePromise!({ ok: true, json: () => Promise.resolve({ id: 'id' }) }); + }); + }); + + it('handles empty quizzes array', async () => { + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.importQuizzes([]); + }); + + expect(mockAuthFetch).not.toHaveBeenCalled(); + }); + + it('reports partial success when some imports fail', async () => { + mockAuthFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) + .mockResolvedValueOnce({ ok: false, status: 400 }); + + const { result } = renderHook(() => useQuizLibrary()); + + const quizzesToImport = [ + { title: 'Quiz 1', source: 'manual' as const, questions: createMockQuiz().questions }, + { title: 'Quiz 2', source: 'manual' as const, questions: createMockQuiz().questions }, + ]; + + try { + await act(async () => { + await result.current.importQuizzes(quizzesToImport); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toContain('Invalid quiz data'); + } + + expect(result.current.importing).toBe(false); + }); + + it('refreshes quiz list after successful import', async () => { + const mockQuizList = [{ id: 'q1', title: 'Quiz 1', source: 'manual', questionCount: 1, createdAt: '2024-01-01', updatedAt: '2024-01-01' }]; + + mockAuthFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockQuizList) }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.importQuizzes([ + { title: 'Quiz 1', source: 'manual', questions: createMockQuiz().questions }, + ]); + }); + + expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/quizzes'); + expect(result.current.quizzes).toEqual(mockQuizList); + }); + + it('preserves aiTopic for AI-generated quizzes', async () => { + mockAuthFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const { result } = renderHook(() => useQuizLibrary()); + + await act(async () => { + await result.current.importQuizzes([ + { title: 'AI Quiz', source: 'ai_generated', aiTopic: 'Space', questions: createMockQuiz().questions }, + ]); + }); + + const [, options] = mockAuthFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.source).toBe('ai_generated'); + expect(body.aiTopic).toBe('Space'); + }); + + it('preserves game config during import', async () => { + mockAuthFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'q1' }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const { result } = renderHook(() => useQuizLibrary()); + + const config = { + shuffleQuestions: true, + shuffleAnswers: true, + hostParticipates: false, + randomNamesEnabled: true, + streakBonusEnabled: true, + streakThreshold: 5, + streakMultiplier: 1.5, + comebackBonusEnabled: false, + comebackBonusPoints: 100, + penaltyForWrongAnswer: true, + penaltyPercent: 10, + firstCorrectBonusEnabled: true, + firstCorrectBonusPoints: 25, + }; + + await act(async () => { + await result.current.importQuizzes([ + { title: 'Quiz', source: 'manual', config, questions: createMockQuiz().questions }, + ]); + }); + + const [, options] = mockAuthFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.gameConfig).toEqual(config); + }); + + it('handles network error during import', async () => { + mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useQuizLibrary()); + + try { + await act(async () => { + await result.current.importQuizzes([ + { title: 'Quiz', source: 'manual', questions: createMockQuiz().questions }, + ]); + }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toBe('Network error'); + } + + expect(result.current.importing).toBe(false); + }); + }); }); diff --git a/types.ts b/types.ts index 46064b1..4cf6c2e 100644 --- a/types.ts +++ b/types.ts @@ -167,6 +167,20 @@ export interface Player { } // Network Types +export interface ExportedQuiz { + title: string; + source: QuizSource; + aiTopic?: string; + config?: GameConfig; + questions: Question[]; +} + +export interface QuizExportFile { + version: 1; + exportedAt: string; + quizzes: ExportedQuiz[]; +} + export type NetworkMessage = | { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } } | { type: 'WELCOME'; payload: {