Compare commits
2 commits
90fba17a1e
...
fc270d437f
| Author | SHA1 | Date | |
|---|---|---|---|
|
fc270d437f |
|||
|
af21f2bcdc |
24 changed files with 3137 additions and 204 deletions
11
App.tsx
11
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<Quiz | null>(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}
|
||||
|
|
|
|||
95
components/DefaultConfigModal.tsx
Normal file
95
components/DefaultConfigModal.tsx
Normal file
|
|
@ -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<DefaultConfigModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
onSave,
|
||||
saving,
|
||||
}) => {
|
||||
useBodyScrollLock(isOpen);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<div className="p-2 bg-white/20 rounded-xl">
|
||||
<Settings size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">Default Game Settings</h2>
|
||||
<p className="text-sm opacity-80">Applied to all new quizzes</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-gray-50">
|
||||
<GameConfigPanel
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
questionCount={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Defaults'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
325
components/GameConfigPanel.tsx
Normal file
325
components/GameConfigPanel.tsx
Normal file
|
|
@ -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<TooltipProps> = ({ content }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(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 (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={handleClick}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition"
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
{show && (
|
||||
<div
|
||||
className="fixed z-[100] w-64 p-3 text-sm bg-gray-900 text-white rounded-lg shadow-lg"
|
||||
style={{
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-2 h-2 bg-gray-900 rotate-45" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<ToggleRowProps> = ({
|
||||
icon,
|
||||
iconActive,
|
||||
label: labelText,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
tooltip,
|
||||
children,
|
||||
}) => {
|
||||
const inputId = React.useId();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
|
||||
<label htmlFor={inputId} className="flex items-center gap-3 cursor-pointer flex-1">
|
||||
<div className={`p-2 rounded-lg transition ${iconActive ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">{labelText}</p>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{tooltip && <Tooltip content={tooltip} />}
|
||||
<label htmlFor={inputId} className="relative cursor-pointer">
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors" />
|
||||
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{checked && children && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
const NumberInput: React.FC<NumberInputProps> = ({ label, value, onChange, min, max, step = 1, suffix }) => (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => 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 && <span className="text-sm text-gray-500">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
questionCount,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
|
||||
const update = (partial: Partial<GameConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
};
|
||||
|
||||
const suggestedComebackBonus = Math.round(50 + (questionCount * 5));
|
||||
const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5));
|
||||
|
||||
if (compact && !expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 hover:border-theme-primary transition"
|
||||
>
|
||||
<span className="font-bold text-gray-700">Game Settings</span>
|
||||
<ChevronDown size={20} className="text-gray-400" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{compact && (
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-100 rounded-xl hover:bg-gray-200 transition"
|
||||
>
|
||||
<span className="font-bold text-gray-700">Game Settings</span>
|
||||
<ChevronUp size={20} className="text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ToggleRow
|
||||
icon={<Shuffle size={20} />}
|
||||
iconActive={config.shuffleQuestions}
|
||||
label="Shuffle Questions"
|
||||
description="Randomize question order when starting"
|
||||
checked={config.shuffleQuestions}
|
||||
onChange={(v) => update({ shuffleQuestions: v })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Shuffle size={20} />}
|
||||
iconActive={config.shuffleAnswers}
|
||||
label="Shuffle Answers"
|
||||
description="Randomize answer positions for each question"
|
||||
checked={config.shuffleAnswers}
|
||||
onChange={(v) => update({ shuffleAnswers: v })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Eye size={20} />}
|
||||
iconActive={config.hostParticipates}
|
||||
label="Host Participates"
|
||||
description="Join as a player and answer questions"
|
||||
checked={config.hostParticipates}
|
||||
onChange={(v) => update({ hostParticipates: v })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Flame size={20} />}
|
||||
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.`}
|
||||
>
|
||||
<div className="mt-3 space-y-1">
|
||||
<NumberInput
|
||||
label="Streak threshold"
|
||||
value={config.streakThreshold}
|
||||
onChange={(v) => update({ streakThreshold: v })}
|
||||
min={2}
|
||||
max={10}
|
||||
suffix="correct"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Multiplier"
|
||||
value={config.streakMultiplier}
|
||||
onChange={(v) => update({ streakMultiplier: v })}
|
||||
min={1.05}
|
||||
max={2}
|
||||
step={0.05}
|
||||
suffix="x"
|
||||
/>
|
||||
</div>
|
||||
</ToggleRow>
|
||||
|
||||
<ToggleRow
|
||||
icon={<TrendingUp size={20} />}
|
||||
iconActive={config.comebackBonusEnabled}
|
||||
label="Comeback Bonus"
|
||||
description="Extra points for players not in top 3"
|
||||
checked={config.comebackBonusEnabled}
|
||||
onChange={(v) => update({ comebackBonusEnabled: v })}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<NumberInput
|
||||
label="Bonus points"
|
||||
value={config.comebackBonusPoints}
|
||||
onChange={(v) => update({ comebackBonusPoints: v })}
|
||||
min={10}
|
||||
max={500}
|
||||
suffix="pts"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Suggested for {questionCount} questions: {suggestedComebackBonus} pts
|
||||
</p>
|
||||
</div>
|
||||
</ToggleRow>
|
||||
|
||||
<ToggleRow
|
||||
icon={<MinusCircle size={20} />}
|
||||
iconActive={config.penaltyForWrongAnswer}
|
||||
label="Wrong Answer Penalty"
|
||||
description="Deduct points for incorrect answers"
|
||||
checked={config.penaltyForWrongAnswer}
|
||||
onChange={(v) => update({ penaltyForWrongAnswer: v })}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<NumberInput
|
||||
label="Penalty"
|
||||
value={config.penaltyPercent}
|
||||
onChange={(v) => update({ penaltyPercent: v })}
|
||||
min={5}
|
||||
max={100}
|
||||
suffix="%"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts)
|
||||
</p>
|
||||
</div>
|
||||
</ToggleRow>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Award size={20} />}
|
||||
iconActive={config.firstCorrectBonusEnabled}
|
||||
label="First Correct Bonus"
|
||||
description="Extra points for first player to answer correctly"
|
||||
checked={config.firstCorrectBonusEnabled}
|
||||
onChange={(v) => update({ firstCorrectBonusEnabled: v })}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<NumberInput
|
||||
label="Bonus points"
|
||||
value={config.firstCorrectBonusPoints}
|
||||
onChange={(v) => update({ firstCorrectBonusPoints: v })}
|
||||
min={10}
|
||||
max={500}
|
||||
suffix="pts"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts
|
||||
</p>
|
||||
</div>
|
||||
</ToggleRow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ interface GameScreenProps {
|
|||
onAnswer: (isCorrect: boolean) => void;
|
||||
hasAnswered: boolean;
|
||||
lastPointsEarned: number | null;
|
||||
hostPlays?: boolean;
|
||||
}
|
||||
|
||||
export const GameScreen: React.FC<GameScreenProps> = ({
|
||||
|
|
@ -24,8 +25,10 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
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<GameScreenProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
|
||||
{isClient ? 'Controller' : 'Host'}
|
||||
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -80,12 +83,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
|
||||
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 (
|
||||
<motion.button
|
||||
|
|
@ -93,14 +98,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
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
|
||||
`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<LandingProps> = ({ 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<GameConfig | null>(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<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingDefaultConfig(defaultConfig);
|
||||
setDefaultConfigOpen(true);
|
||||
}}
|
||||
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
|
||||
title="Default Game Settings"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
)}
|
||||
<AuthButton />
|
||||
</div>
|
||||
<motion.div
|
||||
|
|
@ -416,6 +434,24 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
onDeleteQuiz={deleteQuiz}
|
||||
onRetry={retryLibrary}
|
||||
/>
|
||||
|
||||
<DefaultConfigModal
|
||||
isOpen={defaultConfigOpen}
|
||||
onClose={() => {
|
||||
setDefaultConfigOpen(false);
|
||||
setEditingDefaultConfig(null);
|
||||
}}
|
||||
config={editingDefaultConfig || defaultConfig}
|
||||
onChange={setEditingDefaultConfig}
|
||||
onSave={async () => {
|
||||
if (editingDefaultConfig) {
|
||||
await saveDefaultConfig(editingDefaultConfig);
|
||||
setDefaultConfigOpen(false);
|
||||
setEditingDefaultConfig(null);
|
||||
}
|
||||
}}
|
||||
saving={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<QuizEditorProps> = ({
|
||||
quiz: initialQuiz,
|
||||
onSave,
|
||||
onStartGame,
|
||||
onConfigChange,
|
||||
onBack,
|
||||
showSaveButton = true,
|
||||
isSaving
|
||||
isSaving,
|
||||
defaultConfig,
|
||||
}) => {
|
||||
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||
const [shuffleAnswers, setShuffleAnswers] = useState(false);
|
||||
const [config, setConfig] = useState<GameConfig>(
|
||||
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<QuizEditorProps> = ({
|
|||
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<QuizEditorProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
onStartGame({ ...quiz, questions });
|
||||
onStartGame({ ...quiz, questions, config }, config);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -242,51 +253,12 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg transition ${shuffleQuestions ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||
<Shuffle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">Shuffle Questions</p>
|
||||
<p className="text-sm text-gray-500">Randomize question order when starting</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shuffleQuestions}
|
||||
onChange={(e) => setShuffleQuestions(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
||||
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg transition ${shuffleAnswers ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||
<Shuffle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">Shuffle Answers</p>
|
||||
<p className="text-sm text-gray-500">Randomize answer positions for each question</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shuffleAnswers}
|
||||
onChange={(e) => setShuffleAnswers(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
|
||||
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<GameConfigPanel
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
questionCount={quiz.questions.length}
|
||||
compact
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleStartGame}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion, useSpring, useTransform } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Player, PointsBreakdown } from '../types';
|
||||
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
|
||||
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
|
||||
import { PlayerAvatar } from './PlayerAvatar';
|
||||
|
||||
const AnimatedScoreLabel: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
value: number;
|
||||
}> = ({ x, y, width, value }) => {
|
||||
const spring = useSpring(0, { duration: 500 });
|
||||
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
|
||||
const spring = useSpring(0, { duration });
|
||||
const display = useTransform(spring, (latest) => Math.round(latest));
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
|
|
@ -21,18 +15,169 @@ const AnimatedScoreLabel: React.FC<{
|
|||
return () => unsubscribe();
|
||||
}, [value, spring, display]);
|
||||
|
||||
return <span>{displayValue}</span>;
|
||||
};
|
||||
|
||||
interface BonusBadgeProps {
|
||||
points: number;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const BonusBadge: React.FC<BonusBadgeProps> = ({ points, label, icon, color, delay }) => (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-white font-bold text-sm ${color}`}
|
||||
>
|
||||
{icon}
|
||||
<span>+{points}</span>
|
||||
<span className="text-white/80">{label}</span>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-500 text-white font-bold text-sm"
|
||||
>
|
||||
<X size={14} />
|
||||
<span>-{points}</span>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
interface PlayerRowProps {
|
||||
player: Player & { displayName: string };
|
||||
index: number;
|
||||
maxScore: number;
|
||||
}
|
||||
|
||||
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
|
||||
const [phase, setPhase] = useState(0);
|
||||
const breakdown = player.pointsBreakdown;
|
||||
const baseDelay = index * 0.3;
|
||||
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000));
|
||||
if (breakdown) {
|
||||
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000));
|
||||
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000));
|
||||
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
|
||||
}
|
||||
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [baseDelay, breakdown]);
|
||||
|
||||
const getDisplayScore = () => {
|
||||
if (!breakdown) return player.previousScore;
|
||||
|
||||
let score = player.previousScore;
|
||||
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty;
|
||||
if (phase >= 2) score += breakdown.streakBonus;
|
||||
if (phase >= 3) score += breakdown.comebackBonus;
|
||||
if (phase >= 4) score += breakdown.firstCorrectBonus;
|
||||
return Math.max(0, score);
|
||||
};
|
||||
|
||||
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x + width + 15}
|
||||
y={y}
|
||||
dy={35}
|
||||
fill="black"
|
||||
fontSize={24}
|
||||
fontWeight={900}
|
||||
fontFamily="Fredoka"
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: baseDelay, duration: 0.4 }}
|
||||
className="flex items-center gap-4 py-3"
|
||||
>
|
||||
{displayValue}
|
||||
</text>
|
||||
<div className="flex items-center gap-3 w-48 shrink-0">
|
||||
<PlayerAvatar seed={player.avatarSeed} size={32} />
|
||||
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="flex-1 h-12 bg-gray-100 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: player.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-20 text-right">
|
||||
<span className="font-black text-2xl font-display">
|
||||
<AnimatedNumber value={getDisplayScore()} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-[280px] justify-start">
|
||||
<AnimatePresence>
|
||||
{breakdown === null && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: baseDelay + 0.3 }}
|
||||
className="text-gray-400 font-medium text-sm"
|
||||
>
|
||||
No answer
|
||||
</motion.span>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
|
||||
<PenaltyBadge points={breakdown.penalty} delay={0} />
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm"
|
||||
>
|
||||
<span>+{breakdown.basePoints}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
|
||||
<BonusBadge
|
||||
points={breakdown.streakBonus}
|
||||
label="Streak"
|
||||
icon={<Flame size={14} />}
|
||||
color="bg-amber-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
|
||||
<BonusBadge
|
||||
points={breakdown.comebackBonus}
|
||||
label="Comeback"
|
||||
icon={<Rocket size={14} />}
|
||||
color="bg-blue-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
|
||||
<BonusBadge
|
||||
points={breakdown.firstCorrectBonus}
|
||||
label="First!"
|
||||
icon={<Zap size={14} />}
|
||||
color="bg-yellow-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -49,59 +194,23 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
|
|||
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
||||
}));
|
||||
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-8">
|
||||
<header className="text-center mb-12">
|
||||
<header className="text-center mb-8">
|
||||
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50">
|
||||
<div className="flex flex-col justify-around py-4 pr-4">
|
||||
{sortedPlayers.map((player) => (
|
||||
<div key={player.id} className="flex items-center gap-3 h-[50px]">
|
||||
<PlayerAvatar seed={player.avatarSeed} size={24} />
|
||||
<span className="font-black text-xl font-display whitespace-nowrap">{player.displayName}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-8 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-8 border-white/50 overflow-hidden">
|
||||
<div className="space-y-2">
|
||||
{sortedPlayers.map((player, index) => (
|
||||
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sortedPlayers}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 100, left: 0, bottom: 5 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="displayName" hide />
|
||||
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
|
||||
{sortedPlayers.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
className="filter drop-shadow-md"
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="score"
|
||||
position="right"
|
||||
offset={15}
|
||||
content={({ x, y, width, value }) => (
|
||||
<AnimatedScoreLabel
|
||||
x={x as number}
|
||||
y={y as number}
|
||||
width={width as number}
|
||||
value={value as number}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto">
|
||||
<div className="mt-8 flex justify-end max-w-5xl w-full mx-auto">
|
||||
{isHost ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
|
|
@ -111,11 +220,11 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
|
|||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 bg-white/10 px-8 py-4 rounded-2xl backdrop-blur-md border-2 border-white/20">
|
||||
<Loader2 className="animate-spin w-8 h-8" />
|
||||
<span className="text-xl font-bold">Waiting for host...</span>
|
||||
<Loader2 className="animate-spin w-8 h-8" />
|
||||
<span className="text-xl font-bold">Waiting for host...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
74
constants.ts
74
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',
|
||||
|
|
|
|||
|
|
@ -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<Promise<string> | 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<string | null> => {
|
||||
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<string>;
|
||||
|
||||
return silentRefreshInProgress.current;
|
||||
}, [auth]);
|
||||
|
||||
const ensureValidToken = useCallback(async (): Promise<string> => {
|
||||
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<Response> => {
|
||||
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 {
|
||||
|
|
|
|||
110
hooks/useGame.ts
110
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<string | null>(null);
|
||||
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
||||
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
|
||||
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const peerRef = useRef<Peer | null>(null);
|
||||
|
|
@ -36,11 +38,13 @@ export const useGame = () => {
|
|||
const playersRef = useRef<Player[]>([]);
|
||||
const currentQuestionIndexRef = useRef(0);
|
||||
const quizRef = useRef<Quiz | null>(null);
|
||||
const gameConfigRef = useRef<GameConfig>(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
|
||||
|
|
|
|||
|
|
@ -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<SavedQuiz>;
|
||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
||||
deleteQuiz: (id: string) => Promise<void>;
|
||||
retry: () => Promise<void>;
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
setDeletingQuizId(id);
|
||||
setError(null);
|
||||
|
|
@ -253,6 +275,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
loadQuiz,
|
||||
saveQuiz,
|
||||
updateQuiz,
|
||||
updateQuizConfig,
|
||||
deleteQuiz,
|
||||
retry,
|
||||
clearError,
|
||||
|
|
|
|||
73
hooks/useUserConfig.ts
Normal file
73
hooks/useUserConfig.ts
Normal file
|
|
@ -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<void>;
|
||||
saveDefaultConfig: (config: GameConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserConfig = (): UseUserConfigReturn => {
|
||||
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
||||
const [defaultConfig, setDefaultConfig] = useState<GameConfig>(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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
8
server/src/db/migrations.sql
Normal file
8
server/src/db/migrations.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 = ?
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
if (!quiz.gameConfig) throw new Error('Missing gameConfig');
|
||||
const config = quiz.gameConfig as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const config = quiz.gameConfig as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const config = quiz.gameConfig as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const config = user.defaultGameConfig as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const config = user.defaultGameConfig as Record<string, unknown>;
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -61,12 +61,23 @@ ${baseInstructions}`;
|
|||
${baseInstructions}`;
|
||||
}
|
||||
|
||||
function shuffleArray<T>(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],
|
||||
|
|
|
|||
330
tests/components/DefaultConfigModal.test.tsx
Normal file
330
tests/components/DefaultConfigModal.test.tsx
Normal file
|
|
@ -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(<DefaultConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Default Game Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays subtitle explaining the settings', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Applied to all new quizzes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GameConfigPanel with config', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host Participates')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Cancel button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Save Defaults button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close X button', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
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(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onChange when config is modified', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
const saveButton = screen.getByText('Saving...').closest('button');
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Saving... text when saving', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles rapid clicks on Save button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} isOpen={true} />);
|
||||
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(<DefaultConfigModal {...defaultProps} config={customConfig} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
|
||||
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates display when config prop changes', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(false);
|
||||
|
||||
const newConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
|
||||
rerender(<DefaultConfigModal {...defaultProps} config={newConfig} />);
|
||||
|
||||
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions', () => {
|
||||
it('transitions from not saving to saving correctly', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={false} />);
|
||||
|
||||
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('transitions from saving back to not saving', () => {
|
||||
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={true} />);
|
||||
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
|
||||
rerender(<DefaultConfigModal {...defaultProps} saving={false} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} config={customConfig} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={false} />);
|
||||
rerender(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={true} />);
|
||||
|
||||
expect(getCheckbox().checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('all interactive elements are focusable', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).not.toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('modal traps focus within when open', () => {
|
||||
render(<DefaultConfigModal {...defaultProps} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
|
||||
|
||||
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(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
|
||||
|
||||
await user.click(screen.getByText('Save Defaults'));
|
||||
|
||||
expect(asyncOnSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
449
tests/components/GameConfigPanel.test.tsx
Normal file
449
tests/components/GameConfigPanel.test.tsx
Normal file
|
|
@ -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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} compact />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} compact />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} compact />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
expect(screen.getByText('Bonus points')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows penalty settings when penaltyForWrongAnswer enabled', () => {
|
||||
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
|
||||
render(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
expect(screen.getByText('Penalty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates streakThreshold value', async () => {
|
||||
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
|
||||
render(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
|
||||
|
||||
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(
|
||||
<GameConfigPanel {...defaultProps} config={config} questionCount={10} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GameConfigPanel {...defaultProps} config={config} questionCount={20} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={config} />);
|
||||
|
||||
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
|
||||
|
||||
rerender(<GameConfigPanel {...defaultProps} config={{ ...config, streakBonusEnabled: false }} />);
|
||||
|
||||
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip interactions', () => {
|
||||
it('shows tooltip on hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={customConfig} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />);
|
||||
|
||||
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(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />);
|
||||
|
||||
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Bonus points')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Penalty')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
422
tests/components/QuizEditorConfig.test.tsx
Normal file
422
tests/components/QuizEditorConfig.test.tsx
Normal file
|
|
@ -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>): 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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} defaultConfig={customDefault} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={quizWithConfig} defaultConfig={customDefault} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={multiQuestionQuiz} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={fullConfigQuiz} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} onConfigChange={undefined} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={emptyQuiz} />);
|
||||
|
||||
const startButton = screen.getByText(/Start Game/).closest('button');
|
||||
expect(startButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles quiz without title', () => {
|
||||
const noTitleQuiz = createMockQuiz({ title: '' });
|
||||
|
||||
render(<QuizEditor {...defaultProps} quiz={noTitleQuiz} />);
|
||||
|
||||
expect(screen.getByText('Untitled Quiz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigChange callback timing', () => {
|
||||
it('calls onConfigChange immediately when toggle changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuizEditor {...defaultProps} />);
|
||||
|
||||
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(<QuizEditor {...defaultProps} quiz={quizWithStreak} />);
|
||||
|
||||
await user.click(screen.getByText('Game Settings'));
|
||||
|
||||
const thresholdInput = screen.getByDisplayValue('3');
|
||||
await user.clear(thresholdInput);
|
||||
await user.type(thresholdInput, '5');
|
||||
|
||||
expect(mockOnConfigChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
503
tests/hooks/useUserConfig.test.tsx
Normal file
503
tests/hooks/useUserConfig.test.tsx
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
45
types.ts
45
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[] } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue