Phase 5 complete
This commit is contained in:
parent
342ff60b70
commit
93ea01525e
7 changed files with 433 additions and 37 deletions
41
App.tsx
41
App.tsx
|
|
@ -1,5 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { useGame } from './hooks/useGame';
|
||||
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
||||
import { Landing } from './components/Landing';
|
||||
import { Lobby } from './components/Lobby';
|
||||
import { GameScreen } from './components/GameScreen';
|
||||
|
|
@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard';
|
|||
import { Podium } from './components/Podium';
|
||||
import { QuizCreator } from './components/QuizCreator';
|
||||
import { RevealScreen } from './components/RevealScreen';
|
||||
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
||||
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed * 9999) * 10000;
|
||||
|
|
@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => {
|
|||
});
|
||||
|
||||
function App() {
|
||||
const auth = useAuth();
|
||||
const { saveQuiz } = useQuizLibrary();
|
||||
const {
|
||||
role,
|
||||
gameState,
|
||||
|
|
@ -57,9 +62,19 @@ function App() {
|
|||
selectedOption,
|
||||
currentPlayerScore,
|
||||
currentStreak,
|
||||
currentPlayerId
|
||||
currentPlayerId,
|
||||
pendingQuizToSave,
|
||||
dismissSavePrompt
|
||||
} = useGame();
|
||||
|
||||
const handleSaveQuiz = async () => {
|
||||
if (!pendingQuizToSave) return;
|
||||
const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual';
|
||||
const topic = pendingQuizToSave.topic || undefined;
|
||||
await saveQuiz(pendingQuizToSave.quiz, source, topic);
|
||||
dismissSavePrompt();
|
||||
};
|
||||
|
||||
const currentQ = quiz?.questions[currentQuestionIndex];
|
||||
|
||||
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
||||
|
|
@ -92,13 +107,23 @@ function App() {
|
|||
) : null}
|
||||
|
||||
{gameState === 'LOBBY' ? (
|
||||
<Lobby
|
||||
quizTitle={quiz?.title || 'Kaboot'}
|
||||
players={players}
|
||||
gamePin={gamePin}
|
||||
role={role}
|
||||
onStart={startGame}
|
||||
/>
|
||||
<>
|
||||
<Lobby
|
||||
quizTitle={quiz?.title || 'Kaboot'}
|
||||
players={players}
|
||||
gamePin={gamePin}
|
||||
role={role}
|
||||
onStart={startGame}
|
||||
/>
|
||||
{auth.isAuthenticated && pendingQuizToSave && (
|
||||
<SaveQuizPrompt
|
||||
isOpen={true}
|
||||
quizTitle={pendingQuizToSave.quiz.title}
|
||||
onSave={handleSaveQuiz}
|
||||
onSkip={dismissSavePrompt}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
|
||||
|
|
|
|||
|
|
@ -236,31 +236,31 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
|
|||
## Phase 5: Save Integration
|
||||
|
||||
### 5.1 Save After AI Generation
|
||||
- [ ] Modify `src/hooks/useGame.ts`:
|
||||
- [ ] Add `pendingQuizToSave` state
|
||||
- [ ] After successful AI generation, set `pendingQuizToSave`
|
||||
- [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions
|
||||
- [ ] Export these for UI to consume
|
||||
- [ ] Create `src/components/SaveQuizPrompt.tsx`:
|
||||
- [ ] Modal asking "Save this quiz to your library?"
|
||||
- [ ] Show quiz title
|
||||
- [ ] "Save" and "Skip" buttons
|
||||
- [ ] Only show when authenticated
|
||||
- [x] Modify `hooks/useGame.ts`:
|
||||
- [x] Add `pendingQuizToSave` state
|
||||
- [x] After successful AI generation, set `pendingQuizToSave`
|
||||
- [x] Add `dismissSavePrompt()` function
|
||||
- [x] Export these for UI to consume
|
||||
- [x] Create `components/SaveQuizPrompt.tsx`:
|
||||
- [x] Modal asking "Save this quiz to your library?"
|
||||
- [x] Show quiz title
|
||||
- [x] "Save" and "Skip" buttons
|
||||
- [x] Loading state while saving
|
||||
- [x] Wire up in `App.tsx`:
|
||||
- [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists
|
||||
- [x] Handle save via useQuizLibrary hook
|
||||
|
||||
### 5.2 Save in Quiz Creator
|
||||
- [ ] Modify `src/components/QuizCreator.tsx`:
|
||||
- [ ] Add checkbox or toggle: "Save to my library"
|
||||
- [ ] Pass `shouldSave` flag to `onFinalize`
|
||||
- [ ] Modify `src/hooks/useGame.ts`:
|
||||
- [ ] Update `finalizeManualQuiz` to accept save preference
|
||||
- [ ] If save requested + authenticated, call `saveQuiz`
|
||||
- [x] Modify `components/QuizCreator.tsx`:
|
||||
- [x] Add "Save to my library" checkbox (only shown when authenticated)
|
||||
- [x] Pass `saveToLibrary` flag to `onFinalize`
|
||||
- [x] Modify `hooks/useGame.ts`:
|
||||
- [x] Update `finalizeManualQuiz` to accept save preference
|
||||
- [x] If save requested, set `pendingQuizToSave`
|
||||
|
||||
### 5.3 Load Quiz Flow
|
||||
- [ ] Modify `src/hooks/useGame.ts`:
|
||||
- [ ] Add `loadSavedQuiz(quiz: Quiz)` function
|
||||
- [ ] Initialize game state with loaded quiz
|
||||
- [ ] Transition to LOBBY state
|
||||
- [ ] Wire up from Landing → QuizLibrary → useGame
|
||||
### 5.3 Load Quiz Flow (Already done in Phase 4)
|
||||
- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts
|
||||
- [x] Wire up from Landing → QuizLibrary → useGame
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -407,6 +407,6 @@ kaboot/
|
|||
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
|
||||
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
|
||||
| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration |
|
||||
| Phase 5 | Not Started | |
|
||||
| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration |
|
||||
| Phase 6 | Not Started | |
|
||||
| Phase 7 | Not Started | |
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { Quiz, Question, AnswerOption } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
|
||||
import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react';
|
||||
import { COLORS, SHAPES } from '../constants';
|
||||
|
||||
interface QuizCreatorProps {
|
||||
onFinalize: (quiz: Quiz) => void;
|
||||
onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
|
||||
const auth = useAuth();
|
||||
const [title, setTitle] = useState('');
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [qText, setQText] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '', '', '']);
|
||||
const [reasons, setReasons] = useState<string[]>(['', '', '', '']);
|
||||
const [correctIdx, setCorrectIdx] = useState<number>(0);
|
||||
const [saveToLibrary, setSaveToLibrary] = useState(false);
|
||||
|
||||
const handleAddQuestion = () => {
|
||||
if (!qText.trim() || options.some(o => !o.trim())) {
|
||||
|
|
@ -53,7 +56,7 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
|||
|
||||
const handleFinalize = () => {
|
||||
if (!title.trim() || questions.length === 0) return;
|
||||
onFinalize({ title, questions });
|
||||
onFinalize({ title, questions }, saveToLibrary);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -176,7 +179,25 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
|
||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-between items-center">
|
||||
{auth.isAuthenticated ? (
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveToLibrary}
|
||||
onChange={(e) => setSaveToLibrary(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-6 h-6 border-2 border-gray-300 rounded-lg flex items-center justify-center peer-checked:bg-theme-primary peer-checked:border-theme-primary transition-all group-hover:border-gray-400">
|
||||
{saveToLibrary && <CheckCircle size={16} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-gray-600 font-bold flex items-center gap-2">
|
||||
<BookOpen size={18} /> Save to my library
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"
|
||||
|
|
|
|||
102
components/SaveQuizPrompt.tsx
Normal file
102
components/SaveQuizPrompt.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Save, X, Loader2, BrainCircuit } from 'lucide-react';
|
||||
|
||||
interface SaveQuizPromptProps {
|
||||
isOpen: boolean;
|
||||
quizTitle: string;
|
||||
onSave: () => Promise<void>;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export const SaveQuizPrompt: React.FC<SaveQuizPromptProps> = ({
|
||||
isOpen,
|
||||
quizTitle,
|
||||
onSave,
|
||||
onSkip
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={!isSaving ? onSkip : undefined}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", bounce: 0.4 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white w-full max-w-md flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
|
||||
>
|
||||
<div className="p-8 text-center">
|
||||
<div className="bg-theme-primary/10 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<BrainCircuit size={40} className="text-theme-primary" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-black text-gray-900 mb-2 tracking-tight">Save this Quiz?</h2>
|
||||
<p className="text-gray-500 font-bold mb-6">
|
||||
"{quizTitle}" generated successfully! Would you like to save it to your library?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full bg-theme-primary text-white py-4 rounded-2xl text-xl font-black shadow-[0_4px_0_#1e40af] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2 hover:bg-theme-primary-dark disabled:opacity-70 disabled:active:shadow-[0_4px_0_#1e40af] disabled:active:translate-y-0 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={24} strokeWidth={3} />
|
||||
Save to Library
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={isSaving}
|
||||
className="w-full bg-gray-100 text-gray-500 py-3 rounded-2xl text-lg font-bold hover:bg-gray-200 hover:text-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip & Don't Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={isSaving}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600 disabled:opacity-0"
|
||||
>
|
||||
<X size={24} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ export const useGame = () => {
|
|||
const [currentStreak, setCurrentStreak] = useState(0);
|
||||
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
|
||||
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const peerRef = useRef<Peer | null>(null);
|
||||
|
|
@ -48,6 +49,7 @@ export const useGame = () => {
|
|||
setError(null);
|
||||
setRole('HOST');
|
||||
const generatedQuiz = await generateQuiz(topic);
|
||||
setPendingQuizToSave({ quiz: generatedQuiz, topic });
|
||||
initializeHostGame(generatedQuiz);
|
||||
} catch (e) {
|
||||
setError("Failed to generate quiz.");
|
||||
|
|
@ -55,12 +57,19 @@ export const useGame = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const dismissSavePrompt = () => {
|
||||
setPendingQuizToSave(null);
|
||||
};
|
||||
|
||||
const startManualCreation = () => {
|
||||
setRole('HOST');
|
||||
setGameState('CREATING');
|
||||
};
|
||||
|
||||
const finalizeManualQuiz = (manualQuiz: Quiz) => {
|
||||
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
|
||||
if (saveToLibrary) {
|
||||
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
|
||||
}
|
||||
initializeHostGame(manualQuiz);
|
||||
};
|
||||
|
||||
|
|
@ -383,6 +392,7 @@ export const useGame = () => {
|
|||
|
||||
return {
|
||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
||||
pendingQuizToSave, dismissSavePrompt,
|
||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
|
||||
};
|
||||
};
|
||||
|
|
@ -88,7 +88,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
|||
const body = req.body as QuizBody;
|
||||
const { title, source, aiTopic, questions } = body;
|
||||
|
||||
if (!title || !source || !questions?.length) {
|
||||
if (!title?.trim() || !source || !questions?.length) {
|
||||
res.status(400).json({ error: 'Missing required fields: title, source, questions' });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -426,6 +426,244 @@ async function runTests() {
|
|||
await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
console.log('\nSave Integration Tests (Phase 5):');
|
||||
let manualSaveQuizId: string | null = null;
|
||||
let aiSaveQuizId: string | null = null;
|
||||
|
||||
await test('POST /api/quizzes manual quiz without aiTopic', async () => {
|
||||
const manualQuiz = {
|
||||
title: 'Manual Save Test Quiz',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'What is 1+1?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: '1', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '2', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
{ text: '3', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: '4', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', manualQuiz, 201);
|
||||
const result = data as { id: string };
|
||||
manualSaveQuizId = result.id;
|
||||
});
|
||||
|
||||
await test('GET manual quiz has null aiTopic', async () => {
|
||||
if (!manualSaveQuizId) throw new Error('No manual quiz created');
|
||||
const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`);
|
||||
const quiz = data as Record<string, unknown>;
|
||||
if (quiz.source !== 'manual') throw new Error('Wrong source');
|
||||
if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => {
|
||||
const aiQuiz = {
|
||||
title: 'AI Quiz Empty Topic',
|
||||
source: 'ai_generated',
|
||||
aiTopic: '',
|
||||
questions: [
|
||||
{
|
||||
text: 'Test?',
|
||||
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
|
||||
aiSaveQuizId = (data as { id: string }).id;
|
||||
|
||||
const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`);
|
||||
const quiz = getResult as Record<string, unknown>;
|
||||
if (quiz.aiTopic !== null && quiz.aiTopic !== '') {
|
||||
throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('DELETE cleanup manual save quiz', async () => {
|
||||
if (manualSaveQuizId) {
|
||||
await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204);
|
||||
}
|
||||
});
|
||||
|
||||
await test('DELETE cleanup ai save quiz', async () => {
|
||||
if (aiSaveQuizId) {
|
||||
await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nOption Preservation Tests:');
|
||||
let optionQuizId: string | null = null;
|
||||
|
||||
await test('POST /api/quizzes preserves all option fields including reason', async () => {
|
||||
const quizWithReasons = {
|
||||
title: 'Quiz With Reasons',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'Capital of France?',
|
||||
timeLimit: 15,
|
||||
options: [
|
||||
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' },
|
||||
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' },
|
||||
{ text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' },
|
||||
{ text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201);
|
||||
optionQuizId = (data as { id: string }).id;
|
||||
|
||||
const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`);
|
||||
const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] };
|
||||
|
||||
const options = quiz.questions[0].options;
|
||||
if (options.length !== 4) throw new Error('Expected 4 options');
|
||||
|
||||
const parisOpt = options.find(o => o.text === 'Paris');
|
||||
if (!parisOpt) throw new Error('Paris option not found');
|
||||
if (!parisOpt.isCorrect) throw new Error('Paris should be correct');
|
||||
if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved');
|
||||
|
||||
const londonOpt = options.find(o => o.text === 'London');
|
||||
if (!londonOpt) throw new Error('London option not found');
|
||||
if (londonOpt.isCorrect) throw new Error('London should not be correct');
|
||||
if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved');
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes options without reason field are preserved', async () => {
|
||||
const quizNoReasons = {
|
||||
title: 'Quiz Without Reasons',
|
||||
source: 'ai_generated',
|
||||
aiTopic: 'Geography',
|
||||
questions: [
|
||||
{
|
||||
text: 'Largest ocean?',
|
||||
options: [
|
||||
{ text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201);
|
||||
const quizId = (data as { id: string }).id;
|
||||
|
||||
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||
const quiz = getResult as { questions: { options: { reason?: string }[] }[] };
|
||||
|
||||
const options = quiz.questions[0].options;
|
||||
const pacificOpt = options.find((o: any) => o.text === 'Pacific');
|
||||
if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) {
|
||||
throw new Error('Expected null/undefined reason for option without reason');
|
||||
}
|
||||
|
||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('DELETE cleanup option quiz', async () => {
|
||||
if (optionQuizId) {
|
||||
await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nConcurrent Save Tests:');
|
||||
|
||||
await test('Multiple quizzes can be saved by same user', async () => {
|
||||
const quiz1 = {
|
||||
title: 'Concurrent Quiz 1',
|
||||
source: 'manual',
|
||||
questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
|
||||
};
|
||||
const quiz2 = {
|
||||
title: 'Concurrent Quiz 2',
|
||||
source: 'ai_generated',
|
||||
aiTopic: 'Science',
|
||||
questions: [{ text: 'Q2?', options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }] }],
|
||||
};
|
||||
|
||||
const [res1, res2] = await Promise.all([
|
||||
request('POST', '/api/quizzes', quiz1, 201),
|
||||
request('POST', '/api/quizzes', quiz2, 201),
|
||||
]);
|
||||
|
||||
const id1 = (res1.data as { id: string }).id;
|
||||
const id2 = (res2.data as { id: string }).id;
|
||||
|
||||
if (id1 === id2) throw new Error('Quiz IDs should be unique');
|
||||
|
||||
const { data: listData } = await request('GET', '/api/quizzes');
|
||||
const list = listData as { id: string; title: string }[];
|
||||
|
||||
const found1 = list.find(q => q.id === id1);
|
||||
const found2 = list.find(q => q.id === id2);
|
||||
|
||||
if (!found1) throw new Error('Quiz 1 not in list');
|
||||
if (!found2) throw new Error('Quiz 2 not in list');
|
||||
|
||||
await Promise.all([
|
||||
request('DELETE', `/api/quizzes/${id1}`, undefined, 204),
|
||||
request('DELETE', `/api/quizzes/${id2}`, undefined, 204),
|
||||
]);
|
||||
});
|
||||
|
||||
console.log('\nEdge Case Tests:');
|
||||
|
||||
await test('POST /api/quizzes with very long title', async () => {
|
||||
const longTitle = 'A'.repeat(500);
|
||||
const quiz = {
|
||||
title: longTitle,
|
||||
source: 'manual',
|
||||
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
const quizId = (data as { id: string }).id;
|
||||
|
||||
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||
if ((getResult as { title: string }).title !== longTitle) {
|
||||
throw new Error('Long title not preserved');
|
||||
}
|
||||
|
||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes with special characters in title', async () => {
|
||||
const specialTitle = 'Quiz with "quotes" & <tags> and emoji test';
|
||||
const quiz = {
|
||||
title: specialTitle,
|
||||
source: 'manual',
|
||||
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
const quizId = (data as { id: string }).id;
|
||||
|
||||
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
|
||||
if ((getResult as { title: string }).title !== specialTitle) {
|
||||
throw new Error('Special characters not preserved');
|
||||
}
|
||||
|
||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes with whitespace-only title returns 400', async () => {
|
||||
const quiz = {
|
||||
title: ' ',
|
||||
source: 'manual',
|
||||
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
|
||||
};
|
||||
|
||||
await request('POST', '/api/quizzes', quiz, 400);
|
||||
});
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue