diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1059de8..33597e7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,4 +1,4 @@ -name: Lint +name: CI on: push: @@ -31,10 +31,29 @@ jobs: - name: Run format check run: npm run format:check + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test:run + build: name: Build runs-on: ubuntu-latest - needs: lint-and-format + needs: [lint-and-format, test] steps: - name: Check out Git repository diff --git a/components/ai/AIChat.vue b/components/ai/AIChat.vue index b8ea892..2698805 100644 --- a/components/ai/AIChat.vue +++ b/components/ai/AIChat.vue @@ -1,14 +1,78 @@ - @@ -104,6 +221,21 @@ const handleSendMessage = () => { background: $primary-light; color: $primary; border: 1px solid color.adjust($primary, $lightness: 35%); + + &.loading { + opacity: 0.7; + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 0.4; + } } .bubble.user { background: $bg-gray; @@ -142,4 +274,106 @@ const handleSendMessage = () => { background: $primary-hover; } } + +.message-text { + white-space: pre-wrap; +} + +.results-container { + margin-top: $spacing-2; + padding-top: $spacing-2; + border-top: 1px solid color.adjust($primary, $lightness: 30%); +} + +.result-scalar { + font-size: $font-size-2xl; + font-weight: $font-bold; + color: $primary; +} + +.result-pair, +.pair-row { + display: flex; + gap: $spacing-2; + padding: $spacing-1 0; + + .pair-label { + font-weight: $font-semibold; + color: $text-secondary; + } + + .pair-value { + font-weight: $font-bold; + } +} + +.result-record { + .record-row { + display: flex; + gap: $spacing-2; + padding: $spacing-1 0; + border-bottom: 1px solid color.adjust($primary, $lightness: 35%); + + &:last-child { + border-bottom: none; + } + + .record-key { + font-weight: $font-semibold; + color: $text-secondary; + min-width: 100px; + } + } +} + +.result-list { + margin: 0; + padding-left: $spacing-4; + + li { + padding: $spacing-1 0; + } +} + +.result-pair-list { + display: flex; + flex-direction: column; + gap: $spacing-1; +} + +.result-table-wrapper { + overflow-x: auto; + margin-top: $spacing-1; +} + +.result-table { + width: 100%; + border-collapse: collapse; + font-size: $font-size-sm; + + th, + td { + padding: $spacing-1 $spacing-2; + text-align: left; + border-bottom: 1px solid color.adjust($primary, $lightness: 35%); + } + + th { + font-weight: $font-semibold; + background: color.adjust($primary-light, $lightness: 5%); + } + + tr:last-child td { + border-bottom: none; + } +} + +.result-raw { + background: $bg-gray; + padding: $spacing-2; + border-radius: $radius-md; + overflow-x: auto; + font-size: $font-size-sm; + margin: 0; +} diff --git a/package-lock.json b/package-lock.json index b9a58df..55828aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,17 +22,22 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@nuxt/eslint": "^1.9.0", + "@nuxt/test-utils": "^3.21.0", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^8.45.0", "@typescript-eslint/parser": "^8.45.0", + "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^9.0.0", + "@vue/test-utils": "^2.4.6", "eslint": "^9.37.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vue": "^9.27.0", + "happy-dom": "^20.0.11", "prettier": "^3.3.3", - "sass": "^1.89.0" + "sass": "^1.89.0", + "vitest": "^3.2.4" } }, "node_modules/@antfu/install-pkg": { @@ -305,9 +310,10 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -333,11 +339,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -423,12 +430,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2695,6 +2703,153 @@ "node": ">=18.12.0" } }, + "node_modules/@nuxt/test-utils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-3.21.0.tgz", + "integrity": "sha512-A6XExfgHq88+XuXAU4MMr5QBHS2mWA5qRVSvsMPP2U+YSsnk+Vt7P7dxbvJPE4+n6LHbC1IM0QjTVteo+VCxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nuxt/kit": "^3.20.1", + "c12": "^3.3.2", + "consola": "^3.4.2", + "defu": "^6.1.4", + "destr": "^2.0.5", + "estree-walker": "^3.0.3", + "exsolve": "^1.0.8", + "fake-indexeddb": "^6.2.5", + "get-port-please": "^3.2.0", + "h3": "^1.15.4", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "node-fetch-native": "^1.6.7", + "node-mock-http": "^1.0.3", + "ofetch": "^1.5.1", + "pathe": "^2.0.3", + "perfect-debounce": "^2.0.0", + "radix3": "^1.1.2", + "scule": "^1.3.0", + "std-env": "^3.10.0", + "tinyexec": "^1.0.2", + "ufo": "^1.6.1", + "unplugin": "^2.3.11", + "vitest-environment-nuxt": "^1.0.1", + "vue": "^3.5.25" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@cucumber/cucumber": "^10.3.1 || >=11.0.0", + "@jest/globals": "^29.5.0 || >=30.0.0", + "@playwright/test": "^1.43.1", + "@testing-library/vue": "^7.0.0 || ^8.0.1", + "@vue/test-utils": "^2.4.2", + "happy-dom": "*", + "jsdom": "*", + "playwright-core": "^1.43.1", + "vitest": "^3.2.0" + }, + "peerDependenciesMeta": { + "@cucumber/cucumber": { + "optional": true + }, + "@jest/globals": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "@testing-library/vue": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "@vue/test-utils": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@nuxt/test-utils/node_modules/@nuxt/kit": { + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.20.2.tgz", + "integrity": "sha512-laqfmMcWWNV1FsVmm1+RQUoGY8NIJvCRl0z0K8ikqPukoEry0LXMqlQ+xaf8xJRvoH2/78OhZmsEEsUBTXipcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "c12": "^3.3.2", + "consola": "^3.4.2", + "defu": "^6.1.4", + "destr": "^2.0.5", + "errx": "^0.1.0", + "exsolve": "^1.0.8", + "ignore": "^7.0.5", + "jiti": "^2.6.1", + "klona": "^2.0.6", + "knitwork": "^1.3.0", + "mlly": "^1.8.0", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2", + "scule": "^1.3.0", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ufo": "^1.6.1", + "unctx": "^2.4.1", + "untyped": "^2.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@nuxt/test-utils/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@nuxt/test-utils/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nuxt/test-utils/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/@nuxt/vite-builder": { "version": "3.17.7", "resolved": "https://registry.npmjs.org/@nuxt/vite-builder/-/vite-builder-3.17.7.tgz", @@ -2740,6 +2895,19 @@ "vue": "^3.3.4" } }, + "node_modules/@nuxt/vite-builder/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, "node_modules/@nuxt/vite-builder/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2886,6 +3054,13 @@ } } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-parser/binding-android-arm64": { "version": "0.76.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.76.0.tgz", @@ -3483,9 +3658,10 @@ "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.38", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", - "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==" + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "license": "MIT" }, "node_modules/@rollup/plugin-alias": { "version": "5.1.1", @@ -4068,6 +4244,17 @@ "tslib": "^2.4.0" } }, + "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/conventional-commits-parser": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", @@ -4077,6 +4264,13 @@ "@types/node": "*" } }, + "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", @@ -4116,6 +4310,13 @@ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", @@ -4669,14 +4870,19 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, @@ -4698,6 +4904,121 @@ "vue": "^3.0.0" } }, + "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": "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/@vue-macros/common": { "version": "3.0.0-beta.15", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.0.0-beta.15.tgz", @@ -4772,43 +5093,59 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", - "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", - "dependencies": { - "@babel/parser": "^7.28.3", - "@vue/shared": "3.5.21", - "entities": "^4.5.0", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/@vue/compiler-core/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/@vue/compiler-dom": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", - "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", - "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", - "dependencies": { - "@babel/parser": "^7.28.3", - "@vue/compiler-core": "3.5.21", - "@vue/compiler-dom": "3.5.21", - "@vue/compiler-ssr": "3.5.21", - "@vue/shared": "3.5.21", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", "estree-walker": "^2.0.2", - "magic-string": "^0.30.18", + "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } @@ -4819,12 +5156,13 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", - "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/devtools-api": { @@ -4885,49 +5223,65 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", - "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", "dependencies": { - "@vue/shared": "3.5.21" + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", - "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", - "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.21", - "@vue/runtime-core": "3.5.21", - "@vue/shared": "3.5.21", - "csstype": "^3.1.3" + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", - "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { - "vue": "3.5.21" + "vue": "3.5.26" } }, "node_modules/@vue/shared": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", - "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==" + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", @@ -5250,6 +5604,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-kit": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.2.tgz", @@ -5553,17 +5917,18 @@ } }, "node_modules/c12": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.0.tgz", - "integrity": "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", + "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", + "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", - "dotenv": "^17.2.2", - "exsolve": "^1.0.7", + "dotenv": "^17.2.3", + "exsolve": "^1.0.8", "giget": "^2.0.0", - "jiti": "^2.5.1", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", @@ -5571,7 +5936,7 @@ "rc9": "^2.1.2" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -5579,10 +5944,26 @@ } } }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/c12/node_modules/dotenv": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", - "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -5595,6 +5976,19 @@ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==" }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5689,6 +6083,23 @@ } ] }, + "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/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5707,6 +6118,16 @@ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "dev": true }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5953,6 +6374,24 @@ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -6317,9 +6756,10 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, "node_modules/dargs": { "version": "8.1.0", @@ -6433,6 +6873,16 @@ } } }, + "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/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6663,6 +7113,61 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/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/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7838,10 +8343,21 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "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/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" }, "node_modules/externality": { "version": "1.0.2", @@ -7859,6 +8375,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8488,6 +9014,38 @@ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" }, + "node_modules/happy-dom": { + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -9367,13 +9925,72 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9507,9 +10124,10 @@ } }, "node_modules/knitwork": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.2.0.tgz", - "integrity": "sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.3.0.tgz", + "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", + "license": "MIT" }, "node_modules/launch-editor": { "version": "2.11.1", @@ -9745,6 +10363,13 @@ "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", "dev": true }, + "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": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9762,9 +10387,10 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -10532,13 +11158,14 @@ } }, "node_modules/ofetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", - "integrity": "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", "dependencies": { - "destr": "^2.0.3", - "node-fetch-native": "^1.6.4", - "ufo": "^1.5.4" + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" } }, "node_modules/ohash": { @@ -10859,6 +11486,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, + "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/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -11486,6 +12123,13 @@ "node": ">= 6" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/protocols": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", @@ -12021,9 +12665,10 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -12235,6 +12880,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -12375,6 +13027,13 @@ "node": ">=12.0.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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -12389,9 +13048,10 @@ } }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==" + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", @@ -12839,10 +13499,21 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.14", @@ -12859,6 +13530,36 @@ "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13166,9 +13867,10 @@ } }, "node_modules/unplugin": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz", - "integrity": "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==", + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", @@ -13669,21 +14371,112 @@ "vue": "^3.5.0" } }, + "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/vitest-environment-nuxt": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vitest-environment-nuxt/-/vitest-environment-nuxt-1.0.1.tgz", + "integrity": "sha512-eBCwtIQriXW5/M49FjqNKfnlJYlG2LWMSNFsRVKomc8CaMqmhQPBS5LZ9DlgYL9T8xIVsiA6RZn2lk7vxov3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nuxt/test-utils": ">=3.13.1" + } + }, + "node_modules/vitest/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/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" }, "node_modules/vue": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", - "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.21", - "@vue/compiler-sfc": "3.5.21", - "@vue/runtime-dom": "3.5.21", - "@vue/server-renderer": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" @@ -13702,6 +14495,13 @@ "ufo": "^1.6.1" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-devtools-stub": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz", @@ -13764,6 +14564,16 @@ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -13872,6 +14682,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 835a139..3ffbe6a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write ./", - "format:check": "prettier --check ./" + "format:check": "prettier --check ./", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@dicebear/collection": "^9.2.4", @@ -30,16 +33,21 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@nuxt/eslint": "^1.9.0", + "@nuxt/test-utils": "^3.21.0", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^8.45.0", "@typescript-eslint/parser": "^8.45.0", + "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^9.0.0", + "@vue/test-utils": "^2.4.6", "eslint": "^9.37.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vue": "^9.27.0", + "happy-dom": "^20.0.11", "prettier": "^3.3.3", - "sass": "^1.89.0" + "sass": "^1.89.0", + "vitest": "^3.2.4" } } diff --git a/services/api/aiApi.ts b/services/api/aiApi.ts new file mode 100644 index 0000000..ad9573d --- /dev/null +++ b/services/api/aiApi.ts @@ -0,0 +1,35 @@ +export type FormatType = 'scalar' | 'pair' | 'record' | 'list' | 'pair_list' | 'table' | 'raw'; + +export interface AskResponse { + success: boolean; + data?: { + answer: string; + format_type: FormatType | null; + results: Record[]; + }; + message?: string; +} + +export interface HealthResponse { + available: boolean; +} + +const aiApi = { + async ask(message: string): Promise { + const api = useApi(); + const response = await api('/ai/chat', { + method: 'POST', + body: { message } + }); + return response; + }, + + async checkHealth(): Promise { + const api = useApi(); + const response = await api('/ai/health'); + return response; + } +}; + +export default aiApi; +export { aiApi }; diff --git a/tests/components/AIChat.test.ts b/tests/components/AIChat.test.ts new file mode 100644 index 0000000..5102ae7 --- /dev/null +++ b/tests/components/AIChat.test.ts @@ -0,0 +1,440 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import AIChat from '@/components/ai/AIChat.vue'; + +const mockAsk = vi.fn(); +vi.mock('@/services/api/aiApi', () => ({ + aiApi: { + ask: (...args: unknown[]) => mockAsk(...args) + } +})); + +describe('AIChat', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders initial greeting message', () => { + const wrapper = mount(AIChat); + + expect(wrapper.text()).toContain('Hello! I am your personal financial assistant'); + }); + + it('displays user message when sent', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Test response', + format_type: 'scalar', + results: [{ total: 100 }] + } + }); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('How much did I spend?'); + await form.trigger('submit'); + + expect(wrapper.text()).toContain('How much did I spend?'); + }); + + it('shows loading state while waiting for response', async () => { + let resolvePromise: (value: unknown) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockAsk.mockReturnValueOnce(promise); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('Test question'); + await form.trigger('submit'); + await nextTick(); + + expect(wrapper.text()).toContain('Thinking...'); + + resolvePromise!({ + success: true, + data: { answer: 'Response', format_type: null, results: [] } + }); + await flushPromises(); + + expect(wrapper.text()).not.toContain('Thinking...'); + }); + + it('displays AI response after successful request', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'You spent $500 on food last month.', + format_type: 'scalar', + results: [{ total: 500 }] + } + }); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('How much on food?'); + await form.trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('You spent $500 on food last month.'); + }); + + it('displays error message on failed request', async () => { + mockAsk.mockResolvedValueOnce({ + success: false, + message: 'Service unavailable' + }); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('Test'); + await form.trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('Service unavailable'); + }); + + it('displays fallback error on exception', async () => { + mockAsk.mockRejectedValueOnce(new Error('Network error')); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('Test'); + await form.trigger('submit'); + await flushPromises(); + + expect(wrapper.text()).toContain('AI service is currently unavailable'); + }); + + it('disables input and button while loading', async () => { + let resolvePromise: (value: unknown) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockAsk.mockReturnValueOnce(promise); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const button = wrapper.find('.send-btn'); + const form = wrapper.find('form'); + + await input.setValue('Test'); + await form.trigger('submit'); + await nextTick(); + + expect(input.attributes('disabled')).toBeDefined(); + expect(button.attributes('disabled')).toBeDefined(); + + resolvePromise!({ + success: true, + data: { answer: 'Done', format_type: null, results: [] } + }); + await flushPromises(); + + expect(input.attributes('disabled')).toBeUndefined(); + }); + + it('does not send empty messages', async () => { + const wrapper = mount(AIChat); + const form = wrapper.find('form'); + + await form.trigger('submit'); + + expect(mockAsk).not.toHaveBeenCalled(); + }); + + it('clears input after sending message', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { answer: 'Response', format_type: null, results: [] } + }); + + const wrapper = mount(AIChat); + const input = wrapper.find('.chat-input'); + const form = wrapper.find('form'); + + await input.setValue('Test message'); + await form.trigger('submit'); + + expect((input.element as HTMLInputElement).value).toBe(''); + }); +}); + +describe('AIChat format type rendering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders scalar format as large bold value', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Your total spending is:', + format_type: 'scalar', + results: [{ total: 5000 }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Total?'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-scalar').exists()).toBe(true); + expect(wrapper.find('.result-scalar').text()).toContain('5000'); + }); + + it('renders pair format as label-value', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Top category:', + format_type: 'pair', + results: [{ category: 'Food', amount: 1500 }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Top category?'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-pair').exists()).toBe(true); + expect(wrapper.find('.pair-label').text()).toContain('category'); + }); + + it('renders record format as key-value rows', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Transaction details:', + format_type: 'record', + results: [{ id: 1, name: 'Rent', amount: 2000, date: '2025-01-01' }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Show transaction'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-record').exists()).toBe(true); + expect(wrapper.findAll('.record-row').length).toBe(4); + }); + + it('renders list format as bullet points', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Your categories:', + format_type: 'list', + results: [{ name: 'Food' }, { name: 'Transport' }, { name: 'Entertainment' }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('List categories'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-list').exists()).toBe(true); + expect(wrapper.findAll('.result-list li').length).toBe(3); + }); + + it('renders pair_list format as multiple label-value pairs', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Spending by category:', + format_type: 'pair_list', + results: [ + { category: 'Food', total: 500 }, + { category: 'Transport', total: 200 } + ] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Breakdown'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-pair-list').exists()).toBe(true); + expect(wrapper.findAll('.pair-row').length).toBe(2); + }); + + it('renders table format with headers and rows', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Recent transactions:', + format_type: 'table', + results: [ + { date: '2025-01-01', description: 'Groceries', amount: 50 }, + { date: '2025-01-02', description: 'Gas', amount: 30 } + ] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Show transactions'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-table').exists()).toBe(true); + expect(wrapper.findAll('.result-table th').length).toBe(3); + expect(wrapper.findAll('.result-table tbody tr').length).toBe(2); + }); + + it('renders raw format as JSON', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Raw data:', + format_type: 'raw', + results: [{ complex: { nested: 'data' } }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Raw data'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-raw').exists()).toBe(true); + expect(wrapper.find('.result-raw').text()).toContain('nested'); + }); + + it('does not render results container when results are empty', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'No data found.', + format_type: 'table', + results: [] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Empty query'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.results-container').exists()).toBe(false); + }); + + it('does not render results when format_type is null', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Just a text response.', + format_type: null, + results: [{ data: 'value' }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Text only'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.results-container').exists()).toBe(false); + }); +}); + +describe('AIChat formatValue helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('formats decimal numbers with two decimal places', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Amount:', + format_type: 'scalar', + results: [{ amount: 1234.5 }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Test'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-scalar').text()).toContain('1,234.50'); + }); + + it('formats integer numbers without decimal places', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Count:', + format_type: 'scalar', + results: [{ count: 42 }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Test'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-scalar').text()).toBe('42'); + }); + + it('displays dash for null values', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Record:', + format_type: 'record', + results: [{ name: 'Test', value: null }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Test'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.result-record').text()).toContain('-'); + }); +}); + +describe('AIChat formatKey helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('converts snake_case to Title Case', async () => { + mockAsk.mockResolvedValueOnce({ + success: true, + data: { + answer: 'Record:', + format_type: 'record', + results: [{ total_amount: 100 }] + } + }); + + const wrapper = mount(AIChat); + await wrapper.find('.chat-input').setValue('Test'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(wrapper.find('.record-key').text()).toContain('Total Amount'); + }); +}); diff --git a/tests/components/TButton.test.ts b/tests/components/TButton.test.ts new file mode 100644 index 0000000..4519971 --- /dev/null +++ b/tests/components/TButton.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import TButton from '@/components/TButton.vue'; + +const stubs = { + NuxtLink: { + template: '', + props: ['to'] + }, + Loader2: { + template: 'Loading...' + } +}; + +describe('TButton', () => { + describe('rendering', () => { + it('renders with default props', () => { + const wrapper = mount(TButton, { global: { stubs } }); + + expect(wrapper.find('button').exists()).toBe(true); + expect(wrapper.text()).toContain('Button'); + }); + + it('renders custom text via prop', () => { + const wrapper = mount(TButton, { + props: { text: 'Submit' }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Submit'); + }); + + it('renders slot content instead of text prop', () => { + const wrapper = mount(TButton, { + slots: { default: 'Click Me' }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Click Me'); + }); + + it('renders as NuxtLink when "to" prop is provided', () => { + const wrapper = mount(TButton, { + props: { to: '/dashboard' }, + global: { stubs } + }); + + expect(wrapper.find('a').exists()).toBe(true); + expect(wrapper.find('button').exists()).toBe(false); + }); + }); + + describe('variants', () => { + it('applies primary variant class by default', () => { + const wrapper = mount(TButton, { global: { stubs } }); + + expect(wrapper.classes()).toContain('button--primary'); + }); + + it('applies secondary variant class', () => { + const wrapper = mount(TButton, { + props: { variant: 'secondary' }, + global: { stubs } + }); + + expect(wrapper.classes()).toContain('button--secondary'); + }); + + it('applies outline variant class', () => { + const wrapper = mount(TButton, { + props: { variant: 'outline' }, + global: { stubs } + }); + + expect(wrapper.classes()).toContain('button--outline'); + }); + + it('applies text variant class', () => { + const wrapper = mount(TButton, { + props: { variant: 'text' }, + global: { stubs } + }); + + expect(wrapper.classes()).toContain('button--text'); + }); + }); + + describe('sizes', () => { + it('applies medium size class by default', () => { + const wrapper = mount(TButton, { global: { stubs } }); + + expect(wrapper.classes()).toContain('button--medium'); + }); + + it('applies small size class', () => { + const wrapper = mount(TButton, { + props: { size: 'small' }, + global: { stubs } + }); + + expect(wrapper.classes()).toContain('button--small'); + }); + + it('applies large size class', () => { + const wrapper = mount(TButton, { + props: { size: 'large' }, + global: { stubs } + }); + + expect(wrapper.classes()).toContain('button--large'); + }); + }); + + describe('states', () => { + it('applies full-width class by default', () => { + const wrapper = mount(TButton, { global: { stubs } }); + + expect(wrapper.classes()).toContain('button--full-width'); + }); + + it('does not apply full-width class when disabled', () => { + const wrapper = mount(TButton, { + props: { fullWidth: false }, + global: { stubs } + }); + + expect(wrapper.classes()).not.toContain('button--full-width'); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(TButton, { + props: { disabled: true }, + global: { stubs } + }); + + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + expect(wrapper.classes()).toContain('button--disabled'); + }); + + it('is disabled when loading prop is true', () => { + const wrapper = mount(TButton, { + props: { loading: true }, + global: { stubs } + }); + + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + expect(wrapper.classes()).toContain('button--disabled'); + }); + + it('shows loading spinner when loading', () => { + const wrapper = mount(TButton, { + props: { loading: true }, + global: { stubs } + }); + + expect(wrapper.find('.spinner').exists()).toBe(true); + }); + + it('hides slot content when loading', () => { + const wrapper = mount(TButton, { + props: { loading: true }, + slots: { default: 'Submit' }, + global: { stubs } + }); + + expect(wrapper.text()).not.toContain('Submit'); + }); + }); + + describe('button type', () => { + it('has type="button" by default', () => { + const wrapper = mount(TButton, { global: { stubs } }); + + expect(wrapper.find('button').attributes('type')).toBe('button'); + }); + + it('can be type="submit"', () => { + const wrapper = mount(TButton, { + props: { type: 'submit' }, + global: { stubs } + }); + + expect(wrapper.find('button').attributes('type')).toBe('submit'); + }); + }); + + describe('left icon slot', () => { + it('renders left icon slot content', () => { + const wrapper = mount(TButton, { + slots: { + 'left-icon': 'Icon' + }, + global: { stubs } + }); + + expect(wrapper.find('.button__icon-left').exists()).toBe(true); + expect(wrapper.find('.test-icon').exists()).toBe(true); + }); + }); +}); diff --git a/tests/components/TransactionForm.test.ts b/tests/components/TransactionForm.test.ts new file mode 100644 index 0000000..f01daf0 --- /dev/null +++ b/tests/components/TransactionForm.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import TransactionForm from '@/components/TransactionForm.vue'; + +const mockSharedData = { + parties: ref([ + { id: 1, name: 'Grocery Store' }, + { id: 2, name: 'Gas Station' } + ]), + groups: ref([ + { id: 1, name: 'Food & Dining' }, + { id: 2, name: 'Transportation' } + ]), + wallets: ref([ + { id: 1, name: 'Main Wallet', currency: 'USD' }, + { id: 2, name: 'Savings', currency: 'USD' } + ]), + getExpenseCategories: ref([ + { id: 1, name: 'Groceries', type: 'expense' }, + { id: 2, name: 'Gas', type: 'expense' } + ]), + getIncomeCategories: ref([ + { id: 3, name: 'Salary', type: 'income' }, + { id: 4, name: 'Freelance', type: 'income' } + ]), + getDefaultCurrency: ref('USD'), + getDefaultWallet: ref({ id: 1, name: 'Main Wallet', currency: 'USD' }), + getDefaultGroup: ref({ id: 1, name: 'Food & Dining' }), + loadAllData: vi.fn().mockResolvedValue(undefined) +}; + +vi.mock('~/composables/useSharedData', () => ({ + useSharedData: () => mockSharedData +})); + +const stubs = { + TButton: { + template: + '', + props: ['text'] + }, + SearchableDropdown: { + template: + '
', + props: ['modelValue', 'label', 'options', 'placeholder', 'multiple', 'error', 'disabled'], + emits: ['update:modelValue', 'select'] + }, + CheckIcon: { template: '' }, + PencilIcon: { template: '' } +}; + +describe('TransactionForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the form with all required fields', () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + expect(wrapper.find('input[type="number"]').exists()).toBe(true); + expect(wrapper.find('textarea').exists()).toBe(true); + expect(wrapper.find('input[type="date"]').exists()).toBe(true); + expect(wrapper.find('input[type="time"]').exists()).toBe(true); + expect(wrapper.find('select').exists()).toBe(true); + }); + + it('shows "Record expense" button when isOutcomeSelected is true', () => { + const wrapper = mount(TransactionForm, { + props: { isOutcomeSelected: true }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Record expense'); + }); + + it('shows "Record income" button when isOutcomeSelected is false', () => { + const wrapper = mount(TransactionForm, { + props: { isOutcomeSelected: false }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Record income'); + }); + + it('shows "Update expense" button when editing an expense', () => { + const wrapper = mount(TransactionForm, { + props: { + isOutcomeSelected: true, + editingItem: { id: 1, amount: '100 USD' } + }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Update expense'); + }); + + it('shows "Update income" button when editing an income', () => { + const wrapper = mount(TransactionForm, { + props: { + isOutcomeSelected: false, + editingItem: { id: 1, amount: '100 USD' } + }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Update income'); + }); + }); + + describe('form fields', () => { + it('initializes with current date', () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + const dateInput = wrapper.find('input[type="date"]'); + const today = new Date().toISOString().slice(0, 10); + expect(dateInput.element.value).toBe(today); + }); + + it('allows entering amount', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + const amountInput = wrapper.find('input[type="number"]'); + await amountInput.setValue('250'); + expect(amountInput.element.value).toBe('250'); + }); + + it('allows entering description', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + const textarea = wrapper.find('textarea'); + await textarea.setValue('Grocery shopping'); + expect(textarea.element.value).toBe('Grocery shopping'); + }); + + it('has currency selector with available currencies', () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + const select = wrapper.find('select'); + const options = select.findAll('option'); + expect(options.length).toBeGreaterThan(0); + }); + }); + + describe('validation', () => { + it('shows error when amount is empty on submit', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + await wrapper.find('button').trigger('click'); + + expect(wrapper.text()).toContain('Enter a valid amount greater than 0'); + }); + + it('shows error when amount is zero', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + await wrapper.find('input[type="number"]').setValue('0'); + await wrapper.find('button').trigger('click'); + + expect(wrapper.text()).toContain('Enter a valid amount greater than 0'); + }); + + it('shows error when amount is negative', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + await wrapper.find('input[type="number"]').setValue('-50'); + await wrapper.find('button').trigger('click'); + + expect(wrapper.text()).toContain('Enter a valid amount greater than 0'); + }); + + it('does not emit submit when validation fails', async () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + await wrapper.find('button').trigger('click'); + + expect(wrapper.emitted('submit')).toBeFalsy(); + }); + }); + + describe('form submission', () => { + it('emits submit with correct payload for expense', async () => { + const wrapper = mount(TransactionForm, { + props: { isOutcomeSelected: true }, + global: { stubs } + }); + + await wrapper.find('input[type="number"]').setValue('100'); + await wrapper.find('textarea').setValue('Test expense'); + await wrapper.find('button').trigger('click'); + + expect(wrapper.emitted('submit')).toBeTruthy(); + const payload = wrapper.emitted('submit')?.[0]?.[0]; + expect(payload.type).toBe('EXPENSE'); + expect(payload.amount).toContain('100'); + }); + + it('emits submit with correct payload for income', async () => { + const wrapper = mount(TransactionForm, { + props: { isOutcomeSelected: false }, + global: { stubs } + }); + + await wrapper.find('input[type="number"]').setValue('500'); + await wrapper.find('textarea').setValue('Test income'); + await wrapper.find('button').trigger('click'); + + expect(wrapper.emitted('submit')).toBeTruthy(); + const payload = wrapper.emitted('submit')?.[0]?.[0]; + expect(payload.type).toBe('INCOME'); + expect(payload.amount).toContain('500'); + }); + + it('includes id in payload when editing', async () => { + const wrapper = mount(TransactionForm, { + props: { + isOutcomeSelected: true, + editingItem: { id: 123, amount: '100 USD' } + }, + global: { stubs } + }); + + await wrapper.find('input[type="number"]').setValue('150'); + await wrapper.find('button').trigger('click'); + + const payload = wrapper.emitted('submit')?.[0]?.[0]; + expect(payload.id).toBe(123); + }); + }); + + describe('file attachments', () => { + it('renders file upload section', () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + expect(wrapper.find('input[type="file"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Browse files'); + }); + + it('shows hint for file types', () => { + const wrapper = mount(TransactionForm, { + global: { stubs } + }); + + expect(wrapper.text()).toContain('Images, PDFs or docs'); + }); + }); + + describe('editing mode', () => { + it('populates form with existing data when editing', async () => { + const wrapper = mount(TransactionForm, { + props: { + editingItem: { + id: 1, + date: '2025-01-15', + time: '14:30', + amount: '250.50 USD', + description: 'Test transaction' + } + }, + global: { stubs } + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('input[type="date"]').element.value).toBe('2025-01-15'); + expect(wrapper.find('input[type="time"]').element.value).toBe('14:30'); + expect(wrapper.find('textarea').element.value).toBe('Test transaction'); + }); + }); +}); diff --git a/tests/pages/categories.test.ts b/tests/pages/categories.test.ts new file mode 100644 index 0000000..8f2b201 --- /dev/null +++ b/tests/pages/categories.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; + +const mockCategories = ref([ + { id: 1, name: 'Groceries', type: 'expense', icon: 'shopping-cart' }, + { id: 2, name: 'Restaurants', type: 'expense', icon: 'utensils' }, + { id: 3, name: 'Salary', type: 'income', icon: 'briefcase' }, + { id: 4, name: 'Freelance', type: 'income', icon: 'laptop' }, + { id: 5, name: 'Gas', type: 'expense', icon: 'car' } +]); + +const mockUseCategories = { + categories: mockCategories, + isLoading: ref(false), + error: ref(null), + fetchCategories: vi.fn().mockResolvedValue(undefined), + createCategory: vi.fn().mockResolvedValue({ id: 6, name: 'New Category' }), + updateCategory: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Category' }), + deleteCategory: vi.fn().mockResolvedValue(undefined) +}; + +const mockNotifications = { + confirmDelete: vi.fn().mockResolvedValue(true), + showSuccess: vi.fn(), + showError: vi.fn() +}; + +vi.mock('@/composables/useCategories', () => ({ + useCategories: () => mockUseCategories +})); + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: () => mockNotifications +})); + +vi.mock('@/composables/useSidebar', () => ({ + useSidebar: () => ({ + isTabletOrBelow: ref(false) + }) +})); + +describe('Categories Page Logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCategories.value = [ + { id: 1, name: 'Groceries', type: 'expense', icon: 'shopping-cart' }, + { id: 2, name: 'Restaurants', type: 'expense', icon: 'utensils' }, + { id: 3, name: 'Salary', type: 'income', icon: 'briefcase' }, + { id: 4, name: 'Freelance', type: 'income', icon: 'laptop' }, + { id: 5, name: 'Gas', type: 'expense', icon: 'car' } + ]; + }); + + describe('useCategories composable', () => { + it('returns categories list', () => { + const { categories } = mockUseCategories; + expect(categories.value).toHaveLength(5); + }); + + it('fetchCategories loads category data', async () => { + await mockUseCategories.fetchCategories(); + expect(mockUseCategories.fetchCategories).toHaveBeenCalled(); + }); + + it('createCategory adds new category', async () => { + const newCategory = { name: 'Shopping', type: 'expense', icon: 'bag' }; + await mockUseCategories.createCategory(newCategory); + expect(mockUseCategories.createCategory).toHaveBeenCalledWith(newCategory); + }); + + it('updateCategory modifies existing category', async () => { + const updateData = { name: 'Updated Name', icon: 'star' }; + await mockUseCategories.updateCategory(1, updateData); + expect(mockUseCategories.updateCategory).toHaveBeenCalledWith(1, updateData); + }); + + it('deleteCategory removes category', async () => { + await mockUseCategories.deleteCategory(1); + expect(mockUseCategories.deleteCategory).toHaveBeenCalledWith(1); + }); + }); + + describe('category filtering by type', () => { + it('filters expense categories', () => { + const expenseCategories = mockCategories.value.filter((c) => c.type === 'expense'); + expect(expenseCategories).toHaveLength(3); + expect(expenseCategories.every((c) => c.type === 'expense')).toBe(true); + }); + + it('filters income categories', () => { + const incomeCategories = mockCategories.value.filter((c) => c.type === 'income'); + expect(incomeCategories).toHaveLength(2); + expect(incomeCategories.every((c) => c.type === 'income')).toBe(true); + }); + + it('returns correct expense category names', () => { + const expenseCategories = mockCategories.value.filter((c) => c.type === 'expense'); + const names = expenseCategories.map((c) => c.name); + expect(names).toContain('Groceries'); + expect(names).toContain('Restaurants'); + expect(names).toContain('Gas'); + }); + + it('returns correct income category names', () => { + const incomeCategories = mockCategories.value.filter((c) => c.type === 'income'); + const names = incomeCategories.map((c) => c.name); + expect(names).toContain('Salary'); + expect(names).toContain('Freelance'); + }); + }); + + describe('category operations', () => { + it('shows confirmation before deleting', async () => { + await mockNotifications.confirmDelete('category'); + expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('category'); + }); + + it('shows success notification after deletion', () => { + mockNotifications.showSuccess('Category deleted', 'Groceries has been deleted successfully'); + expect(mockNotifications.showSuccess).toHaveBeenCalled(); + }); + + it('does not delete when confirmation is cancelled', async () => { + mockNotifications.confirmDelete.mockResolvedValueOnce(false); + + const confirmed = await mockNotifications.confirmDelete('category'); + + if (!confirmed) { + expect(mockUseCategories.deleteCategory).not.toHaveBeenCalled(); + } + }); + }); + + describe('category data structure', () => { + it('category has required fields', () => { + const category = mockCategories.value[0]; + expect(category).toHaveProperty('id'); + expect(category).toHaveProperty('name'); + expect(category).toHaveProperty('type'); + }); + + it('category type is either income or expense', () => { + mockCategories.value.forEach((category) => { + expect(['income', 'expense']).toContain(category.type); + }); + }); + + it('category can have optional icon', () => { + const category = mockCategories.value[0]; + expect(category).toHaveProperty('icon'); + }); + }); +}); + +describe('Category Form Validation', () => { + it('requires category name', () => { + const categoryData = { name: '', type: 'expense' }; + const isValid = categoryData.name.trim().length > 0; + expect(isValid).toBe(false); + }); + + it('accepts valid category name', () => { + const categoryData = { name: 'My Category', type: 'expense' }; + const isValid = categoryData.name.trim().length > 0; + expect(isValid).toBe(true); + }); + + it('requires type selection', () => { + const categoryData = { name: 'My Category', type: '' }; + const isValid = ['income', 'expense'].includes(categoryData.type); + expect(isValid).toBe(false); + }); + + it('accepts expense type', () => { + const categoryData = { name: 'My Category', type: 'expense' }; + const isValid = ['income', 'expense'].includes(categoryData.type); + expect(isValid).toBe(true); + }); + + it('accepts income type', () => { + const categoryData = { name: 'My Category', type: 'income' }; + const isValid = ['income', 'expense'].includes(categoryData.type); + expect(isValid).toBe(true); + }); +}); + +describe('Category Search/Filter', () => { + it('can search categories by name', () => { + const searchQuery = 'groc'; + const filtered = mockCategories.value.filter((c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('Groceries'); + }); + + it('returns empty array for no matches', () => { + const searchQuery = 'xyz'; + const filtered = mockCategories.value.filter((c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(0); + }); + + it('can combine type filter and search', () => { + const searchQuery = 'sal'; + const typeFilter = 'income'; + const filtered = mockCategories.value.filter( + (c) => c.type === typeFilter && c.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('Salary'); + }); +}); + +describe('Category Statistics', () => { + it('counts total categories', () => { + expect(mockCategories.value.length).toBe(5); + }); + + it('counts expense categories', () => { + const count = mockCategories.value.filter((c) => c.type === 'expense').length; + expect(count).toBe(3); + }); + + it('counts income categories', () => { + const count = mockCategories.value.filter((c) => c.type === 'income').length; + expect(count).toBe(2); + }); +}); diff --git a/tests/pages/dashboard.test.ts b/tests/pages/dashboard.test.ts new file mode 100644 index 0000000..a429152 --- /dev/null +++ b/tests/pages/dashboard.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import DashboardKPIs from '@/components/dashboard/DashboardKPIs.vue'; + +const mockStatistics = { + total_balance: 5000, + total_income: 8000, + total_expenses: 3000, + top_categories: [ + { name: 'Food', amount: 1200 }, + { name: 'Transport', amount: 800 } + ] +}; + +const mockUseStatistics = { + currentStatistics: ref(mockStatistics), + currentPeriod: ref('month'), + customFilters: ref(null), + isLoading: ref(false), + error: ref(null), + selectedWalletId: ref(null), + availableWallets: ref([ + { id: null, name: 'All Wallets' }, + { id: 1, name: 'Main Wallet' } + ]), + formatCompactCurrency: (val: number) => `$${val.toLocaleString()}`, + setSelectedWallet: vi.fn(), + setCustomFilters: vi.fn(), + clearCustomFilters: vi.fn(), + setPeriod: vi.fn() +}; + +vi.mock('@/composables/useStatistics', () => ({ + useStatistics: () => mockUseStatistics +})); + +vi.mock('@/composables/useWallets', () => ({ + useWallets: () => ({ + wallets: ref([]) + }) +})); + +vi.mock('@/composables/useTransactions', () => ({ + useTransactions: () => ({ + transactions: ref([]), + recentTransactions: ref([]), + isLoading: ref(false) + }) +})); + +vi.mock('@/composables/useSharedData', () => ({ + useSharedData: () => ({ + loadAllData: vi.fn() + }) +})); + +const stubs = { + ChevronDown: { template: '' }, + XIcon: { template: '' }, + NuxtLink: { template: '' } +}; + +describe('DashboardKPIs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders KPI cards', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.find('.kpi-grid').exists()).toBe(true); + expect(wrapper.findAll('.kpi-card').length).toBe(4); + }); + + it('displays Balance KPI', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.text()).toContain('Balance'); + expect(wrapper.text()).toContain('5,000'); + }); + + it('displays Income KPI with positive styling', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.text()).toContain('Income'); + expect(wrapper.text()).toContain('8,000'); + expect(wrapper.find('.kpi-value.is-positive').exists()).toBe(true); + }); + + it('displays Expenses KPI with negative styling', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.text()).toContain('Expenses'); + expect(wrapper.text()).toContain('3,000'); + expect(wrapper.find('.kpi-value.is-negative').exists()).toBe(true); + }); + + it('displays Net value (income - expenses)', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.text()).toContain('Net'); + expect(wrapper.text()).toContain('5,000'); + }); + }); + + describe('wallet selector', () => { + it('renders wallet selector', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.find('.wallet-selector').exists()).toBe(true); + }); + + it('shows "All Wallets" by default', () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.find('.wallet-name').text()).toBe('All Wallets'); + }); + + it('toggles dropdown on click', async () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + expect(wrapper.find('.wallet-dropdown').exists()).toBe(false); + + await wrapper.find('.wallet-selector').trigger('click'); + + expect(wrapper.find('.wallet-dropdown').exists()).toBe(true); + }); + + it('shows wallet options in dropdown', async () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + await wrapper.find('.wallet-selector').trigger('click'); + + const options = wrapper.findAll('.wallet-option'); + expect(options.length).toBe(2); + expect(options[0].text()).toBe('All Wallets'); + expect(options[1].text()).toBe('Main Wallet'); + }); + + it('calls setSelectedWallet when option is clicked', async () => { + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + await wrapper.find('.wallet-selector').trigger('click'); + await wrapper.findAll('.wallet-option')[1].trigger('click'); + + expect(mockUseStatistics.setSelectedWallet).toHaveBeenCalledWith(1); + }); + }); + + describe('net value styling', () => { + it('applies positive class when net is positive', () => { + mockUseStatistics.currentStatistics.value = { + ...mockStatistics, + total_income: 5000, + total_expenses: 2000 + }; + + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + const netCard = wrapper.findAll('.kpi-card')[3]; + expect(netCard.find('.is-positive').exists()).toBe(true); + }); + + it('applies negative class when net is negative', () => { + mockUseStatistics.currentStatistics.value = { + ...mockStatistics, + total_income: 2000, + total_expenses: 5000 + }; + + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + const netCard = wrapper.findAll('.kpi-card')[3]; + expect(netCard.find('.is-negative').exists()).toBe(true); + }); + }); + + describe('empty state', () => { + it('displays zero values when no statistics', () => { + mockUseStatistics.currentStatistics.value = null; + + const wrapper = mount(DashboardKPIs, { + global: { stubs } + }); + + const values = wrapper.findAll('.kpi-value'); + values.forEach((value) => { + expect(value.text()).toContain('0'); + }); + }); + }); +}); diff --git a/tests/pages/groups.test.ts b/tests/pages/groups.test.ts new file mode 100644 index 0000000..35aa126 --- /dev/null +++ b/tests/pages/groups.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; + +const mockGroups = ref([ + { id: 1, name: 'Food & Dining', icon: 'utensils', color: '#FF5733' }, + { id: 2, name: 'Transportation', icon: 'car', color: '#33FF57' }, + { id: 3, name: 'Entertainment', icon: 'film', color: '#3357FF' } +]); + +const mockUseGroups = { + groups: mockGroups, + isLoading: ref(false), + error: ref(null), + fetchGroups: vi.fn().mockResolvedValue(undefined), + createGroup: vi.fn().mockResolvedValue({ id: 4, name: 'New Group' }), + updateGroup: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Group' }), + deleteGroup: vi.fn().mockResolvedValue(undefined) +}; + +const mockNotifications = { + confirmDelete: vi.fn().mockResolvedValue(true), + showSuccess: vi.fn(), + showError: vi.fn() +}; + +vi.mock('@/composables/useGroups', () => ({ + useGroups: () => mockUseGroups +})); + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: () => mockNotifications +})); + +vi.mock('@/composables/useSidebar', () => ({ + useSidebar: () => ({ + isTabletOrBelow: ref(false) + }) +})); + +describe('Groups Page Logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGroups.value = [ + { id: 1, name: 'Food & Dining', icon: 'utensils', color: '#FF5733' }, + { id: 2, name: 'Transportation', icon: 'car', color: '#33FF57' }, + { id: 3, name: 'Entertainment', icon: 'film', color: '#3357FF' } + ]; + }); + + describe('useGroups composable', () => { + it('returns groups list', () => { + const { groups } = mockUseGroups; + expect(groups.value).toHaveLength(3); + expect(groups.value[0].name).toBe('Food & Dining'); + }); + + it('fetchGroups loads group data', async () => { + await mockUseGroups.fetchGroups(); + expect(mockUseGroups.fetchGroups).toHaveBeenCalled(); + }); + + it('createGroup adds new group', async () => { + const newGroup = { name: 'Shopping', icon: 'shopping-cart', color: '#FFAA00' }; + await mockUseGroups.createGroup(newGroup); + expect(mockUseGroups.createGroup).toHaveBeenCalledWith(newGroup); + }); + + it('updateGroup modifies existing group', async () => { + const updateData = { name: 'Updated Name', icon: 'star' }; + await mockUseGroups.updateGroup(1, updateData); + expect(mockUseGroups.updateGroup).toHaveBeenCalledWith(1, updateData); + }); + + it('deleteGroup removes group', async () => { + await mockUseGroups.deleteGroup(1); + expect(mockUseGroups.deleteGroup).toHaveBeenCalledWith(1); + }); + }); + + describe('group operations', () => { + it('shows confirmation before deleting', async () => { + await mockNotifications.confirmDelete('group'); + expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('group'); + }); + + it('shows success notification after deletion', () => { + mockNotifications.showSuccess('Group deleted', 'Food & Dining has been deleted successfully'); + expect(mockNotifications.showSuccess).toHaveBeenCalledWith( + 'Group deleted', + 'Food & Dining has been deleted successfully' + ); + }); + + it('does not delete when confirmation is cancelled', async () => { + mockNotifications.confirmDelete.mockResolvedValueOnce(false); + + const confirmed = await mockNotifications.confirmDelete('group'); + + if (!confirmed) { + expect(mockUseGroups.deleteGroup).not.toHaveBeenCalled(); + } + }); + }); + + describe('loading states', () => { + it('isLoading starts as false', () => { + expect(mockUseGroups.isLoading.value).toBe(false); + }); + + it('error starts as null', () => { + expect(mockUseGroups.error.value).toBeNull(); + }); + }); + + describe('group data structure', () => { + it('group has required fields', () => { + const group = mockGroups.value[0]; + expect(group).toHaveProperty('id'); + expect(group).toHaveProperty('name'); + }); + + it('group can have optional icon', () => { + const group = mockGroups.value[0]; + expect(group).toHaveProperty('icon'); + }); + + it('group can have optional color', () => { + const group = mockGroups.value[0]; + expect(group).toHaveProperty('color'); + }); + }); +}); + +describe('Group Form Validation', () => { + it('requires group name', () => { + const groupData = { name: '' }; + const isValid = groupData.name.trim().length > 0; + expect(isValid).toBe(false); + }); + + it('accepts valid group name', () => { + const groupData = { name: 'My Group' }; + const isValid = groupData.name.trim().length > 0; + expect(isValid).toBe(true); + }); + + it('trims whitespace from name', () => { + const groupData = { name: ' My Group ' }; + const trimmedName = groupData.name.trim(); + expect(trimmedName).toBe('My Group'); + }); + + it('accepts name with special characters', () => { + const groupData = { name: 'Food & Dining' }; + const isValid = groupData.name.trim().length > 0; + expect(isValid).toBe(true); + }); +}); + +describe('Group Filtering', () => { + it('can filter groups by name', () => { + const searchQuery = 'food'; + const filtered = mockGroups.value.filter((g) => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('Food & Dining'); + }); + + it('returns empty array for no matches', () => { + const searchQuery = 'xyz'; + const filtered = mockGroups.value.filter((g) => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(0); + }); + + it('case insensitive search', () => { + const searchQuery = 'TRANSPORTATION'; + const filtered = mockGroups.value.filter((g) => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + expect(filtered).toHaveLength(1); + }); +}); diff --git a/tests/pages/wallets.test.ts b/tests/pages/wallets.test.ts new file mode 100644 index 0000000..c6f3733 --- /dev/null +++ b/tests/pages/wallets.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ref } from 'vue'; + +const mockWallets = ref([ + { id: 1, name: 'Main Wallet', balance: 5000, currency: 'USD' }, + { id: 2, name: 'Savings', balance: 10000, currency: 'USD' } +]); + +const mockUseWallets = { + wallets: mockWallets, + isLoading: ref(false), + error: ref(null), + fetchWallets: vi.fn().mockResolvedValue(undefined), + createWallet: vi.fn().mockResolvedValue({ id: 3, name: 'New Wallet' }), + updateWallet: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Wallet' }), + deleteWallet: vi.fn().mockResolvedValue(undefined) +}; + +const mockNotifications = { + confirmDelete: vi.fn().mockResolvedValue(true), + showSuccess: vi.fn(), + showError: vi.fn() +}; + +vi.mock('@/composables/useWallets', () => ({ + useWallets: () => mockUseWallets +})); + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: () => mockNotifications +})); + +vi.mock('@/composables/useSharedData', () => ({ + useSharedData: () => ({ + getDefaultWallet: ref({ id: 1, name: 'Main Wallet' }), + loadConfigurations: vi.fn().mockResolvedValue(undefined) + }) +})); + +vi.mock('@/composables/useSidebar', () => ({ + useSidebar: () => ({ + isTabletOrBelow: ref(false) + }) +})); + +describe('Wallets Page Logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWallets.value = [ + { id: 1, name: 'Main Wallet', balance: 5000, currency: 'USD' }, + { id: 2, name: 'Savings', balance: 10000, currency: 'USD' } + ]; + }); + + describe('useWallets composable', () => { + it('returns wallets list', () => { + const { wallets } = mockUseWallets; + expect(wallets.value).toHaveLength(2); + expect(wallets.value[0].name).toBe('Main Wallet'); + }); + + it('fetchWallets loads wallet data', async () => { + await mockUseWallets.fetchWallets(); + expect(mockUseWallets.fetchWallets).toHaveBeenCalled(); + }); + + it('createWallet adds new wallet', async () => { + const newWallet = { name: 'New Wallet', currency: 'EUR' }; + await mockUseWallets.createWallet(newWallet); + expect(mockUseWallets.createWallet).toHaveBeenCalledWith(newWallet); + }); + + it('updateWallet modifies existing wallet', async () => { + const updateData = { name: 'Updated Name' }; + await mockUseWallets.updateWallet(1, updateData); + expect(mockUseWallets.updateWallet).toHaveBeenCalledWith(1, updateData); + }); + + it('deleteWallet removes wallet', async () => { + await mockUseWallets.deleteWallet(1); + expect(mockUseWallets.deleteWallet).toHaveBeenCalledWith(1); + }); + }); + + describe('wallet operations', () => { + it('shows confirmation before deleting', async () => { + await mockNotifications.confirmDelete('wallet'); + expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('wallet'); + }); + + it('shows success notification after deletion', () => { + mockNotifications.showSuccess('Wallet deleted', 'Main Wallet has been deleted successfully'); + expect(mockNotifications.showSuccess).toHaveBeenCalledWith( + 'Wallet deleted', + 'Main Wallet has been deleted successfully' + ); + }); + + it('shows error notification on delete failure', () => { + mockNotifications.showError('Delete failed', 'Failed to delete wallet. Please try again.'); + expect(mockNotifications.showError).toHaveBeenCalled(); + }); + + it('does not delete when confirmation is cancelled', async () => { + mockNotifications.confirmDelete.mockResolvedValueOnce(false); + + const confirmed = await mockNotifications.confirmDelete('wallet'); + + if (!confirmed) { + expect(mockUseWallets.deleteWallet).not.toHaveBeenCalled(); + } + }); + }); + + describe('loading states', () => { + it('isLoading starts as false', () => { + expect(mockUseWallets.isLoading.value).toBe(false); + }); + + it('error starts as null', () => { + expect(mockUseWallets.error.value).toBeNull(); + }); + }); + + describe('wallet data structure', () => { + it('wallet has required fields', () => { + const wallet = mockWallets.value[0]; + expect(wallet).toHaveProperty('id'); + expect(wallet).toHaveProperty('name'); + expect(wallet).toHaveProperty('balance'); + expect(wallet).toHaveProperty('currency'); + }); + + it('wallet balance is a number', () => { + const wallet = mockWallets.value[0]; + expect(typeof wallet.balance).toBe('number'); + }); + }); +}); + +describe('Wallet Form Validation', () => { + it('requires wallet name', () => { + const walletData = { name: '', currency: 'USD' }; + const isValid = walletData.name.trim().length > 0; + expect(isValid).toBe(false); + }); + + it('accepts valid wallet name', () => { + const walletData = { name: 'My Wallet', currency: 'USD' }; + const isValid = walletData.name.trim().length > 0; + expect(isValid).toBe(true); + }); + + it('requires currency selection', () => { + const walletData = { name: 'My Wallet', currency: '' }; + const isValid = walletData.currency.length > 0; + expect(isValid).toBe(false); + }); + + it('accepts valid currency', () => { + const walletData = { name: 'My Wallet', currency: 'EUR' }; + const isValid = walletData.currency.length > 0; + expect(isValid).toBe(true); + }); +}); diff --git a/tests/services/aiApi.test.ts b/tests/services/aiApi.test.ts new file mode 100644 index 0000000..314ca74 --- /dev/null +++ b/tests/services/aiApi.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { aiApi, type FormatType, type AskResponse } from '@/services/api/aiApi'; + +const mockApi = vi.fn(); +vi.mock('#imports', () => ({ + useApi: () => mockApi +})); + +vi.stubGlobal('useApi', () => mockApi); + +describe('aiApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ask', () => { + it('should send message to /ai/chat endpoint', async () => { + const mockResponse: AskResponse = { + success: true, + data: { + answer: 'You spent $500 on food last month.', + format_type: 'scalar', + results: [{ total: 500 }] + } + }; + mockApi.mockResolvedValueOnce(mockResponse); + + const result = await aiApi.ask('How much did I spend on food?'); + + expect(mockApi).toHaveBeenCalledWith('/ai/chat', { + method: 'POST', + body: { message: 'How much did I spend on food?' } + }); + expect(result).toEqual(mockResponse); + }); + + it('should handle error responses', async () => { + const mockResponse: AskResponse = { + success: false, + message: 'AI service unavailable' + }; + mockApi.mockResolvedValueOnce(mockResponse); + + const result = await aiApi.ask('test question'); + + expect(result.success).toBe(false); + expect(result.message).toBe('AI service unavailable'); + }); + }); + + describe('checkHealth', () => { + it('should call /ai/health endpoint', async () => { + mockApi.mockResolvedValueOnce({ available: true }); + + const result = await aiApi.checkHealth(); + + expect(mockApi).toHaveBeenCalledWith('/ai/health'); + expect(result.available).toBe(true); + }); + + it('should return unavailable when service is down', async () => { + mockApi.mockResolvedValueOnce({ available: false }); + + const result = await aiApi.checkHealth(); + + expect(result.available).toBe(false); + }); + }); +}); + +describe('FormatType', () => { + it('should accept valid format types', () => { + const validTypes: FormatType[] = [ + 'scalar', + 'pair', + 'record', + 'list', + 'pair_list', + 'table', + 'raw' + ]; + + validTypes.forEach((type) => { + const response: AskResponse = { + success: true, + data: { + answer: 'test', + format_type: type, + results: [] + } + }; + expect(response.data?.format_type).toBe(type); + }); + }); + + it('should allow null format_type', () => { + const response: AskResponse = { + success: true, + data: { + answer: 'test', + format_type: null, + results: [] + } + }; + expect(response.data?.format_type).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1746a26 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import vue from '@vitejs/plugin-vue'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'happy-dom', + include: ['tests/**/*.{test,spec}.{js,ts}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['components/**/*.vue', 'composables/**/*.ts', 'services/**/*.ts'] + } + }, + resolve: { + alias: { + '@': resolve(__dirname, './'), + '~': resolve(__dirname, './') + } + } +});