From 0b271bc3527a14338c6852375f3ca2307fc6d932 Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 14 Feb 2026 12:20:38 +0100 Subject: [PATCH] testing added and done --- .gitignore | 3 + package-lock.json | 2201 ++++++++++++++++++++++++++++- package.json | 12 +- src/lib/AlertModal.test.js | 71 + src/lib/Card.test.js | 32 + src/lib/ConfirmModal.test.js | 76 + src/lib/Counter.test.js | 15 + src/lib/Footer.test.js | 13 + src/lib/Navbar.test.js | 165 +++ src/lib/QuestionEditor.test.js | 59 + src/lib/RatingModal.test.js | 77 + src/lib/ReviewsModal.test.js | 97 ++ src/lib/api/decks.test.js | 267 ++++ src/lib/api/profile.test.js | 124 ++ src/lib/deckConfig.test.js | 35 + src/lib/stores/auth.test.js | 130 ++ src/lib/stores/navContext.test.js | 29 + src/main.test.js | 23 + src/routes/Community.test.js | 92 ++ src/routes/CommunityUser.test.js | 71 + src/routes/CreateDeck.test.js | 107 ++ src/routes/DeckPreview.test.js | 85 ++ src/routes/DeckReviews.test.js | 72 + src/routes/EditDeck.test.js | 72 + src/routes/MyDecks.test.js | 77 + src/routes/Settings.test.js | 76 + src/test/setup.js | 1 + vite.config.js | 20 +- 28 files changed, 4032 insertions(+), 70 deletions(-) create mode 100644 src/lib/AlertModal.test.js create mode 100644 src/lib/Card.test.js create mode 100644 src/lib/ConfirmModal.test.js create mode 100644 src/lib/Counter.test.js create mode 100644 src/lib/Footer.test.js create mode 100644 src/lib/Navbar.test.js create mode 100644 src/lib/QuestionEditor.test.js create mode 100644 src/lib/RatingModal.test.js create mode 100644 src/lib/ReviewsModal.test.js create mode 100644 src/lib/api/decks.test.js create mode 100644 src/lib/api/profile.test.js create mode 100644 src/lib/deckConfig.test.js create mode 100644 src/lib/stores/auth.test.js create mode 100644 src/lib/stores/navContext.test.js create mode 100644 src/main.test.js create mode 100644 src/routes/Community.test.js create mode 100644 src/routes/CommunityUser.test.js create mode 100644 src/routes/CreateDeck.test.js create mode 100644 src/routes/DeckPreview.test.js create mode 100644 src/routes/DeckReviews.test.js create mode 100644 src/routes/EditDeck.test.js create mode 100644 src/routes/MyDecks.test.js create mode 100644 src/routes/Settings.test.js create mode 100644 src/test/setup.js diff --git a/.gitignore b/.gitignore index 8c17ed9..74f881c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Test coverage +coverage diff --git a/package-lock.json b/package-lock.json index 8dc4b34..52ccb47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,286 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@vitest/coverage-v8": "^3.2.4", + "jsdom": "^27.0.1", "svelte": "^5.45.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^3.2.4" + } + }, + "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/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "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.5" + } + }, + "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.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.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/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "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": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -459,6 +737,34 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -509,6 +815,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -988,6 +1305,128 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "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", + "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/dom/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/@testing-library/dom/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/@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/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "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/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/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", @@ -1019,29 +1458,240 @@ "@types/node": "*" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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/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/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1052,6 +1702,70 @@ "node": ">= 0.4" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "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/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1062,6 +1776,137 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "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/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1072,6 +1917,16 @@ "node": ">=0.10.0" } }, + "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/devalue": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", @@ -1079,6 +1934,47 @@ "dev": true, "license": "MIT" }, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "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/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1138,6 +2034,26 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "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/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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1156,6 +2072,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1171,6 +2104,86 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -1180,33 +2193,302 @@ "node": ">=20.0.0" } }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "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/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "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-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.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", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.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", + "rrweb-cssom": "^0.8.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-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "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": { - "@types/estree": "^1.0.6" + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "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": "MIT" + "license": "CC0-1.0" }, - "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==", + "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", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1237,6 +2519,77 @@ ], "license": "MIT" }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "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", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1286,6 +2639,52 @@ "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/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-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/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/regexparam": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", @@ -1295,6 +2694,16 @@ "node": ">=8" } }, + "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/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -1311,43 +2720,283 @@ "node": ">=18.0.0", "npm": ">=8.0.0" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/strip-literal/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": "BSD-3-Clause", + "license": "MIT" + }, + "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": ">=0.10.0" + "node": ">=8" } }, "node_modules/svelte": { @@ -1389,6 +3038,42 @@ "url": "https://github.com/sponsors/ItalyPaleAle" } }, + "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/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1406,6 +3091,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "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", @@ -1493,6 +3254,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -1513,6 +3297,274 @@ } } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "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/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/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "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", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "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", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -1534,6 +3586,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/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 7a6cd01..1fc918f 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,20 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@vitest/coverage-v8": "^3.2.4", + "jsdom": "^27.0.1", "svelte": "^5.45.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^3.2.4" }, "dependencies": { "@supabase/supabase-js": "^2.95.3", diff --git a/src/lib/AlertModal.test.js b/src/lib/AlertModal.test.js new file mode 100644 index 0000000..2d87c53 --- /dev/null +++ b/src/lib/AlertModal.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import AlertModal from './AlertModal.svelte' + +describe('AlertModal', () => { + it('renders nothing when open is false', () => { + render(AlertModal, { + props: { open: false, title: 'Notice', message: 'Hello' }, + }) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('renders title and message when open', () => { + render(AlertModal, { + props: { + open: true, + title: 'Error', + message: 'Something went wrong.', + }, + }) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Something went wrong.')).toBeInTheDocument() + }) + + it('calls onClose when OK is clicked', async () => { + const onClose = vi.fn() + render(AlertModal, { + props: { + open: true, + title: 'Notice', + message: 'Done.', + okLabel: 'OK', + onClose, + }, + }) + await fireEvent.click(screen.getByRole('button', { name: 'OK' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose on Escape key', async () => { + const onClose = vi.fn() + render(AlertModal, { + props: { open: true, title: 'Notice', message: 'Hi', onClose }, + }) + const dialog = screen.getByRole('alertdialog') + await fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose on Enter key', async () => { + const onClose = vi.fn() + render(AlertModal, { + props: { open: true, title: 'Notice', message: 'Hi', onClose }, + }) + const dialog = screen.getByRole('alertdialog') + await fireEvent.keyDown(dialog, { key: 'Enter' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when backdrop is clicked', async () => { + const onClose = vi.fn() + const { container } = render(AlertModal, { + props: { open: true, title: 'Notice', message: 'Hi', onClose }, + }) + const backdrop = container.querySelector('.modal-backdrop') + expect(backdrop).toBeTruthy() + await fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/Card.test.js b/src/lib/Card.test.js new file mode 100644 index 0000000..d8a3013 --- /dev/null +++ b/src/lib/Card.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import Card from './Card.svelte' + +describe('Card', () => { + it('renders card title, description, and image', () => { + const card = { + title: 'My Deck', + description: 'A cool flashcard deck.', + imageUrl: 'https://example.com/img.png', + } + render(Card, { props: { card } }) + expect(screen.getByText('My Deck')).toBeInTheDocument() + expect(screen.getByText('A cool flashcard deck.')).toBeInTheDocument() + const img = screen.getByRole('img', { name: 'My Deck' }) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/img.png') + }) + + it('calls useCard when Use button is clicked', async () => { + const card = { + title: 'Test Deck', + description: 'Desc', + imageUrl: '/img.png', + } + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + render(Card, { props: { card } }) + await fireEvent.click(screen.getByRole('button', { name: 'Use' })) + expect(logSpy).toHaveBeenCalledWith('Test Deck') + logSpy.mockRestore() + }) +}) diff --git a/src/lib/ConfirmModal.test.js b/src/lib/ConfirmModal.test.js new file mode 100644 index 0000000..cde9f78 --- /dev/null +++ b/src/lib/ConfirmModal.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import ConfirmModal from './ConfirmModal.svelte' + +describe('ConfirmModal', () => { + it('renders nothing when open is false', () => { + render(ConfirmModal, { + props: { open: false, title: 'Confirm', message: 'Are you sure?' }, + }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders title and message when open', () => { + render(ConfirmModal, { + props: { + open: true, + title: 'Delete deck', + message: 'This cannot be undone.', + }, + }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete deck')).toBeInTheDocument() + expect(screen.getByText('This cannot be undone.')).toBeInTheDocument() + }) + + it('calls onConfirm when Confirm is clicked', async () => { + const onConfirm = vi.fn() + render(ConfirmModal, { + props: { + open: true, + title: 'Confirm', + message: 'Proceed?', + confirmLabel: 'Yes', + onConfirm, + }, + }) + await fireEvent.click(screen.getByRole('button', { name: 'Yes' })) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when Cancel is clicked', async () => { + const onCancel = vi.fn() + render(ConfirmModal, { + props: { + open: true, + title: 'Confirm', + message: 'Proceed?', + cancelLabel: 'No', + onCancel, + }, + }) + await fireEvent.click(screen.getByRole('button', { name: 'No' })) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel on Escape key', async () => { + const onCancel = vi.fn() + render(ConfirmModal, { + props: { open: true, title: 'Confirm', message: 'Proceed?', onCancel }, + }) + const dialog = screen.getByRole('dialog') + await fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when backdrop is clicked', async () => { + const onCancel = vi.fn() + const { container } = render(ConfirmModal, { + props: { open: true, title: 'Confirm', message: 'Proceed?', onCancel }, + }) + const backdrop = container.querySelector('.modal-backdrop') + expect(backdrop).toBeTruthy() + await fireEvent.click(backdrop) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/Counter.test.js b/src/lib/Counter.test.js new file mode 100644 index 0000000..cff5c8e --- /dev/null +++ b/src/lib/Counter.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import Counter from './Counter.svelte' + +describe('Counter', () => { + it('starts at 0 and increments on click', async () => { + render(Counter) + const btn = screen.getByRole('button', { name: /count is 0/i }) + expect(btn).toBeInTheDocument() + await fireEvent.click(btn) + expect(screen.getByRole('button', { name: /count is 1/i })).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: /count is 1/i })) + expect(screen.getByRole('button', { name: /count is 2/i })).toBeInTheDocument() + }) +}) diff --git a/src/lib/Footer.test.js b/src/lib/Footer.test.js new file mode 100644 index 0000000..ee7b27e --- /dev/null +++ b/src/lib/Footer.test.js @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/svelte' +import Footer from './Footer.svelte' + +describe('Footer', () => { + it('renders brand name and copyright with current year', () => { + render(Footer) + expect(screen.getByText('Omotomo')).toBeInTheDocument() + const year = new Date().getFullYear() + expect(screen.getByText(new RegExp(`©\\s*${year}`))).toBeInTheDocument() + expect(screen.getByText(/All rights reserved/)).toBeInTheDocument() + }) +}) diff --git a/src/lib/Navbar.test.js b/src/lib/Navbar.test.js new file mode 100644 index 0000000..8a6bc5d --- /dev/null +++ b/src/lib/Navbar.test.js @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import { writable } from 'svelte/store' +import Navbar from './Navbar.svelte' +import { auth } from './stores/auth.js' + +const mockPush = vi.fn() +const locationStore = writable('/') +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), + location: { subscribe: (...args) => locationStore.subscribe(...args) }, +})) + +vi.mock('./stores/auth.js', () => { + const { writable } = require('svelte/store') + const authStore = writable({ user: null, loading: false, error: null }) + return { + auth: { + subscribe: authStore.subscribe, + set: authStore.set, + clearError: vi.fn(), + login: vi.fn().mockResolvedValue(true), + register: vi.fn().mockResolvedValue({ success: true, needsConfirmation: false }), + logout: vi.fn(), + }, + } +}) + +vi.mock('./api/profile.js', () => ({ + getProfile: vi.fn().mockResolvedValue({ id: 'u1', display_name: 'Test User', email: 'test@example.com' }), +})) + +describe('Navbar', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: null, loading: false, error: null }) + vi.mocked(auth.login).mockResolvedValue(true) + vi.mocked(auth.register).mockResolvedValue({ success: true, needsConfirmation: false }) + }) + + describe('when logged out', () => { + it('shows Login button and Community link', () => { + render(Navbar) + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Community/i })).toBeInTheDocument() + }) + + it('openPopup: clicking Login opens auth popup', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + expect(auth.clearError).toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: /Sign in/i })).toBeInTheDocument() + expect(screen.getByPlaceholderText('Email or username')).toBeInTheDocument() + }) + + it('closePopup: Close button closes popup', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + await fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('handleBackdropClick: clicking backdrop closes popup', async () => { + const { container } = render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + const backdrop = container.querySelector('.popup-backdrop') + await fireEvent.click(backdrop) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('handleSubmit (login): submit form calls auth.login and closes on success', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + const form = screen.getByRole('dialog').querySelector('form') + await fireEvent.input(screen.getByPlaceholderText('Email or username'), { target: { value: 'a@b.com' } }) + await fireEvent.input(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } }) + await fireEvent.submit(form) + expect(auth.login).toHaveBeenCalledWith('a@b.com', 'secret') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('switch to Register tab and handleSubmit (register)', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + await fireEvent.click(screen.getByRole('button', { name: 'Register' })) + expect(screen.getByRole('dialog', { name: /Register/i })).toBeInTheDocument() + await fireEvent.input(screen.getByPlaceholderText('Email'), { target: { value: 'new@b.com' } }) + await fireEvent.input(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } }) + await fireEvent.input(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Create account' })) + expect(auth.register).toHaveBeenCalledWith('new@b.com', 'secret', 'newuser') + }) + + it('handleSubmit returns early when email or password empty', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'Login' })) + const form = screen.getByRole('dialog').querySelector('form') + await fireEvent.submit(form) + expect(auth.login).not.toHaveBeenCalled() + }) + }) + + describe('when logged in', () => { + beforeEach(() => { + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + }) + + it('shows My decks, Community, Create deck and user menu', () => { + render(Navbar) + expect(screen.getByRole('link', { name: /My decks/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create deck' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'User menu' })).toBeInTheDocument() + }) + + it('toggleUserMenu: opens dropdown and fetches profile', async () => { + const { getProfile } = await import('./api/profile.js') + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'User menu' })) + expect(screen.getByRole('menu')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument() + await waitFor(() => expect(getProfile).toHaveBeenCalledWith('u1')) + }) + + it('goSettings: Settings link calls push and closes menu', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'User menu' })) + await fireEvent.click(screen.getByRole('menuitem', { name: 'Settings' })) + expect(mockPush).toHaveBeenCalledWith('/settings') + }) + + it('handleLogout: Logout calls auth.logout and push', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'User menu' })) + await fireEvent.click(screen.getByRole('menuitem', { name: 'Logout' })) + expect(auth.logout).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('handleClickOutside: click outside user menu closes it', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'User menu' })) + expect(screen.getByRole('menu')).toBeInTheDocument() + await fireEvent.click(document.body) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + }) + + it('onProfileUpdated: profile-updated event updates profile', async () => { + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + render(Navbar) + await fireEvent.click(screen.getByRole('button', { name: 'User menu' })) + await waitFor(() => expect(screen.getByText('Test User')).toBeInTheDocument()) + window.dispatchEvent(new CustomEvent('profile-updated', { detail: { id: 'u1', display_name: 'Updated', email: 'u@u.com' } })) + await waitFor(() => expect(screen.getByText('Updated')).toBeInTheDocument()) + }) + + it('brand and Community link call push', async () => { + render(Navbar) + await fireEvent.click(screen.getByRole('link', { name: /Omotomo/i })) + expect(mockPush).toHaveBeenCalledWith('/') + await fireEvent.click(screen.getByRole('link', { name: /Community/i })) + expect(mockPush).toHaveBeenCalledWith('/community') + }) +}) diff --git a/src/lib/QuestionEditor.test.js b/src/lib/QuestionEditor.test.js new file mode 100644 index 0000000..33f8e34 --- /dev/null +++ b/src/lib/QuestionEditor.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import QuestionEditor from './QuestionEditor.svelte' + +describe('QuestionEditor', () => { + it('renders question index and prompt placeholder', () => { + const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] } + render(QuestionEditor, { props: { question, index: 0 } }) + expect(screen.getByText('Question 1')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Prompt')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Explanation (optional)')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Answer 1')).toBeInTheDocument() + }) + + it('calls onRemove when Remove question is clicked', async () => { + const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] } + const onRemove = vi.fn() + render(QuestionEditor, { props: { question, index: 0, onRemove } }) + await fireEvent.click(screen.getByTitle('Remove question')) + expect(onRemove).toHaveBeenCalledTimes(1) + }) + + it('adds answer row when Add answer is clicked', async () => { + const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] } + render(QuestionEditor, { props: { question, index: 0 } }) + expect(screen.getByPlaceholderText('Answer 1')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: '+ Add answer' })) + expect(screen.getByPlaceholderText('Answer 2')).toBeInTheDocument() + }) + + it('shows remove answer button when there is more than one answer', () => { + const question = { + prompt: '', + explanation: '', + answers: ['A', 'B'], + correct_answer_indices: [0], + } + render(QuestionEditor, { props: { question, index: 0 } }) + const removeButtons = screen.getAllByTitle('Remove answer') + expect(removeButtons.length).toBeGreaterThanOrEqual(1) + }) + + it('updates answer text on input', async () => { + const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] } + render(QuestionEditor, { props: { question, index: 0 } }) + const input = screen.getByPlaceholderText('Answer 1') + await fireEvent.input(input, { target: { value: 'New answer' } }) + expect(question.answers[0]).toBe('New answer') + }) + + it('toggles correct checkbox', async () => { + const question = { prompt: '', explanation: '', answers: ['A', 'B'], correct_answer_indices: [0] } + render(QuestionEditor, { props: { question, index: 0 } }) + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes.length).toBeGreaterThanOrEqual(2) + await fireEvent.click(checkboxes[1]) + expect(question.correct_answer_indices).toContain(1) + }) +}) diff --git a/src/lib/RatingModal.test.js b/src/lib/RatingModal.test.js new file mode 100644 index 0000000..2a36a4c --- /dev/null +++ b/src/lib/RatingModal.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import RatingModal from './RatingModal.svelte' + +describe('RatingModal', () => { + const deck = { id: 'deck-1', title: 'Cool Deck' } + + it('renders nothing when deck is null', () => { + render(RatingModal, { props: { deck: null } }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders title and star buttons when deck is set', () => { + render(RatingModal, { props: { deck } }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText(/Rate: Cool Deck/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '1 star' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '5 stars' })).toBeInTheDocument() + }) + + it('Submit is disabled when no stars selected', () => { + render(RatingModal, { props: { deck } }) + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled() + }) + + it('selecting stars enables Submit and calls onSubmit with rating and comment', async () => { + const onSubmit = vi.fn() + const onClose = vi.fn() + render(RatingModal, { + props: { deck, onSubmit, onClose }, + }) + await fireEvent.click(screen.getByRole('button', { name: '3 stars' })) + expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled() + const textarea = screen.getByPlaceholderText('Add a comment…') + await fireEvent.input(textarea, { target: { value: ' Great deck!' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(onSubmit).toHaveBeenCalledWith({ rating: 3, comment: 'Great deck!' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('Cancel calls onClose', async () => { + const onClose = vi.fn() + render(RatingModal, { props: { deck, onClose } }) + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('Escape key calls onClose', async () => { + const onClose = vi.fn() + render(RatingModal, { props: { deck, onClose } }) + const dialog = screen.getByRole('dialog') + await fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('uses initialRating and initialComment when deck is set', () => { + render(RatingModal, { + props: { + deck, + initialRating: 4, + initialComment: 'Already had a comment', + }, + }) + expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled() + expect(screen.getByDisplayValue('Already had a comment')).toBeInTheDocument() + }) + + it('submits with null comment when comment is empty or whitespace', async () => { + const onSubmit = vi.fn() + const onClose = vi.fn() + render(RatingModal, { props: { deck, onSubmit, onClose } }) + await fireEvent.click(screen.getByRole('button', { name: '2 stars' })) + await fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(onSubmit).toHaveBeenCalledWith({ rating: 2, comment: null }) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/ReviewsModal.test.js b/src/lib/ReviewsModal.test.js new file mode 100644 index 0000000..5a87d47 --- /dev/null +++ b/src/lib/ReviewsModal.test.js @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/svelte' +import ReviewsModal from './ReviewsModal.svelte' + +describe('ReviewsModal', () => { + it('renders nothing when open is false', () => { + render(ReviewsModal, { + props: { open: false, deckTitle: 'Deck', reviews: [] }, + }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows loading message when loading', () => { + render(ReviewsModal, { + props: { open: true, deckTitle: 'My Deck', loading: true, reviews: [] }, + }) + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect(screen.getByText(/Reviews: My Deck/)).toBeInTheDocument() + }) + + it('shows "No reviews yet" when not loading and reviews empty', () => { + render(ReviewsModal, { + props: { open: true, deckTitle: 'Deck', loading: false, reviews: [] }, + }) + expect(screen.getByText('No reviews yet.')).toBeInTheDocument() + }) + + it('renders review list with reviewer name and comment', () => { + const reviews = [ + { + user_id: 'u1', + rating: 5, + comment: 'Great deck!', + display_name: 'Alice', + email: 'alice@example.com', + }, + { + user_id: 'u2', + rating: 3, + comment: null, + display_name: null, + email: 'bob@example.com', + }, + { + user_id: 'u3', + rating: 4, + comment: null, + display_name: null, + email: null, + }, + { + user_id: 'u4', + rating: 2, + comment: 'Could be better', + display_name: ' ', + email: null, + }, + ] + render(ReviewsModal, { + props: { open: true, deckTitle: 'Deck', loading: false, reviews }, + }) + expect(screen.getByText('Alice')).toBeInTheDocument() + expect(screen.getByText('Great deck!')).toBeInTheDocument() + expect(screen.getByText('bob@example.com')).toBeInTheDocument() + expect(screen.getAllByText('Anonymous').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Could be better')).toBeInTheDocument() + }) + + it('calls onClose when Close is clicked', async () => { + const onClose = vi.fn() + render(ReviewsModal, { + props: { open: true, deckTitle: 'Deck', loading: false, reviews: [], onClose }, + }) + await fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose on Escape key', async () => { + const onClose = vi.fn() + render(ReviewsModal, { + props: { open: true, deckTitle: 'Deck', reviews: [], onClose }, + }) + const dialog = screen.getByRole('dialog') + await fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when backdrop is clicked', async () => { + const onClose = vi.fn() + const { container } = render(ReviewsModal, { + props: { open: true, deckTitle: 'Deck', reviews: [], onClose }, + }) + const backdrop = container.querySelector('.modal-backdrop') + await fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/api/decks.test.js b/src/lib/api/decks.test.js new file mode 100644 index 0000000..102fcbe --- /dev/null +++ b/src/lib/api/decks.test.js @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as decksApi from './decks.js' + +const nextResults = [] +const countResults = [] +function createChain() { + const getResult = () => nextResults.shift() ?? { data: null, error: null } + const getCount = () => countResults.shift() ?? { count: 0, error: null } + const countThenable = { + eq: vi.fn(function () { return this }), + then(resolve) { return Promise.resolve(getCount()).then(resolve) }, + catch(fn) { return Promise.resolve(getCount()).catch(fn) }, + } + const chain = { + from: vi.fn(function () { return this }), + select: vi.fn(function (cols, opts) { + if (opts?.count === 'exact' && opts?.head) return countThenable + return this + }), + eq: vi.fn(function () { return this }), + order: vi.fn(function () { return this }), + in: vi.fn(function () { return this }), + not: vi.fn(function () { return this }), + limit: vi.fn(function () { return this }), + or: vi.fn(function () { return this }), + single: vi.fn(() => Promise.resolve(getResult())), + insert: vi.fn(function () { return this }), + update: vi.fn(function () { return this }), + delete: vi.fn(function () { return this }), + maybeSingle: vi.fn(() => Promise.resolve(getResult())), + upsert: vi.fn(() => Promise.resolve(getResult())), + then(resolve) { return Promise.resolve(getResult()).then(resolve) }, + catch(fn) { return Promise.resolve(getResult()).catch(fn) }, + } + return chain +} + +let fromImpl = () => createChain() +vi.mock('../supabase.js', () => ({ + supabase: { + from: vi.fn((...args) => fromImpl(...args)), + }, +})) + +describe('decks API', () => { + beforeEach(() => { + vi.clearAllMocks() + nextResults.length = 0 + countResults.length = 0 + fromImpl = () => createChain() + }) + + describe('fetchMyDecks', () => { + it('returns empty array when no decks', async () => { + nextResults.push({ data: [], error: null }) + const result = await decksApi.fetchMyDecks('user1') + expect(result).toEqual([]) + }) + + it('returns decks with question_count and needs_update', async () => { + const decks = [ + { id: 'd1', title: 'Deck 1', copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' }, + ] + nextResults.push({ data: decks, error: null }, { data: [], error: null }, { data: [], error: null }) + countResults.push({ count: 3, error: null }) + const result = await decksApi.fetchMyDecks('user1') + expect(result).toHaveLength(1) + expect(result[0].question_count).toBe(3) + expect(result[0].title).toBe('Deck 1') + }) + }) + + describe('fetchPublishedDecks', () => { + it('returns empty array when no published decks', async () => { + nextResults.push({ data: [], error: null }) + const result = await decksApi.fetchPublishedDecks() + expect(result).toEqual([]) + }) + }) + + describe('fetchDeckWithQuestions', () => { + it('returns deck with questions', async () => { + const deck = { id: 'd1', title: 'Deck', version: 1 } + const questions = [{ id: 'q1', prompt: 'Q?', sort_order: 0 }] + nextResults.push({ data: deck, error: null }, { data: questions, error: null }) + const result = await decksApi.fetchDeckWithQuestions('d1') + expect(result).toMatchObject({ ...deck, questions }) + expect(result.questions).toHaveLength(1) + }) + + it('throws when deck not found', async () => { + nextResults.push({ data: null, error: new Error('Not found') }) + await expect(decksApi.fetchDeckWithQuestions('bad')).rejects.toThrow() + }) + }) + + describe('userHasDeck', () => { + it('returns false when userId is null', async () => { + expect(await decksApi.userHasDeck('d1', null)).toBe(false) + }) + + it('returns true when user has deck', async () => { + nextResults.push({ data: [{ id: 'd1' }], error: null }) + expect(await decksApi.userHasDeck('d1', 'user1')).toBe(true) + }) + + it('returns false when no match', async () => { + nextResults.push({ data: [], error: null }) + expect(await decksApi.userHasDeck('d1', 'user1')).toBe(false) + }) + }) + + describe('togglePublished', () => { + it('calls update and throws on error', async () => { + nextResults.push({ error: new Error('DB error') }) + await expect(decksApi.togglePublished('d1', true)).rejects.toThrow('DB error') + }) + + it('succeeds when no error', async () => { + nextResults.push({ error: null }) + await expect(decksApi.togglePublished('d1', false)).resolves.toBeUndefined() + }) + }) + + describe('deleteDeck', () => { + it('throws on error', async () => { + nextResults.push({ error: new Error('FK violation') }) + await expect(decksApi.deleteDeck('d1')).rejects.toThrow('FK violation') + }) + + it('succeeds when no error', async () => { + nextResults.push({ error: null }) + await expect(decksApi.deleteDeck('d1')).resolves.toBeUndefined() + }) + }) + + describe('getMyDeckRating', () => { + it('returns null when userId is null', async () => { + expect(await decksApi.getMyDeckRating('d1', null)).toBe(null) + }) + + it('returns rating and comment when present', async () => { + nextResults.push({ data: { rating: 4, comment: 'Great!' }, error: null }) + const result = await decksApi.getMyDeckRating('d1', 'user1') + expect(result).toEqual({ rating: 4, comment: 'Great!' }) + }) + + it('returns null when no rating', async () => { + nextResults.push({ data: null, error: null }) + expect(await decksApi.getMyDeckRating('d1', 'user1')).toBe(null) + }) + }) + + describe('submitDeckRating', () => { + it('clamps rating to 1-5 and trims comment', async () => { + nextResults.push({ error: null }) + await decksApi.submitDeckRating('d1', 'user1', { rating: 10, comment: ' ok ' }) + expect(nextResults.length).toBe(0) + }) + + it('throws on error', async () => { + nextResults.push({ error: new Error('Unique violation') }) + await expect(decksApi.submitDeckRating('d1', 'user1', { rating: 3 })).rejects.toThrow('Unique violation') + }) + }) + + describe('getDeckReviews', () => { + it('returns empty array when no ratings', async () => { + nextResults.push({ data: [], error: null }) + const result = await decksApi.getDeckReviews('d1') + expect(result).toEqual([]) + }) + + it('returns reviews with display names from profiles', async () => { + nextResults.push( + { data: [{ user_id: 'u1', rating: 5, comment: 'Nice', created_at: '2025-01-01' }], error: null }, + { data: [{ id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null }], error: null } + ) + const result = await decksApi.getDeckReviews('d1') + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ rating: 5, comment: 'Nice', display_name: 'Alice', email: 'a@b.com' }) + }) + }) + + describe('createDeck', () => { + it('creates deck and returns id', async () => { + nextResults.push({ data: { id: 'new-id' }, error: null }) + const id = await decksApi.createDeck('user1', { title: 'Title', description: 'Desc', config: {} }) + expect(id).toBe('new-id') + }) + + it('throws when insert fails', async () => { + nextResults.push({ data: null, error: new Error('Failed') }) + await expect(decksApi.createDeck('user1', { title: 'T', description: '', config: {} })).rejects.toThrow() + }) + }) + + describe('updateDeck', () => { + it('fetches current version then updates', async () => { + nextResults.push( + { data: { version: 1, published: false }, error: null }, + { error: null }, + { error: null } + ) + await decksApi.updateDeck('d1', { title: 'New', description: '', config: {}, questions: [] }) + expect(nextResults.length).toBe(0) + }) + + it('bumps version when published', async () => { + nextResults.push( + { data: { version: 2, published: true }, error: null }, + { error: null }, + { error: null } + ) + await decksApi.updateDeck('d1', { title: 'T', description: '', config: {}, questions: [] }) + expect(nextResults.length).toBe(0) + }) + }) + + describe('copyDeckToUser', () => { + it('throws when deck not published', async () => { + nextResults.push( + { data: { id: 's1', title: 'S', published: false, questions: [] }, error: null }, + { data: [], error: null } + ) + await expect(decksApi.copyDeckToUser('s1', 'user1')).rejects.toThrow('not available to copy') + }) + + it('creates copy and returns new deck id', async () => { + nextResults.push( + { data: { id: 's1', title: 'Source', published: true, version: 1, description: '', config: {}, questions: [] }, error: null }, + { data: [], error: null }, + { data: { id: 'new-id' }, error: null } + ) + const id = await decksApi.copyDeckToUser('s1', 'user1') + expect(id).toBe('new-id') + }) + }) + + describe('getSourceUpdatePreview', () => { + it('throws when deck not found or not a copy', async () => { + nextResults.push({ data: null, error: new Error('Not found') }) + await expect(decksApi.getSourceUpdatePreview('d1', 'user1')).rejects.toThrow('not found or not a copy') + }) + + it('returns source, copy and changes', async () => { + const copy = { id: 'c1', copied_from_deck_id: 's1', title: 'Old', description: 'D', questions: [] } + const sourceDeck = { id: 's1', title: 'New Title', published: true, version: 2, description: 'D', config: {}, questions: [{ prompt: 'Q' }] } + nextResults.push( + { data: copy, error: null }, + { data: sourceDeck, error: null }, + { data: [{ prompt: 'Q' }], error: null } + ) + const result = await decksApi.getSourceUpdatePreview('c1', 'user1') + expect(result.source).toBeDefined() + expect(result.copy).toBeDefined() + expect(result.changes).toEqual(expect.any(Array)) + }) + }) + + describe('applySourceUpdate', () => { + it('throws when deck not a copy', async () => { + nextResults.push({ data: null, error: new Error('Not found') }) + await expect(decksApi.applySourceUpdate('d1', 'user1')).rejects.toThrow() + }) + }) +}) diff --git a/src/lib/api/profile.test.js b/src/lib/api/profile.test.js new file mode 100644 index 0000000..de6e061 --- /dev/null +++ b/src/lib/api/profile.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import * as profileApi from './profile.js' + +const nextResults = [] +function createChain() { + const getResult = () => nextResults.shift() ?? { data: null, error: null } + const chain = { + from: vi.fn(function () { return this }), + select: vi.fn(function () { return this }), + eq: vi.fn(function () { return this }), + ilike: vi.fn(function () { return this }), + limit: vi.fn(function () { return this }), + single: vi.fn(() => Promise.resolve(getResult())), + update: vi.fn(function () { return this }), + then(resolve) { return Promise.resolve(getResult()).then(resolve) }, + catch(fn) { return Promise.resolve(getResult()).catch(fn) }, + } + return chain +} + +const mockStorageFrom = { + getPublicUrl: vi.fn(() => ({ data: { publicUrl: 'https://example.com/avatars/path' } })), + upload: vi.fn(() => Promise.resolve({ error: null })), +} + +vi.mock('../supabase.js', () => ({ + supabase: { + from: vi.fn(() => createChain()), + storage: { from: vi.fn(() => mockStorageFrom) }, + }, +})) + +describe('profile API', () => { + beforeEach(() => { + vi.clearAllMocks() + nextResults.length = 0 + }) + + describe('getEmailForUsername', () => { + it('returns null for empty or whitespace username', async () => { + expect(await profileApi.getEmailForUsername('')).toBe(null) + expect(await profileApi.getEmailForUsername(' ')).toBe(null) + expect(await profileApi.getEmailForUsername(null)).toBe(null) + }) + + it('returns email when profile found', async () => { + nextResults.push({ data: [{ email: 'user@example.com' }], error: null }) + const email = await profileApi.getEmailForUsername('johndoe') + expect(email).toBe('user@example.com') + }) + + it('returns null when error or no data', async () => { + nextResults.push({ data: [], error: null }) + expect(await profileApi.getEmailForUsername('x')).toBe(null) + nextResults.push({ data: null, error: { message: 'err' } }) + expect(await profileApi.getEmailForUsername('y')).toBe(null) + nextResults.push({ data: [{}], error: null }) + expect(await profileApi.getEmailForUsername('z')).toBe(null) + }) + }) + + describe('getProfile', () => { + it('returns profile when found', async () => { + const p = { id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null } + nextResults.push({ data: p, error: null }) + const result = await profileApi.getProfile('u1') + expect(result).toEqual(p) + }) + + it('returns null when not found (PGRST116)', async () => { + nextResults.push({ data: null, error: { code: 'PGRST116' } }) + const result = await profileApi.getProfile('u1') + expect(result).toBe(null) + }) + + it('throws when other error', async () => { + nextResults.push({ data: null, error: new Error('Network error') }) + await expect(profileApi.getProfile('u1')).rejects.toThrow('Network error') + }) + }) + + describe('updateProfile', () => { + it('updates display_name and returns data', async () => { + const updated = { id: 'u1', display_name: 'New Name', email: 'a@b.com', avatar_url: null } + nextResults.push({ data: updated, error: null }) + const result = await profileApi.updateProfile('u1', { display_name: 'New Name' }) + expect(result).toEqual(updated) + }) + + it('trims display_name and allows avatar_url', async () => { + nextResults.push({ data: { id: 'u1' }, error: null }) + await profileApi.updateProfile('u1', { display_name: ' Trim ', avatar_url: 'path/to/av.jpg' }) + expect(nextResults.length).toBe(0) + }) + + it('throws on error', async () => { + nextResults.push({ data: null, error: new Error('DB error') }) + await expect(profileApi.updateProfile('u1', { display_name: 'X' })).rejects.toThrow('DB error') + }) + }) + + describe('getAvatarPublicUrl', () => { + it('returns publicUrl from storage', () => { + const url = profileApi.getAvatarPublicUrl('user1/avatar.jpg') + expect(url).toBe('https://example.com/avatars/path') + expect(mockStorageFrom.getPublicUrl).toHaveBeenCalledWith('user1/avatar.jpg') + }) + }) + + describe('uploadAvatar', () => { + it('uploads file and returns public URL', async () => { + const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }) + const url = await profileApi.uploadAvatar('u1', file) + expect(mockStorageFrom.upload).toHaveBeenCalledWith('u1/avatar.jpg', file, { upsert: true, contentType: 'image/jpeg' }) + expect(url).toBe('https://example.com/avatars/path') + }) + + it('throws on upload error', async () => { + mockStorageFrom.upload.mockResolvedValueOnce({ error: new Error('Storage full') }) + const file = new File(['x'], 'a.png', { type: 'image/png' }) + await expect(profileApi.uploadAvatar('u1', file)).rejects.toThrow('Storage full') + }) + }) +}) diff --git a/src/lib/deckConfig.test.js b/src/lib/deckConfig.test.js new file mode 100644 index 0000000..8cdcf07 --- /dev/null +++ b/src/lib/deckConfig.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_DECK_CONFIG } from './deckConfig.js' + +describe('deckConfig', () => { + it('exports DEFAULT_DECK_CONFIG with expected keys', () => { + expect(DEFAULT_DECK_CONFIG).toMatchObject({ + requiredConsecutiveCorrect: 3, + defaultAttemptSize: 10, + priorityIncreaseOnIncorrect: 5, + priorityDecreaseOnCorrect: 2, + immediateFeedbackEnabled: true, + includeKnownInAttempts: false, + shuffleAnswerOrder: true, + excludeFlaggedQuestions: false, + timeLimitSeconds: null, + }) + }) + + it('has only known config keys', () => { + const known = new Set([ + 'requiredConsecutiveCorrect', + 'defaultAttemptSize', + 'priorityIncreaseOnIncorrect', + 'priorityDecreaseOnCorrect', + 'immediateFeedbackEnabled', + 'includeKnownInAttempts', + 'shuffleAnswerOrder', + 'excludeFlaggedQuestions', + 'timeLimitSeconds', + ]) + for (const key of Object.keys(DEFAULT_DECK_CONFIG)) { + expect(known.has(key)).toBe(true) + } + }) +}) diff --git a/src/lib/stores/auth.test.js b/src/lib/stores/auth.test.js new file mode 100644 index 0000000..6dccfa6 --- /dev/null +++ b/src/lib/stores/auth.test.js @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { get } from 'svelte/store' +import { auth } from './auth.js' + +const mockGetSession = vi.fn() +const mockOnAuthStateChange = vi.fn() +const mockSignInWithPassword = vi.fn() +const mockSignUp = vi.fn() +const mockSignOut = vi.fn() + +vi.mock('../supabase.js', () => ({ + supabase: { + auth: { + getSession: (...args) => mockGetSession(...args), + onAuthStateChange: (...args) => mockOnAuthStateChange(...args), + signInWithPassword: (...args) => mockSignInWithPassword(...args), + signUp: (...args) => mockSignUp(...args), + signOut: (...args) => mockSignOut(...args), + }, + }, +})) + +const mockGetEmailForUsername = vi.fn() +const mockUpdateProfile = vi.fn() +vi.mock('../api/profile.js', () => ({ + getEmailForUsername: (...args) => mockGetEmailForUsername(...args), + updateProfile: (...args) => mockUpdateProfile(...args), +})) + +describe('auth store', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ data: { session: null } }) + mockSignOut.mockResolvedValue(undefined) + }) + + it('has initial state with loading true', () => { + expect(get(auth)).toMatchObject({ user: null, loading: true, error: null }) + }) + + it('clearError sets error to null', async () => { + auth.clearError() + expect(get(auth).error).toBe(null) + }) + + it('init sets session from getSession and registers onAuthStateChange', async () => { + const user = { id: 'u1', email: 'a@b.com' } + mockGetSession.mockResolvedValue({ data: { session: { user } } }) + await auth.init() + expect(get(auth)).toMatchObject({ user, loading: false, error: null }) + expect(mockOnAuthStateChange).toHaveBeenCalled() + }) + + it('init sets error when getSession throws', async () => { + mockGetSession.mockRejectedValue(new Error('Network error')) + await auth.init() + expect(get(auth)).toMatchObject({ user: null, loading: false, error: 'Network error' }) + }) + + it('login with empty email sets error and returns false', async () => { + const result = await auth.login('', 'pass') + expect(result).toBe(false) + expect(get(auth).error).toBe('Enter your email or username.') + }) + + it('login with username resolves email and signs in', async () => { + mockGetEmailForUsername.mockResolvedValue('user@example.com') + mockSignInWithPassword.mockResolvedValue({ error: null }) + const result = await auth.login('username', 'pass') + expect(mockGetEmailForUsername).toHaveBeenCalledWith('username') + expect(mockSignInWithPassword).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'pass', + }) + expect(result).toBe(true) + expect(get(auth).error).toBe(null) + }) + + it('login with unknown username sets error and returns false', async () => { + mockGetEmailForUsername.mockResolvedValue(null) + const result = await auth.login('unknown', 'pass') + expect(result).toBe(false) + expect(get(auth).error).toBe('No account with that username.') + }) + + it('login with email signs in directly', async () => { + mockSignInWithPassword.mockResolvedValue({ error: null }) + const result = await auth.login('a@b.com', 'pass') + expect(mockGetEmailForUsername).not.toHaveBeenCalled() + expect(mockSignInWithPassword).toHaveBeenCalledWith({ email: 'a@b.com', password: 'pass' }) + expect(result).toBe(true) + }) + + it('login with signIn error sets error and returns false', async () => { + mockSignInWithPassword.mockResolvedValue({ error: { message: 'Invalid login' } }) + const result = await auth.login('a@b.com', 'wrong') + expect(result).toBe(false) + expect(get(auth).error).toBe('Invalid login') + }) + + it('register success returns success and data', async () => { + const user = { id: 'u1' } + const session = {} + mockSignUp.mockResolvedValue({ data: { user, session }, error: null }) + const result = await auth.register('a@b.com', 'pass', 'Alice') + expect(result).toEqual({ success: true, needsConfirmation: false, data: { user, session } }) + expect(get(auth).error).toBe(null) + }) + + it('register with displayName calls updateProfile', async () => { + const user = { id: 'u1' } + mockSignUp.mockResolvedValue({ data: { user, session: {} }, error: null }) + mockUpdateProfile.mockResolvedValue(undefined) + await auth.register('a@b.com', 'pass', 'Alice') + expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'Alice' }) + }) + + it('register with signUp error returns success false', async () => { + mockSignUp.mockResolvedValue({ data: null, error: { message: 'Email taken' } }) + const result = await auth.register('a@b.com', 'pass', '') + expect(result).toEqual({ success: false }) + expect(get(auth).error).toBe('Email taken') + }) + + it('logout calls signOut and clears user', async () => { + await auth.logout() + expect(mockSignOut).toHaveBeenCalled() + expect(get(auth)).toMatchObject({ user: null, loading: false, error: null }) + }) +}) diff --git a/src/lib/stores/navContext.test.js b/src/lib/stores/navContext.test.js new file mode 100644 index 0000000..45d344f --- /dev/null +++ b/src/lib/stores/navContext.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { get } from 'svelte/store' +import { navContext } from './navContext.js' + +describe('navContext', () => { + afterEach(() => { + navContext.set(null) + }) + + it('is a writable store with initial value null', () => { + expect(get(navContext)).toBe(null) + }) + + it('updates when set is called', () => { + navContext.set('my-decks') + expect(get(navContext)).toBe('my-decks') + navContext.set('community') + expect(get(navContext)).toBe('community') + navContext.set(null) + expect(get(navContext)).toBe(null) + }) + + it('updates when update is called', () => { + navContext.set('community') + navContext.update((v) => (v === 'community' ? 'my-decks' : v)) + expect(get(navContext)).toBe('my-decks') + navContext.set(null) + }) +}) diff --git a/src/main.test.js b/src/main.test.js new file mode 100644 index 0000000..f67cc39 --- /dev/null +++ b/src/main.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +describe('main', () => { + let appRoot + + beforeEach(() => { + appRoot = document.createElement('div') + appRoot.id = 'app' + document.body.innerHTML = '' + document.body.appendChild(appRoot) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('mounts App to #app and exports the app instance', async () => { + const main = await import('./main.js') + expect(main.default).toBeDefined() + expect(main.default).toHaveProperty('$destroy') + expect(appRoot.hasChildNodes()).toBe(true) + }) +}) diff --git a/src/routes/Community.test.js b/src/routes/Community.test.js new file mode 100644 index 0000000..ec90dbe --- /dev/null +++ b/src/routes/Community.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Community from './Community.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) })) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockFetchPublishedDecks = vi.fn() +const mockCopyDeckToUser = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchPublishedDecks: (...args) => mockFetchPublishedDecks(...args), + copyDeckToUser: (...args) => mockCopyDeckToUser(...args), + getMyDeckRating: vi.fn(), + submitDeckRating: vi.fn(), +})) + +describe('Community', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockFetchPublishedDecks.mockResolvedValue([]) + }) + + it('shows loading then empty message when no decks', async () => { + render(Community) + expect(screen.getByText(/Loading/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/No published decks yet/)).toBeInTheDocument()) + expect(mockFetchPublishedDecks).toHaveBeenCalled() + }) + + it('shows error when fetch fails', async () => { + mockFetchPublishedDecks.mockRejectedValue(new Error('Network error')) + render(Community) + await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()) + }) + + it('renders deck list and goPreview on card click', async () => { + mockFetchPublishedDecks.mockResolvedValue([ + { + id: 'd1', + title: 'Cool Deck', + description: 'A deck', + owner_id: 'o1', + owner_display_name: 'Alice', + owner_email: 'a@b.com', + question_count: 5, + average_rating: 4.5, + rating_count: 2, + user_has_this: false, + }, + ]) + render(Community) + await waitFor(() => expect(screen.getByText('Cool Deck')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: /Cool Deck/ })) + expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview') + }) + + it('handleUse without userId shows sign in error', async () => { + auth.set({ user: null, loading: false, error: null }) + mockFetchPublishedDecks.mockResolvedValue([ + { id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: false }, + ]) + render(Community) + await waitFor(() => expect(screen.getByText('Deck')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(screen.getByText(/Sign in to add this deck/)).toBeInTheDocument() + expect(mockCopyDeckToUser).not.toHaveBeenCalled() + }) + + it('handleUse with userId calls copyDeckToUser and reloads', async () => { + mockFetchPublishedDecks + .mockResolvedValueOnce([ + { id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: false }, + ]) + .mockResolvedValueOnce([ + { id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: true }, + ]) + mockCopyDeckToUser.mockResolvedValue(undefined) + render(Community) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Add' })) + await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1')) + await waitFor(() => expect(screen.getByText('In My decks')).toBeInTheDocument()) + }) +}) diff --git a/src/routes/CommunityUser.test.js b/src/routes/CommunityUser.test.js new file mode 100644 index 0000000..a7f11a8 --- /dev/null +++ b/src/routes/CommunityUser.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import CommunityUser from './CommunityUser.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), +})) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockFetchPublishedDecksByOwner = vi.fn() +const mockCopyDeckToUser = vi.fn() +const mockGetMyDeckRating = vi.fn() +const mockSubmitDeckRating = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchPublishedDecksByOwner: (...args) => mockFetchPublishedDecksByOwner(...args), + copyDeckToUser: (...args) => mockCopyDeckToUser(...args), + getMyDeckRating: (...args) => mockGetMyDeckRating(...args), + submitDeckRating: (...args) => mockSubmitDeckRating(...args), +})) + +describe('CommunityUser', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockFetchPublishedDecksByOwner.mockResolvedValue({ + decks: [], + owner_display_name: 'Test User', + owner_email: 'test@example.com', + }) + }) + + it('loads and shows owner name when no decks', async () => { + render(CommunityUser, { props: { params: { id: 'owner1' } } }) + expect(screen.getByText('Loading…')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/Decks by Test User/)).toBeInTheDocument()) + expect(mockFetchPublishedDecksByOwner).toHaveBeenCalledWith('owner1', 'u1') + }) + + it('shows error when fetch fails', async () => { + mockFetchPublishedDecksByOwner.mockRejectedValue(new Error('Not found')) + render(CommunityUser, { props: { params: { id: 'owner1' } } }) + await waitFor(() => expect(screen.getByText('Not found')).toBeInTheDocument()) + }) + + it('goCommunity button calls push to /community', async () => { + render(CommunityUser, { props: { params: { id: 'owner1' } } }) + await waitFor(() => expect(screen.getByRole('button', { name: /Community/ })).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: /Community/ })) + expect(mockPush).toHaveBeenCalledWith('/community') + }) + + it('renders deck list when decks returned', async () => { + mockFetchPublishedDecksByOwner.mockResolvedValue({ + decks: [ + { id: 'd1', title: 'Deck One', owner_id: 'owner1', question_count: 3, user_has_this: false }, + ], + owner_display_name: 'Alice', + owner_email: 'a@b.com', + }) + render(CommunityUser, { props: { params: { id: 'owner1' } } }) + await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument()) + expect(screen.getByText(/Decks by Alice/)).toBeInTheDocument() + }) +}) diff --git a/src/routes/CreateDeck.test.js b/src/routes/CreateDeck.test.js new file mode 100644 index 0000000..6dfe2a9 --- /dev/null +++ b/src/routes/CreateDeck.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import CreateDeck from './CreateDeck.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), +})) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockCreateDeck = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + createDeck: (...args) => mockCreateDeck(...args), +})) + +describe('CreateDeck', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockCreateDeck.mockResolvedValue('new-deck-id') + }) + + it('shows form when user is logged in', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Save deck' })).toBeInTheDocument() + }) + + it('shows sign in message when user is null', () => { + auth.set({ user: null, loading: false, error: null }) + render(CreateDeck) + expect(screen.getByText('Sign in to create a deck.')).toBeInTheDocument() + }) + + it('Cancel calls push to home', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('loadFromJson sets error for invalid JSON', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + const textarea = screen.getByPlaceholderText(/Paste deck JSON/) + await fireEvent.input(textarea, { target: { value: 'not json' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' })) + expect(screen.getByText(/Invalid JSON/)).toBeInTheDocument() + }) + + it('loadFromJson sets error when title missing', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + const textarea = screen.getByPlaceholderText(/Paste deck JSON/) + await fireEvent.input(textarea, { target: { value: '{"description":"x","questions":[{"prompt":"Q","answers":["A"],"correctAnswerIndices":[0]}]}' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' })) + expect(screen.getByText(/must include a "title"/)).toBeInTheDocument() + }) + + it('loadFromJson populates title and questions from valid JSON', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + const validJson = JSON.stringify({ + title: 'Imported Deck', + description: 'Desc', + questions: [{ prompt: 'Question?', answers: ['A', 'B'], correctAnswerIndices: [0] }], + }) + const textarea = screen.getByPlaceholderText(/Paste deck JSON/) + await fireEvent.input(textarea, { target: { value: validJson } }) + await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' })) + expect(screen.getByDisplayValue('Imported Deck')).toBeInTheDocument() + expect(screen.getByDisplayValue('Question?')).toBeInTheDocument() + }) + + it('save with empty title shows error', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Save deck' })) + expect(screen.getByText('Title is required')).toBeInTheDocument() + expect(mockCreateDeck).not.toHaveBeenCalled() + }) + + it('save with title and question calls createDeck and pushes home', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument()) + await fireEvent.input(screen.getByPlaceholderText('Deck title'), { target: { value: 'My Deck' } }) + await fireEvent.input(screen.getByPlaceholderText('Prompt'), { target: { value: 'First question?' } }) + await fireEvent.input(screen.getByPlaceholderText('Answer 1'), { target: { value: 'Yes' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Save deck' })) + await waitFor(() => expect(mockCreateDeck).toHaveBeenCalled()) + expect(mockCreateDeck).toHaveBeenCalledWith('u1', expect.objectContaining({ title: 'My Deck' })) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('Add question button adds a question editor', async () => { + render(CreateDeck) + await waitFor(() => expect(screen.getByText('Question 1')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: '+ Add question' })) + expect(screen.getByText('Question 2')).toBeInTheDocument() + }) +}) diff --git a/src/routes/DeckPreview.test.js b/src/routes/DeckPreview.test.js new file mode 100644 index 0000000..cdde33a --- /dev/null +++ b/src/routes/DeckPreview.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import DeckPreview from './DeckPreview.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), +})) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockFetchDeckWithQuestions = vi.fn() +const mockUserHasDeck = vi.fn() +const mockCopyDeckToUser = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args), + userHasDeck: (...args) => mockUserHasDeck(...args), + copyDeckToUser: (...args) => mockCopyDeckToUser(...args), +})) + +describe('DeckPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'Preview Deck', + description: 'Desc', + published: true, + owner_id: 'o1', + copied_from_deck_id: null, + questions: [{ prompt: 'Q?', answers: ['A'], correct_answer_indices: [0] }], + }) + mockUserHasDeck.mockResolvedValue(false) + }) + + it('loads and shows deck title', async () => { + render(DeckPreview, { props: { params: { id: 'd1' } } }) + expect(screen.getByText('Loading deck…')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument()) + expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1') + }) + + it('shows error when deck not available', async () => { + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'Private', + published: false, + owner_id: 'o1', + questions: [], + }) + render(DeckPreview, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('This deck is not available.')).toBeInTheDocument()) + }) + + it('goBack pushes to community for published deck', async () => { + render(DeckPreview, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: /Community/ })) + expect(mockPush).toHaveBeenCalledWith('/community') + }) + + it('addToMyDecks calls copyDeckToUser', async () => { + render(DeckPreview, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument()) + const addBtn = screen.getByRole('button', { name: /Add to My decks/i }) + mockCopyDeckToUser.mockResolvedValue(undefined) + await fireEvent.click(addBtn) + await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1')) + }) + + it('addToMyDecks without userId shows sign in error', async () => { + auth.set({ user: null, loading: false, error: null }) + render(DeckPreview, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument()) + const addBtn = screen.getByRole('button', { name: /Add to My decks/i }) + await fireEvent.click(addBtn) + expect(screen.getByText(/Sign in to add this deck/)).toBeInTheDocument() + }) +}) diff --git a/src/routes/DeckReviews.test.js b/src/routes/DeckReviews.test.js new file mode 100644 index 0000000..01a580a --- /dev/null +++ b/src/routes/DeckReviews.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import DeckReviews from './DeckReviews.svelte' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), +})) + +const mockFetchDeckWithQuestions = vi.fn() +const mockGetDeckReviews = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args), + getDeckReviews: (...args) => mockGetDeckReviews(...args), +})) + +describe('DeckReviews', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'Test Deck', + published: true, + questions: [], + }) + mockGetDeckReviews.mockResolvedValue([]) + }) + + it('shows loading then deck title and no reviews', async () => { + render(DeckReviews, { props: { params: { id: 'd1' } } }) + expect(screen.getByText('Loading…')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText('Test Deck')).toBeInTheDocument()) + expect(screen.getByText('No reviews yet.')).toBeInTheDocument() + expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1') + expect(mockGetDeckReviews).toHaveBeenCalledWith('d1') + }) + + it('shows error when deck fetch fails', async () => { + mockFetchDeckWithQuestions.mockRejectedValue(new Error('Network error')) + render(DeckReviews, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()) + }) + + it('shows error when deck is not published', async () => { + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'Private', + published: false, + questions: [], + }) + render(DeckReviews, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('This deck is not available.')).toBeInTheDocument()) + }) + + it('renders reviews list and goBack calls push', async () => { + mockGetDeckReviews.mockResolvedValue([ + { + user_id: 'u1', + rating: 5, + comment: 'Great!', + display_name: 'Alice', + email: 'a@b.com', + avatar_url: null, + }, + ]) + render(DeckReviews, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()) + expect(screen.getByText('Great!')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: /Back to deck/i })) + expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview') + }) +}) diff --git a/src/routes/EditDeck.test.js b/src/routes/EditDeck.test.js new file mode 100644 index 0000000..f01ea91 --- /dev/null +++ b/src/routes/EditDeck.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import EditDeck from './EditDeck.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) })) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockFetchDeckWithQuestions = vi.fn() +const mockUpdateDeck = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args), + updateDeck: (...args) => mockUpdateDeck(...args), + togglePublished: vi.fn(), + deleteDeck: vi.fn(), +})) + +describe('EditDeck', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'My Deck', + description: 'Desc', + owner_id: 'u1', + published: false, + copied_from_deck_id: null, + config: {}, + questions: [{ prompt: 'Q?', explanation: '', answers: ['A'], correct_answer_indices: [0] }], + }) + }) + + it('shows loading then form when deck owned by user', async () => { + render(EditDeck, { props: { params: { id: 'd1' } } }) + expect(screen.getByText(/Loading deck/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument()) + }) + + it('redirects when deck owner is not current user', async () => { + mockFetchDeckWithQuestions.mockResolvedValue({ + id: 'd1', + title: 'Other', + owner_id: 'other-user', + questions: [], + }) + render(EditDeck, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/')) + }) + + it('cancel calls push to home', async () => { + render(EditDeck, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('save calls updateDeck and pushes home', async () => { + mockUpdateDeck.mockResolvedValue(undefined) + render(EditDeck, { props: { params: { id: 'd1' } } }) + await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(mockUpdateDeck).toHaveBeenCalledWith('d1', expect.any(Object))) + expect(mockPush).toHaveBeenCalledWith('/') + }) +}) diff --git a/src/routes/MyDecks.test.js b/src/routes/MyDecks.test.js new file mode 100644 index 0000000..8a21264 --- /dev/null +++ b/src/routes/MyDecks.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import MyDecks from './MyDecks.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) })) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockFetchMyDecks = vi.fn() +const mockTogglePublished = vi.fn() +const mockGetMyDeckRating = vi.fn() +const mockSubmitDeckRating = vi.fn() +const mockGetSourceUpdatePreview = vi.fn() +const mockApplySourceUpdate = vi.fn() +const mockDeleteDeck = vi.fn() +vi.mock('../lib/api/decks.js', () => ({ + fetchMyDecks: (...args) => mockFetchMyDecks(...args), + togglePublished: (...args) => mockTogglePublished(...args), + getMyDeckRating: (...args) => mockGetMyDeckRating(...args), + submitDeckRating: (...args) => mockSubmitDeckRating(...args), + getSourceUpdatePreview: (...args) => mockGetSourceUpdatePreview(...args), + applySourceUpdate: (...args) => mockApplySourceUpdate(...args), + deleteDeck: (...args) => mockDeleteDeck(...args), +})) + +describe('MyDecks', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockFetchMyDecks.mockResolvedValue([]) + }) + + it('shows sign in message when no user', () => { + auth.set({ user: null, loading: false, error: null }) + render(MyDecks) + expect(screen.getByText(/Sign in to create and manage your decks/)).toBeInTheDocument() + }) + + it('shows loading then empty message when no decks', async () => { + render(MyDecks) + expect(screen.getByText(/Loading/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/No decks yet/)).toBeInTheDocument()) + expect(mockFetchMyDecks).toHaveBeenCalledWith('u1') + }) + + it('shows error when fetch fails', async () => { + mockFetchMyDecks.mockRejectedValue(new Error('Failed to load')) + render(MyDecks) + await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument()) + }) + + it('renders deck list and goEdit navigates to edit', async () => { + mockFetchMyDecks.mockResolvedValue([ + { id: 'd1', title: 'Deck One', description: '', question_count: 2, copied_from_deck_id: null }, + ]) + render(MyDecks) + await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument()) + await fireEvent.click(screen.getByTitle('Edit deck')) + expect(mockPush).toHaveBeenCalledWith('/decks/d1/edit') + }) + + it('clicking card goes to preview', async () => { + mockFetchMyDecks.mockResolvedValue([ + { id: 'd1', title: 'Deck One', description: '', question_count: 2, copied_from_deck_id: null }, + ]) + render(MyDecks) + await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: /Deck One/ })) + expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview') + }) +}) diff --git a/src/routes/Settings.test.js b/src/routes/Settings.test.js new file mode 100644 index 0000000..b15495f --- /dev/null +++ b/src/routes/Settings.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Settings from './Settings.svelte' +import { auth } from '../lib/stores/auth.js' + +const mockPush = vi.fn() +vi.mock('svelte-spa-router', () => ({ + push: (...args) => mockPush(...args), +})) + +vi.mock('../lib/stores/auth.js', () => { + const { writable } = require('svelte/store') + const store = writable({ user: { id: 'u1' }, loading: false, error: null }) + return { auth: { subscribe: store.subscribe, set: store.set } } +}) + +const mockGetProfile = vi.fn() +const mockUpdateProfile = vi.fn() +const mockUploadAvatar = vi.fn() +vi.mock('../lib/api/profile.js', () => ({ + getProfile: (...args) => mockGetProfile(...args), + updateProfile: (...args) => mockUpdateProfile(...args), + uploadAvatar: (...args) => mockUploadAvatar(...args), +})) + +describe('Settings', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.set({ user: { id: 'u1' }, loading: false, error: null }) + mockGetProfile.mockResolvedValue({ + id: 'u1', + display_name: 'Test User', + email: 'test@example.com', + avatar_url: null, + }) + mockUpdateProfile.mockResolvedValue({}) + }) + + it('loads profile and shows display name', async () => { + render(Settings) + await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument()) + expect(mockGetProfile).toHaveBeenCalledWith('u1') + }) + + it('Cancel button calls push to home', async () => { + render(Settings) + await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument()) + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('shows error for non-image file', async () => { + render(Settings) + await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument()) + const input = document.querySelector('input[type="file"]') + expect(input).toBeTruthy() + const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' }) + await fireEvent.change(input, { target: { files: [file] } }) + expect(screen.getByText(/Please choose an image file/)).toBeInTheDocument() + }) + + it('save updates profile and shows success', async () => { + mockGetProfile + .mockResolvedValueOnce({ id: 'u1', display_name: 'Test User', email: 't@t.com', avatar_url: null }) + .mockResolvedValueOnce({ id: 'u1', display_name: 'New Name', email: 't@t.com', avatar_url: null }) + render(Settings) + await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument()) + const nameInput = screen.getByDisplayValue('Test User') + await fireEvent.input(nameInput, { target: { value: 'New Name' } }) + await fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'New Name', avatar_url: null }) + ) + await waitFor(() => expect(screen.getByText('Settings saved.')).toBeInTheDocument()) + }) +}) diff --git a/src/test/setup.js b/src/test/setup.js new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/test/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/vite.config.js b/vite.config.js index d32eba1..d1b7607 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,22 @@ import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [svelte()], -}) + resolve: { + // Use browser Svelte build in tests so mount() is available (avoid "lifecycle_function_unavailable") + conditions: mode === 'test' ? ['browser'] : [], + }, + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.js'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.{js,ts,svelte}'], + exclude: ['src/test/**', '**/*.test.js', '**/*.spec.js'], + }, + }, +}))