diff --git a/App.tsx b/App.tsx
index b50611a..18cecb2 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
@@ -11,6 +11,7 @@ import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
+import { SaveOptionsModal } from './components/SaveOptionsModal';
import type { Quiz } from './types';
const seededRandom = (seed: number) => {
@@ -40,7 +41,9 @@ const FloatingShapes = React.memo(() => {
function App() {
const auth = useAuth();
- const { saveQuiz } = useQuizLibrary();
+ const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
+ const [showSaveOptions, setShowSaveOptions] = useState(false);
+ const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null);
const {
role,
gameState,
@@ -85,12 +88,33 @@ function App() {
const handleEditorSave = async (editedQuiz: Quiz) => {
updateQuizFromEditor(editedQuiz);
if (auth.isAuthenticated) {
- const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
- const topic = pendingQuizToSave?.topic || undefined;
- await saveQuiz(editedQuiz, source, topic);
+ if (sourceQuizId) {
+ // Quiz was loaded from library - show options modal
+ setPendingEditedQuiz(editedQuiz);
+ setShowSaveOptions(true);
+ } else {
+ // New quiz (AI-generated or manual) - save as new
+ const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
+ const topic = pendingQuizToSave?.topic || undefined;
+ await saveQuiz(editedQuiz, source, topic);
+ }
}
};
+ const handleOverwriteQuiz = async () => {
+ if (!pendingEditedQuiz || !sourceQuizId) return;
+ await updateQuiz(sourceQuizId, pendingEditedQuiz);
+ setShowSaveOptions(false);
+ setPendingEditedQuiz(null);
+ };
+
+ const handleSaveAsNew = async () => {
+ if (!pendingEditedQuiz) return;
+ await saveQuiz(pendingEditedQuiz, 'manual');
+ setShowSaveOptions(false);
+ setPendingEditedQuiz(null);
+ };
+
const currentQ = quiz?.questions[currentQuestionIndex];
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
@@ -204,6 +228,17 @@ function App() {
/>
) : null}
+
+ {
+ setShowSaveOptions(false);
+ setPendingEditedQuiz(null);
+ }}
+ onOverwrite={handleOverwriteQuiz}
+ onSaveNew={handleSaveAsNew}
+ isSaving={saving}
+ />
);
}
diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx
index 97d275b..65b9a5a 100644
--- a/components/QuizEditor.tsx
+++ b/components/QuizEditor.tsx
@@ -13,7 +13,7 @@ interface QuizEditorProps {
onSave: (quiz: Quiz) => void;
onStartGame: (quiz: Quiz) => void;
onBack: () => void;
- sourceQuizId?: string | null;
+ showSaveButton?: boolean;
isSaving?: boolean;
}
@@ -22,7 +22,7 @@ export const QuizEditor: React.FC = ({
onSave,
onStartGame,
onBack,
- sourceQuizId,
+ showSaveButton = true,
isSaving
}) => {
const [quiz, setQuiz] = useState(initialQuiz);
@@ -146,14 +146,17 @@ export const QuizEditor: React.FC = ({
-
+ {showSaveButton && (
+
+ )}
+ {!showSaveButton && }
diff --git a/components/SaveOptionsModal.tsx b/components/SaveOptionsModal.tsx
new file mode 100644
index 0000000..8c99061
--- /dev/null
+++ b/components/SaveOptionsModal.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Save, Copy, X } from 'lucide-react';
+
+interface SaveOptionsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSaveNew: () => void;
+ onOverwrite: () => void;
+ isSaving?: boolean;
+}
+
+export const SaveOptionsModal: React.FC = ({
+ isOpen,
+ onClose,
+ onSaveNew,
+ onOverwrite,
+ isSaving
+}) => {
+ if (!isOpen) return null;
+
+ return (
+
+ e.stopPropagation()}
+ >
+
+
Save Quiz
+
+
+
+
+ This quiz was loaded from your library. How would you like to save your changes?
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts
index 976f82f..35c8187 100644
--- a/hooks/useQuizLibrary.ts
+++ b/hooks/useQuizLibrary.ts
@@ -13,6 +13,7 @@ interface UseQuizLibraryReturn {
fetchQuizzes: () => Promise;
loadQuiz: (id: string) => Promise;
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise;
+ updateQuiz: (id: string, quiz: Quiz) => Promise;
deleteQuiz: (id: string) => Promise;
retry: () => Promise;
clearError: () => void;
@@ -160,6 +161,48 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
}
}, [authFetch]);
+ const updateQuiz = useCallback(async (id: string, quiz: Quiz): Promise => {
+ setSaving(true);
+ setError(null);
+
+ try {
+ const response = await authFetch(`/api/quizzes/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ title: quiz.title,
+ questions: quiz.questions.map(q => ({
+ text: q.text,
+ timeLimit: q.timeLimit,
+ options: q.options.map(o => ({
+ text: o.text,
+ isCorrect: o.isCorrect,
+ shape: o.shape,
+ color: o.color,
+ reason: o.reason,
+ })),
+ })),
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = response.status === 404
+ ? 'Quiz not found.'
+ : 'Failed to update quiz.';
+ throw new Error(errorText);
+ }
+
+ toast.success('Quiz updated!');
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to update quiz';
+ if (!message.includes('redirecting')) {
+ toast.error(message);
+ }
+ throw err;
+ } finally {
+ setSaving(false);
+ }
+ }, [authFetch]);
+
const deleteQuiz = useCallback(async (id: string): Promise => {
setDeletingQuizId(id);
setError(null);
@@ -209,6 +252,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
fetchQuizzes,
loadQuiz,
saveQuiz,
+ updateQuiz,
deleteQuiz,
retry,
clearError,
diff --git a/package-lock.json b/package-lock.json
index d29d414..7323a05 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,12 +25,87 @@
"uuid": "^13.0.0"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
+ "@vitest/coverage-v8": "^4.0.17",
+ "jsdom": "^27.4.0",
"typescript": "~5.8.2",
- "vite": "^6.2.0"
+ "vite": "^6.2.0",
+ "vitest": "^4.0.17"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
+ "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.4",
+ "@csstools/css-color-parser": "^3.1.0",
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4",
+ "lru-cache": "^11.2.4"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
+ "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.4"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -266,6 +341,16 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -314,6 +399,153 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.25",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
+ "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -810,6 +1042,24 @@
"node": ">=18"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz",
+ "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@exodus/crypto": "^1.0.0-rc.4"
+ },
+ "peerDependenciesMeta": {
+ "@exodus/crypto": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@google/genai": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.35.0.tgz",
@@ -1322,6 +1572,103 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
+ "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1367,6 +1714,17 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -1430,6 +1788,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1475,6 +1840,148 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz",
+ "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.17",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.17",
+ "vitest": "4.0.17"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz",
+ "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.17",
+ "@vitest/utils": "4.0.17",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz",
+ "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.17",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz",
+ "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz",
+ "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz",
+ "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.17",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz",
+ "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz",
+ "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.17",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1508,6 +2015,45 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+ "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1544,6 +2090,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -1634,6 +2190,16 @@
"url": "https://www.paypal.me/kirilvatev"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1682,6 +2248,53 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1819,6 +2432,20 @@
"node": ">= 12"
}
},
+ "node_modules/data-urls": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
+ "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1836,12 +2463,36 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1870,6 +2521,26 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-toolkit": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
@@ -1932,12 +2603,32 @@
"node": ">=6"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -2163,6 +2854,50 @@
"node": ">=18"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -2186,6 +2921,16 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -2204,12 +2949,58 @@
"node": ">=8"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -2232,6 +3023,47 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "27.4.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
+ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@acemir/cssom": "^0.9.28",
+ "@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.6.0",
+ "cssstyle": "^5.3.4",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.0",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.1.0",
+ "ws": "^8.18.3",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2316,6 +3148,84 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
+ "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -2425,6 +3335,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/oidc-client-ts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
@@ -2444,6 +3365,19 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -2475,6 +3409,13 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/peerjs": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.5.5.tgz",
@@ -2557,6 +3498,61 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@@ -2687,6 +3683,20 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -2703,6 +3713,16 @@
"redux": "^5.0.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -2789,6 +3809,19 @@
],
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2832,6 +3865,13 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -2854,6 +3894,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -2950,12 +4004,62 @@
"node": ">=8"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2973,6 +4077,62 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
+ "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.19"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
+ "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3151,6 +4311,98 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.0.17",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz",
+ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vitest/expect": "4.0.17",
+ "@vitest/mocker": "4.0.17",
+ "@vitest/pretty-format": "4.0.17",
+ "@vitest/runner": "4.0.17",
+ "@vitest/snapshot": "4.0.17",
+ "@vitest/spy": "4.0.17",
+ "@vitest/utils": "4.0.17",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.17",
+ "@vitest/browser-preview": "4.0.17",
+ "@vitest/browser-webdriverio": "4.0.17",
+ "@vitest/ui": "4.0.17",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -3160,6 +4412,16 @@
"node": ">= 8"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/webrtc-adapter": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz",
@@ -3173,6 +4435,30 @@
"npm": ">=3.10.0"
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3188,6 +4474,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
@@ -3300,6 +4603,23 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 8f9d39c..72eb488 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -26,9 +29,15 @@
"uuid": "^13.0.0"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
+ "@vitest/coverage-v8": "^4.0.17",
+ "jsdom": "^27.4.0",
"typescript": "~5.8.2",
- "vite": "^6.2.0"
+ "vite": "^6.2.0",
+ "vitest": "^4.0.17"
}
}
diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts
index 4d108a2..8be303f 100644
--- a/server/tests/api.test.ts
+++ b/server/tests/api.test.ts
@@ -941,6 +941,322 @@ async function runTests() {
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
+ console.log('\nPUT Endpoint Edge Case Tests:');
+
+ await test('PUT /api/quizzes/:id with whitespace-only title returns 400', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT whitespace test',
+ source: 'manual',
+ questions: [
+ {
+ text: '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', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const invalidUpdate = {
+ title: ' ',
+ questions: [
+ {
+ text: 'Q?',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id with question without text returns 400', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT empty question test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original 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', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const invalidUpdate = {
+ title: 'Updated title',
+ questions: [
+ {
+ text: '',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id with single option returns 400', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT single option test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original 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', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const invalidUpdate = {
+ title: 'Updated title',
+ questions: [
+ {
+ text: 'Question with one option?',
+ options: [
+ { text: 'Only one', isCorrect: true, shape: 'triangle', color: 'red' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id with no correct answer returns 400', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT no correct test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original 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', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const invalidUpdate = {
+ title: 'Updated title',
+ questions: [
+ {
+ text: 'Question with no correct?',
+ options: [
+ { text: 'A', isCorrect: false, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id with null questions returns 400', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT null questions test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original 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', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const invalidUpdate = {
+ title: 'Updated title',
+ questions: null,
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, invalidUpdate, 400);
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id preserves source and aiTopic', async () => {
+ const aiQuiz = {
+ title: 'AI Quiz for PUT preserve test',
+ source: 'ai_generated',
+ aiTopic: 'History',
+ questions: [
+ {
+ text: 'Original 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', aiQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const update = {
+ title: 'Updated AI Quiz',
+ questions: [
+ {
+ text: 'New question?',
+ options: [
+ { text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' },
+ { text: 'Y', isCorrect: false, shape: 'square', color: 'green' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, update);
+
+ const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
+ const quiz = getResult as Record;
+
+ if (quiz.source !== 'ai_generated') throw new Error('Source should be preserved');
+ if (quiz.aiTopic !== 'History') throw new Error('aiTopic should be preserved');
+ if (quiz.title !== 'Updated AI Quiz') throw new Error('Title should be updated');
+
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id on another users quiz returns 404', async () => {
+ const quiz = {
+ title: 'User isolation test',
+ questions: [
+ {
+ text: 'Q?',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', '/api/quizzes/non-existent-user-quiz-id', quiz, 404);
+ });
+
+ await test('PUT /api/quizzes/:id with many questions succeeds', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT many questions test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original?',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const manyQuestions = Array.from({ length: 30 }, (_, i) => ({
+ text: `Updated question ${i + 1}?`,
+ timeLimit: 15,
+ options: [
+ { text: `A${i}`, isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: `B${i}`, isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ }));
+
+ const update = {
+ title: 'Quiz with 30 questions',
+ questions: manyQuestions,
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, update);
+
+ const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
+ const quiz = getResult as { questions: unknown[] };
+ if (quiz.questions.length !== 30) {
+ throw new Error(`Expected 30 questions, got ${quiz.questions.length}`);
+ }
+
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
+ await test('PUT /api/quizzes/:id preserves reason fields in options', async () => {
+ const validQuiz = {
+ title: 'Quiz for PUT reason test',
+ source: 'manual',
+ questions: [
+ {
+ text: 'Original?',
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ };
+
+ const { data } = await request('POST', '/api/quizzes', validQuiz, 201);
+ const quizId = (data as { id: string }).id;
+
+ const updateWithReasons = {
+ title: 'Quiz with reasons',
+ questions: [
+ {
+ text: 'Why is the sky blue?',
+ options: [
+ { text: 'Rayleigh scattering', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Light scatters in atmosphere' },
+ { text: 'Paint', isCorrect: false, shape: 'diamond', color: 'blue', reason: 'That is not how it works' },
+ ],
+ },
+ ],
+ };
+
+ await request('PUT', `/api/quizzes/${quizId}`, updateWithReasons);
+
+ const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
+ const quiz = getResult as { questions: { options: { reason?: string }[] }[] };
+
+ const correctOpt = quiz.questions[0].options.find((o: any) => o.isCorrect);
+ if (correctOpt?.reason !== 'Light scatters in atmosphere') {
+ throw new Error('Reason not preserved on update');
+ }
+
+ await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
+ });
+
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
await test('POST /api/quizzes with same data creates separate quizzes', async () => {
diff --git a/tests/components/SaveOptionsModal.test.tsx b/tests/components/SaveOptionsModal.test.tsx
new file mode 100644
index 0000000..079559a
--- /dev/null
+++ b/tests/components/SaveOptionsModal.test.tsx
@@ -0,0 +1,200 @@
+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 { SaveOptionsModal } from '../../components/SaveOptionsModal';
+
+describe('SaveOptionsModal', () => {
+ const defaultProps = {
+ isOpen: true,
+ onClose: vi.fn(),
+ onSaveNew: vi.fn(),
+ onOverwrite: vi.fn(),
+ isSaving: false,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders nothing when isOpen is false', () => {
+ render();
+ expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
+ });
+
+ it('renders modal when isOpen is true', () => {
+ render();
+ expect(screen.getByText('Save Quiz')).toBeInTheDocument();
+ });
+
+ it('displays explanation text', () => {
+ render();
+ expect(screen.getByText(/loaded from your library/i)).toBeInTheDocument();
+ });
+
+ it('shows update existing button', () => {
+ render();
+ expect(screen.getByText('Update existing quiz')).toBeInTheDocument();
+ });
+
+ it('shows save as new button', () => {
+ render();
+ expect(screen.getByText('Save as new quiz')).toBeInTheDocument();
+ });
+
+ it('shows close button', () => {
+ render();
+ const closeButtons = screen.getAllByRole('button');
+ expect(closeButtons.length).toBeGreaterThanOrEqual(3);
+ });
+ });
+
+ describe('interactions - happy path', () => {
+ it('calls onOverwrite when update existing is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText('Update existing quiz'));
+
+ expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onSaveNew when save as new is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText('Save as new quiz'));
+
+ expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when close button (X) is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const closeButton = screen.getByRole('button', { name: '' });
+ await user.click(closeButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when backdrop is clicked', async () => {
+ render();
+
+ const backdrop = document.querySelector('.fixed.inset-0');
+ expect(backdrop).toBeInTheDocument();
+ fireEvent.click(backdrop!);
+
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ it('does not close when modal content is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText('Save Quiz'));
+
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('interactions - unhappy path / edge cases', () => {
+ it('disables buttons when isSaving is true', () => {
+ render();
+
+ const updateButton = screen.getByText('Update existing quiz').closest('button');
+ const saveNewButton = screen.getByText('Save as new quiz').closest('button');
+
+ expect(updateButton).toBeDisabled();
+ expect(saveNewButton).toBeDisabled();
+ });
+
+ it('does not call onOverwrite when disabled and clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const button = screen.getByText('Update existing quiz').closest('button')!;
+ await user.click(button);
+
+ expect(defaultProps.onOverwrite).not.toHaveBeenCalled();
+ });
+
+ it('does not call onSaveNew when disabled and clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const button = screen.getByText('Save as new quiz').closest('button')!;
+ await user.click(button);
+
+ expect(defaultProps.onSaveNew).not.toHaveBeenCalled();
+ });
+
+ it('handles rapid clicks gracefully', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const button = screen.getByText('Update existing quiz');
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+
+ expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(3);
+ });
+
+ it('remains functional after re-opening', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render();
+
+ await user.click(screen.getByText('Update existing quiz'));
+ expect(defaultProps.onOverwrite).toHaveBeenCalledTimes(1);
+
+ rerender();
+ expect(screen.queryByText('Save Quiz')).not.toBeInTheDocument();
+
+ rerender();
+ await user.click(screen.getByText('Save as new quiz'));
+ expect(defaultProps.onSaveNew).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('accessibility', () => {
+ it('all interactive elements are focusable', () => {
+ render();
+
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach(button => {
+ expect(button).not.toHaveAttribute('tabindex', '-1');
+ });
+ });
+
+ it('buttons have descriptive text for screen readers', () => {
+ render();
+
+ expect(screen.getByText('Overwrite the original with your changes')).toBeInTheDocument();
+ expect(screen.getByText('Keep the original and create a copy')).toBeInTheDocument();
+ });
+ });
+
+ describe('state transitions', () => {
+ it('transitions from not saving to saving correctly', async () => {
+ const { rerender } = render();
+
+ const updateButton = screen.getByText('Update existing quiz').closest('button');
+ expect(updateButton).not.toBeDisabled();
+
+ rerender();
+ expect(updateButton).toBeDisabled();
+ });
+
+ it('transitions from saving back to not saving', async () => {
+ const { rerender } = render();
+
+ const updateButton = screen.getByText('Update existing quiz').closest('button');
+ expect(updateButton).toBeDisabled();
+
+ rerender();
+ expect(updateButton).not.toBeDisabled();
+ });
+ });
+});
diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx
new file mode 100644
index 0000000..ec55ef3
--- /dev/null
+++ b/tests/hooks/useQuizLibrary.test.tsx
@@ -0,0 +1,353 @@
+import React from 'react';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { useQuizLibrary } from '../../hooks/useQuizLibrary';
+import type { Quiz } 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(),
+ },
+}));
+
+const createMockQuiz = (overrides?: Partial): Quiz => ({
+ title: 'Test Quiz',
+ questions: [
+ {
+ id: 'q1',
+ text: 'What is 2+2?',
+ timeLimit: 20,
+ options: [
+ { text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
+ { text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
+ { text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
+ { text: '6', isCorrect: false, shape: 'square', color: 'green' },
+ ],
+ },
+ ],
+ ...overrides,
+});
+
+describe('useQuizLibrary - updateQuiz', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockIsAuthenticated.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe('happy path', () => {
+ it('successfully updates a quiz', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'quiz-123' }),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz({ title: 'Updated Quiz' });
+
+ await act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ });
+
+ expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
+ method: 'PUT',
+ body: expect.stringContaining('Updated Quiz'),
+ });
+ expect(result.current.saving).toBe(false);
+ });
+
+ it('sets saving to true during update', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockAuthFetch.mockReturnValueOnce(pendingPromise);
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ act(() => {
+ result.current.updateQuiz('quiz-123', quiz);
+ });
+
+ await waitFor(() => {
+ expect(result.current.saving).toBe(true);
+ });
+
+ await act(async () => {
+ resolvePromise!({ ok: true, json: () => Promise.resolve({}) });
+ });
+
+ await waitFor(() => {
+ expect(result.current.saving).toBe(false);
+ });
+ });
+
+ it('sends correct request body structure', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz({
+ title: 'My Quiz',
+ questions: [
+ {
+ id: 'q1',
+ text: 'Question 1',
+ timeLimit: 30,
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ });
+
+ await act(async () => {
+ await result.current.updateQuiz('quiz-456', quiz);
+ });
+
+ const [, options] = mockAuthFetch.mock.calls[0];
+ const body = JSON.parse(options.body);
+
+ expect(body.title).toBe('My Quiz');
+ expect(body.questions).toHaveLength(1);
+ expect(body.questions[0].text).toBe('Question 1');
+ expect(body.questions[0].timeLimit).toBe(30);
+ expect(body.questions[0].options[0].reason).toBe('Correct!');
+ });
+
+ it('handles quiz with multiple questions', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = 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: 25,
+ options: [
+ { text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
+ { text: 'D', isCorrect: true, shape: 'square', color: 'green' },
+ ],
+ },
+ ],
+ });
+
+ await act(async () => {
+ await result.current.updateQuiz('quiz-789', quiz);
+ });
+
+ const [, options] = mockAuthFetch.mock.calls[0];
+ const body = JSON.parse(options.body);
+ expect(body.questions).toHaveLength(2);
+ });
+ });
+
+ describe('unhappy path - API errors', () => {
+ it('handles 404 not found error', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await expect(
+ act(async () => {
+ await result.current.updateQuiz('non-existent', quiz);
+ })
+ ).rejects.toThrow('Quiz not found');
+
+ expect(result.current.saving).toBe(false);
+ });
+
+ it('handles generic server error', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await expect(
+ act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ })
+ ).rejects.toThrow('Failed to update quiz');
+ });
+
+ it('handles network error', async () => {
+ mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await expect(
+ act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ })
+ ).rejects.toThrow('Network error');
+
+ expect(result.current.saving).toBe(false);
+ });
+
+ it('handles timeout/abort error', async () => {
+ const abortError = new Error('The operation was aborted');
+ abortError.name = 'AbortError';
+ mockAuthFetch.mockRejectedValueOnce(abortError);
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await expect(
+ act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ })
+ ).rejects.toThrow();
+
+ expect(result.current.saving).toBe(false);
+ });
+ });
+
+ describe('unhappy path - edge cases', () => {
+ it('resets saving state even on error', async () => {
+ mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ try {
+ await act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ });
+ } catch {
+ // Expected to throw
+ }
+
+ expect(result.current.saving).toBe(false);
+ });
+
+ it('handles empty quiz ID', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await expect(
+ act(async () => {
+ await result.current.updateQuiz('', quiz);
+ })
+ ).rejects.toThrow();
+ });
+
+ it('handles quiz with empty title', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz({ title: '' });
+
+ await act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ });
+
+ const [, options] = mockAuthFetch.mock.calls[0];
+ const body = JSON.parse(options.body);
+ expect(body.title).toBe('');
+ });
+
+ it('strips undefined reason fields to null', async () => {
+ mockAuthFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz({
+ questions: [
+ {
+ id: 'q1',
+ text: 'Q',
+ timeLimit: 20,
+ options: [
+ { text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined },
+ { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
+ ],
+ },
+ ],
+ });
+
+ await act(async () => {
+ await result.current.updateQuiz('quiz-123', quiz);
+ });
+
+ const [, options] = mockAuthFetch.mock.calls[0];
+ const body = JSON.parse(options.body);
+ expect(body.questions[0].options[0]).not.toHaveProperty('reason');
+ });
+ });
+
+ describe('concurrent operations', () => {
+ it('allows update after save completes', async () => {
+ mockAuthFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'new-quiz' }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+
+ const { result } = renderHook(() => useQuizLibrary());
+ const quiz = createMockQuiz();
+
+ await act(async () => {
+ await result.current.saveQuiz(quiz, 'manual');
+ });
+
+ await act(async () => {
+ await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' });
+ });
+
+ expect(mockAuthFetch).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/tests/setup.tsx b/tests/setup.tsx
new file mode 100644
index 0000000..bf5ea33
--- /dev/null
+++ b/tests/setup.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach, vi } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+});
+
+vi.mock('framer-motion', async () => {
+ const actual = await vi.importActual('framer-motion');
+ return {
+ ...actual,
+ motion: {
+ div: ({ children, ...props }: React.HTMLAttributes) => {children}
,
+ button: ({ children, ...props }: React.ButtonHTMLAttributes) => ,
+ },
+ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ };
+});
diff --git a/tsconfig.json b/tsconfig.json
index 21643e7..115a286 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,8 @@
"skipLibCheck": true,
"types": [
"node",
- "vite/client"
+ "vite/client",
+ "vitest/globals"
],
"moduleResolution": "bundler",
"isolatedModules": true,
diff --git a/vite.config.ts b/vite.config.ts
index 8738b76..af3544b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -18,6 +18,17 @@ export default defineConfig(({ mode }) => {
alias: {
'@': path.resolve(__dirname, '.'),
}
- }
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./tests/setup.tsx'],
+ include: ['tests/**/*.test.{ts,tsx}'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ include: ['components/**', 'hooks/**'],
+ },
+ },
};
});