diff --git a/App.tsx b/App.tsx index a0096ac..18cecb2 100644 --- a/App.tsx +++ b/App.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; import { useQuizLibrary } from './hooks/useQuizLibrary'; -import { useUserConfig } from './hooks/useUserConfig'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -13,7 +12,7 @@ import { RevealScreen } from './components/RevealScreen'; import { SaveQuizPrompt } from './components/SaveQuizPrompt'; import { QuizEditor } from './components/QuizEditor'; import { SaveOptionsModal } from './components/SaveOptionsModal'; -import type { Quiz, GameConfig } from './types'; +import type { Quiz } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -43,7 +42,6 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); - const { defaultConfig } = useUserConfig(); const [showSaveOptions, setShowSaveOptions] = useState(false); const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null); const { @@ -76,8 +74,7 @@ function App() { sourceQuizId, updateQuizFromEditor, startGameFromEditor, - backFromEditor, - gameConfig + backFromEditor } = useGame(); const handleSaveQuiz = async () => { @@ -155,8 +152,7 @@ function App() { onSave={handleEditorSave} onStartGame={startGameFromEditor} onBack={backFromEditor} - showSaveButton={auth.isAuthenticated} - defaultConfig={defaultConfig} + sourceQuizId={sourceQuizId} /> ) : null} @@ -199,7 +195,6 @@ function App() { onAnswer={handleAnswer} hasAnswered={hasAnswered} lastPointsEarned={lastPointsEarned} - hostPlays={gameConfig.hostParticipates} /> ) ) : null} diff --git a/components/DefaultConfigModal.tsx b/components/DefaultConfigModal.tsx deleted file mode 100644 index c2831c1..0000000 --- a/components/DefaultConfigModal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -import { motion } from 'framer-motion'; -import { X, Settings, Loader2 } from 'lucide-react'; -import { GameConfigPanel } from './GameConfigPanel'; -import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; -import type { GameConfig } from '../types'; - -interface DefaultConfigModalProps { - isOpen: boolean; - onClose: () => void; - config: GameConfig; - onChange: (config: GameConfig) => void; - onSave: () => void; - saving: boolean; -} - -export const DefaultConfigModal: React.FC = ({ - isOpen, - onClose, - config, - onChange, - onSave, - saving, -}) => { - useBodyScrollLock(isOpen); - - if (!isOpen) return null; - - return ( - - e.stopPropagation()} - > -
-
-
- -
-
-

Default Game Settings

-

Applied to all new quizzes

-
-
- -
- -
- -
- -
- - -
-
-
- ); -}; diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx deleted file mode 100644 index df29120..0000000 --- a/components/GameConfigPanel.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import React, { useState } from 'react'; -import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react'; -import type { GameConfig } from '../types'; - -interface GameConfigPanelProps { - config: GameConfig; - onChange: (config: GameConfig) => void; - questionCount: number; - compact?: boolean; -} - -interface TooltipProps { - content: string; -} - -const Tooltip: React.FC = ({ content }) => { - const [show, setShow] = useState(false); - const [coords, setCoords] = useState({ top: 0, left: 0 }); - const buttonRef = React.useRef(null); - - const updatePosition = () => { - if (buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - setCoords({ - top: rect.top + rect.height / 2, - left: rect.right + 8, - }); - } - }; - - const handleMouseEnter = () => { - updatePosition(); - setShow(true); - }; - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - updatePosition(); - setShow(!show); - }; - - return ( -
- - {show && ( -
- {content} -
-
- )} -
- ); -}; - -interface ToggleRowProps { - icon: React.ReactNode; - iconActive: boolean; - label: string; - description: string; - checked: boolean; - onChange: (checked: boolean) => void; - tooltip?: string; - children?: React.ReactNode; -} - -const ToggleRow: React.FC = ({ - icon, - iconActive, - label: labelText, - description, - checked, - onChange, - tooltip, - children, -}) => { - const inputId = React.useId(); - - return ( -
-
- -
- {tooltip && } -
- ); -}; - -interface NumberInputProps { - label: string; - value: number; - onChange: (value: number) => void; - min?: number; - max?: number; - step?: number; - suffix?: string; -} - -const NumberInput: React.FC = ({ label, value, onChange, min, max, step = 1, suffix }) => ( -
- {label} -
- onChange(Number(e.target.value))} - min={min} - max={max} - step={step} - className="w-20 px-3 py-1.5 border-2 border-gray-200 rounded-lg text-center font-bold text-gray-900 bg-white focus:border-theme-primary focus:ring-2 focus:ring-theme-primary/20 outline-none" - /> - {suffix && {suffix}} -
-
-); - -export const GameConfigPanel: React.FC = ({ - config, - onChange, - questionCount, - compact = false, -}) => { - const [expanded, setExpanded] = useState(!compact); - - const update = (partial: Partial) => { - onChange({ ...config, ...partial }); - }; - - const suggestedComebackBonus = Math.round(50 + (questionCount * 5)); - const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5)); - - if (compact && !expanded) { - return ( - - ); - } - - return ( -
- {compact && ( - - )} - - } - iconActive={config.shuffleQuestions} - label="Shuffle Questions" - description="Randomize question order when starting" - checked={config.shuffleQuestions} - onChange={(v) => update({ shuffleQuestions: v })} - /> - - } - iconActive={config.shuffleAnswers} - label="Shuffle Answers" - description="Randomize answer positions for each question" - checked={config.shuffleAnswers} - onChange={(v) => update({ shuffleAnswers: v })} - /> - - } - iconActive={config.hostParticipates} - label="Host Participates" - description="Join as a player and answer questions" - checked={config.hostParticipates} - onChange={(v) => update({ hostParticipates: v })} - /> - - } - iconActive={config.streakBonusEnabled} - label="Streak Bonus" - description="Multiply points for consecutive correct answers" - checked={config.streakBonusEnabled} - onChange={(v) => update({ streakBonusEnabled: v })} - tooltip={`After ${config.streakThreshold} correct answers in a row, points are multiplied by ${config.streakMultiplier}x. The multiplier increases by ${((config.streakMultiplier - 1) * 100).toFixed(0)}% for each additional correct answer.`} - > -
- update({ streakThreshold: v })} - min={2} - max={10} - suffix="correct" - /> - update({ streakMultiplier: v })} - min={1.05} - max={2} - step={0.05} - suffix="x" - /> -
-
- - } - iconActive={config.comebackBonusEnabled} - label="Comeback Bonus" - description="Extra points for players not in top 3" - checked={config.comebackBonusEnabled} - onChange={(v) => update({ comebackBonusEnabled: v })} - > -
- update({ comebackBonusPoints: v })} - min={10} - max={500} - suffix="pts" - /> -

- Suggested for {questionCount} questions: {suggestedComebackBonus} pts -

-
-
- - } - iconActive={config.penaltyForWrongAnswer} - label="Wrong Answer Penalty" - description="Deduct points for incorrect answers" - checked={config.penaltyForWrongAnswer} - onChange={(v) => update({ penaltyForWrongAnswer: v })} - > -
- update({ penaltyPercent: v })} - min={5} - max={100} - suffix="%" - /> -

- Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts) -

-
-
- - } - iconActive={config.firstCorrectBonusEnabled} - label="First Correct Bonus" - description="Extra points for first player to answer correctly" - checked={config.firstCorrectBonusEnabled} - onChange={(v) => update({ firstCorrectBonusEnabled: v })} - > -
- update({ firstCorrectBonusPoints: v })} - min={10} - max={500} - suffix="pts" - /> -

- Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts -

-
-
-
- ); -}; diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index 060de98..c6866fc 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -13,7 +13,6 @@ interface GameScreenProps { onAnswer: (isCorrect: boolean) => void; hasAnswered: boolean; lastPointsEarned: number | null; - hostPlays?: boolean; } export const GameScreen: React.FC = ({ @@ -25,10 +24,8 @@ export const GameScreen: React.FC = ({ role, onAnswer, hasAnswered, - hostPlays = true, }) => { const isClient = role === 'CLIENT'; - const isSpectator = role === 'HOST' && !hostPlays; const displayOptions = question?.options || []; const timeLeftSeconds = Math.ceil(timeLeft / 1000); @@ -54,7 +51,7 @@ export const GameScreen: React.FC = ({
- {isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'} + {isClient ? 'Controller' : 'Host'}
@@ -83,14 +80,12 @@ export const GameScreen: React.FC = ({ let opacityClass = "opacity-100"; let scaleClass = "scale-100"; - let cursorClass = ""; - if (isSpectator) { - cursorClass = "cursor-default"; - } else if (hasAnswered) { + // If answering phase and user answered, dim everything + if (hasAnswered) { opacityClass = "opacity-50 cursor-not-allowed grayscale"; scaleClass = "scale-95"; - } + } return ( = ({ initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }} - disabled={hasAnswered || isSpectator} - onClick={() => !isSpectator && onAnswer(option as any)} + disabled={hasAnswered} + onClick={() => onAnswer(option as any)} className={` - ${colorClass} ${opacityClass} ${scaleClass} ${cursorClass} + ${colorClass} ${opacityClass} ${scaleClass} rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)] flex flex-col md:flex-row items-center justify-center md:justify-start p-4 md:p-8 - ${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''} + active:shadow-none active:translate-y-[8px] active:scale-95 transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10 `} > diff --git a/components/Landing.tsx b/components/Landing.tsx index 80628cf..b82c94d 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,13 +1,11 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react'; import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; import { QuizLibrary } from './QuizLibrary'; -import { DefaultConfigModal } from './DefaultConfigModal'; import { useQuizLibrary } from '../hooks/useQuizLibrary'; -import { useUserConfig } from '../hooks/useUserConfig'; -import type { Quiz, GameConfig } from '../types'; +import type { Quiz } from '../types'; type GenerateMode = 'topic' | 'document'; @@ -33,14 +31,10 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [questionCount, setQuestionCount] = useState(10); const [isDragging, setIsDragging] = useState(false); const [useOcr, setUseOcr] = useState(false); - const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); - const [editingDefaultConfig, setEditingDefaultConfig] = useState(null); const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/')); const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type)); const showOcrOption = hasImageFile || hasDocumentFile; - - const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { quizzes, @@ -138,19 +132,7 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on return (
-
- {auth.isAuthenticated && ( - - )} +
= ({ onGenerate, onCreateManual, on onDeleteQuiz={deleteQuiz} onRetry={retryLibrary} /> - - { - setDefaultConfigOpen(false); - setEditingDefaultConfig(null); - }} - config={editingDefaultConfig || defaultConfig} - onChange={setEditingDefaultConfig} - onSave={async () => { - if (editingDefaultConfig) { - await saveDefaultConfig(editingDefaultConfig); - setDefaultConfigOpen(false); - setEditingDefaultConfig(null); - } - }} - saving={savingConfig} - />
); }; diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index 8cb3157..09b2d28 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -1,52 +1,41 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types'; +import { Quiz, Question } from '../types'; import { SortableQuestionCard } from './SortableQuestionCard'; import { QuestionEditModal } from './QuestionEditModal'; -import { GameConfigPanel } from './GameConfigPanel'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import { v4 as uuidv4 } from 'uuid'; interface QuizEditorProps { quiz: Quiz; onSave: (quiz: Quiz) => void; - onStartGame: (quiz: Quiz, config: GameConfig) => void; - onConfigChange?: (config: GameConfig) => void; + onStartGame: (quiz: Quiz) => void; onBack: () => void; showSaveButton?: boolean; isSaving?: boolean; - defaultConfig?: GameConfig; } export const QuizEditor: React.FC = ({ quiz: initialQuiz, onSave, onStartGame, - onConfigChange, onBack, showSaveButton = true, - isSaving, - defaultConfig, + isSaving }) => { const [quiz, setQuiz] = useState(initialQuiz); const [expandedId, setExpandedId] = useState(null); const [editingQuestion, setEditingQuestion] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [titleEditing, setTitleEditing] = useState(false); - const [config, setConfig] = useState( - initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG - ); + const [shuffleQuestions, setShuffleQuestions] = useState(false); + const [shuffleAnswers, setShuffleAnswers] = useState(false); useBodyScrollLock(!!showDeleteConfirm); - const handleConfigChange = useCallback((newConfig: GameConfig) => { - setConfig(newConfig); - onConfigChange?.(newConfig); - }, [onConfigChange]); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } @@ -128,11 +117,11 @@ export const QuizEditor: React.FC = ({ const handleStartGame = () => { let questions = [...quiz.questions]; - if (config.shuffleQuestions) { + if (shuffleQuestions) { questions = questions.sort(() => Math.random() - 0.5); } - if (config.shuffleAnswers) { + if (shuffleAnswers) { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; @@ -149,7 +138,7 @@ export const QuizEditor: React.FC = ({ }); } - onStartGame({ ...quiz, questions, config }, config); + onStartGame({ ...quiz, questions }); }; return ( @@ -253,12 +242,51 @@ export const QuizEditor: React.FC = ({
- +
+ + + +
) : (
- - Waiting for host... + + Waiting for host...
)}
); -}; +}; \ No newline at end of file diff --git a/constants.ts b/constants.ts index 476eff1..6f3e583 100644 --- a/constants.ts +++ b/constants.ts @@ -1,5 +1,4 @@ import { Triangle, Diamond, Circle, Square } from 'lucide-react'; -import type { GameConfig, Player, PointsBreakdown } from './types'; export const COLORS = { red: 'bg-red-600', @@ -26,79 +25,6 @@ export const QUESTION_TIME = 20; // seconds export const QUESTION_TIME_MS = 20000; // milliseconds export const POINTS_PER_QUESTION = 1000; -export const calculateBasePoints = (timeLeftMs: number, questionTimeMs: number, maxPoints: number = POINTS_PER_QUESTION): number => { - const responseTimeMs = questionTimeMs - timeLeftMs; - const responseTimeSec = responseTimeMs / 1000; - const questionTimeSec = questionTimeMs / 1000; - - if (responseTimeSec < 0.5) { - return maxPoints; - } - - return Math.round((1 - (responseTimeSec / questionTimeSec) / 2) * maxPoints); -}; - -interface PointsCalculationParams { - isCorrect: boolean; - timeLeftMs: number; - questionTimeMs: number; - streak: number; - playerRank: number; - isFirstCorrect: boolean; - config: GameConfig; -} - -export const calculatePointsWithBreakdown = (params: PointsCalculationParams): PointsBreakdown => { - const { isCorrect, timeLeftMs, questionTimeMs, streak, playerRank, isFirstCorrect, config } = params; - - const breakdown: PointsBreakdown = { - basePoints: 0, - streakBonus: 0, - comebackBonus: 0, - firstCorrectBonus: 0, - penalty: 0, - total: 0, - }; - - if (!isCorrect) { - if (config.penaltyForWrongAnswer) { - breakdown.penalty = Math.round(POINTS_PER_QUESTION * (config.penaltyPercent / 100)); - breakdown.total = -breakdown.penalty; - } - return breakdown; - } - - breakdown.basePoints = calculateBasePoints(timeLeftMs, questionTimeMs); - let pointsAfterStreak = breakdown.basePoints; - - if (config.streakBonusEnabled && streak >= config.streakThreshold) { - const streakCount = streak - config.streakThreshold; - const multiplier = config.streakMultiplier + (streakCount * (config.streakMultiplier - 1)); - pointsAfterStreak = Math.round(breakdown.basePoints * multiplier); - breakdown.streakBonus = pointsAfterStreak - breakdown.basePoints; - } - - if (config.comebackBonusEnabled && playerRank > 3) { - breakdown.comebackBonus = config.comebackBonusPoints; - } - - if (config.firstCorrectBonusEnabled && isFirstCorrect) { - breakdown.firstCorrectBonus = config.firstCorrectBonusPoints; - } - - breakdown.total = pointsAfterStreak + breakdown.comebackBonus + breakdown.firstCorrectBonus; - return breakdown; -}; - -export const calculatePoints = (params: PointsCalculationParams): number => { - return calculatePointsWithBreakdown(params).total; -}; - -export const getPlayerRank = (playerId: string, players: Player[]): number => { - const sorted = [...players].sort((a, b) => b.score - a.score); - return sorted.findIndex(p => p.id === playerId) + 1; -}; - export const PLAYER_COLORS = [ '#2563eb', '#e21b3c', diff --git a/hooks/useAuthenticatedFetch.ts b/hooks/useAuthenticatedFetch.ts index 82544c7..6e96d7d 100644 --- a/hooks/useAuthenticatedFetch.ts +++ b/hooks/useAuthenticatedFetch.ts @@ -1,13 +1,10 @@ import { useAuth } from 'react-oidc-context'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; -let isRedirecting = false; - export const useAuthenticatedFetch = () => { const auth = useAuth(); - const silentRefreshInProgress = useRef | null>(null); const isTokenExpired = useCallback(() => { if (!auth.user?.expires_at) return true; @@ -17,58 +14,28 @@ export const useAuthenticatedFetch = () => { return now >= expiresAt - bufferMs; }, [auth.user?.expires_at]); - const redirectToLogin = useCallback(() => { - if (isRedirecting) return; - isRedirecting = true; - auth.signinRedirect(); - }, [auth]); - - const attemptSilentRefresh = useCallback(async (): Promise => { - if (silentRefreshInProgress.current) { - return silentRefreshInProgress.current; - } - - silentRefreshInProgress.current = (async () => { - try { - const user = await auth.signinSilent(); - return user?.access_token || null; - } catch { - return null; - } finally { - silentRefreshInProgress.current = null; - } - })() as Promise; - - return silentRefreshInProgress.current; - }, [auth]); - const ensureValidToken = useCallback(async (): Promise => { - if (isRedirecting) { - throw new Error('Session expired, redirecting to login'); - } - if (!auth.user?.access_token) { throw new Error('Not authenticated'); } if (isTokenExpired()) { - const newToken = await attemptSilentRefresh(); - if (newToken) { - return newToken; + try { + const user = await auth.signinSilent(); + if (user?.access_token) { + return user.access_token; + } + } catch { + auth.signinRedirect(); + throw new Error('Session expired, redirecting to login'); } - redirectToLogin(); - throw new Error('Session expired, redirecting to login'); } return auth.user.access_token; - }, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]); + }, [auth, isTokenExpired]); const authFetch = useCallback( async (path: string, options: RequestInit = {}): Promise => { - if (isRedirecting) { - throw new Error('Session expired, redirecting to login'); - } - if (!navigator.onLine) { throw new Error('You appear to be offline. Please check your connection.'); } @@ -94,18 +61,21 @@ export const useAuthenticatedFetch = () => { } if (response.status === 401) { - const newToken = await attemptSilentRefresh(); - if (newToken) { - return fetch(url, { - ...options, - headers: { - ...options.headers, - Authorization: `Bearer ${newToken}`, - 'Content-Type': 'application/json', - }, - }); + try { + const user = await auth.signinSilent(); + if (user?.access_token) { + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${user.access_token}`, + 'Content-Type': 'application/json', + }, + }); + } + } catch { + auth.signinRedirect(); } - redirectToLogin(); throw new Error('Session expired, redirecting to login'); } @@ -115,7 +85,7 @@ export const useAuthenticatedFetch = () => { return response; }, - [ensureValidToken, attemptSilentRefresh, redirectToLogin] + [auth, ensureValidToken] ); return { diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 97b6faa..76bd4db 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; +import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument } from '../types'; import { generateQuiz } from '../services/geminiService'; -import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; +import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants'; import { Peer, DataConnection } from 'peerjs'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; @@ -25,8 +25,6 @@ export const useGame = () => { const [currentPlayerName, setCurrentPlayerName] = useState(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const [sourceQuizId, setSourceQuizId] = useState(null); - const [gameConfig, setGameConfig] = useState(DEFAULT_GAME_CONFIG); - const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState(null); const timerRef = useRef | null>(null); const peerRef = useRef(null); @@ -38,13 +36,11 @@ export const useGame = () => { const playersRef = useRef([]); const currentQuestionIndexRef = useRef(0); const quizRef = useRef(null); - const gameConfigRef = useRef(DEFAULT_GAME_CONFIG); useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]); useEffect(() => { playersRef.current = players; }, [players]); useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]); useEffect(() => { quizRef.current = quiz; }, [quiz]); - useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]); const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + ""; @@ -127,10 +123,9 @@ export const useGame = () => { setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' }); }; - const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => { + const startGameFromEditor = (finalQuiz: Quiz) => { setQuiz(finalQuiz); - setGameConfig(config); - initializeHostGame(finalQuiz, config.hostParticipates); + initializeHostGame(finalQuiz); }; const backFromEditor = () => { @@ -144,7 +139,7 @@ export const useGame = () => { // This prevents stale closures in the PeerJS event listeners const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); - const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => { + const initializeHostGame = (newQuiz: Quiz) => { setQuiz(newQuiz); const pin = generateGamePin(); setGamePin(pin); @@ -153,27 +148,19 @@ export const useGame = () => { peerRef.current = peer; peer.on('open', (id) => { - if (hostParticipates) { - const hostPlayer: Player = { - id: 'host', - name: 'Host', - score: 0, - previousScore: 0, - streak: 0, - lastAnswerCorrect: null, - pointsBreakdown: null, - isBot: false, - avatarSeed: Math.random(), - color: PLAYER_COLORS[0] - }; - setPlayers([hostPlayer]); - setCurrentPlayerId('host'); - setCurrentPlayerName('Host'); - } else { - setPlayers([]); - setCurrentPlayerId(null); - setCurrentPlayerName(null); - } + const hostPlayer: Player = { + id: 'host', + name: 'Host', + score: 0, + streak: 0, + lastAnswerCorrect: null, + isBot: false, + avatarSeed: Math.random(), + color: PLAYER_COLORS[0] + }; + setPlayers([hostPlayer]); + setCurrentPlayerId('host'); + setCurrentPlayerName('Host'); setGameState('LOBBY'); }); @@ -199,10 +186,8 @@ export const useGame = () => { id: conn.peer, name: data.payload.name, score: 0, - previousScore: 0, streak: 0, lastAnswerCorrect: null, - pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: PLAYER_COLORS[colorIndex] @@ -220,31 +205,15 @@ export const useGame = () => { if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return; - const isFirstCorrect = isCorrect && firstCorrectPlayerId === null; - if (isFirstCorrect) { - setFirstCorrectPlayerId(playerId); - } - - const newStreak = isCorrect ? currentPlayer.streak + 1 : 0; - const playerRank = getPlayerRank(playerId, playersRef.current); - - const breakdown = calculatePointsWithBreakdown({ - isCorrect, - timeLeftMs: timeLeftRef.current, - questionTimeMs: QUESTION_TIME_MS, - streak: newStreak, - playerRank, - isFirstCorrect, - config: gameConfigRef.current, - }); - const newScore = Math.max(0, currentPlayer.score + breakdown.total); + const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0; + const newScore = currentPlayer.score + points; setPlayers(prev => prev.map(p => { if (p.id !== playerId) return p; - return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; + return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect }; })); - conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } }); + conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } }); } }; @@ -287,8 +256,7 @@ export const useGame = () => { setLastPointsEarned(null); setSelectedOption(null); setTimeLeft(QUESTION_TIME_MS); - setFirstCorrectPlayerId(null); - setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }))); + setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null }))); const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; @@ -453,44 +421,24 @@ export const useGame = () => { const handleAnswer = (arg: boolean | AnswerOption) => { if (hasAnswered || gameState !== 'QUESTION') return; - if (role === 'HOST' && !gameConfigRef.current.hostParticipates) return; - setHasAnswered(true); if (role === 'HOST') { const option = arg as AnswerOption; const isCorrect = option.isCorrect; setSelectedOption(option); + const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0; + setLastPointsEarned(points); const hostPlayer = playersRef.current.find(p => p.id === 'host'); - const currentStrk = hostPlayer?.streak || 0; - const newStreak = isCorrect ? currentStrk + 1 : 0; - - const isFirstCorrect = isCorrect && firstCorrectPlayerId === null; - if (isFirstCorrect) { - setFirstCorrectPlayerId('host'); - } - - const playerRank = getPlayerRank('host', playersRef.current); - - const breakdown = calculatePointsWithBreakdown({ - isCorrect, - timeLeftMs: timeLeftRef.current, - questionTimeMs: QUESTION_TIME_MS, - streak: newStreak, - playerRank, - isFirstCorrect, - config: gameConfigRef.current, - }); - - setLastPointsEarned(breakdown.total); - const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total); + const newScore = (hostPlayer?.score || 0) + points; + const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0; setCurrentPlayerScore(newScore); setCurrentStreak(newStreak); setPlayers(prev => prev.map(p => { if (p.id !== 'host') return p; - return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; + return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect }; })); } else { const option = arg as AnswerOption; @@ -514,7 +462,7 @@ export const useGame = () => { }, []); return { - role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, + role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, pendingQuizToSave, dismissSavePrompt, sourceQuizId, startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, updateQuizFromEditor, startGameFromEditor, backFromEditor diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts index 2286860..35c8187 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 } from '../types'; interface UseQuizLibraryReturn { quizzes: QuizListItem[]; @@ -14,7 +14,6 @@ interface UseQuizLibraryReturn { loadQuiz: (id: string) => Promise; saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise; updateQuiz: (id: string, quiz: Quiz) => Promise; - updateQuizConfig: (id: string, config: GameConfig) => Promise; deleteQuiz: (id: string) => Promise; retry: () => Promise; clearError: () => void; @@ -127,7 +126,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { title: quiz.title, source, aiTopic, - gameConfig: quiz.config, questions: quiz.questions.map(q => ({ text: q.text, timeLimit: q.timeLimit, @@ -172,7 +170,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { method: 'PUT', body: JSON.stringify({ title: quiz.title, - gameConfig: quiz.config, questions: quiz.questions.map(q => ({ text: q.text, timeLimit: q.timeLimit, @@ -206,25 +203,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { } }, [authFetch]); - const updateQuizConfig = useCallback(async (id: string, config: GameConfig): Promise => { - try { - const response = await authFetch(`/api/quizzes/${id}/config`, { - method: 'PATCH', - body: JSON.stringify({ gameConfig: config }), - }); - - if (!response.ok) { - throw new Error('Failed to update config'); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to update config'; - if (!message.includes('redirecting')) { - toast.error(message); - } - throw err; - } - }, [authFetch]); - const deleteQuiz = useCallback(async (id: string): Promise => { setDeletingQuizId(id); setError(null); @@ -275,7 +253,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { loadQuiz, saveQuiz, updateQuiz, - updateQuizConfig, deleteQuiz, retry, clearError, diff --git a/hooks/useUserConfig.ts b/hooks/useUserConfig.ts deleted file mode 100644 index 13dbbe0..0000000 --- a/hooks/useUserConfig.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useState, useCallback, useEffect } from 'react'; -import toast from 'react-hot-toast'; -import { useAuthenticatedFetch } from './useAuthenticatedFetch'; -import type { GameConfig } from '../types'; -import { DEFAULT_GAME_CONFIG } from '../types'; - -interface UseUserConfigReturn { - defaultConfig: GameConfig; - loading: boolean; - saving: boolean; - fetchDefaultConfig: () => Promise; - saveDefaultConfig: (config: GameConfig) => Promise; -} - -export const useUserConfig = (): UseUserConfigReturn => { - const { authFetch, isAuthenticated } = useAuthenticatedFetch(); - const [defaultConfig, setDefaultConfig] = useState(DEFAULT_GAME_CONFIG); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - - const fetchDefaultConfig = useCallback(async () => { - if (!isAuthenticated) return; - - setLoading(true); - try { - const response = await authFetch('/api/users/me'); - if (response.ok) { - const data = await response.json(); - if (data.defaultGameConfig) { - setDefaultConfig(data.defaultGameConfig); - } - } - } catch { - } finally { - setLoading(false); - } - }, [authFetch, isAuthenticated]); - - const saveDefaultConfig = useCallback(async (config: GameConfig) => { - setSaving(true); - try { - const response = await authFetch('/api/users/me/default-config', { - method: 'PUT', - body: JSON.stringify({ defaultGameConfig: config }), - }); - - if (!response.ok) { - throw new Error('Failed to save defaults'); - } - - setDefaultConfig(config); - toast.success('Default settings saved!'); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to save defaults'; - toast.error(message); - throw err; - } finally { - setSaving(false); - } - }, [authFetch]); - - useEffect(() => { - fetchDefaultConfig(); - }, [fetchDefaultConfig]); - - return { - defaultConfig, - loading, - saving, - fetchDefaultConfig, - saveDefaultConfig, - }; -}; diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index 90b7166..b7bfc69 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -16,24 +16,4 @@ db.pragma('foreign_keys = ON'); const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); db.exec(schema); -const runMigrations = () => { - const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[]; - const hasGameConfig = tableInfo.some(col => col.name === 'game_config'); - - if (!hasGameConfig) { - db.exec("ALTER TABLE quizzes ADD COLUMN game_config TEXT"); - console.log("Migration: Added game_config to quizzes"); - } - - const userTableInfo = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; - const hasDefaultConfig = userTableInfo.some(col => col.name === 'default_game_config'); - - if (!hasDefaultConfig) { - db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT"); - console.log("Migration: Added default_game_config to users"); - } -}; - -runMigrations(); - console.log(`Database initialized at ${DB_PATH}`); diff --git a/server/src/db/migrations.sql b/server/src/db/migrations.sql deleted file mode 100644 index a079afc..0000000 --- a/server/src/db/migrations.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Migration: Add game_config columns --- Run these statements to migrate existing databases - --- Add game_config to quizzes table -ALTER TABLE quizzes ADD COLUMN game_config TEXT; - --- Add default_game_config to users table -ALTER TABLE users ADD COLUMN default_game_config TEXT; diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 9755c3d..9e4c0dc 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -4,8 +4,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT, display_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_login DATETIME, - default_game_config TEXT + last_login DATETIME ); CREATE TABLE IF NOT EXISTS quizzes ( @@ -14,7 +13,6 @@ CREATE TABLE IF NOT EXISTS quizzes ( title TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')), ai_topic TEXT, - game_config TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts index 33f1f3f..25d049a 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -7,26 +7,10 @@ const router = Router(); router.use(requireAuth); -interface GameConfig { - shuffleQuestions: boolean; - shuffleAnswers: boolean; - hostParticipates: boolean; - streakBonusEnabled: boolean; - streakThreshold: number; - streakMultiplier: number; - comebackBonusEnabled: boolean; - comebackBonusPoints: number; - penaltyForWrongAnswer: boolean; - penaltyPercent: number; - firstCorrectBonusEnabled: boolean; - firstCorrectBonusPoints: number; -} - interface QuizBody { title: string; source: 'manual' | 'ai_generated'; aiTopic?: string; - gameConfig?: GameConfig; questions: { text: string; timeLimit?: number; @@ -60,7 +44,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => { router.get('/:id', (req: AuthenticatedRequest, res: Response) => { const quiz = db.prepare(` - SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt + SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt FROM quizzes WHERE id = ? AND user_id = ? `).get(req.params.id, req.user!.sub) as Record | undefined; @@ -94,18 +78,8 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => { }; }); - let parsedConfig = null; - if (quiz.gameConfig && typeof quiz.gameConfig === 'string') { - try { - parsedConfig = JSON.parse(quiz.gameConfig); - } catch { - parsedConfig = null; - } - } - res.json({ ...quiz, - gameConfig: parsedConfig, questions: questionsWithOptions, }); }); @@ -144,7 +118,7 @@ function validateQuizBody(body: QuizBody): string | null { router.post('/', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; - const { title, source, aiTopic, gameConfig, questions } = body; + const { title, source, aiTopic, questions } = body; const validationError = validateQuizBody(body); if (validationError) { @@ -164,8 +138,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { `); const insertQuiz = db.prepare(` - INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO quizzes (id, user_id, title, source, ai_topic) + VALUES (?, ?, ?, ?, ?) `); const insertQuestion = db.prepare(` @@ -186,7 +160,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { req.user!.name || null ); - insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null); + insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null); questions.forEach((q, qIdx) => { const questionId = uuidv4(); @@ -213,7 +187,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { router.put('/:id', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; - const { title, questions, gameConfig } = body; + const { title, questions } = body; const quizId = req.params.id; if (!title?.trim()) { @@ -253,7 +227,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => { } const updateQuiz = db.prepare(` - UPDATE quizzes SET title = ?, game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`); @@ -269,7 +243,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => { `); const transaction = db.transaction(() => { - updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId); + updateQuiz.run(title, quizId); deleteQuestions.run(quizId); questions.forEach((q, qIdx) => { @@ -295,26 +269,6 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => { res.json({ id: quizId }); }); -router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => { - const quizId = req.params.id; - const { gameConfig } = req.body as { gameConfig: GameConfig }; - - const existing = db.prepare(` - SELECT id FROM quizzes WHERE id = ? AND user_id = ? - `).get(quizId, req.user!.sub); - - if (!existing) { - res.status(404).json({ error: 'Quiz not found' }); - return; - } - - db.prepare(` - UPDATE quizzes SET game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(gameConfig ? JSON.stringify(gameConfig) : null, quizId); - - res.json({ success: true }); -}); - router.delete('/:id', (req: AuthenticatedRequest, res: Response) => { const result = db.prepare(` DELETE FROM quizzes WHERE id = ? AND user_id = ? diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 24925b5..9e4ece6 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -8,7 +8,7 @@ router.use(requireAuth); router.get('/me', (req: AuthenticatedRequest, res: Response) => { const user = db.prepare(` - SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin + SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin FROM users WHERE id = ? `).get(req.user!.sub) as Record | undefined; @@ -19,7 +19,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { username: req.user!.preferred_username, email: req.user!.email, displayName: req.user!.name, - defaultGameConfig: null, createdAt: null, lastLogin: null, isNew: true, @@ -27,41 +26,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { return; } - let parsedConfig = null; - if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') { - try { - parsedConfig = JSON.parse(user.defaultGameConfig); - } catch { - parsedConfig = null; - } - } - - res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false }); -}); - -router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => { - const { defaultGameConfig } = req.body; - - const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, default_game_config, last_login) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(id) DO UPDATE SET - default_game_config = ?, - last_login = CURRENT_TIMESTAMP - `); - - const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null; - - upsertUser.run( - req.user!.sub, - req.user!.preferred_username, - req.user!.email || null, - req.user!.name || null, - configJson, - configJson - ); - - res.json({ success: true }); + res.json({ ...user, isNew: false }); }); export default router; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index d4813da..8be303f 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -1257,271 +1257,6 @@ async function runTests() { await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); }); - console.log('\nGame Config Tests:'); - let gameConfigQuizId: string | null = null; - - await test('POST /api/quizzes with gameConfig saves config', async () => { - const quizWithConfig = { - title: 'Quiz With Game Config', - source: 'manual', - gameConfig: { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: false, - streakBonusEnabled: true, - streakThreshold: 5, - streakMultiplier: 1.5, - comebackBonusEnabled: true, - comebackBonusPoints: 100, - penaltyForWrongAnswer: true, - penaltyPercent: 30, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 75, - }, - questions: [ - { - text: 'Config test question?', - timeLimit: 20, - options: [ - { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, - { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, - ], - }, - ], - }; - - const { data } = await request('POST', '/api/quizzes', quizWithConfig, 201); - gameConfigQuizId = (data as { id: string }).id; - }); - - await test('GET /api/quizzes/:id returns gameConfig', async () => { - if (!gameConfigQuizId) throw new Error('No game config quiz created'); - const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); - const quiz = data as Record; - - if (!quiz.gameConfig) throw new Error('Missing gameConfig'); - const config = quiz.gameConfig as Record; - if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not preserved'); - if (config.shuffleAnswers !== true) throw new Error('shuffleAnswers not preserved'); - if (config.hostParticipates !== false) throw new Error('hostParticipates not preserved'); - if (config.streakBonusEnabled !== true) throw new Error('streakBonusEnabled not preserved'); - if (config.streakThreshold !== 5) throw new Error('streakThreshold not preserved'); - if (config.streakMultiplier !== 1.5) throw new Error('streakMultiplier not preserved'); - if (config.comebackBonusEnabled !== true) throw new Error('comebackBonusEnabled not preserved'); - if (config.comebackBonusPoints !== 100) throw new Error('comebackBonusPoints not preserved'); - if (config.penaltyForWrongAnswer !== true) throw new Error('penaltyForWrongAnswer not preserved'); - if (config.penaltyPercent !== 30) throw new Error('penaltyPercent not preserved'); - if (config.firstCorrectBonusEnabled !== true) throw new Error('firstCorrectBonusEnabled not preserved'); - if (config.firstCorrectBonusPoints !== 75) throw new Error('firstCorrectBonusPoints not preserved'); - }); - - await test('PUT /api/quizzes/:id updates gameConfig', async () => { - if (!gameConfigQuizId) throw new Error('No game config quiz created'); - - const updatedQuiz = { - title: 'Updated Config Quiz', - gameConfig: { - shuffleQuestions: false, - shuffleAnswers: false, - hostParticipates: true, - streakBonusEnabled: false, - streakThreshold: 3, - streakMultiplier: 1.1, - comebackBonusEnabled: false, - comebackBonusPoints: 50, - penaltyForWrongAnswer: false, - penaltyPercent: 25, - firstCorrectBonusEnabled: false, - firstCorrectBonusPoints: 50, - }, - questions: [ - { - text: 'Updated question?', - options: [ - { text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' }, - { text: 'Y', isCorrect: false, shape: 'square', color: 'green' }, - ], - }, - ], - }; - - await request('PUT', `/api/quizzes/${gameConfigQuizId}`, updatedQuiz); - - const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); - const quiz = data as Record; - const config = quiz.gameConfig as Record; - - if (config.shuffleQuestions !== false) throw new Error('shuffleQuestions not updated'); - if (config.hostParticipates !== true) throw new Error('hostParticipates not updated'); - if (config.streakBonusEnabled !== false) throw new Error('streakBonusEnabled not updated'); - }); - - await test('PATCH /api/quizzes/:id/config updates only gameConfig', async () => { - if (!gameConfigQuizId) throw new Error('No game config quiz created'); - - const newConfig = { - gameConfig: { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: true, - streakBonusEnabled: true, - streakThreshold: 4, - streakMultiplier: 1.3, - comebackBonusEnabled: true, - comebackBonusPoints: 150, - penaltyForWrongAnswer: true, - penaltyPercent: 20, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 100, - }, - }; - - await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, newConfig); - - const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); - const quiz = data as Record; - const config = quiz.gameConfig as Record; - - if (config.shuffleQuestions !== true) throw new Error('PATCH did not update shuffleQuestions'); - if (config.streakThreshold !== 4) throw new Error('PATCH did not update streakThreshold'); - if (config.comebackBonusPoints !== 150) throw new Error('PATCH did not update comebackBonusPoints'); - if (quiz.title !== 'Updated Config Quiz') throw new Error('PATCH should not have changed title'); - }); - - await test('PATCH /api/quizzes/:id/config with non-existent ID returns 404', async () => { - const config = { - gameConfig: { - shuffleQuestions: true, - shuffleAnswers: false, - hostParticipates: true, - streakBonusEnabled: false, - streakThreshold: 3, - streakMultiplier: 1.1, - comebackBonusEnabled: false, - comebackBonusPoints: 50, - penaltyForWrongAnswer: false, - penaltyPercent: 25, - firstCorrectBonusEnabled: false, - firstCorrectBonusPoints: 50, - }, - }; - await request('PATCH', '/api/quizzes/non-existent-id/config', config, 404); - }); - - await test('PATCH /api/quizzes/:id/config with null gameConfig clears config', async () => { - if (!gameConfigQuizId) throw new Error('No game config quiz created'); - - await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, { gameConfig: null }); - - const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`); - const quiz = data as Record; - - if (quiz.gameConfig !== null) throw new Error('gameConfig should be null after clearing'); - }); - - await test('POST /api/quizzes without gameConfig sets null config', async () => { - const quizNoConfig = { - title: 'Quiz Without Config', - source: 'manual', - questions: [ - { - text: 'No config question?', - options: [ - { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, - { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, - ], - }, - ], - }; - - const { data } = await request('POST', '/api/quizzes', quizNoConfig, 201); - const quizId = (data as { id: string }).id; - - const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); - const quiz = getResult as Record; - - if (quiz.gameConfig !== null) throw new Error('Expected null gameConfig for quiz without config'); - - await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); - }); - - await test('DELETE cleanup game config quiz', async () => { - if (gameConfigQuizId) { - await request('DELETE', `/api/quizzes/${gameConfigQuizId}`, undefined, 204); - } - }); - - console.log('\nUser Default Config Tests:'); - - await test('GET /api/users/me returns defaultGameConfig', async () => { - const { data } = await request('GET', '/api/users/me'); - const user = data as Record; - - if (!('defaultGameConfig' in user)) throw new Error('Missing defaultGameConfig field'); - }); - - await test('PUT /api/users/me/default-config saves default config', async () => { - const defaultConfig = { - defaultGameConfig: { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: false, - streakBonusEnabled: true, - streakThreshold: 4, - streakMultiplier: 1.25, - comebackBonusEnabled: true, - comebackBonusPoints: 75, - penaltyForWrongAnswer: true, - penaltyPercent: 15, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 60, - }, - }; - - await request('PUT', '/api/users/me/default-config', defaultConfig); - - const { data } = await request('GET', '/api/users/me'); - const user = data as Record; - const config = user.defaultGameConfig as Record; - - if (!config) throw new Error('defaultGameConfig not saved'); - if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not saved'); - if (config.streakThreshold !== 4) throw new Error('streakThreshold not saved'); - if (config.comebackBonusPoints !== 75) throw new Error('comebackBonusPoints not saved'); - }); - - await test('PUT /api/users/me/default-config with null clears config', async () => { - await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null }); - - const { data } = await request('GET', '/api/users/me'); - const user = data as Record; - - if (user.defaultGameConfig !== null) throw new Error('defaultGameConfig should be null after clearing'); - }); - - await test('PUT /api/users/me/default-config with partial config saves as-is', async () => { - const partialConfig = { - defaultGameConfig: { - shuffleQuestions: true, - hostParticipates: false, - }, - }; - - await request('PUT', '/api/users/me/default-config', partialConfig); - - const { data } = await request('GET', '/api/users/me'); - const user = data as Record; - const config = user.defaultGameConfig as Record; - - if (!config) throw new Error('Partial config not saved'); - if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not in partial config'); - if (config.hostParticipates !== false) throw new Error('hostParticipates not in partial config'); - }); - - await test('PUT /api/users/me/default-config cleanup - reset to null', async () => { - await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null }); - }); - console.log('\nPhase 6 - Duplicate/Idempotency Tests:'); await test('POST /api/quizzes with same data creates separate quizzes', async () => { diff --git a/services/geminiService.ts b/services/geminiService.ts index edcef00..5e1cd18 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -61,23 +61,12 @@ ${baseInstructions}`; ${baseInstructions}`; } -function shuffleArray(array: T[]): T[] { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -} - function transformToQuiz(data: any): Quiz { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; const questions: Question[] = data.questions.map((q: any) => { - const shuffledOpts = shuffleArray(q.options); - - const options: AnswerOption[] = shuffledOpts.map((opt: any, index: number) => ({ + const options: AnswerOption[] = q.options.map((opt: any, index: number) => ({ text: opt.text, isCorrect: opt.isCorrect, shape: shapes[index % 4], diff --git a/tests/components/DefaultConfigModal.test.tsx b/tests/components/DefaultConfigModal.test.tsx deleted file mode 100644 index 0c73f1a..0000000 --- a/tests/components/DefaultConfigModal.test.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { DefaultConfigModal } from '../../components/DefaultConfigModal'; -import { DEFAULT_GAME_CONFIG } from '../../types'; -import type { GameConfig } from '../../types'; - -describe('DefaultConfigModal', () => { - const mockOnClose = vi.fn(); - const mockOnChange = vi.fn(); - const mockOnSave = vi.fn(); - - const defaultProps = { - isOpen: true, - onClose: mockOnClose, - config: { ...DEFAULT_GAME_CONFIG }, - onChange: mockOnChange, - onSave: mockOnSave, - saving: false, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders nothing when isOpen is false', () => { - render(); - expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument(); - }); - - it('renders modal when isOpen is true', () => { - render(); - expect(screen.getByText('Default Game Settings')).toBeInTheDocument(); - }); - - it('displays subtitle explaining the settings', () => { - render(); - expect(screen.getByText('Applied to all new quizzes')).toBeInTheDocument(); - }); - - it('renders GameConfigPanel with config', () => { - render(); - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - expect(screen.getByText('Host Participates')).toBeInTheDocument(); - }); - - it('renders Cancel button', () => { - render(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - - it('renders Save Defaults button', () => { - render(); - expect(screen.getByText('Save Defaults')).toBeInTheDocument(); - }); - - it('renders close X button', () => { - render(); - const closeButtons = screen.getAllByRole('button'); - const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x')); - expect(xButton).toBeInTheDocument(); - }); - }); - - describe('interactions - happy path', () => { - it('calls onClose when Cancel is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Cancel')); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it('calls onClose when X button is clicked', async () => { - const user = userEvent.setup(); - render(); - - const closeButtons = screen.getAllByRole('button'); - const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x')); - await user.click(xButton!); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it('calls onClose when backdrop is clicked', () => { - render(); - - const backdrop = document.querySelector('.fixed.inset-0'); - fireEvent.click(backdrop!); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it('does not close when modal content is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Default Game Settings')); - - expect(mockOnClose).not.toHaveBeenCalled(); - }); - - it('calls onSave when Save Defaults is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Save Defaults')); - - expect(mockOnSave).toHaveBeenCalledTimes(1); - }); - - it('calls onChange when config is modified', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Shuffle Questions')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - }); - }); - }); - - describe('interactions - unhappy path / edge cases', () => { - it('disables Save button when saving is true', () => { - render(); - - const saveButton = screen.getByText('Saving...').closest('button'); - expect(saveButton).toBeDisabled(); - }); - - it('shows Saving... text when saving', () => { - render(); - - expect(screen.getByText('Saving...')).toBeInTheDocument(); - expect(screen.queryByText('Save Defaults')).not.toBeInTheDocument(); - }); - - it('does not call onSave when button is disabled and clicked', async () => { - const user = userEvent.setup(); - render(); - - const saveButton = screen.getByText('Saving...').closest('button')!; - await user.click(saveButton); - - expect(mockOnSave).not.toHaveBeenCalled(); - }); - - it('Cancel button is still clickable when saving', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Cancel')); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it('handles rapid clicks on Save button', async () => { - const user = userEvent.setup(); - render(); - - const saveButton = screen.getByText('Save Defaults'); - await user.click(saveButton); - await user.click(saveButton); - await user.click(saveButton); - - expect(mockOnSave).toHaveBeenCalledTimes(3); - }); - - it('remains functional after re-opening', async () => { - const user = userEvent.setup(); - const { rerender } = render(); - - await user.click(screen.getByText('Save Defaults')); - expect(mockOnSave).toHaveBeenCalledTimes(1); - - rerender(); - expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument(); - - rerender(); - await user.click(screen.getByText('Save Defaults')); - expect(mockOnSave).toHaveBeenCalledTimes(2); - }); - }); - - describe('config propagation', () => { - const getCheckboxForRow = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - it('displays provided config values', () => { - const customConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - streakBonusEnabled: true, - streakThreshold: 5, - }; - - render(); - - expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true); - expect(screen.getByDisplayValue('5')).toBeInTheDocument(); - }); - - it('updates display when config prop changes', () => { - const { rerender } = render(); - - expect(getCheckboxForRow('Shuffle Questions').checked).toBe(false); - - const newConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true }; - rerender(); - - expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true); - }); - }); - - describe('state transitions', () => { - it('transitions from not saving to saving correctly', () => { - const { rerender } = render(); - - expect(screen.getByText('Save Defaults')).toBeInTheDocument(); - - rerender(); - - expect(screen.getByText('Saving...')).toBeInTheDocument(); - }); - - it('transitions from saving back to not saving', () => { - const { rerender } = render(); - - expect(screen.getByText('Saving...')).toBeInTheDocument(); - - rerender(); - - expect(screen.getByText('Save Defaults')).toBeInTheDocument(); - }); - - it('transitions from open to closed preserves state for re-open', () => { - const customConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true }; - const { rerender } = render(); - - const getCheckbox = () => { - const label = screen.getByText('Shuffle Questions'); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - expect(getCheckbox().checked).toBe(true); - - rerender(); - rerender(); - - expect(getCheckbox().checked).toBe(true); - }); - }); - - describe('accessibility', () => { - it('all interactive elements are focusable', () => { - render(); - - const buttons = screen.getAllByRole('button'); - buttons.forEach(button => { - expect(button).not.toHaveAttribute('tabindex', '-1'); - }); - }); - - it('modal traps focus within when open', () => { - render(); - - const modal = document.querySelector('[class*="bg-white rounded-2xl"]'); - expect(modal).toBeInTheDocument(); - }); - }); - - describe('with all config options enabled', () => { - it('renders all nested settings when all options are enabled', () => { - const allEnabledConfig: GameConfig = { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: true, - streakBonusEnabled: true, - streakThreshold: 3, - streakMultiplier: 1.2, - comebackBonusEnabled: true, - comebackBonusPoints: 100, - penaltyForWrongAnswer: true, - penaltyPercent: 30, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 75, - }; - - render(); - - expect(screen.getByText('Streak threshold')).toBeInTheDocument(); - expect(screen.getByText('Multiplier')).toBeInTheDocument(); - expect(screen.getAllByText('Bonus points').length).toBe(2); - expect(screen.getByText('Penalty')).toBeInTheDocument(); - }); - }); - - describe('async save behavior', () => { - it('handles async onSave that resolves', async () => { - const user = userEvent.setup(); - const asyncOnSave = vi.fn().mockResolvedValue(undefined); - - render(); - - await user.click(screen.getByText('Save Defaults')); - - expect(asyncOnSave).toHaveBeenCalledTimes(1); - }); - - it('handles async onSave that rejects', async () => { - const user = userEvent.setup(); - const asyncOnSave = vi.fn().mockRejectedValue(new Error('Save failed')); - - render(); - - await user.click(screen.getByText('Save Defaults')); - - expect(asyncOnSave).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/tests/components/GameConfigPanel.test.tsx b/tests/components/GameConfigPanel.test.tsx deleted file mode 100644 index 8e8359b..0000000 --- a/tests/components/GameConfigPanel.test.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { GameConfigPanel } from '../../components/GameConfigPanel'; -import { DEFAULT_GAME_CONFIG } from '../../types'; -import type { GameConfig } from '../../types'; - -describe('GameConfigPanel', () => { - const mockOnChange = vi.fn(); - - const defaultProps = { - config: { ...DEFAULT_GAME_CONFIG }, - onChange: mockOnChange, - questionCount: 10, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders all config toggles', () => { - render(); - - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - expect(screen.getByText('Shuffle Answers')).toBeInTheDocument(); - expect(screen.getByText('Host Participates')).toBeInTheDocument(); - expect(screen.getByText('Streak Bonus')).toBeInTheDocument(); - expect(screen.getByText('Comeback Bonus')).toBeInTheDocument(); - expect(screen.getByText('Wrong Answer Penalty')).toBeInTheDocument(); - expect(screen.getByText('First Correct Bonus')).toBeInTheDocument(); - }); - - it('renders descriptions for each toggle', () => { - render(); - - expect(screen.getByText('Randomize question order when starting')).toBeInTheDocument(); - expect(screen.getByText('Randomize answer positions for each question')).toBeInTheDocument(); - expect(screen.getByText('Join as a player and answer questions')).toBeInTheDocument(); - }); - - it('renders compact collapsed state when compact=true', () => { - render(); - - expect(screen.getByText('Game Settings')).toBeInTheDocument(); - expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument(); - }); - - it('expands when clicking collapsed compact view', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - }); - - it('collapses when clicking expanded compact view header', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - - await user.click(screen.getByText('Game Settings')); - expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument(); - }); - }); - - describe('toggle interactions - happy path', () => { - const getCheckboxForLabel = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]')!; - }; - - it('calls onChange when toggling shuffleQuestions', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Shuffle Questions')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - }); - }); - - it('calls onChange when toggling shuffleAnswers', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Shuffle Answers')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - shuffleAnswers: true, - }); - }); - - it('calls onChange when toggling hostParticipates off', async () => { - const user = userEvent.setup(); - const config = { ...DEFAULT_GAME_CONFIG, hostParticipates: true }; - render(); - - await user.click(screen.getByText('Host Participates')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...config, - hostParticipates: false, - }); - }); - - it('calls onChange when enabling streakBonus', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Streak Bonus')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - streakBonusEnabled: true, - }); - }); - - it('calls onChange when enabling comebackBonus', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Comeback Bonus')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - comebackBonusEnabled: true, - }); - }); - - it('calls onChange when enabling penalty', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Wrong Answer Penalty')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - penaltyForWrongAnswer: true, - }); - }); - - it('calls onChange when enabling firstCorrectBonus', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('First Correct Bonus')); - - expect(mockOnChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - firstCorrectBonusEnabled: true, - }); - }); - }); - - describe('nested number inputs - happy path', () => { - it('shows streak settings when streakBonusEnabled', () => { - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - render(); - - expect(screen.getByText('Streak threshold')).toBeInTheDocument(); - expect(screen.getByText('Multiplier')).toBeInTheDocument(); - }); - - it('shows comeback settings when comebackBonusEnabled', () => { - const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true }; - render(); - - expect(screen.getByText('Bonus points')).toBeInTheDocument(); - }); - - it('shows penalty settings when penaltyForWrongAnswer enabled', () => { - const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true }; - render(); - - expect(screen.getByText('Penalty')).toBeInTheDocument(); - }); - - it('updates streakThreshold value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - render(); - - const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement; - fireEvent.change(thresholdInput, { target: { value: '5' } }); - - expect(mockOnChange).toHaveBeenLastCalledWith( - expect.objectContaining({ streakThreshold: 5 }) - ); - }); - - it('updates streakMultiplier value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - render(); - - const multiplierInput = screen.getByDisplayValue('1.1') as HTMLInputElement; - fireEvent.change(multiplierInput, { target: { value: '1.5' } }); - - expect(mockOnChange).toHaveBeenLastCalledWith( - expect.objectContaining({ streakMultiplier: 1.5 }) - ); - }); - - it('updates comebackBonusPoints value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true }; - render(); - - const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement; - fireEvent.change(bonusInput, { target: { value: '100' } }); - - expect(mockOnChange).toHaveBeenLastCalledWith( - expect.objectContaining({ comebackBonusPoints: 100 }) - ); - }); - - it('updates penaltyPercent value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true }; - render(); - - const penaltyInput = screen.getByDisplayValue('25') as HTMLInputElement; - fireEvent.change(penaltyInput, { target: { value: '50' } }); - - expect(mockOnChange).toHaveBeenLastCalledWith( - expect.objectContaining({ penaltyPercent: 50 }) - ); - }); - - it('updates firstCorrectBonusPoints value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true }; - render(); - - const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement; - fireEvent.change(bonusInput, { target: { value: '75' } }); - - expect(mockOnChange).toHaveBeenLastCalledWith( - expect.objectContaining({ firstCorrectBonusPoints: 75 }) - ); - }); - }); - - describe('suggested values', () => { - it('displays suggested comeback bonus based on question count', () => { - const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true }; - render(); - - expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument(); - }); - - it('displays suggested first correct bonus based on question count', () => { - const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true }; - render(); - - expect(screen.getByText(/Suggested for 10 questions: 50 pts/)).toBeInTheDocument(); - }); - - it('updates suggested values when question count changes', () => { - const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true }; - const { rerender } = render( - - ); - - expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument(); - - rerender(); - - expect(screen.getByText(/Suggested for 20 questions: 150 pts/)).toBeInTheDocument(); - }); - }); - - describe('interactions - unhappy path / edge cases', () => { - it('handles rapid toggle clicks', async () => { - const user = userEvent.setup(); - render(); - - const label = screen.getByText('Shuffle Questions'); - - await user.click(label); - await user.click(label); - await user.click(label); - - expect(mockOnChange).toHaveBeenCalledTimes(3); - }); - - it('handles number input with invalid value (converts to 0)', async () => { - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - render(); - - const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement; - fireEvent.change(thresholdInput, { target: { value: 'abc' } }); - - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ streakThreshold: 0 }) - ); - }); - - it('handles number input with negative value', async () => { - const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true }; - render(); - - const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement; - fireEvent.change(bonusInput, { target: { value: '-10' } }); - - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ comebackBonusPoints: -10 }) - ); - }); - - it('handles empty number input (converts to 0)', async () => { - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - render(); - - const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement; - fireEvent.change(thresholdInput, { target: { value: '' } }); - - expect(mockOnChange).toHaveBeenCalledWith( - expect.objectContaining({ streakThreshold: 0 }) - ); - }); - - it('does not show nested settings when toggle is off', () => { - render(); - - expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument(); - expect(screen.queryByText('Multiplier')).not.toBeInTheDocument(); - }); - - it('hides nested settings when toggle is turned off', async () => { - const user = userEvent.setup(); - const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }; - const { rerender } = render(); - - expect(screen.getByText('Streak threshold')).toBeInTheDocument(); - - rerender(); - - expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument(); - }); - }); - - describe('tooltip interactions', () => { - it('shows tooltip on hover', async () => { - const user = userEvent.setup(); - render(); - - const infoButtons = screen.getAllByRole('button').filter( - btn => btn.querySelector('svg') - ); - - if (infoButtons.length > 0) { - await user.hover(infoButtons[0]); - } - }); - - it('shows tooltip on click', async () => { - const user = userEvent.setup(); - render(); - - const infoButtons = screen.getAllByRole('button').filter( - btn => btn.querySelector('svg') - ); - - if (infoButtons.length > 0) { - await user.click(infoButtons[0]); - } - }); - }); - - describe('state management', () => { - const getCheckboxForRow = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - it('reflects updated config values correctly', () => { - const customConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: false, - streakBonusEnabled: true, - streakThreshold: 5, - streakMultiplier: 1.5, - }; - - render(); - - expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true); - expect(getCheckboxForRow('Shuffle Answers').checked).toBe(true); - expect(getCheckboxForRow('Host Participates').checked).toBe(false); - - expect(screen.getByDisplayValue('5')).toBeInTheDocument(); - expect(screen.getByDisplayValue('1.5')).toBeInTheDocument(); - }); - - it('handles config with all options enabled', () => { - const allEnabledConfig: GameConfig = { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: true, - streakBonusEnabled: true, - streakThreshold: 3, - streakMultiplier: 1.2, - comebackBonusEnabled: true, - comebackBonusPoints: 100, - penaltyForWrongAnswer: true, - penaltyPercent: 30, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 75, - }; - - render(); - - expect(screen.getByText('Streak threshold')).toBeInTheDocument(); - expect(screen.getAllByText('Bonus points').length).toBe(2); - expect(screen.getByText('Penalty')).toBeInTheDocument(); - }); - - it('handles config with all options disabled', () => { - const allDisabledConfig: GameConfig = { - shuffleQuestions: false, - shuffleAnswers: false, - hostParticipates: false, - streakBonusEnabled: false, - streakThreshold: 3, - streakMultiplier: 1.1, - comebackBonusEnabled: false, - comebackBonusPoints: 50, - penaltyForWrongAnswer: false, - penaltyPercent: 25, - firstCorrectBonusEnabled: false, - firstCorrectBonusPoints: 50, - }; - - render(); - - expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument(); - expect(screen.queryByText('Bonus points')).not.toBeInTheDocument(); - expect(screen.queryByText('Penalty')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/tests/components/QuizEditorConfig.test.tsx b/tests/components/QuizEditorConfig.test.tsx deleted file mode 100644 index 1171623..0000000 --- a/tests/components/QuizEditorConfig.test.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { QuizEditor } from '../../components/QuizEditor'; -import { DEFAULT_GAME_CONFIG } from '../../types'; -import type { Quiz, GameConfig } from '../../types'; - -vi.mock('uuid', () => ({ - v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9), -})); - -const createMockQuiz = (overrides?: Partial): Quiz => ({ - title: 'Test Quiz', - 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' }, - { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' }, - { text: '6', isCorrect: false, shape: 'square', color: 'green' }, - ], - }, - ], - ...overrides, -}); - -describe('QuizEditor - Game Config Integration', () => { - const mockOnSave = vi.fn(); - const mockOnStartGame = vi.fn(); - const mockOnConfigChange = vi.fn(); - const mockOnBack = vi.fn(); - - const defaultProps = { - quiz: createMockQuiz(), - onSave: mockOnSave, - onStartGame: mockOnStartGame, - onConfigChange: mockOnConfigChange, - onBack: mockOnBack, - showSaveButton: true, - isSaving: false, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('config initialization', () => { - it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => { - render(); - - expect(screen.getByText('Game Settings')).toBeInTheDocument(); - }); - - it('uses quiz config when provided', async () => { - const user = userEvent.setup(); - const quizWithConfig = createMockQuiz({ - config: { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - streakBonusEnabled: true, - }, - }); - - render(); - - await user.click(screen.getByText('Game Settings')); - - const getCheckbox = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - expect(getCheckbox('Shuffle Questions').checked).toBe(true); - }); - - it('uses defaultConfig prop when quiz has no config', async () => { - const user = userEvent.setup(); - const customDefault: GameConfig = { - ...DEFAULT_GAME_CONFIG, - hostParticipates: false, - penaltyForWrongAnswer: true, - }; - - render(); - - await user.click(screen.getByText('Game Settings')); - - const getCheckbox = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - expect(getCheckbox('Host Participates').checked).toBe(false); - expect(getCheckbox('Wrong Answer Penalty').checked).toBe(true); - }); - - it('prioritizes quiz config over defaultConfig prop', async () => { - const user = userEvent.setup(); - const quizWithConfig = createMockQuiz({ - config: { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - }, - }); - const customDefault: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: false, - shuffleAnswers: true, - }; - - render(); - - await user.click(screen.getByText('Game Settings')); - - const getCheckbox = (labelText: string) => { - const label = screen.getByText(labelText); - const row = label.closest('[class*="bg-white rounded-xl"]')!; - return row.querySelector('input[type="checkbox"]') as HTMLInputElement; - }; - - expect(getCheckbox('Shuffle Questions').checked).toBe(true); - expect(getCheckbox('Shuffle Answers').checked).toBe(false); - }); - }); - - describe('config panel interactions', () => { - it('renders collapsed Game Settings by default (compact mode)', () => { - render(); - - expect(screen.getByText('Game Settings')).toBeInTheDocument(); - expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument(); - }); - - it('expands config panel when Game Settings is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - expect(screen.getByText('Host Participates')).toBeInTheDocument(); - }); - - it('collapses config panel when Game Settings is clicked again', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - expect(screen.getByText('Shuffle Questions')).toBeInTheDocument(); - - await user.click(screen.getByText('Game Settings')); - expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument(); - }); - - it('calls onConfigChange when config is modified', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - - expect(mockOnConfigChange).toHaveBeenCalledWith({ - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - }); - }); - - it('calls onConfigChange multiple times for multiple changes', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - await user.click(screen.getByText('Shuffle Answers')); - - expect(mockOnConfigChange).toHaveBeenCalledTimes(2); - }); - }); - - describe('start game with config', () => { - it('passes config to onStartGame', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Test Quiz', - config: DEFAULT_GAME_CONFIG, - }), - DEFAULT_GAME_CONFIG - ); - }); - - it('passes modified config to onStartGame', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - shuffleQuestions: true, - }), - }), - expect.objectContaining({ - shuffleQuestions: true, - }) - ); - }); - - it('shuffles questions when shuffleQuestions is enabled', async () => { - const user = userEvent.setup(); - const multiQuestionQuiz = createMockQuiz({ - 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: 'C', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }] }, - { id: 'q3', text: 'Q3', timeLimit: 20, options: [{ text: 'E', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'F', isCorrect: false, shape: 'diamond', color: 'blue' }] }, - ], - config: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true }, - }); - - render(); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - questions: expect.any(Array), - }), - expect.objectContaining({ - shuffleQuestions: true, - }) - ); - - const calledQuiz = mockOnStartGame.mock.calls[0][0]; - expect(calledQuiz.questions).toHaveLength(3); - }); - - it('shuffles answers when shuffleAnswers is enabled', async () => { - const user = userEvent.setup(); - const quizWithConfig = createMockQuiz({ - config: { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true }, - }); - - render(); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - questions: expect.arrayContaining([ - expect.objectContaining({ - options: expect.any(Array), - }), - ]), - }), - expect.objectContaining({ - shuffleAnswers: true, - }) - ); - }); - }); - - describe('config persistence', () => { - it('maintains config state across quiz title edits', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - - await user.click(screen.getByText('Test Quiz')); - const titleInput = screen.getByDisplayValue('Test Quiz'); - await user.clear(titleInput); - await user.type(titleInput, 'New Title'); - fireEvent.blur(titleInput); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - shuffleQuestions: true, - }), - }), - expect.any(Object) - ); - }); - - it('maintains config state across question additions', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Host Participates')); - - await user.click(screen.getByText('Add Question')); - - expect(mockOnConfigChange).toHaveBeenCalledWith( - expect.objectContaining({ - hostParticipates: false, - }) - ); - }); - }); - - describe('edge cases', () => { - it('handles quiz with all config options enabled', async () => { - const user = userEvent.setup(); - const fullConfigQuiz = createMockQuiz({ - config: { - shuffleQuestions: true, - shuffleAnswers: true, - hostParticipates: true, - streakBonusEnabled: true, - streakThreshold: 5, - streakMultiplier: 1.5, - comebackBonusEnabled: true, - comebackBonusPoints: 100, - penaltyForWrongAnswer: true, - penaltyPercent: 30, - firstCorrectBonusEnabled: true, - firstCorrectBonusPoints: 75, - }, - }); - - render(); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - shuffleQuestions: true, - streakBonusEnabled: true, - comebackBonusEnabled: true, - penaltyForWrongAnswer: true, - firstCorrectBonusEnabled: true, - }), - }), - expect.any(Object) - ); - }); - - it('handles config without onConfigChange callback', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - - await user.click(screen.getByText(/Start Game/)); - - expect(mockOnStartGame).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - shuffleQuestions: true, - }), - }), - expect.any(Object) - ); - }); - - it('handles empty questions array', () => { - const emptyQuiz = createMockQuiz({ questions: [] }); - - render(); - - const startButton = screen.getByText(/Start Game/).closest('button'); - expect(startButton).toBeDisabled(); - }); - - it('handles quiz without title', () => { - const noTitleQuiz = createMockQuiz({ title: '' }); - - render(); - - expect(screen.getByText('Untitled Quiz')).toBeInTheDocument(); - }); - }); - - describe('onConfigChange callback timing', () => { - it('calls onConfigChange immediately when toggle changes', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('Game Settings')); - await user.click(screen.getByText('Shuffle Questions')); - - expect(mockOnConfigChange).toHaveBeenCalledTimes(1); - }); - - it('calls onConfigChange for nested number input changes', async () => { - const user = userEvent.setup(); - const quizWithStreak = createMockQuiz({ - config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true }, - }); - - render(); - - await user.click(screen.getByText('Game Settings')); - - const thresholdInput = screen.getByDisplayValue('3'); - await user.clear(thresholdInput); - await user.type(thresholdInput, '5'); - - expect(mockOnConfigChange).toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/hooks/useUserConfig.test.tsx b/tests/hooks/useUserConfig.test.tsx deleted file mode 100644 index b0c6f0d..0000000 --- a/tests/hooks/useUserConfig.test.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import React from 'react'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { useUserConfig } from '../../hooks/useUserConfig'; -import { DEFAULT_GAME_CONFIG } from '../../types'; -import type { GameConfig } from '../../types'; - -const mockAuthFetch = vi.fn(); -const mockIsAuthenticated = vi.fn(() => true); - -vi.mock('../../hooks/useAuthenticatedFetch', () => ({ - useAuthenticatedFetch: () => ({ - authFetch: mockAuthFetch, - isAuthenticated: mockIsAuthenticated(), - }), -})); - -vi.mock('react-hot-toast', () => ({ - default: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -describe('useUserConfig', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockIsAuthenticated.mockReturnValue(true); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('initialization', () => { - it('returns DEFAULT_GAME_CONFIG initially', () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }); - - const { result } = renderHook(() => useUserConfig()); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - - it('fetches default config on mount when authenticated', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }); - - renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me'); - }); - }); - - it('does not fetch when not authenticated', () => { - mockIsAuthenticated.mockReturnValue(false); - - renderHook(() => useUserConfig()); - - expect(mockAuthFetch).not.toHaveBeenCalled(); - }); - }); - - describe('fetchDefaultConfig - happy path', () => { - it('updates defaultConfig when server returns config', async () => { - const serverConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - streakBonusEnabled: true, - }; - - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: serverConfig }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.defaultConfig).toEqual(serverConfig); - }); - }); - - it('sets loading to true during fetch', async () => { - let resolvePromise: (value: unknown) => void; - const pendingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - mockAuthFetch.mockReturnValueOnce(pendingPromise); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(true); - }); - - await act(async () => { - resolvePromise!({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }); - }); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - }); - - it('keeps DEFAULT_GAME_CONFIG when server returns null config', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - }); - - describe('fetchDefaultConfig - unhappy path', () => { - it('handles non-ok response gracefully', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - - it('handles network error gracefully', async () => { - mockAuthFetch.mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - - it('handles 401 unauthorized gracefully', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - }); - - describe('saveDefaultConfig - happy path', () => { - it('successfully saves config to server', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - const newConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - hostParticipates: false, - }; - - await act(async () => { - await result.current.saveDefaultConfig(newConfig); - }); - - expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/users/me/default-config', { - method: 'PUT', - body: JSON.stringify({ defaultGameConfig: newConfig }), - }); - }); - - it('updates local state after successful save', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - const newConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleAnswers: true, - }; - - await act(async () => { - await result.current.saveDefaultConfig(newConfig); - }); - - expect(result.current.defaultConfig).toEqual(newConfig); - }); - - it('sets saving to true during save', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - let resolvePromise: (value: unknown) => void; - const pendingPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - mockAuthFetch.mockReturnValueOnce(pendingPromise); - - act(() => { - result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); - }); - - await waitFor(() => { - expect(result.current.saving).toBe(true); - }); - - await act(async () => { - resolvePromise!({ - ok: true, - json: () => Promise.resolve({ success: true }), - }); - }); - - await waitFor(() => { - expect(result.current.saving).toBe(false); - }); - }); - }); - - describe('saveDefaultConfig - unhappy path', () => { - it('throws error when save fails with non-ok response', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - await expect( - act(async () => { - await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); - }) - ).rejects.toThrow('Failed to save defaults'); - }); - - it('throws error when network fails', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockRejectedValueOnce(new Error('Network error')); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - await expect( - act(async () => { - await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); - }) - ).rejects.toThrow('Network error'); - }); - - it('resets saving state on error', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockRejectedValueOnce(new Error('Server error')); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - try { - await act(async () => { - await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG }); - }); - } catch { - // Expected to throw - } - - expect(result.current.saving).toBe(false); - }); - - it('does not update local state on save failure', async () => { - const initialConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleQuestions: true, - }; - - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: initialConfig }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.defaultConfig).toEqual(initialConfig); - }); - - const newConfig: GameConfig = { - ...DEFAULT_GAME_CONFIG, - shuffleAnswers: true, - }; - - try { - await act(async () => { - await result.current.saveDefaultConfig(newConfig); - }); - } catch { - // Expected to throw - } - - expect(result.current.defaultConfig).toEqual(initialConfig); - }); - }); - - describe('concurrent operations', () => { - it('handles multiple save calls correctly', async () => { - mockAuthFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: null }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - const config1: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true }; - const config2: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true }; - - await act(async () => { - await result.current.saveDefaultConfig(config1); - }); - - await act(async () => { - await result.current.saveDefaultConfig(config2); - }); - - expect(result.current.defaultConfig).toEqual(config2); - }); - }); - - describe('edge cases', () => { - it('handles partial config from server', async () => { - const partialConfig = { - shuffleQuestions: true, - }; - - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: partialConfig }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(partialConfig); - }); - - it('handles empty response body', async () => { - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG); - }); - - it('handles config with extra fields from server', async () => { - const serverConfigWithExtras = { - ...DEFAULT_GAME_CONFIG, - unknownField: 'value', - anotherUnknown: 123, - }; - - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: serverConfigWithExtras }), - }); - - const { result } = renderHook(() => useUserConfig()); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.defaultConfig).toEqual(serverConfigWithExtras); - }); - }); - - describe('re-authentication scenarios', () => { - it('re-fetches config when authentication status changes', async () => { - mockIsAuthenticated.mockReturnValue(false); - - const { result, rerender } = renderHook(() => useUserConfig()); - - expect(mockAuthFetch).not.toHaveBeenCalled(); - - mockIsAuthenticated.mockReturnValue(true); - mockAuthFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ defaultGameConfig: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true } }), - }); - - rerender(); - - await waitFor(() => { - expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me'); - }); - }); - }); -}); diff --git a/types.ts b/types.ts index 0733de7..84004ea 100644 --- a/types.ts +++ b/types.ts @@ -27,40 +27,9 @@ export interface Question { timeLimit: number; // in seconds } -export interface GameConfig { - shuffleQuestions: boolean; - shuffleAnswers: boolean; - hostParticipates: boolean; - streakBonusEnabled: boolean; - streakThreshold: number; - streakMultiplier: number; - comebackBonusEnabled: boolean; - comebackBonusPoints: number; - penaltyForWrongAnswer: boolean; - penaltyPercent: number; - firstCorrectBonusEnabled: boolean; - firstCorrectBonusPoints: number; -} - -export const DEFAULT_GAME_CONFIG: GameConfig = { - shuffleQuestions: false, - shuffleAnswers: false, - hostParticipates: true, - streakBonusEnabled: false, - streakThreshold: 3, - streakMultiplier: 1.1, - comebackBonusEnabled: false, - comebackBonusPoints: 50, - penaltyForWrongAnswer: false, - penaltyPercent: 25, - firstCorrectBonusEnabled: false, - firstCorrectBonusPoints: 50, -}; - export interface Quiz { title: string; questions: Question[]; - config?: GameConfig; } export type QuizSource = 'manual' | 'ai_generated'; @@ -71,7 +40,6 @@ export interface SavedQuiz extends Quiz { aiTopic?: string; createdAt: string; updatedAt: string; - config?: GameConfig; } export interface QuizListItem { @@ -96,23 +64,12 @@ export interface GenerateQuizOptions { documents?: ProcessedDocument[]; } -export interface PointsBreakdown { - basePoints: number; - streakBonus: number; - comebackBonus: number; - firstCorrectBonus: number; - penalty: number; - total: number; -} - export interface Player { id: string; name: string; score: number; - previousScore: number; streak: number; lastAnswerCorrect: boolean | null; - pointsBreakdown: PointsBreakdown | null; isBot: boolean; avatarSeed: number; color: string; @@ -137,7 +94,7 @@ export type NetworkMessage = } } | { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } } - | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } } + | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } } | { type: 'TIME_SYNC'; payload: { timeLeft: number } } | { type: 'TIME_UP'; payload: {} } | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }