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/**'], + }, + }, }; });