From 279dc7f2c3eb5c453675ac24b731160dfa92b73f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 15 Jan 2026 08:21:38 -0700 Subject: [PATCH] Scoreboard ui stuff --- App.tsx | 8 +- README.md | 4 - components/GameScreen.tsx | 36 +- components/Landing.tsx | 87 +- components/PreferencesModal.tsx | 8 +- components/QuizCreator.tsx | 26 +- components/Scoreboard.tsx | 4 +- hooks/useGame.ts | 133 ++- index.tsx | 71 +- package-lock.json | 58 ++ package.json | 1 + tests/hooks/useGame.navigation.test.tsx | 1199 +++++++++++++++++++++++ 12 files changed, 1558 insertions(+), 77 deletions(-) create mode 100644 tests/hooks/useGame.navigation.test.tsx diff --git a/App.tsx b/App.tsx index 1bcfac8..50c2bdd 100644 --- a/App.tsx +++ b/App.tsx @@ -60,6 +60,7 @@ function App() { gamePin, startQuizGen, startManualCreation, + cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, @@ -148,14 +149,15 @@ function App() { onLoadQuiz={loadSavedQuiz} onJoin={joinGame} isLoading={gameState === 'GENERATING'} - error={error} + error={error} + initialPin={gamePin} /> ) : null} {gameState === 'CREATING' ? ( window.location.reload()} + onCancel={cancelCreation} /> ) : null} @@ -261,7 +263,7 @@ function App() { {gameState === 'PODIUM' ? ( window.location.reload()} + onRestart={endGame} /> ) : null} diff --git a/README.md b/README.md index 28d215a..b067d9e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -
-GHBanner -
- # Kaboot Kaboot is an AI-powered quiz party game inspired by Kahoot. It leverages the Google Gemini API to instantly generate engaging quizzes on any topic, allowing users to host and join multiplayer games with ease. diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index d68279d..244da2b 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -126,18 +126,38 @@ export const GameScreen: React.FC = ({ {isClient && hasAnswered && ( - 🚀 +
+
+ + + 🚀 + +

+ Answer Sent! +

+

+ Cross your fingers... +

-

Answer Sent!

-

Cross your fingers...

)} diff --git a/components/Landing.tsx b/components/Landing.tsx index 0e8ceb5..22a91c1 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings, Palette, Lock } from 'lucide-react'; import { useAuth } from 'react-oidc-context'; @@ -21,25 +22,87 @@ interface LandingProps { onJoin: (pin: string, name: string) => void; isLoading: boolean; error: string | null; + initialPin?: string | null; } -export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { +export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error, initialPin }) => { const auth = useAuth(); - const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN'); + const [searchParams, setSearchParams] = useSearchParams(); + + const getModeFromUrl = (): 'HOST' | 'JOIN' => { + if (initialPin) return 'JOIN'; + const modeParam = searchParams.get('mode'); + return modeParam === 'host' ? 'HOST' : 'JOIN'; + }; + + const [mode, setModeState] = useState<'HOST' | 'JOIN'>(getModeFromUrl); + + const setMode = (newMode: 'HOST' | 'JOIN') => { + setModeState(newMode); + const newParams = new URLSearchParams(searchParams); + if (newMode === 'HOST') { + newParams.set('mode', 'host'); + } else { + newParams.delete('mode'); + } + setSearchParams(newParams, { replace: true }); + }; const [generateMode, setGenerateMode] = useState('topic'); const [topic, setTopic] = useState(''); - const [pin, setPin] = useState(''); + const [pin, setPin] = useState(initialPin || ''); const [name, setName] = useState(''); - const [libraryOpen, setLibraryOpen] = useState(false); + + const modalParam = searchParams.get('modal'); + const libraryOpen = modalParam === 'library'; + const preferencesOpen = modalParam === 'preferences'; + const defaultConfigOpen = modalParam === 'settings'; + const accountSettingsOpen = modalParam === 'account'; + + const setLibraryOpen = (open: boolean) => { + const newParams = new URLSearchParams(searchParams); + if (open) { + newParams.set('modal', 'library'); + } else { + newParams.delete('modal'); + } + setSearchParams(newParams); + }; + + const setPreferencesOpen = (open: boolean) => { + const newParams = new URLSearchParams(searchParams); + if (open) { + newParams.set('modal', 'preferences'); + } else { + newParams.delete('modal'); + } + setSearchParams(newParams); + }; + + const setDefaultConfigOpen = (open: boolean) => { + const newParams = new URLSearchParams(searchParams); + if (open) { + newParams.set('modal', 'settings'); + } else { + newParams.delete('modal'); + } + setSearchParams(newParams); + }; + + const setAccountSettingsOpen = (open: boolean) => { + const newParams = new URLSearchParams(searchParams); + if (open) { + newParams.set('modal', 'account'); + } else { + newParams.delete('modal'); + } + setSearchParams(newParams); + }; const [selectedFiles, setSelectedFiles] = useState([]); const [questionCount, setQuestionCount] = useState(10); const [isDragging, setIsDragging] = useState(false); const [useOcr, setUseOcr] = useState(false); - const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); const [editingDefaultConfig, setEditingDefaultConfig] = useState(null); - const [preferencesOpen, setPreferencesOpen] = useState(false); - const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null); const [checkingPin, setCheckingPin] = useState(false); @@ -69,6 +132,14 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on } }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); + useEffect(() => { + if (defaultConfigOpen) { + setEditingDefaultConfig(defaultConfig); + } else { + setEditingDefaultConfig(null); + } + }, [defaultConfigOpen, defaultConfig]); + useEffect(() => { const checkGamePin = async () => { if (pin.trim().length === 6) { @@ -404,7 +475,7 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setQuestionCount(Number(e.target.value))} className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" diff --git a/components/PreferencesModal.tsx b/components/PreferencesModal.tsx index 871e6e1..88bf8a8 100644 --- a/components/PreferencesModal.tsx +++ b/components/PreferencesModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { X, Palette, Loader2, Check } from 'lucide-react'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; @@ -25,6 +25,12 @@ export const PreferencesModal: React.FC = ({ useBodyScrollLock(isOpen); const [localPrefs, setLocalPrefs] = useState(preferences); + useEffect(() => { + if (isOpen) { + setLocalPrefs(preferences); + } + }, [isOpen, preferences]); + if (!isOpen) return null; const handleColorSelect = (schemeId: string) => { diff --git a/components/QuizCreator.tsx b/components/QuizCreator.tsx index acaebee..262da1b 100644 --- a/components/QuizCreator.tsx +++ b/components/QuizCreator.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useAuth } from 'react-oidc-context'; import { Quiz, Question, AnswerOption } from '../types'; import { v4 as uuidv4 } from 'uuid'; @@ -18,7 +18,14 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } const [options, setOptions] = useState(['', '', '', '']); const [reasons, setReasons] = useState(['', '', '', '']); const [correctIdx, setCorrectIdx] = useState(0); - const [saveToLibrary, setSaveToLibrary] = useState(false); + const [saveToLibrary, setSaveToLibrary] = useState(auth.isAuthenticated); + const [hasToggledSave, setHasToggledSave] = useState(false); + + useEffect(() => { + if (auth.isAuthenticated && !hasToggledSave) { + setSaveToLibrary(true); + } + }, [auth.isAuthenticated, hasToggledSave]); const handleAddQuestion = () => { if (!qText.trim() || options.some(o => !o.trim())) { @@ -182,12 +189,15 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel }
{auth.isAuthenticated ? (
@@ -191,7 +191,7 @@ export const Scoreboard: React.FC = ({ players, onNext, isHost, displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name })); const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score); - const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1); + const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1); return (
diff --git a/hooks/useGame.ts b/hooks/useGame.ts index fbb2d69..2a12d5f 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { generateQuiz } from '../services/geminiService'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; @@ -35,6 +36,9 @@ const clearStoredSession = () => { }; export const useGame = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [role, setRole] = useState('HOST'); const [gameState, setGameState] = useState('LANDING'); const [quiz, setQuiz] = useState(null); @@ -87,6 +91,53 @@ export const useGame = () => { useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]); useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]); + const isInitializingFromUrl = useRef(false); + + useEffect(() => { + if (isInitializingFromUrl.current) return; + if (location.pathname === '/callback') return; + + const getTargetPath = () => { + switch (gameState) { + case 'LANDING': + if (gamePin && location.pathname.startsWith('/play/')) { + return `/play/${gamePin}`; + } + return '/'; + case 'CREATING': + case 'GENERATING': + return '/create'; + case 'EDITING': + return '/edit'; + case 'LOBBY': + case 'COUNTDOWN': + case 'QUESTION': + case 'REVEAL': + case 'SCOREBOARD': + case 'PODIUM': + case 'HOST_RECONNECTED': + if (gamePin) { + return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`; + } + return '/'; + case 'DISCONNECTED': + case 'WAITING_TO_REJOIN': + if (gamePin) { + return `/play/${gamePin}`; + } + return '/'; + default: + return '/'; + } + }; + + const targetPath = getTargetPath(); + if (location.pathname !== targetPath) { + const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState); + navigate(targetPath + location.search, { replace: useReplace }); + } + }, [gameState, gamePin, role, navigate, location.pathname, location.search]); + const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + ""; const generateRandomName = (): string => { @@ -391,14 +442,69 @@ export const useGame = () => { }; useEffect(() => { - const session = getStoredSession(); - if (session) { - if (session.role === 'HOST') { - reconnectAsHost(session); - } else { - reconnectAsClient(session); + const initializeFromUrl = async () => { + const path = location.pathname; + + if (path === '/callback') { + return; } - } + + const hostMatch = path.match(/^\/host\/(\d+)$/); + const playMatch = path.match(/^\/play\/(\d+)$/); + + if (hostMatch) { + const pin = hostMatch[1]; + const session = getStoredSession(); + if (session && session.pin === pin && session.role === 'HOST') { + isInitializingFromUrl.current = true; + await reconnectAsHost(session); + isInitializingFromUrl.current = false; + } else { + navigate('/', { replace: true }); + } + return; + } + + if (playMatch) { + const pin = playMatch[1]; + const session = getStoredSession(); + if (session && session.pin === pin && session.role === 'CLIENT') { + isInitializingFromUrl.current = true; + await reconnectAsClient(session); + isInitializingFromUrl.current = false; + } else { + setGamePin(pin); + } + return; + } + + if (path === '/create') { + isInitializingFromUrl.current = true; + setGameState('CREATING'); + setRole('HOST'); + isInitializingFromUrl.current = false; + return; + } + + if (path === '/edit') { + const session = getStoredSession(); + if (!session) { + navigate('/', { replace: true }); + } + return; + } + + const session = getStoredSession(); + if (session) { + if (session.role === 'HOST') { + reconnectAsHost(session); + } else { + reconnectAsClient(session); + } + } + }; + + initializeFromUrl(); return () => { if (timerRef.current) clearInterval(timerRef.current); @@ -465,6 +571,10 @@ export const useGame = () => { setGameState('CREATING'); }; + const cancelCreation = () => { + setGameState('LANDING'); + }; + const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => { if (saveToLibrary) { setPendingQuizToSave({ quiz: manualQuiz, topic: '' }); @@ -940,6 +1050,7 @@ export const useGame = () => { if (peerRef.current) { peerRef.current.destroy(); } + setGamePin(null); setGameState('LANDING'); setError(null); }; @@ -1175,7 +1286,11 @@ export const useGame = () => { if (timerRef.current) clearInterval(timerRef.current); if (syncTimerRef.current) clearInterval(syncTimerRef.current); if (peerRef.current) peerRef.current.destroy(); - window.location.reload(); + setGamePin(null); + setQuiz(null); + setPlayers([]); + setGameState('LANDING'); + navigate('/', { replace: true }); }; useEffect(() => { @@ -1187,7 +1302,7 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, - startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, + startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame }; }; diff --git a/index.tsx b/index.tsx index 256ee3f..2882a3d 100644 --- a/index.tsx +++ b/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import { AuthProvider } from 'react-oidc-context'; import { Toaster } from 'react-hot-toast'; import App from './App'; @@ -11,45 +12,47 @@ if (!rootElement) { } const onSigninCallback = () => { - window.history.replaceState({}, document.title, window.location.pathname); + window.history.replaceState({}, document.title, '/'); }; const root = ReactDOM.createRoot(rootElement); root.render( - { - window.localStorage.clear(); - }} - > - + { + localStorage.removeItem('kaboot_session'); }} - /> - - + > + + + + ); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6c215ed..ecabad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", "react-oidc-context": "^3.2.0", + "react-router-dom": "^7.12.0", "recharts": "^3.6.0", "unique-names-generator": "^4.7.1", "uuid": "^13.0.0" @@ -2235,6 +2236,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3648,6 +3662,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/recharts": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", @@ -3845,6 +3897,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 1077f01..c744a2d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", "react-oidc-context": "^3.2.0", + "react-router-dom": "^7.12.0", "recharts": "^3.6.0", "unique-names-generator": "^4.7.1", "uuid": "^13.0.0" diff --git a/tests/hooks/useGame.navigation.test.tsx b/tests/hooks/useGame.navigation.test.tsx new file mode 100644 index 0000000..ae76624 --- /dev/null +++ b/tests/hooks/useGame.navigation.test.tsx @@ -0,0 +1,1199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('URL Routing and Navigation', () => { + describe('URL Path Generation', () => { + const getTargetPath = ( + gameState: string, + gamePin: string | null, + role: 'HOST' | 'CLIENT', + currentPath: string + ): string => { + switch (gameState) { + case 'LANDING': + if (gamePin && currentPath.startsWith('/play/')) { + return `/play/${gamePin}`; + } + return '/'; + case 'CREATING': + case 'GENERATING': + return '/create'; + case 'EDITING': + return '/edit'; + case 'LOBBY': + case 'COUNTDOWN': + case 'QUESTION': + case 'REVEAL': + case 'SCOREBOARD': + case 'PODIUM': + case 'HOST_RECONNECTED': + if (gamePin) { + return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`; + } + return '/'; + case 'DISCONNECTED': + case 'WAITING_TO_REJOIN': + if (gamePin) { + return `/play/${gamePin}`; + } + return '/'; + default: + return '/'; + } + }; + + describe('LANDING state', () => { + it('should return "/" for LANDING state without gamePin', () => { + expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/'); + }); + + it('should preserve /play/:pin URL when LANDING with gamePin (join flow)', () => { + expect(getTargetPath('LANDING', '123456', 'CLIENT', '/play/123456')).toBe('/play/123456'); + }); + + it('should return "/" when LANDING with gamePin but not on /play/ path', () => { + expect(getTargetPath('LANDING', '123456', 'HOST', '/')).toBe('/'); + }); + }); + + describe('CREATING/GENERATING states', () => { + it('should return "/create" for CREATING state', () => { + expect(getTargetPath('CREATING', null, 'HOST', '/')).toBe('/create'); + }); + + it('should return "/create" for GENERATING state', () => { + expect(getTargetPath('GENERATING', null, 'HOST', '/')).toBe('/create'); + }); + }); + + describe('EDITING state', () => { + it('should return "/edit" for EDITING state', () => { + expect(getTargetPath('EDITING', null, 'HOST', '/')).toBe('/edit'); + }); + }); + + describe('In-game HOST states', () => { + const hostStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM', 'HOST_RECONNECTED']; + + hostStates.forEach(state => { + it(`should return "/host/:pin" for ${state} state as HOST`, () => { + expect(getTargetPath(state, '123456', 'HOST', '/')).toBe('/host/123456'); + }); + }); + + it('should return "/" for HOST states without gamePin', () => { + expect(getTargetPath('LOBBY', null, 'HOST', '/')).toBe('/'); + }); + }); + + describe('In-game CLIENT states', () => { + const clientStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM']; + + clientStates.forEach(state => { + it(`should return "/play/:pin" for ${state} state as CLIENT`, () => { + expect(getTargetPath(state, '123456', 'CLIENT', '/')).toBe('/play/123456'); + }); + }); + }); + + describe('Disconnected states', () => { + it('should return "/play/:pin" for DISCONNECTED state with gamePin', () => { + expect(getTargetPath('DISCONNECTED', '123456', 'CLIENT', '/')).toBe('/play/123456'); + }); + + it('should return "/play/:pin" for WAITING_TO_REJOIN state with gamePin', () => { + expect(getTargetPath('WAITING_TO_REJOIN', '123456', 'CLIENT', '/')).toBe('/play/123456'); + }); + + it('should return "/" for DISCONNECTED state without gamePin', () => { + expect(getTargetPath('DISCONNECTED', null, 'CLIENT', '/')).toBe('/'); + }); + }); + + describe('Unknown states', () => { + it('should return "/" for unknown game state', () => { + expect(getTargetPath('UNKNOWN_STATE', '123456', 'HOST', '/')).toBe('/'); + }); + }); + }); + + describe('URL Initialization Logic', () => { + interface StoredSession { + pin: string; + role: 'HOST' | 'CLIENT'; + hostSecret?: string; + playerName?: string; + playerId?: string; + } + + const simulateUrlInit = ( + path: string, + storedSession: StoredSession | null + ): { action: string; gamePin?: string; shouldReconnect?: boolean } => { + const hostMatch = path.match(/^\/host\/(\d+)$/); + const playMatch = path.match(/^\/play\/(\d+)$/); + + if (hostMatch) { + const pin = hostMatch[1]; + if (storedSession && storedSession.pin === pin && storedSession.role === 'HOST') { + return { action: 'reconnectAsHost', shouldReconnect: true }; + } + return { action: 'navigateHome' }; + } + + if (playMatch) { + const pin = playMatch[1]; + if (storedSession && storedSession.pin === pin && storedSession.role === 'CLIENT') { + return { action: 'reconnectAsClient', shouldReconnect: true }; + } + return { action: 'setGamePin', gamePin: pin }; + } + + if (path === '/create') { + return { action: 'startCreating' }; + } + + if (path === '/edit') { + if (!storedSession) { + return { action: 'navigateHome' }; + } + return { action: 'continueEditing' }; + } + + if (storedSession) { + if (storedSession.role === 'HOST') { + return { action: 'reconnectAsHost', shouldReconnect: true }; + } + return { action: 'reconnectAsClient', shouldReconnect: true }; + } + + return { action: 'showLanding' }; + }; + + describe('/host/:pin URL', () => { + it('should reconnect as host when session matches', () => { + const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/host/123456', session); + expect(result.action).toBe('reconnectAsHost'); + expect(result.shouldReconnect).toBe(true); + }); + + it('should navigate home when no session for host URL', () => { + const result = simulateUrlInit('/host/123456', null); + expect(result.action).toBe('navigateHome'); + }); + + it('should navigate home when session pin does not match', () => { + const session: StoredSession = { pin: '654321', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/host/123456', session); + expect(result.action).toBe('navigateHome'); + }); + + it('should navigate home when session role is CLIENT not HOST', () => { + const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; + const result = simulateUrlInit('/host/123456', session); + expect(result.action).toBe('navigateHome'); + }); + }); + + describe('/play/:pin URL', () => { + it('should reconnect as client when session matches', () => { + const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; + const result = simulateUrlInit('/play/123456', session); + expect(result.action).toBe('reconnectAsClient'); + expect(result.shouldReconnect).toBe(true); + }); + + it('should set gamePin when no session (for join flow)', () => { + const result = simulateUrlInit('/play/123456', null); + expect(result.action).toBe('setGamePin'); + expect(result.gamePin).toBe('123456'); + }); + + it('should set gamePin when session pin does not match', () => { + const session: StoredSession = { pin: '654321', role: 'CLIENT', playerName: 'Test' }; + const result = simulateUrlInit('/play/123456', session); + expect(result.action).toBe('setGamePin'); + expect(result.gamePin).toBe('123456'); + }); + }); + + describe('/create URL', () => { + it('should start creating mode', () => { + const result = simulateUrlInit('/create', null); + expect(result.action).toBe('startCreating'); + }); + + it('should start creating even with existing session', () => { + const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/create', session); + expect(result.action).toBe('startCreating'); + }); + }); + + describe('/edit URL', () => { + it('should navigate home when no session', () => { + const result = simulateUrlInit('/edit', null); + expect(result.action).toBe('navigateHome'); + }); + + it('should continue editing when session exists', () => { + const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/edit', session); + expect(result.action).toBe('continueEditing'); + }); + }); + + describe('Root URL "/"', () => { + it('should show landing when no session', () => { + const result = simulateUrlInit('/', null); + expect(result.action).toBe('showLanding'); + }); + + it('should reconnect as host when HOST session exists', () => { + const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' }; + const result = simulateUrlInit('/', session); + expect(result.action).toBe('reconnectAsHost'); + }); + + it('should reconnect as client when CLIENT session exists', () => { + const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' }; + const result = simulateUrlInit('/', session); + expect(result.action).toBe('reconnectAsClient'); + }); + }); + }); + + describe('Navigation Functions', () => { + describe('cancelCreation', () => { + it('should set gameState to LANDING', () => { + let gameState = 'CREATING'; + + const cancelCreation = () => { + gameState = 'LANDING'; + }; + + cancelCreation(); + expect(gameState).toBe('LANDING'); + }); + + it('should trigger URL change to "/"', () => { + let gameState = 'CREATING'; + let currentPath = '/create'; + + const cancelCreation = () => { + gameState = 'LANDING'; + }; + + cancelCreation(); + + if (gameState === 'LANDING') { + currentPath = '/'; + } + + expect(currentPath).toBe('/'); + }); + }); + + describe('endGame', () => { + it('should clear session storage', () => { + const storage = new Map(); + storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'HOST' })); + + const clearStoredSession = () => storage.delete('kaboot_session'); + + clearStoredSession(); + expect(storage.has('kaboot_session')).toBe(false); + }); + + it('should reset gamePin to null', () => { + let gamePin: string | null = '123456'; + + const endGame = () => { + gamePin = null; + }; + + endGame(); + expect(gamePin).toBeNull(); + }); + + it('should reset quiz to null', () => { + let quiz: any = { title: 'Test Quiz', questions: [] }; + + const endGame = () => { + quiz = null; + }; + + endGame(); + expect(quiz).toBeNull(); + }); + + it('should reset players to empty array', () => { + let players = [{ id: 'player1', name: 'Test', score: 100 }]; + + const endGame = () => { + players = []; + }; + + endGame(); + expect(players).toEqual([]); + }); + + it('should set gameState to LANDING', () => { + let gameState = 'PODIUM'; + + const endGame = () => { + gameState = 'LANDING'; + }; + + endGame(); + expect(gameState).toBe('LANDING'); + }); + + it('should navigate to "/" with replace', () => { + let navigatedTo: string | null = null; + let replaceUsed = false; + + const navigate = (path: string, options?: { replace?: boolean }) => { + navigatedTo = path; + replaceUsed = options?.replace ?? false; + }; + + navigate('/', { replace: true }); + + expect(navigatedTo).toBe('/'); + expect(replaceUsed).toBe(true); + }); + }); + + describe('goHomeFromDisconnected', () => { + it('should clear session storage', () => { + const storage = new Map(); + storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'CLIENT' })); + + const clearStoredSession = () => storage.delete('kaboot_session'); + + clearStoredSession(); + expect(storage.has('kaboot_session')).toBe(false); + }); + + it('should reset gamePin to null', () => { + let gamePin: string | null = '123456'; + + const goHomeFromDisconnected = () => { + gamePin = null; + }; + + goHomeFromDisconnected(); + expect(gamePin).toBeNull(); + }); + + it('should set gameState to LANDING', () => { + let gameState = 'DISCONNECTED'; + + const goHomeFromDisconnected = () => { + gameState = 'LANDING'; + }; + + goHomeFromDisconnected(); + expect(gameState).toBe('LANDING'); + }); + + it('should clear error state', () => { + let error: string | null = 'Connection lost'; + + const goHomeFromDisconnected = () => { + error = null; + }; + + goHomeFromDisconnected(); + expect(error).toBeNull(); + }); + + it('should result in URL change to "/" (not stay on /play/:pin)', () => { + let gameState = 'DISCONNECTED'; + let gamePin: string | null = '123456'; + + const goHomeFromDisconnected = () => { + gamePin = null; + gameState = 'LANDING'; + }; + + goHomeFromDisconnected(); + + const getTargetPath = () => { + if (gameState === 'LANDING' && !gamePin) { + return '/'; + } + return `/play/${gamePin}`; + }; + + expect(getTargetPath()).toBe('/'); + }); + }); + + describe('backFromEditor', () => { + it('should reset quiz to null', () => { + let quiz: any = { title: 'Test', questions: [] }; + + const backFromEditor = () => { + quiz = null; + }; + + backFromEditor(); + expect(quiz).toBeNull(); + }); + + it('should reset pendingQuizToSave to null', () => { + let pendingQuizToSave: any = { quiz: {}, topic: 'Test' }; + + const backFromEditor = () => { + pendingQuizToSave = null; + }; + + backFromEditor(); + expect(pendingQuizToSave).toBeNull(); + }); + + it('should reset sourceQuizId to null', () => { + let sourceQuizId: string | null = 'quiz-123'; + + const backFromEditor = () => { + sourceQuizId = null; + }; + + backFromEditor(); + expect(sourceQuizId).toBeNull(); + }); + + it('should set gameState to LANDING', () => { + let gameState = 'EDITING'; + + const backFromEditor = () => { + gameState = 'LANDING'; + }; + + backFromEditor(); + expect(gameState).toBe('LANDING'); + }); + }); + }); + + describe('Non-Happy Path Scenarios', () => { + describe('Page reload on different URLs', () => { + it('should NOT get stuck on /create after cancel (regression test)', () => { + let gameState = 'CREATING'; + let navigatedTo: string | null = null; + + const cancelCreation = () => { + gameState = 'LANDING'; + }; + + cancelCreation(); + + if (gameState === 'LANDING') { + navigatedTo = '/'; + } + + expect(gameState).toBe('LANDING'); + expect(navigatedTo).toBe('/'); + }); + + it('should NOT get stuck on /host/:pin after endGame (regression test)', () => { + let gameState = 'PODIUM'; + let gamePin: string | null = '123456'; + let sessionCleared = false; + let navigatedTo: string | null = null; + + const endGame = () => { + sessionCleared = true; + gamePin = null; + gameState = 'LANDING'; + navigatedTo = '/'; + }; + + endGame(); + + expect(sessionCleared).toBe(true); + expect(gamePin).toBeNull(); + expect(gameState).toBe('LANDING'); + expect(navigatedTo).toBe('/'); + }); + + it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => { + let gameState = 'DISCONNECTED'; + let gamePin: string | null = '123456'; + let sessionCleared = false; + + const goHomeFromDisconnected = () => { + sessionCleared = true; + gamePin = null; + gameState = 'LANDING'; + }; + + goHomeFromDisconnected(); + + expect(gamePin).toBeNull(); + expect(gameState).toBe('LANDING'); + }); + }); + + describe('Missing or invalid sessions', () => { + it('should redirect to home when accessing /host/:pin without host session', () => { + const path = '/host/123456'; + const session = null; + + const hostMatch = path.match(/^\/host\/(\d+)$/); + let action = 'none'; + + if (hostMatch) { + if (!session) { + action = 'navigateHome'; + } + } + + expect(action).toBe('navigateHome'); + }); + + it('should redirect to home when accessing /host/:pin with CLIENT session', () => { + const path = '/host/123456'; + const session: { pin: string; role: 'HOST' | 'CLIENT'; playerName?: string } = { + pin: '123456', + role: 'CLIENT', + playerName: 'Test' + }; + + const hostMatch = path.match(/^\/host\/(\d+)$/); + let action = 'none'; + + if (hostMatch) { + const pin = hostMatch[1]; + if (!session || session.role !== 'HOST' || session.pin !== pin) { + action = 'navigateHome'; + } + } + + expect(action).toBe('navigateHome'); + }); + + it('should show join form when accessing /play/:pin without session (shareable link)', () => { + const path = '/play/123456'; + const session = null; + + const playMatch = path.match(/^\/play\/(\d+)$/); + let gamePin: string | null = null; + + if (playMatch) { + const pin = playMatch[1]; + if (!session) { + gamePin = pin; + } + } + + expect(gamePin).toBe('123456'); + }); + + it('should redirect to home when accessing /edit without any session', () => { + const path = '/edit'; + const session = null; + + let action = 'none'; + + if (path === '/edit') { + if (!session) { + action = 'navigateHome'; + } + } + + expect(action).toBe('navigateHome'); + }); + }); + + describe('Stale session handling', () => { + it('should handle mismatched PIN in session vs URL', () => { + const urlPin = '123456'; + const session = { pin: '654321', role: 'HOST' as const, hostSecret: 'secret' }; + + const shouldReconnect = session && session.pin === urlPin && session.role === 'HOST'; + + expect(shouldReconnect).toBe(false); + }); + + it('should handle expired/deleted game on reconnection attempt', () => { + const session = { pin: '123456', role: 'HOST' as const, hostSecret: 'secret' }; + const gameExistsOnServer = false; + + let action = 'none'; + + if (session && !gameExistsOnServer) { + action = 'clearSessionAndGoHome'; + } + + expect(action).toBe('clearSessionAndGoHome'); + }); + }); + + describe('URL with search params preservation', () => { + it('should preserve search params when navigating', () => { + const currentPath = '/'; + const currentSearch = '?modal=library'; + const targetPath = '/'; + + const fullPath = targetPath + currentSearch; + + expect(fullPath).toBe('/?modal=library'); + }); + + it('should preserve modal param when gameState changes', () => { + const searchParams = new URLSearchParams('?modal=settings'); + const modalParam = searchParams.get('modal'); + + expect(modalParam).toBe('settings'); + }); + }); + + describe('Race conditions', () => { + it('should handle URL init completing before auth loads', () => { + let authLoaded = false; + let urlInitComplete = false; + let gameState = 'LANDING'; + + urlInitComplete = true; + + setTimeout(() => { + authLoaded = true; + }, 100); + + expect(gameState).toBe('LANDING'); + expect(urlInitComplete).toBe(true); + }); + + it('should not navigate when isInitializingFromUrl is true', () => { + let isInitializingFromUrl = true; + let navigateCalled = false; + + const urlSyncEffect = () => { + if (isInitializingFromUrl) return; + navigateCalled = true; + }; + + urlSyncEffect(); + + expect(navigateCalled).toBe(false); + }); + }); + }); + + describe('Replace vs Push Navigation', () => { + it('should use replace for transient states (COUNTDOWN, QUESTION, REVEAL, SCOREBOARD)', () => { + const transientStates = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD']; + + transientStates.forEach(state => { + const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state); + expect(useReplace).toBe(true); + }); + }); + + it('should use push for non-transient states', () => { + const nonTransientStates = ['LANDING', 'CREATING', 'EDITING', 'LOBBY', 'PODIUM']; + + nonTransientStates.forEach(state => { + const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state); + expect(useReplace).toBe(false); + }); + }); + }); +}); + +describe('Host/Join Mode URL Parameter Tests', () => { + describe('Mode state from URL', () => { + const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => { + if (initialPin) return 'JOIN'; + const modeParam = searchParams.get('mode'); + return modeParam === 'host' ? 'HOST' : 'JOIN'; + }; + + it('should return JOIN by default when no mode param', () => { + const searchParams = new URLSearchParams(''); + expect(getModeFromUrl(searchParams)).toBe('JOIN'); + }); + + it('should return HOST when ?mode=host', () => { + const searchParams = new URLSearchParams('?mode=host'); + expect(getModeFromUrl(searchParams)).toBe('HOST'); + }); + + it('should return JOIN when ?mode=join', () => { + const searchParams = new URLSearchParams('?mode=join'); + expect(getModeFromUrl(searchParams)).toBe('JOIN'); + }); + + it('should return JOIN for invalid mode values', () => { + const searchParams = new URLSearchParams('?mode=invalid'); + expect(getModeFromUrl(searchParams)).toBe('JOIN'); + }); + + it('should force JOIN mode when initialPin is provided', () => { + const searchParams = new URLSearchParams('?mode=host'); + expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN'); + }); + + it('should force JOIN mode with initialPin even without mode param', () => { + const searchParams = new URLSearchParams(''); + expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN'); + }); + }); + + describe('Setting mode URL param', () => { + it('should set ?mode=host when switching to HOST', () => { + const searchParams = new URLSearchParams(); + + const setMode = (newMode: 'HOST' | 'JOIN') => { + if (newMode === 'HOST') { + searchParams.set('mode', 'host'); + } else { + searchParams.delete('mode'); + } + }; + + setMode('HOST'); + expect(searchParams.get('mode')).toBe('host'); + }); + + it('should remove mode param when switching to JOIN', () => { + const searchParams = new URLSearchParams('?mode=host'); + + const setMode = (newMode: 'HOST' | 'JOIN') => { + if (newMode === 'HOST') { + searchParams.set('mode', 'host'); + } else { + searchParams.delete('mode'); + } + }; + + setMode('JOIN'); + expect(searchParams.get('mode')).toBeNull(); + }); + + it('should preserve other params when setting mode', () => { + const searchParams = new URLSearchParams('?modal=library'); + + const setMode = (newMode: 'HOST' | 'JOIN') => { + if (newMode === 'HOST') { + searchParams.set('mode', 'host'); + } else { + searchParams.delete('mode'); + } + }; + + setMode('HOST'); + expect(searchParams.get('mode')).toBe('host'); + expect(searchParams.get('modal')).toBe('library'); + }); + }); + + describe('Mode with modal params', () => { + it('should support both mode and modal params together', () => { + const searchParams = new URLSearchParams('?mode=host&modal=settings'); + + expect(searchParams.get('mode')).toBe('host'); + expect(searchParams.get('modal')).toBe('settings'); + }); + + it('should preserve mode when opening modal', () => { + const searchParams = new URLSearchParams('?mode=host'); + + searchParams.set('modal', 'library'); + + expect(searchParams.get('mode')).toBe('host'); + expect(searchParams.get('modal')).toBe('library'); + }); + + it('should preserve mode when closing modal', () => { + const searchParams = new URLSearchParams('?mode=host&modal=library'); + + searchParams.delete('modal'); + + expect(searchParams.get('mode')).toBe('host'); + expect(searchParams.get('modal')).toBeNull(); + }); + }); + + describe('URL representation', () => { + it('should generate clean URL for JOIN mode (no mode param)', () => { + const searchParams = new URLSearchParams(); + const url = '/' + (searchParams.toString() ? '?' + searchParams.toString() : ''); + expect(url).toBe('/'); + }); + + it('should generate correct URL for HOST mode', () => { + const searchParams = new URLSearchParams(); + searchParams.set('mode', 'host'); + const url = '/?' + searchParams.toString(); + expect(url).toBe('/?mode=host'); + }); + + it('should generate correct URL for HOST mode with modal', () => { + const searchParams = new URLSearchParams(); + searchParams.set('mode', 'host'); + searchParams.set('modal', 'settings'); + const url = '/?' + searchParams.toString(); + expect(url).toContain('mode=host'); + expect(url).toContain('modal=settings'); + }); + }); +}); + +describe('Modal URL Parameter Tests', () => { + describe('Modal state from URL', () => { + it('should detect library modal from ?modal=library', () => { + const searchParams = new URLSearchParams('?modal=library'); + const modalParam = searchParams.get('modal'); + + const libraryOpen = modalParam === 'library'; + expect(libraryOpen).toBe(true); + }); + + it('should detect preferences modal from ?modal=preferences', () => { + const searchParams = new URLSearchParams('?modal=preferences'); + const modalParam = searchParams.get('modal'); + + const preferencesOpen = modalParam === 'preferences'; + expect(preferencesOpen).toBe(true); + }); + + it('should detect settings modal from ?modal=settings', () => { + const searchParams = new URLSearchParams('?modal=settings'); + const modalParam = searchParams.get('modal'); + + const settingsOpen = modalParam === 'settings'; + expect(settingsOpen).toBe(true); + }); + + it('should detect account modal from ?modal=account', () => { + const searchParams = new URLSearchParams('?modal=account'); + const modalParam = searchParams.get('modal'); + + const accountOpen = modalParam === 'account'; + expect(accountOpen).toBe(true); + }); + + it('should return false for all modals when no param', () => { + const searchParams = new URLSearchParams(''); + const modalParam = searchParams.get('modal'); + + expect(modalParam).toBeNull(); + expect(modalParam === 'library').toBe(false); + expect(modalParam === 'preferences').toBe(false); + expect(modalParam === 'settings').toBe(false); + expect(modalParam === 'account').toBe(false); + }); + + it('should return false for all modals with unknown param value', () => { + const searchParams = new URLSearchParams('?modal=unknown'); + const modalParam = searchParams.get('modal'); + + expect(modalParam === 'library').toBe(false); + expect(modalParam === 'preferences').toBe(false); + expect(modalParam === 'settings').toBe(false); + expect(modalParam === 'account').toBe(false); + }); + }); + + describe('Setting modal URL params', () => { + it('should set ?modal=library when opening library', () => { + const searchParams = new URLSearchParams(); + + const setLibraryOpen = (open: boolean) => { + if (open) { + searchParams.set('modal', 'library'); + } else { + searchParams.delete('modal'); + } + }; + + setLibraryOpen(true); + expect(searchParams.get('modal')).toBe('library'); + }); + + it('should clear modal param when closing modal', () => { + const searchParams = new URLSearchParams('?modal=library'); + + const setLibraryOpen = (open: boolean) => { + if (open) { + searchParams.set('modal', 'library'); + } else { + searchParams.delete('modal'); + } + }; + + setLibraryOpen(false); + expect(searchParams.get('modal')).toBeNull(); + }); + + it('should replace existing modal param when opening different modal', () => { + const searchParams = new URLSearchParams('?modal=library'); + + searchParams.set('modal', 'settings'); + + expect(searchParams.get('modal')).toBe('settings'); + }); + }); + + describe('Modal with /play/:pin URL', () => { + it('should work with modal param on /play/:pin URL', () => { + const pathname = '/play/123456'; + const search = '?modal=preferences'; + const fullUrl = pathname + search; + + expect(fullUrl).toBe('/play/123456?modal=preferences'); + + const searchParams = new URLSearchParams(search); + expect(searchParams.get('modal')).toBe('preferences'); + }); + }); +}); + +describe('OAuth Callback Handling', () => { + describe('onSigninCallback redirect', () => { + it('should redirect to "/" after successful OAuth callback', () => { + let replacedPath: string | null = null; + + const mockReplaceState = (_data: unknown, _title: string, url: string) => { + replacedPath = url; + }; + + const onSigninCallback = () => { + mockReplaceState({}, document.title, '/'); + }; + + onSigninCallback(); + + expect(replacedPath).toBe('/'); + }); + + it('should NOT stay on /callback path after signin', () => { + let currentPath = '/callback'; + + const onSigninCallback = () => { + currentPath = '/'; + }; + + onSigninCallback(); + + expect(currentPath).not.toBe('/callback'); + expect(currentPath).toBe('/'); + }); + + it('should clear OAuth query params (code, state) from URL', () => { + const urlWithOAuthParams = '/callback?code=abc123&state=xyz789'; + let finalUrl: string | null = null; + + const onSigninCallback = () => { + finalUrl = '/'; + }; + + onSigninCallback(); + + expect(finalUrl).toBe('/'); + expect(finalUrl).not.toContain('code='); + expect(finalUrl).not.toContain('state='); + }); + }); + + describe('URL initialization skips /callback', () => { + const shouldSkipUrlInit = (pathname: string): boolean => { + return pathname === '/callback'; + }; + + it('should skip URL initialization on /callback path', () => { + expect(shouldSkipUrlInit('/callback')).toBe(true); + }); + + it('should NOT skip URL initialization on other paths', () => { + expect(shouldSkipUrlInit('/')).toBe(false); + expect(shouldSkipUrlInit('/create')).toBe(false); + expect(shouldSkipUrlInit('/edit')).toBe(false); + expect(shouldSkipUrlInit('/host/123456')).toBe(false); + expect(shouldSkipUrlInit('/play/123456')).toBe(false); + }); + + it('should not interfere with normal navigation when not on /callback', () => { + let urlInitRan = false; + const pathname: string = '/'; + + const initFromUrl = () => { + if (pathname === '/callback') return; + urlInitRan = true; + }; + + initFromUrl(); + + expect(urlInitRan).toBe(true); + }); + + it('should prevent URL sync from running during OAuth flow', () => { + let urlSyncRan = false; + const pathname = '/callback'; + + const syncUrl = () => { + if (pathname === '/callback') return; + urlSyncRan = true; + }; + + syncUrl(); + + expect(urlSyncRan).toBe(false); + }); + }); + + describe('localStorage preservation during OAuth', () => { + it('should NOT clear OIDC state from localStorage on signout', () => { + const storage = new Map(); + storage.set('kaboot_session', JSON.stringify({ pin: '123456' })); + storage.set('oidc.user:https://auth.example.com:client-id', 'oidc-state'); + storage.set('oidc.some-other-key', 'other-state'); + + const onRemoveUser = () => { + storage.delete('kaboot_session'); + }; + + onRemoveUser(); + + expect(storage.has('kaboot_session')).toBe(false); + expect(storage.has('oidc.user:https://auth.example.com:client-id')).toBe(true); + expect(storage.has('oidc.some-other-key')).toBe(true); + }); + + it('should only clear kaboot_session on signout', () => { + const storage = new Map(); + storage.set('kaboot_session', 'session-data'); + storage.set('other_app_data', 'preserved'); + storage.set('user_preferences', 'also-preserved'); + + const onRemoveUser = () => { + storage.delete('kaboot_session'); + }; + + onRemoveUser(); + + expect(storage.has('kaboot_session')).toBe(false); + expect(storage.get('other_app_data')).toBe('preserved'); + expect(storage.get('user_preferences')).toBe('also-preserved'); + }); + }); + + describe('OAuth callback regression tests', () => { + it('should NOT get stuck on /callback after login (regression test)', () => { + let currentPath: string = '/callback?code=auth_code&state=random_state'; + + const onSigninCallback = () => { + currentPath = '/'; + }; + + onSigninCallback(); + + expect(currentPath).toBe('/'); + expect(currentPath.includes('callback')).toBe(false); + }); + + it('should NOT show "No matching state found" error by preserving OIDC storage', () => { + const storage = new Map(); + const oidcStateKey = 'oidc.state.abc123'; + storage.set(oidcStateKey, JSON.stringify({ nonce: 'xyz', redirect_uri: '/callback' })); + storage.set('kaboot_session', 'old-session'); + + const onRemoveUser = () => { + storage.delete('kaboot_session'); + }; + + onRemoveUser(); + + expect(storage.has(oidcStateKey)).toBe(true); + }); + + it('should handle multiple rapid signin/signout cycles', () => { + const storage = new Map(); + let signinCount = 0; + let signoutCount = 0; + + const onSigninCallback = () => { + signinCount++; + storage.set('kaboot_session', `session-${signinCount}`); + }; + + const onRemoveUser = () => { + signoutCount++; + storage.delete('kaboot_session'); + }; + + onSigninCallback(); + onRemoveUser(); + onSigninCallback(); + onRemoveUser(); + onSigninCallback(); + + expect(signinCount).toBe(3); + expect(signoutCount).toBe(2); + expect(storage.has('kaboot_session')).toBe(true); + }); + }); +}); + +describe('State Sync with Async Data Loading', () => { + describe('Settings modal data sync', () => { + it('should update editingDefaultConfig when defaultConfig loads', () => { + const DEFAULT_CONFIG = { shuffleQuestions: false }; + const LOADED_CONFIG = { shuffleQuestions: true, streakBonusEnabled: true }; + + let defaultConfig = DEFAULT_CONFIG; + let editingDefaultConfig = DEFAULT_CONFIG; + let defaultConfigOpen = true; + + defaultConfig = LOADED_CONFIG; + + if (defaultConfigOpen) { + editingDefaultConfig = defaultConfig; + } + + expect(editingDefaultConfig).toEqual(LOADED_CONFIG); + }); + + it('should not update editingDefaultConfig when modal is closed', () => { + const DEFAULT_CONFIG = { shuffleQuestions: false }; + const LOADED_CONFIG = { shuffleQuestions: true }; + + let defaultConfig = DEFAULT_CONFIG; + let editingDefaultConfig: any = null; + let defaultConfigOpen = false; + + defaultConfig = LOADED_CONFIG; + + if (defaultConfigOpen) { + editingDefaultConfig = defaultConfig; + } else { + editingDefaultConfig = null; + } + + expect(editingDefaultConfig).toBeNull(); + }); + }); + + describe('Preferences modal data sync', () => { + it('should update localPrefs when preferences loads', () => { + const DEFAULT_PREFS = { colorScheme: 'blue' }; + const LOADED_PREFS = { colorScheme: 'purple', geminiApiKey: 'key123' }; + + let preferences = DEFAULT_PREFS; + let localPrefs = DEFAULT_PREFS; + let isOpen = true; + + preferences = LOADED_PREFS; + + if (isOpen) { + localPrefs = preferences; + } + + expect(localPrefs).toEqual(LOADED_PREFS); + }); + }); +});