feat: add comprehensive game configuration system
Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
This commit is contained in:
parent
90fba17a1e
commit
af21f2bcdc
23 changed files with 2925 additions and 133 deletions
11
App.tsx
11
App.tsx
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { useGame } from './hooks/useGame';
|
import { useGame } from './hooks/useGame';
|
||||||
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
||||||
|
import { useUserConfig } from './hooks/useUserConfig';
|
||||||
import { Landing } from './components/Landing';
|
import { Landing } from './components/Landing';
|
||||||
import { Lobby } from './components/Lobby';
|
import { Lobby } from './components/Lobby';
|
||||||
import { GameScreen } from './components/GameScreen';
|
import { GameScreen } from './components/GameScreen';
|
||||||
|
|
@ -12,7 +13,7 @@ import { RevealScreen } from './components/RevealScreen';
|
||||||
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
||||||
import { QuizEditor } from './components/QuizEditor';
|
import { QuizEditor } from './components/QuizEditor';
|
||||||
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
||||||
import type { Quiz } from './types';
|
import type { Quiz, GameConfig } from './types';
|
||||||
|
|
||||||
const seededRandom = (seed: number) => {
|
const seededRandom = (seed: number) => {
|
||||||
const x = Math.sin(seed * 9999) * 10000;
|
const x = Math.sin(seed * 9999) * 10000;
|
||||||
|
|
@ -42,6 +43,7 @@ const FloatingShapes = React.memo(() => {
|
||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
||||||
|
const { defaultConfig } = useUserConfig();
|
||||||
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
||||||
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
|
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
|
||||||
const {
|
const {
|
||||||
|
|
@ -74,7 +76,8 @@ function App() {
|
||||||
sourceQuizId,
|
sourceQuizId,
|
||||||
updateQuizFromEditor,
|
updateQuizFromEditor,
|
||||||
startGameFromEditor,
|
startGameFromEditor,
|
||||||
backFromEditor
|
backFromEditor,
|
||||||
|
gameConfig
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
|
||||||
const handleSaveQuiz = async () => {
|
const handleSaveQuiz = async () => {
|
||||||
|
|
@ -152,7 +155,8 @@ function App() {
|
||||||
onSave={handleEditorSave}
|
onSave={handleEditorSave}
|
||||||
onStartGame={startGameFromEditor}
|
onStartGame={startGameFromEditor}
|
||||||
onBack={backFromEditor}
|
onBack={backFromEditor}
|
||||||
sourceQuizId={sourceQuizId}
|
showSaveButton={auth.isAuthenticated}
|
||||||
|
defaultConfig={defaultConfig}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -195,6 +199,7 @@ function App() {
|
||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
hasAnswered={hasAnswered}
|
hasAnswered={hasAnswered}
|
||||||
lastPointsEarned={lastPointsEarned}
|
lastPointsEarned={lastPointsEarned}
|
||||||
|
hostPlays={gameConfig.hostParticipates}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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;
|
onAnswer: (isCorrect: boolean) => void;
|
||||||
hasAnswered: boolean;
|
hasAnswered: boolean;
|
||||||
lastPointsEarned: number | null;
|
lastPointsEarned: number | null;
|
||||||
|
hostPlays?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameScreen: React.FC<GameScreenProps> = ({
|
export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
|
|
@ -24,8 +25,10 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
role,
|
role,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
hasAnswered,
|
hasAnswered,
|
||||||
|
hostPlays = true,
|
||||||
}) => {
|
}) => {
|
||||||
const isClient = role === 'CLIENT';
|
const isClient = role === 'CLIENT';
|
||||||
|
const isSpectator = role === 'HOST' && !hostPlays;
|
||||||
const displayOptions = question?.options || [];
|
const displayOptions = question?.options || [];
|
||||||
const timeLeftSeconds = Math.ceil(timeLeft / 1000);
|
const timeLeftSeconds = Math.ceil(timeLeft / 1000);
|
||||||
|
|
||||||
|
|
@ -51,7 +54,7 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
</div>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,9 +83,11 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
|
|
||||||
let opacityClass = "opacity-100";
|
let opacityClass = "opacity-100";
|
||||||
let scaleClass = "scale-100";
|
let scaleClass = "scale-100";
|
||||||
|
let cursorClass = "";
|
||||||
|
|
||||||
// If answering phase and user answered, dim everything
|
if (isSpectator) {
|
||||||
if (hasAnswered) {
|
cursorClass = "cursor-default";
|
||||||
|
} else if (hasAnswered) {
|
||||||
opacityClass = "opacity-50 cursor-not-allowed grayscale";
|
opacityClass = "opacity-50 cursor-not-allowed grayscale";
|
||||||
scaleClass = "scale-95";
|
scaleClass = "scale-95";
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +98,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
||||||
initial={{ y: 50, opacity: 0 }}
|
initial={{ y: 50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
|
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered || isSpectator}
|
||||||
onClick={() => onAnswer(option as any)}
|
onClick={() => !isSpectator && onAnswer(option as any)}
|
||||||
className={`
|
className={`
|
||||||
${colorClass} ${opacityClass} ${scaleClass}
|
${colorClass} ${opacityClass} ${scaleClass} ${cursorClass}
|
||||||
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
|
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
|
flex flex-col md:flex-row items-center justify-center md:justify-start
|
||||||
p-4 md:p-8
|
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
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { useAuth } from 'react-oidc-context';
|
||||||
import { AuthButton } from './AuthButton';
|
import { AuthButton } from './AuthButton';
|
||||||
import { QuizLibrary } from './QuizLibrary';
|
import { QuizLibrary } from './QuizLibrary';
|
||||||
|
import { DefaultConfigModal } from './DefaultConfigModal';
|
||||||
import { useQuizLibrary } from '../hooks/useQuizLibrary';
|
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';
|
type GenerateMode = 'topic' | 'document';
|
||||||
|
|
||||||
|
|
@ -31,11 +33,15 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
const [questionCount, setQuestionCount] = useState(10);
|
const [questionCount, setQuestionCount] = useState(10);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [useOcr, setUseOcr] = 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 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 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 showOcrOption = hasImageFile || hasDocumentFile;
|
||||||
|
|
||||||
|
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
quizzes,
|
quizzes,
|
||||||
loading: libraryLoading,
|
loading: libraryLoading,
|
||||||
|
|
@ -132,7 +138,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
<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 />
|
<AuthButton />
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -416,6 +434,24 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
onDeleteQuiz={deleteQuiz}
|
onDeleteQuiz={deleteQuiz}
|
||||||
onRetry={retryLibrary}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,52 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
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 { SortableQuestionCard } from './SortableQuestionCard';
|
||||||
import { QuestionEditModal } from './QuestionEditModal';
|
import { QuestionEditModal } from './QuestionEditModal';
|
||||||
|
import { GameConfigPanel } from './GameConfigPanel';
|
||||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
interface QuizEditorProps {
|
interface QuizEditorProps {
|
||||||
quiz: Quiz;
|
quiz: Quiz;
|
||||||
onSave: (quiz: Quiz) => void;
|
onSave: (quiz: Quiz) => void;
|
||||||
onStartGame: (quiz: Quiz) => void;
|
onStartGame: (quiz: Quiz, config: GameConfig) => void;
|
||||||
|
onConfigChange?: (config: GameConfig) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
showSaveButton?: boolean;
|
showSaveButton?: boolean;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
|
defaultConfig?: GameConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
quiz: initialQuiz,
|
quiz: initialQuiz,
|
||||||
onSave,
|
onSave,
|
||||||
onStartGame,
|
onStartGame,
|
||||||
|
onConfigChange,
|
||||||
onBack,
|
onBack,
|
||||||
showSaveButton = true,
|
showSaveButton = true,
|
||||||
isSaving
|
isSaving,
|
||||||
|
defaultConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
const [titleEditing, setTitleEditing] = useState(false);
|
||||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
const [config, setConfig] = useState<GameConfig>(
|
||||||
const [shuffleAnswers, setShuffleAnswers] = useState(false);
|
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
|
||||||
|
);
|
||||||
|
|
||||||
useBodyScrollLock(!!showDeleteConfirm);
|
useBodyScrollLock(!!showDeleteConfirm);
|
||||||
|
|
||||||
|
const handleConfigChange = useCallback((newConfig: GameConfig) => {
|
||||||
|
setConfig(newConfig);
|
||||||
|
onConfigChange?.(newConfig);
|
||||||
|
}, [onConfigChange]);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 }
|
activationConstraint: { distance: 8 }
|
||||||
|
|
@ -117,11 +128,11 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
const handleStartGame = () => {
|
const handleStartGame = () => {
|
||||||
let questions = [...quiz.questions];
|
let questions = [...quiz.questions];
|
||||||
|
|
||||||
if (shuffleQuestions) {
|
if (config.shuffleQuestions) {
|
||||||
questions = questions.sort(() => Math.random() - 0.5);
|
questions = questions.sort(() => Math.random() - 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shuffleAnswers) {
|
if (config.shuffleAnswers) {
|
||||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||||
const colors = ['red', 'blue', 'yellow', 'green'] 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 (
|
return (
|
||||||
|
|
@ -242,51 +253,12 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
|
||||||
<div className="space-y-2">
|
<GameConfigPanel
|
||||||
<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">
|
config={config}
|
||||||
<div className="flex items-center gap-3">
|
onChange={handleConfigChange}
|
||||||
<div className={`p-2 rounded-lg transition ${shuffleQuestions ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
questionCount={quiz.questions.length}
|
||||||
<Shuffle size={20} />
|
compact
|
||||||
</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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStartGame}
|
onClick={handleStartGame}
|
||||||
|
|
|
||||||
57
constants.ts
57
constants.ts
|
|
@ -1,4 +1,5 @@
|
||||||
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
|
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
|
||||||
|
import type { GameConfig, Player } from './types';
|
||||||
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
red: 'bg-red-600',
|
red: 'bg-red-600',
|
||||||
|
|
@ -25,6 +26,62 @@ export const QUESTION_TIME = 20; // seconds
|
||||||
export const QUESTION_TIME_MS = 20000; // milliseconds
|
export const QUESTION_TIME_MS = 20000; // milliseconds
|
||||||
export const POINTS_PER_QUESTION = 1000;
|
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 calculatePoints = (params: PointsCalculationParams): number => {
|
||||||
|
const { isCorrect, timeLeftMs, questionTimeMs, streak, playerRank, isFirstCorrect, config } = params;
|
||||||
|
|
||||||
|
if (!isCorrect) {
|
||||||
|
if (config.penaltyForWrongAnswer) {
|
||||||
|
return -Math.round(POINTS_PER_QUESTION * (config.penaltyPercent / 100));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
|
||||||
|
if (config.streakBonusEnabled && streak >= config.streakThreshold) {
|
||||||
|
const streakBonus = streak - config.streakThreshold;
|
||||||
|
const multiplier = config.streakMultiplier + (streakBonus * (config.streakMultiplier - 1));
|
||||||
|
points = Math.round(points * multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.comebackBonusEnabled && playerRank > 3) {
|
||||||
|
points += config.comebackBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.firstCorrectBonusEnabled && isFirstCorrect) {
|
||||||
|
points += config.firstCorrectBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = [
|
export const PLAYER_COLORS = [
|
||||||
'#2563eb',
|
'#2563eb',
|
||||||
'#e21b3c',
|
'#e21b3c',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useAuth } from 'react-oidc-context';
|
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';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
let isRedirecting = false;
|
||||||
|
|
||||||
export const useAuthenticatedFetch = () => {
|
export const useAuthenticatedFetch = () => {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const silentRefreshInProgress = useRef<Promise<string> | null>(null);
|
||||||
|
|
||||||
const isTokenExpired = useCallback(() => {
|
const isTokenExpired = useCallback(() => {
|
||||||
if (!auth.user?.expires_at) return true;
|
if (!auth.user?.expires_at) return true;
|
||||||
|
|
@ -14,28 +17,58 @@ export const useAuthenticatedFetch = () => {
|
||||||
return now >= expiresAt - bufferMs;
|
return now >= expiresAt - bufferMs;
|
||||||
}, [auth.user?.expires_at]);
|
}, [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> => {
|
const ensureValidToken = useCallback(async (): Promise<string> => {
|
||||||
|
if (isRedirecting) {
|
||||||
|
throw new Error('Session expired, redirecting to login');
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.user?.access_token) {
|
if (!auth.user?.access_token) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTokenExpired()) {
|
if (isTokenExpired()) {
|
||||||
try {
|
const newToken = await attemptSilentRefresh();
|
||||||
const user = await auth.signinSilent();
|
if (newToken) {
|
||||||
if (user?.access_token) {
|
return newToken;
|
||||||
return user.access_token;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
auth.signinRedirect();
|
|
||||||
throw new Error('Session expired, redirecting to login');
|
|
||||||
}
|
}
|
||||||
|
redirectToLogin();
|
||||||
|
throw new Error('Session expired, redirecting to login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth.user.access_token;
|
return auth.user.access_token;
|
||||||
}, [auth, isTokenExpired]);
|
}, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]);
|
||||||
|
|
||||||
const authFetch = useCallback(
|
const authFetch = useCallback(
|
||||||
async (path: string, options: RequestInit = {}): Promise<Response> => {
|
async (path: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
|
if (isRedirecting) {
|
||||||
|
throw new Error('Session expired, redirecting to login');
|
||||||
|
}
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
throw new Error('You appear to be offline. Please check your connection.');
|
throw new Error('You appear to be offline. Please check your connection.');
|
||||||
}
|
}
|
||||||
|
|
@ -61,21 +94,18 @@ export const useAuthenticatedFetch = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
try {
|
const newToken = await attemptSilentRefresh();
|
||||||
const user = await auth.signinSilent();
|
if (newToken) {
|
||||||
if (user?.access_token) {
|
return fetch(url, {
|
||||||
return fetch(url, {
|
...options,
|
||||||
...options,
|
headers: {
|
||||||
headers: {
|
...options.headers,
|
||||||
...options.headers,
|
Authorization: `Bearer ${newToken}`,
|
||||||
Authorization: `Bearer ${user.access_token}`,
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
auth.signinRedirect();
|
|
||||||
}
|
}
|
||||||
|
redirectToLogin();
|
||||||
throw new Error('Session expired, redirecting to login');
|
throw new Error('Session expired, redirecting to login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +115,7 @@ export const useAuthenticatedFetch = () => {
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[auth, ensureValidToken]
|
[ensureValidToken, attemptSilentRefresh, redirectToLogin]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
100
hooks/useGame.ts
100
hooks/useGame.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 } from '../types';
|
||||||
import { generateQuiz } from '../services/geminiService';
|
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, calculatePoints, getPlayerRank } from '../constants';
|
||||||
import { Peer, DataConnection } from 'peerjs';
|
import { Peer, DataConnection } from 'peerjs';
|
||||||
|
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
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 [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
|
||||||
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
||||||
const [sourceQuizId, setSourceQuizId] = useState<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 timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const peerRef = useRef<Peer | null>(null);
|
const peerRef = useRef<Peer | null>(null);
|
||||||
|
|
@ -36,11 +38,13 @@ export const useGame = () => {
|
||||||
const playersRef = useRef<Player[]>([]);
|
const playersRef = useRef<Player[]>([]);
|
||||||
const currentQuestionIndexRef = useRef(0);
|
const currentQuestionIndexRef = useRef(0);
|
||||||
const quizRef = useRef<Quiz | null>(null);
|
const quizRef = useRef<Quiz | null>(null);
|
||||||
|
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||||
|
|
||||||
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
||||||
useEffect(() => { playersRef.current = players; }, [players]);
|
useEffect(() => { playersRef.current = players; }, [players]);
|
||||||
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
|
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
|
||||||
useEffect(() => { quizRef.current = quiz; }, [quiz]);
|
useEffect(() => { quizRef.current = quiz; }, [quiz]);
|
||||||
|
useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]);
|
||||||
|
|
||||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||||
|
|
||||||
|
|
@ -123,9 +127,10 @@ export const useGame = () => {
|
||||||
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const startGameFromEditor = (finalQuiz: Quiz) => {
|
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
|
||||||
setQuiz(finalQuiz);
|
setQuiz(finalQuiz);
|
||||||
initializeHostGame(finalQuiz);
|
setGameConfig(config);
|
||||||
|
initializeHostGame(finalQuiz, config.hostParticipates);
|
||||||
};
|
};
|
||||||
|
|
||||||
const backFromEditor = () => {
|
const backFromEditor = () => {
|
||||||
|
|
@ -139,7 +144,7 @@ export const useGame = () => {
|
||||||
// This prevents stale closures in the PeerJS event listeners
|
// This prevents stale closures in the PeerJS event listeners
|
||||||
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
||||||
|
|
||||||
const initializeHostGame = (newQuiz: Quiz) => {
|
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
|
||||||
setQuiz(newQuiz);
|
setQuiz(newQuiz);
|
||||||
const pin = generateGamePin();
|
const pin = generateGamePin();
|
||||||
setGamePin(pin);
|
setGamePin(pin);
|
||||||
|
|
@ -148,19 +153,25 @@ export const useGame = () => {
|
||||||
peerRef.current = peer;
|
peerRef.current = peer;
|
||||||
|
|
||||||
peer.on('open', (id) => {
|
peer.on('open', (id) => {
|
||||||
const hostPlayer: Player = {
|
if (hostParticipates) {
|
||||||
id: 'host',
|
const hostPlayer: Player = {
|
||||||
name: 'Host',
|
id: 'host',
|
||||||
score: 0,
|
name: 'Host',
|
||||||
streak: 0,
|
score: 0,
|
||||||
lastAnswerCorrect: null,
|
streak: 0,
|
||||||
isBot: false,
|
lastAnswerCorrect: null,
|
||||||
avatarSeed: Math.random(),
|
isBot: false,
|
||||||
color: PLAYER_COLORS[0]
|
avatarSeed: Math.random(),
|
||||||
};
|
color: PLAYER_COLORS[0]
|
||||||
setPlayers([hostPlayer]);
|
};
|
||||||
setCurrentPlayerId('host');
|
setPlayers([hostPlayer]);
|
||||||
setCurrentPlayerName('Host');
|
setCurrentPlayerId('host');
|
||||||
|
setCurrentPlayerName('Host');
|
||||||
|
} else {
|
||||||
|
setPlayers([]);
|
||||||
|
setCurrentPlayerId(null);
|
||||||
|
setCurrentPlayerName(null);
|
||||||
|
}
|
||||||
setGameState('LOBBY');
|
setGameState('LOBBY');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -205,12 +216,28 @@ export const useGame = () => {
|
||||||
|
|
||||||
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
|
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
|
||||||
|
|
||||||
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
|
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
||||||
const newScore = currentPlayer.score + points;
|
if (isFirstCorrect) {
|
||||||
|
setFirstCorrectPlayerId(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStreak = isCorrect ? currentPlayer.streak + 1 : 0;
|
||||||
|
const playerRank = getPlayerRank(playerId, playersRef.current);
|
||||||
|
|
||||||
|
const points = calculatePoints({
|
||||||
|
isCorrect,
|
||||||
|
timeLeftMs: timeLeftRef.current,
|
||||||
|
questionTimeMs: QUESTION_TIME_MS,
|
||||||
|
streak: newStreak,
|
||||||
|
playerRank,
|
||||||
|
isFirstCorrect,
|
||||||
|
config: gameConfigRef.current,
|
||||||
|
});
|
||||||
|
const newScore = Math.max(0, currentPlayer.score + points);
|
||||||
|
|
||||||
setPlayers(prev => prev.map(p => {
|
setPlayers(prev => prev.map(p => {
|
||||||
if (p.id !== playerId) return p;
|
if (p.id !== playerId) return p;
|
||||||
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
|
return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
|
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
|
||||||
|
|
@ -256,6 +283,7 @@ export const useGame = () => {
|
||||||
setLastPointsEarned(null);
|
setLastPointsEarned(null);
|
||||||
setSelectedOption(null);
|
setSelectedOption(null);
|
||||||
setTimeLeft(QUESTION_TIME_MS);
|
setTimeLeft(QUESTION_TIME_MS);
|
||||||
|
setFirstCorrectPlayerId(null);
|
||||||
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
|
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
|
||||||
|
|
||||||
const currentQuiz = quizRef.current;
|
const currentQuiz = quizRef.current;
|
||||||
|
|
@ -421,18 +449,38 @@ export const useGame = () => {
|
||||||
|
|
||||||
const handleAnswer = (arg: boolean | AnswerOption) => {
|
const handleAnswer = (arg: boolean | AnswerOption) => {
|
||||||
if (hasAnswered || gameState !== 'QUESTION') return;
|
if (hasAnswered || gameState !== 'QUESTION') return;
|
||||||
|
if (role === 'HOST' && !gameConfigRef.current.hostParticipates) return;
|
||||||
|
|
||||||
setHasAnswered(true);
|
setHasAnswered(true);
|
||||||
|
|
||||||
if (role === 'HOST') {
|
if (role === 'HOST') {
|
||||||
const option = arg as AnswerOption;
|
const option = arg as AnswerOption;
|
||||||
const isCorrect = option.isCorrect;
|
const isCorrect = option.isCorrect;
|
||||||
setSelectedOption(option);
|
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 hostPlayer = playersRef.current.find(p => p.id === 'host');
|
||||||
const newScore = (hostPlayer?.score || 0) + points;
|
const currentStrk = hostPlayer?.streak || 0;
|
||||||
const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0;
|
const newStreak = isCorrect ? currentStrk + 1 : 0;
|
||||||
|
|
||||||
|
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
||||||
|
if (isFirstCorrect) {
|
||||||
|
setFirstCorrectPlayerId('host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerRank = getPlayerRank('host', playersRef.current);
|
||||||
|
|
||||||
|
const points = calculatePoints({
|
||||||
|
isCorrect,
|
||||||
|
timeLeftMs: timeLeftRef.current,
|
||||||
|
questionTimeMs: QUESTION_TIME_MS,
|
||||||
|
streak: newStreak,
|
||||||
|
playerRank,
|
||||||
|
isFirstCorrect,
|
||||||
|
config: gameConfigRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLastPointsEarned(points);
|
||||||
|
const newScore = Math.max(0, (hostPlayer?.score || 0) + points);
|
||||||
setCurrentPlayerScore(newScore);
|
setCurrentPlayerScore(newScore);
|
||||||
setCurrentStreak(newStreak);
|
setCurrentStreak(newStreak);
|
||||||
|
|
||||||
|
|
@ -462,7 +510,7 @@ export const useGame = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
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,
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
|
||||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||||
updateQuizFromEditor, startGameFromEditor, backFromEditor
|
updateQuizFromEditor, startGameFromEditor, backFromEditor
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
||||||
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
|
import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types';
|
||||||
|
|
||||||
interface UseQuizLibraryReturn {
|
interface UseQuizLibraryReturn {
|
||||||
quizzes: QuizListItem[];
|
quizzes: QuizListItem[];
|
||||||
|
|
@ -14,6 +14,7 @@ interface UseQuizLibraryReturn {
|
||||||
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
||||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||||
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||||
|
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
||||||
deleteQuiz: (id: string) => Promise<void>;
|
deleteQuiz: (id: string) => Promise<void>;
|
||||||
retry: () => Promise<void>;
|
retry: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
@ -126,6 +127,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
title: quiz.title,
|
title: quiz.title,
|
||||||
source,
|
source,
|
||||||
aiTopic,
|
aiTopic,
|
||||||
|
gameConfig: quiz.config,
|
||||||
questions: quiz.questions.map(q => ({
|
questions: quiz.questions.map(q => ({
|
||||||
text: q.text,
|
text: q.text,
|
||||||
timeLimit: q.timeLimit,
|
timeLimit: q.timeLimit,
|
||||||
|
|
@ -170,6 +172,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: quiz.title,
|
title: quiz.title,
|
||||||
|
gameConfig: quiz.config,
|
||||||
questions: quiz.questions.map(q => ({
|
questions: quiz.questions.map(q => ({
|
||||||
text: q.text,
|
text: q.text,
|
||||||
timeLimit: q.timeLimit,
|
timeLimit: q.timeLimit,
|
||||||
|
|
@ -203,6 +206,25 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [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> => {
|
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
||||||
setDeletingQuizId(id);
|
setDeletingQuizId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -253,6 +275,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
loadQuiz,
|
loadQuiz,
|
||||||
saveQuiz,
|
saveQuiz,
|
||||||
updateQuiz,
|
updateQuiz,
|
||||||
|
updateQuizConfig,
|
||||||
deleteQuiz,
|
deleteQuiz,
|
||||||
retry,
|
retry,
|
||||||
clearError,
|
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');
|
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
|
||||||
db.exec(schema);
|
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}`);
|
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,
|
email TEXT,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login DATETIME
|
last_login DATETIME,
|
||||||
|
default_game_config TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS quizzes (
|
CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
|
|
@ -13,6 +14,7 @@ CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
||||||
ai_topic TEXT,
|
ai_topic TEXT,
|
||||||
|
game_config TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,26 @@ const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
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 {
|
interface QuizBody {
|
||||||
title: string;
|
title: string;
|
||||||
source: 'manual' | 'ai_generated';
|
source: 'manual' | 'ai_generated';
|
||||||
aiTopic?: string;
|
aiTopic?: string;
|
||||||
|
gameConfig?: GameConfig;
|
||||||
questions: {
|
questions: {
|
||||||
text: string;
|
text: string;
|
||||||
timeLimit?: number;
|
timeLimit?: number;
|
||||||
|
|
@ -44,7 +60,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const quiz = db.prepare(`
|
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
|
FROM quizzes
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
|
`).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({
|
res.json({
|
||||||
...quiz,
|
...quiz,
|
||||||
|
gameConfig: parsedConfig,
|
||||||
questions: questionsWithOptions,
|
questions: questionsWithOptions,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -118,7 +144,7 @@ function validateQuizBody(body: QuizBody): string | null {
|
||||||
|
|
||||||
router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const body = req.body as QuizBody;
|
const body = req.body as QuizBody;
|
||||||
const { title, source, aiTopic, questions } = body;
|
const { title, source, aiTopic, gameConfig, questions } = body;
|
||||||
|
|
||||||
const validationError = validateQuizBody(body);
|
const validationError = validateQuizBody(body);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
|
|
@ -138,8 +164,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertQuiz = db.prepare(`
|
const insertQuiz = db.prepare(`
|
||||||
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
|
INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertQuestion = db.prepare(`
|
const insertQuestion = db.prepare(`
|
||||||
|
|
@ -160,7 +186,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
req.user!.name || null
|
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) => {
|
questions.forEach((q, qIdx) => {
|
||||||
const questionId = uuidv4();
|
const questionId = uuidv4();
|
||||||
|
|
@ -187,7 +213,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
|
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const body = req.body as QuizBody;
|
const body = req.body as QuizBody;
|
||||||
const { title, questions } = body;
|
const { title, questions, gameConfig } = body;
|
||||||
const quizId = req.params.id;
|
const quizId = req.params.id;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
|
|
@ -227,7 +253,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateQuiz = db.prepare(`
|
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 = ?`);
|
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(() => {
|
const transaction = db.transaction(() => {
|
||||||
updateQuiz.run(title, quizId);
|
updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId);
|
||||||
deleteQuestions.run(quizId);
|
deleteQuestions.run(quizId);
|
||||||
|
|
||||||
questions.forEach((q, qIdx) => {
|
questions.forEach((q, qIdx) => {
|
||||||
|
|
@ -269,6 +295,26 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
res.json({ id: quizId });
|
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) => {
|
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ router.use(requireAuth);
|
||||||
|
|
||||||
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const user = db.prepare(`
|
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
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(req.user!.sub) as Record<string, unknown> | undefined;
|
`).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,
|
username: req.user!.preferred_username,
|
||||||
email: req.user!.email,
|
email: req.user!.email,
|
||||||
displayName: req.user!.name,
|
displayName: req.user!.name,
|
||||||
|
defaultGameConfig: null,
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
|
|
@ -26,7 +27,41 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||||
return;
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1257,6 +1257,271 @@ async function runTests() {
|
||||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
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:');
|
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
|
||||||
|
|
||||||
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,23 @@ ${baseInstructions}`;
|
||||||
${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 {
|
function transformToQuiz(data: any): Quiz {
|
||||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||||
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
||||||
|
|
||||||
const questions: Question[] = data.questions.map((q: any) => {
|
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,
|
text: opt.text,
|
||||||
isCorrect: opt.isCorrect,
|
isCorrect: opt.isCorrect,
|
||||||
shape: shapes[index % 4],
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
types.ts
32
types.ts
|
|
@ -27,9 +27,40 @@ export interface Question {
|
||||||
timeLimit: number; // in seconds
|
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 {
|
export interface Quiz {
|
||||||
title: string;
|
title: string;
|
||||||
questions: Question[];
|
questions: Question[];
|
||||||
|
config?: GameConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuizSource = 'manual' | 'ai_generated';
|
export type QuizSource = 'manual' | 'ai_generated';
|
||||||
|
|
@ -40,6 +71,7 @@ export interface SavedQuiz extends Quiz {
|
||||||
aiTopic?: string;
|
aiTopic?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
config?: GameConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuizListItem {
|
export interface QuizListItem {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue