diff --git a/App.tsx b/App.tsx index 18cecb2..a0096ac 100644 --- a/App.tsx +++ b/App.tsx @@ -2,6 +2,7 @@ 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'; @@ -12,7 +13,7 @@ import { RevealScreen } from './components/RevealScreen'; import { SaveQuizPrompt } from './components/SaveQuizPrompt'; import { QuizEditor } from './components/QuizEditor'; import { SaveOptionsModal } from './components/SaveOptionsModal'; -import type { Quiz } from './types'; +import type { Quiz, GameConfig } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -42,6 +43,7 @@ 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 { @@ -74,7 +76,8 @@ function App() { sourceQuizId, updateQuizFromEditor, startGameFromEditor, - backFromEditor + backFromEditor, + gameConfig } = useGame(); const handleSaveQuiz = async () => { @@ -152,7 +155,8 @@ function App() { onSave={handleEditorSave} onStartGame={startGameFromEditor} onBack={backFromEditor} - sourceQuizId={sourceQuizId} + showSaveButton={auth.isAuthenticated} + defaultConfig={defaultConfig} /> ) : null} @@ -195,6 +199,7 @@ function App() { onAnswer={handleAnswer} hasAnswered={hasAnswered} lastPointsEarned={lastPointsEarned} + hostPlays={gameConfig.hostParticipates} /> ) ) : null} diff --git a/components/DefaultConfigModal.tsx b/components/DefaultConfigModal.tsx new file mode 100644 index 0000000..c2831c1 --- /dev/null +++ b/components/DefaultConfigModal.tsx @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..df29120 --- /dev/null +++ b/components/GameConfigPanel.tsx @@ -0,0 +1,325 @@ +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 c6866fc..060de98 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -13,6 +13,7 @@ interface GameScreenProps { onAnswer: (isCorrect: boolean) => void; hasAnswered: boolean; lastPointsEarned: number | null; + hostPlays?: boolean; } export const GameScreen: React.FC = ({ @@ -24,8 +25,10 @@ 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); @@ -51,7 +54,7 @@ export const GameScreen: React.FC = ({
- {isClient ? 'Controller' : 'Host'} + {isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
@@ -80,12 +83,14 @@ export const GameScreen: React.FC = ({ let opacityClass = "opacity-100"; let scaleClass = "scale-100"; + let cursorClass = ""; - // If answering phase and user answered, dim everything - if (hasAnswered) { + if (isSpectator) { + cursorClass = "cursor-default"; + } else 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} - onClick={() => onAnswer(option as any)} + disabled={hasAnswered || isSpectator} + onClick={() => !isSpectator && onAnswer(option as any)} className={` - ${colorClass} ${opacityClass} ${scaleClass} + ${colorClass} ${opacityClass} ${scaleClass} ${cursorClass} 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 - active:shadow-none active:translate-y-[8px] active:scale-95 + ${!isSpectator ? '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 b82c94d..80628cf 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } 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 type { Quiz } from '../types'; +import { useUserConfig } from '../hooks/useUserConfig'; +import type { Quiz, GameConfig } from '../types'; type GenerateMode = 'topic' | 'document'; @@ -31,10 +33,14 @@ 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, @@ -132,7 +138,19 @@ 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 09b2d28..8cb3157 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -1,41 +1,52 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle } 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 } from '../types'; +import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } 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) => void; + onStartGame: (quiz: Quiz, config: GameConfig) => void; + onConfigChange?: (config: GameConfig) => void; onBack: () => void; showSaveButton?: boolean; isSaving?: boolean; + defaultConfig?: GameConfig; } export const QuizEditor: React.FC = ({ quiz: initialQuiz, onSave, onStartGame, + onConfigChange, onBack, showSaveButton = true, - isSaving + isSaving, + defaultConfig, }) => { 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 [shuffleQuestions, setShuffleQuestions] = useState(false); - const [shuffleAnswers, setShuffleAnswers] = useState(false); + const [config, setConfig] = useState( + initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG + ); useBodyScrollLock(!!showDeleteConfirm); + const handleConfigChange = useCallback((newConfig: GameConfig) => { + setConfig(newConfig); + onConfigChange?.(newConfig); + }, [onConfigChange]); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } @@ -117,11 +128,11 @@ export const QuizEditor: React.FC = ({ const handleStartGame = () => { let questions = [...quiz.questions]; - if (shuffleQuestions) { + if (config.shuffleQuestions) { questions = questions.sort(() => Math.random() - 0.5); } - if (shuffleAnswers) { + if (config.shuffleAnswers) { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; @@ -138,7 +149,7 @@ export const QuizEditor: React.FC = ({ }); } - onStartGame({ ...quiz, questions }); + onStartGame({ ...quiz, questions, config }, config); }; return ( @@ -242,51 +253,12 @@ 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 6f3e583..476eff1 100644 --- a/constants.ts +++ b/constants.ts @@ -1,4 +1,5 @@ import { Triangle, Diamond, Circle, Square } from 'lucide-react'; +import type { GameConfig, Player, PointsBreakdown } from './types'; export const COLORS = { red: 'bg-red-600', @@ -25,6 +26,79 @@ 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 6e96d7d..82544c7 100644 --- a/hooks/useAuthenticatedFetch.ts +++ b/hooks/useAuthenticatedFetch.ts @@ -1,10 +1,13 @@ import { useAuth } from 'react-oidc-context'; -import { useCallback } from 'react'; +import { useCallback, useRef } 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; @@ -14,28 +17,58 @@ 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()) { - 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'); + const newToken = await attemptSilentRefresh(); + if (newToken) { + return newToken; } + redirectToLogin(); + throw new Error('Session expired, redirecting to login'); } return auth.user.access_token; - }, [auth, isTokenExpired]); + }, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]); 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.'); } @@ -61,21 +94,18 @@ export const useAuthenticatedFetch = () => { } if (response.status === 401) { - 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(); + const newToken = await attemptSilentRefresh(); + if (newToken) { + return fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${newToken}`, + 'Content-Type': 'application/json', + }, + }); } + redirectToLogin(); throw new Error('Session expired, redirecting to login'); } @@ -85,7 +115,7 @@ export const useAuthenticatedFetch = () => { return response; }, - [auth, ensureValidToken] + [ensureValidToken, attemptSilentRefresh, redirectToLogin] ); return { diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 76bd4db..97b6faa 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 } from '../types'; +import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { generateQuiz } from '../services/geminiService'; -import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants'; +import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { Peer, DataConnection } from 'peerjs'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; @@ -25,6 +25,8 @@ 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); @@ -36,11 +38,13 @@ 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 + ""; @@ -123,9 +127,10 @@ export const useGame = () => { setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' }); }; - const startGameFromEditor = (finalQuiz: Quiz) => { + const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => { setQuiz(finalQuiz); - initializeHostGame(finalQuiz); + setGameConfig(config); + initializeHostGame(finalQuiz, config.hostParticipates); }; const backFromEditor = () => { @@ -139,7 +144,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) => { + const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => { setQuiz(newQuiz); const pin = generateGamePin(); setGamePin(pin); @@ -148,19 +153,27 @@ export const useGame = () => { peerRef.current = peer; peer.on('open', (id) => { - 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'); + 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); + } setGameState('LOBBY'); }); @@ -186,8 +199,10 @@ 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] @@ -205,15 +220,31 @@ export const useGame = () => { if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return; - const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0; - const newScore = currentPlayer.score + points; + 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); setPlayers(prev => prev.map(p => { if (p.id !== playerId) return p; - return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect }; + return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; })); - conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } }); + conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } }); } }; @@ -256,7 +287,8 @@ export const useGame = () => { setLastPointsEarned(null); setSelectedOption(null); setTimeLeft(QUESTION_TIME_MS); - setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null }))); + setFirstCorrectPlayerId(null); + setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }))); const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; @@ -421,24 +453,44 @@ 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 newScore = (hostPlayer?.score || 0) + points; - const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0; + 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); setCurrentPlayerScore(newScore); setCurrentStreak(newStreak); setPlayers(prev => prev.map(p => { if (p.id !== 'host') return p; - return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect }; + return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; })); } else { const option = arg as AnswerOption; @@ -462,7 +514,7 @@ export const useGame = () => { }, []); return { - role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, + role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, 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 35c8187..2286860 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 } from '../types'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types'; interface UseQuizLibraryReturn { quizzes: QuizListItem[]; @@ -14,6 +14,7 @@ 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; @@ -126,6 +127,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { title: quiz.title, source, aiTopic, + gameConfig: quiz.config, questions: quiz.questions.map(q => ({ text: q.text, timeLimit: q.timeLimit, @@ -170,6 +172,7 @@ 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, @@ -203,6 +206,25 @@ 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); @@ -253,6 +275,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => { loadQuiz, saveQuiz, updateQuiz, + updateQuizConfig, deleteQuiz, retry, clearError, diff --git a/hooks/useUserConfig.ts b/hooks/useUserConfig.ts new file mode 100644 index 0000000..13dbbe0 --- /dev/null +++ b/hooks/useUserConfig.ts @@ -0,0 +1,73 @@ +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 b7bfc69..90b7166 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -16,4 +16,24 @@ 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 new file mode 100644 index 0000000..a079afc --- /dev/null +++ b/server/src/db/migrations.sql @@ -0,0 +1,8 @@ +-- 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 9e4c0dc..9755c3d 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT, display_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_login DATETIME + last_login DATETIME, + default_game_config TEXT ); CREATE TABLE IF NOT EXISTS quizzes ( @@ -13,6 +14,7 @@ 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 25d049a..33f1f3f 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -7,10 +7,26 @@ 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; @@ -44,7 +60,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, created_at as createdAt, updated_at as updatedAt + SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, 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; @@ -78,8 +94,18 @@ 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, }); }); @@ -118,7 +144,7 @@ function validateQuizBody(body: QuizBody): string | null { router.post('/', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; - const { title, source, aiTopic, questions } = body; + const { title, source, aiTopic, gameConfig, questions } = body; const validationError = validateQuizBody(body); if (validationError) { @@ -138,8 +164,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { `); const insertQuiz = db.prepare(` - INSERT INTO quizzes (id, user_id, title, source, ai_topic) - VALUES (?, ?, ?, ?, ?) + INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config) + VALUES (?, ?, ?, ?, ?, ?) `); const insertQuestion = db.prepare(` @@ -160,7 +186,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { req.user!.name || null ); - insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null); + insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null); questions.forEach((q, qIdx) => { const questionId = uuidv4(); @@ -187,7 +213,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { router.put('/:id', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; - const { title, questions } = body; + const { title, questions, gameConfig } = body; const quizId = req.params.id; if (!title?.trim()) { @@ -227,7 +253,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => { } const updateQuiz = db.prepare(` - UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + UPDATE quizzes SET title = ?, game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`); @@ -243,7 +269,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => { `); const transaction = db.transaction(() => { - updateQuiz.run(title, quizId); + updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId); deleteQuestions.run(quizId); questions.forEach((q, qIdx) => { @@ -269,6 +295,26 @@ 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 9e4ece6..24925b5 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, created_at as createdAt, last_login as lastLogin + SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin FROM users WHERE id = ? `).get(req.user!.sub) as Record | undefined; @@ -19,6 +19,7 @@ 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, @@ -26,7 +27,41 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { return; } - res.json({ ...user, isNew: false }); + 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 }); }); export default router; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 8be303f..d4813da 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -1257,6 +1257,271 @@ 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 5e1cd18..edcef00 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -61,12 +61,23 @@ ${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 options: AnswerOption[] = q.options.map((opt: any, index: number) => ({ + const shuffledOpts = shuffleArray(q.options); + + const options: AnswerOption[] = shuffledOpts.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 new file mode 100644 index 0000000..0c73f1a --- /dev/null +++ b/tests/components/DefaultConfigModal.test.tsx @@ -0,0 +1,330 @@ +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 new file mode 100644 index 0000000..8e8359b --- /dev/null +++ b/tests/components/GameConfigPanel.test.tsx @@ -0,0 +1,449 @@ +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 new file mode 100644 index 0000000..1171623 --- /dev/null +++ b/tests/components/QuizEditorConfig.test.tsx @@ -0,0 +1,422 @@ +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 new file mode 100644 index 0000000..b0c6f0d --- /dev/null +++ b/tests/hooks/useUserConfig.test.tsx @@ -0,0 +1,503 @@ +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 84004ea..0733de7 100644 --- a/types.ts +++ b/types.ts @@ -27,9 +27,40 @@ 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'; @@ -40,6 +71,7 @@ export interface SavedQuiz extends Quiz { aiTopic?: string; createdAt: string; updatedAt: string; + config?: GameConfig; } export interface QuizListItem { @@ -64,12 +96,23 @@ 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; @@ -94,7 +137,7 @@ export type NetworkMessage = } } | { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } } - | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } } + | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } } | { type: 'TIME_SYNC'; payload: { timeLeft: number } } | { type: 'TIME_UP'; payload: {} } | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }