diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d36369f..e68df07 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,31 @@ jobs:
- name: Lint
run: npm run lint
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests with coverage
+ run: npx vitest run --coverage
+
+ - name: Upload coverage to Codecov
+ if: github.event_name == 'push'
+ uses: codecov/codecov-action@v5
+ with:
+ fail_ci_if_error: false
+
build:
name: Build
runs-on: ubuntu-latest
@@ -74,7 +99,7 @@ jobs:
docker:
name: Docker Build
runs-on: ubuntu-latest
- needs: [lint, build]
+ needs: [lint, test, build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
@@ -96,12 +121,13 @@ jobs:
summary:
name: CI Summary
runs-on: ubuntu-latest
- needs: [lint, build]
+ needs: [lint, test, build]
if: always()
steps:
- name: Check results
run: |
if [ "${{ needs.lint.result }}" != "success" ] || \
+ [ "${{ needs.test.result }}" != "success" ] || \
[ "${{ needs.build.result }}" != "success" ]; then
echo "CI failed"
exit 1
diff --git a/README.md b/README.md
index 4eea2a2..279e3b0 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
+
diff --git a/package-lock.json b/package-lock.json
index f3daa06..ce9c94e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,12 +26,14 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^4.1.4",
"eslint": "^9.28.0",
"eslint-config-next": "^16.2.3",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
- "typescript": "^6.0.2"
+ "typescript": "^6.0.2",
+ "vitest": "^4.1.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -203,7 +205,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -213,7 +215,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -247,7 +249,7 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -297,7 +299,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -307,6 +309,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@clack/core": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
@@ -356,21 +368,21 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -378,9 +390,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1870,6 +1882,16 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@prisma/adapter-pg": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz",
@@ -2229,6 +2251,307 @@
}
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
+ "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2597,6 +2920,24 @@
"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/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",
@@ -3045,11 +3386,174 @@
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
- "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
- "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
- "license": "MIT"
+ "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
+ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
+ "license": "MIT"
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",
+ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.4",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.4",
+ "vitest": "4.1.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/coverage-v8/node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vitest/coverage-v8/node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
+ "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
+ "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
+ "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.4",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
+ "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
+ "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
},
"node_modules/acorn": {
"version": "8.16.0",
@@ -3293,6 +3797,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/assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
@@ -3309,6 +3823,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
+ "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -3597,6 +4130,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4103,6 +4646,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4901,6 +5451,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4911,6 +5471,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -5510,6 +6080,13 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
@@ -6035,6 +6612,45 @@
"node": ">=0.10.0"
}
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6538,6 +7154,22 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/maplibre-gl": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.22.0.tgz",
@@ -6986,6 +7618,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -7686,6 +8329,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8007,6 +8684,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",
@@ -8131,6 +8815,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -8366,6 +9057,13 @@
"url": "https://opencollective.com/webpack"
}
},
+ "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.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
@@ -8429,6 +9127,16 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "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",
@@ -8757,6 +9465,207 @@
}
}
},
+ "node_modules/vite": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.15",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
+ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.4",
+ "@vitest/mocker": "4.1.4",
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/runner": "4.1.4",
+ "@vitest/snapshot": "4.1.4",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.4",
+ "@vitest/browser-preview": "4.1.4",
+ "@vitest/browser-webdriverio": "4.1.4",
+ "@vitest/coverage-istanbul": "4.1.4",
+ "@vitest/coverage-v8": "4.1.4",
+ "@vitest/ui": "4.1.4",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest/node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8861,6 +9770,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 028733b..661cdb4 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
"scraper:run": "npx tsx src/scrapers/cli.ts"
},
"repository": {
@@ -37,12 +39,14 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^4.1.4",
"eslint": "^9.28.0",
"eslint-config-next": "^16.2.3",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
- "typescript": "^6.0.2"
+ "typescript": "^6.0.2",
+ "vitest": "^4.1.4"
},
"overrides": {
"hono": "^4.12.12",
diff --git a/src/app/api/exchange-rates/route.test.ts b/src/app/api/exchange-rates/route.test.ts
new file mode 100644
index 0000000..5a04dad
--- /dev/null
+++ b/src/app/api/exchange-rates/route.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock next/server
+vi.mock("next/server", () => ({
+ NextResponse: {
+ json: (data: unknown, init?: { headers?: Record; status?: number }) => ({
+ data,
+ status: init?.status ?? 200,
+ headers: init?.headers ?? {},
+ }),
+ },
+}));
+
+describe("exchange-rates API", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("fetches and parses ECB XML rates", async () => {
+ const mockXml = `
+
+
+
+
+
+
+
+
+
+`;
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ text: async () => mockXml,
+ } as Response);
+
+ const { GET } = await import("./route");
+ const response = await GET();
+
+ expect(response.data.base).toBe("EUR");
+ expect(response.data.rates.EUR).toBe(1);
+ expect(response.data.rates.USD).toBeCloseTo(1.0934, 4);
+ expect(response.data.rates.GBP).toBeCloseTo(0.8561, 4);
+ expect(response.data.rates.CHF).toBeCloseTo(0.9584, 4);
+ expect(response.data.date).toBe("2026-04-10");
+ });
+
+ it("adds approximate rates for non-ECB currencies", async () => {
+ const mockXml = `
+
+ `;
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ text: async () => mockXml,
+ } as Response);
+
+ const { GET } = await import("./route");
+ const response = await GET();
+
+ // Should include fixed/approximate rates
+ expect(response.data.rates.BAM).toBeCloseTo(1.95583, 4);
+ expect(response.data.rates.MKD).toBeDefined();
+ expect(response.data.rates.RSD).toBeDefined();
+ expect(response.data.rates.ARS).toBeDefined();
+ expect(response.data.rates.MDL).toBeDefined();
+ });
+
+ it("returns 502 when ECB fetch fails and no cache", async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
+
+ const { GET } = await import("./route");
+ const response = await GET();
+
+ expect(response.status).toBe(502);
+ expect(response.data.error).toBeDefined();
+ });
+});
diff --git a/src/lib/brand.test.ts b/src/lib/brand.test.ts
new file mode 100644
index 0000000..0aa622e
--- /dev/null
+++ b/src/lib/brand.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import {
+ BOLT_PATH,
+ BOLT_VIEWBOX,
+ BOLT_FILL,
+ BRAND_GRADIENT_START,
+ BRAND_GRADIENT_END,
+} from "./brand";
+
+describe("brand constants", () => {
+ it("BOLT_PATH is a non-empty SVG path", () => {
+ expect(BOLT_PATH).toBeTruthy();
+ expect(BOLT_PATH).toContain("M");
+ });
+
+ it("BOLT_VIEWBOX is a valid viewBox string", () => {
+ expect(BOLT_VIEWBOX).toBe("0 0 32 32");
+ });
+
+ it("BOLT_FILL is a valid hex color", () => {
+ expect(BOLT_FILL).toMatch(/^#[0-9a-f]{6}$/);
+ });
+
+ it("gradient colors are valid hex colors", () => {
+ expect(BRAND_GRADIENT_START).toMatch(/^#[0-9a-f]{6}$/);
+ expect(BRAND_GRADIENT_END).toMatch(/^#[0-9a-f]{6}$/);
+ });
+});
diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts
new file mode 100644
index 0000000..2d7e359
--- /dev/null
+++ b/src/lib/config.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { getConfig, COUNTRIES, type CountryConfig } from "./config";
+
+describe("COUNTRIES", () => {
+ it("contains expected country codes", () => {
+ expect(COUNTRIES.ES).toBeDefined();
+ expect(COUNTRIES.FR).toBeDefined();
+ expect(COUNTRIES.DE).toBeDefined();
+ expect(COUNTRIES.GB).toBeDefined();
+ expect(COUNTRIES.AU).toBeDefined();
+ expect(COUNTRIES.AR).toBeDefined();
+ expect(COUNTRIES.MX).toBeDefined();
+ });
+
+ it("all entries have required fields", () => {
+ for (const [code, config] of Object.entries(COUNTRIES)) {
+ expect(config.code).toBe(code);
+ expect(config.name).toBeTruthy();
+ expect(config.center).toHaveLength(2);
+ expect(config.center[0]).toBeGreaterThanOrEqual(-180);
+ expect(config.center[0]).toBeLessThanOrEqual(180);
+ expect(config.center[1]).toBeGreaterThanOrEqual(-90);
+ expect(config.center[1]).toBeLessThanOrEqual(90);
+ expect(config.zoom).toBeGreaterThan(0);
+ expect(config.defaultFuel).toBeTruthy();
+ }
+ });
+
+ it("each country code is a 2-letter uppercase string", () => {
+ for (const code of Object.keys(COUNTRIES)) {
+ expect(code).toMatch(/^[A-Z]{2}$/);
+ }
+ });
+});
+
+describe("getConfig", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ delete process.env.PUMPERLY_DEFAULT_COUNTRY;
+ delete process.env.PUMPERLY_ENABLED_COUNTRIES;
+ delete process.env.PUMPERLY_DEFAULT_FUEL;
+ delete process.env.PUMPERLY_CLUSTER_STATIONS;
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ it("returns ES defaults when no env vars are set", () => {
+ const config = getConfig();
+ expect(config.defaultCountry).toBe("ES");
+ expect(config.defaultFuel).toBe("B7");
+ expect(config.center).toEqual([-3.7, 40.4]);
+ expect(config.zoom).toBe(6);
+ expect(config.clusterStations).toBe(true);
+ expect(config.enabledCountries).toEqual(Object.keys(COUNTRIES));
+ });
+
+ it("respects PUMPERLY_DEFAULT_COUNTRY", () => {
+ process.env.PUMPERLY_DEFAULT_COUNTRY = "FR";
+ const config = getConfig();
+ expect(config.defaultCountry).toBe("FR");
+ expect(config.defaultFuel).toBe("E10");
+ expect(config.center).toEqual([2.35, 46.85]);
+ });
+
+ it("falls back to ES for unknown country", () => {
+ process.env.PUMPERLY_DEFAULT_COUNTRY = "XX";
+ const config = getConfig();
+ expect(config.center).toEqual(COUNTRIES.ES.center);
+ expect(config.zoom).toBe(COUNTRIES.ES.zoom);
+ });
+
+ it("respects PUMPERLY_ENABLED_COUNTRIES", () => {
+ process.env.PUMPERLY_ENABLED_COUNTRIES = "ES, FR, DE";
+ const config = getConfig();
+ expect(config.enabledCountries).toEqual(["ES", "FR", "DE"]);
+ });
+
+ it("filters out invalid country codes from PUMPERLY_ENABLED_COUNTRIES", () => {
+ process.env.PUMPERLY_ENABLED_COUNTRIES = "ES, XX, FR";
+ const config = getConfig();
+ expect(config.enabledCountries).toEqual(["ES", "FR"]);
+ });
+
+ it("handles case-insensitive country codes", () => {
+ process.env.PUMPERLY_ENABLED_COUNTRIES = "es, fr";
+ const config = getConfig();
+ expect(config.enabledCountries).toEqual(["ES", "FR"]);
+ });
+
+ it("respects PUMPERLY_DEFAULT_FUEL override", () => {
+ process.env.PUMPERLY_DEFAULT_FUEL = "E5";
+ const config = getConfig();
+ expect(config.defaultFuel).toBe("E5");
+ });
+
+ it("respects PUMPERLY_CLUSTER_STATIONS=false", () => {
+ process.env.PUMPERLY_CLUSTER_STATIONS = "false";
+ const config = getConfig();
+ expect(config.clusterStations).toBe(false);
+ });
+
+ it("defaults PUMPERLY_CLUSTER_STATIONS to true", () => {
+ const config = getConfig();
+ expect(config.clusterStations).toBe(true);
+ });
+});
diff --git a/src/lib/og-translations.test.ts b/src/lib/og-translations.test.ts
new file mode 100644
index 0000000..e6eb450
--- /dev/null
+++ b/src/lib/og-translations.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+import { OG_TRANSLATIONS, SUPPORTED_LOCALES, DEFAULT_LOCALE } from "./og-translations";
+
+describe("og-translations", () => {
+ it("DEFAULT_LOCALE is es", () => {
+ expect(DEFAULT_LOCALE).toBe("es");
+ });
+
+ it("SUPPORTED_LOCALES matches OG_TRANSLATIONS keys", () => {
+ expect(SUPPORTED_LOCALES).toEqual(Object.keys(OG_TRANSLATIONS));
+ });
+
+ it("all locales have required OG fields", () => {
+ for (const locale of SUPPORTED_LOCALES) {
+ const og = OG_TRANSLATIONS[locale];
+ expect(og.title, `${locale}.title`).toBeTruthy();
+ expect(og.description, `${locale}.description`).toBeTruthy();
+ expect(og.imageSubtitle, `${locale}.imageSubtitle`).toBeTruthy();
+ expect(og.ogLocale, `${locale}.ogLocale`).toMatch(/^[a-z]{2}_[A-Z]{2}$/);
+ }
+ });
+
+ it("all titles contain Pumperly", () => {
+ for (const locale of SUPPORTED_LOCALES) {
+ expect(OG_TRANSLATIONS[locale].title).toContain("Pumperly");
+ }
+ });
+
+ it("includes at least 10 locales", () => {
+ expect(SUPPORTED_LOCALES.length).toBeGreaterThanOrEqual(10);
+ });
+});
diff --git a/src/lib/photon.test.ts b/src/lib/photon.test.ts
new file mode 100644
index 0000000..a877639
--- /dev/null
+++ b/src/lib/photon.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const MOCK_PHOTON = "http://photon.test";
+
+describe("photon geocode", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ process.env.PHOTON_URL = MOCK_PHOTON;
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ vi.restoreAllMocks();
+ vi.resetModules();
+ });
+
+ it("returns empty array when PHOTON_URL is not set", async () => {
+ delete process.env.PHOTON_URL;
+ const { geocode } = await import("./photon");
+ const results = await geocode("Madrid");
+ expect(results).toEqual([]);
+ });
+
+ it("returns parsed results from Photon API", async () => {
+ const { geocode } = await import("./photon");
+
+ const mockResponse = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [-3.7038, 40.4168] },
+ properties: {
+ name: "Madrid",
+ city: "Madrid",
+ state: "Community of Madrid",
+ country: "Spain",
+ },
+ },
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [-3.6, 40.5] },
+ properties: {
+ name: "Madrid Barajas",
+ country: "Spain",
+ },
+ },
+ ],
+ };
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ const results = await geocode("Madrid", 40.4, -3.7);
+
+ expect(results).toHaveLength(2);
+ expect(results[0]).toEqual({
+ name: "Madrid",
+ city: "Madrid",
+ state: "Community of Madrid",
+ country: "Spain",
+ coordinates: [-3.7038, 40.4168],
+ });
+ expect(results[1].city).toBeNull();
+ expect(results[1].state).toBeNull();
+
+ // Verify fetch was called with correct params
+ const call = vi.mocked(fetch).mock.calls[0];
+ const url = call[0] as string;
+ expect(url).toContain(`${MOCK_PHOTON}/api?`);
+ expect(url).toContain("q=Madrid");
+ expect(url).toContain("lat=40.4");
+ expect(url).toContain("lon=-3.7");
+ expect(url).toContain("limit=5");
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ const { geocode } = await import("./photon");
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: false,
+ status: 500,
+ } as Response);
+
+ const results = await geocode("test");
+ expect(results).toEqual([]);
+ });
+
+ it("uses query as fallback name when property name is missing", async () => {
+ const { geocode } = await import("./photon");
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [0, 0] },
+ properties: {},
+ },
+ ],
+ }),
+ } as Response);
+
+ const results = await geocode("Unknown Place");
+ expect(results[0].name).toBe("Unknown Place");
+ });
+
+ it("does not send lat/lon params when not provided", async () => {
+ const { geocode } = await import("./photon");
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({ type: "FeatureCollection", features: [] }),
+ } as Response);
+
+ await geocode("Berlin");
+
+ const call = vi.mocked(fetch).mock.calls[0];
+ const url = call[0] as string;
+ expect(url).not.toContain("lat=");
+ expect(url).not.toContain("lon=");
+ });
+});
diff --git a/src/lib/valhalla.test.ts b/src/lib/valhalla.test.ts
new file mode 100644
index 0000000..666f511
--- /dev/null
+++ b/src/lib/valhalla.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// We test the internal functions by importing the module, but the exported
+// functions (getRoute, getRoutes, getRouteDuration) depend on fetch + env.
+// We mock fetch globally and set VALHALLA_URL.
+
+const MOCK_VALHALLA = "http://valhalla.test";
+
+// Simple encoded polyline for testing decodePolyline:
+// The Valhalla polyline uses precision 6. We encode two points:
+// (40.0, -3.0) and (40.1, -2.9)
+// lat1=40.0 => 40000000, lon1=-3.0 => -3000000
+// lat2=40.1 => 40100000 (delta +100000), lon2=-2.9 => -2900000 (delta +100000)
+
+function encodeValue(value: number): string {
+ let v = value < 0 ? ~(value << 1) : value << 1;
+ let result = "";
+ while (v >= 0x20) {
+ result += String.fromCharCode((v & 0x1f) | 0x20 + 63);
+ v >>= 5;
+ }
+ // Actually let's use a known encoded polyline from Valhalla docs
+ // For simplicity, test via the round-trip through the exported API
+ return result + String.fromCharCode(v + 63);
+}
+
+describe("valhalla module", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ process.env.VALHALLA_URL = MOCK_VALHALLA;
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ vi.restoreAllMocks();
+ // Clear module cache so VALHALLA_URL is re-read
+ vi.resetModules();
+ });
+
+ describe("getRoute", () => {
+ it("returns null when VALHALLA_URL is not set", async () => {
+ delete process.env.VALHALLA_URL;
+ // Must re-import to pick up env change
+ const { getRoute } = await import("./valhalla");
+ const result = await getRoute([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+ expect(result).toBeNull();
+ });
+
+ it("returns null when fetch fails", async () => {
+ const { getRoute } = await import("./valhalla");
+ vi.mocked(fetch).mockResolvedValue({
+ ok: false,
+ status: 500,
+ } as Response);
+
+ const result = await getRoute([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+ expect(result).toBeNull();
+ });
+
+ it("calls Valhalla with correct body and returns parsed route", async () => {
+ const { getRoute } = await import("./valhalla");
+
+ // Minimal Valhalla trip response with a simple encoded polyline
+ // Encode (40.0, -3.0) -> just use a tiny shape
+ const mockTrip = {
+ legs: [
+ {
+ shape: "_c}|gAz~fjC_seK_seK", // approximate encoding
+ summary: { length: 150.5, time: 5400 },
+ maneuvers: [],
+ },
+ ],
+ summary: { length: 150.5, time: 5400 },
+ };
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({ trip: mockTrip }),
+ } as Response);
+
+ const result = await getRoute([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+
+ expect(fetch).toHaveBeenCalledOnce();
+ const call = vi.mocked(fetch).mock.calls[0];
+ expect(call[0]).toBe(`${MOCK_VALHALLA}/route`);
+ const body = JSON.parse((call[1] as RequestInit).body as string);
+ expect(body.locations).toHaveLength(2);
+ expect(body.costing).toBe("auto");
+
+ expect(result).not.toBeNull();
+ expect(result!.distance).toBe(150.5);
+ expect(result!.duration).toBe(5400);
+ expect(result!.geometry.type).toBe("LineString");
+ expect(result!.bbox).toHaveLength(4);
+ });
+ });
+
+ describe("getRoutes", () => {
+ it("returns empty array when VALHALLA_URL is not set", async () => {
+ delete process.env.VALHALLA_URL;
+ const { getRoutes } = await import("./valhalla");
+ const result = await getRoutes([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+ expect(result).toEqual([]);
+ });
+
+ it("returns multiple routes including alternates", async () => {
+ const { getRoutes } = await import("./valhalla");
+
+ const makeLeg = (length: number, time: number) => ({
+ shape: "_c}|gAz~fjC_seK_seK",
+ summary: { length, time },
+ maneuvers: [],
+ });
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ trip: {
+ legs: [makeLeg(100, 3600)],
+ summary: { length: 100, time: 3600 },
+ },
+ alternates: [
+ {
+ trip: {
+ legs: [makeLeg(120, 4000)],
+ summary: { length: 120, time: 4000 },
+ },
+ },
+ ],
+ }),
+ } as Response);
+
+ const routes = await getRoutes(
+ [{ lat: 40.0, lon: -3.0 }, { lat: 41.0, lon: -2.0 }],
+ 2,
+ );
+
+ expect(routes).toHaveLength(2);
+ expect(routes[0].distance).toBe(100);
+ expect(routes[1].distance).toBe(120);
+ });
+ });
+
+ describe("getRouteDuration", () => {
+ it("returns null when VALHALLA_URL is not set", async () => {
+ delete process.env.VALHALLA_URL;
+ const { getRouteDuration } = await import("./valhalla");
+ const result = await getRouteDuration([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+ expect(result).toBeNull();
+ });
+
+ it("returns duration from trip summary", async () => {
+ const { getRouteDuration } = await import("./valhalla");
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ trip: {
+ legs: [],
+ summary: { length: 50, time: 1800 },
+ },
+ }),
+ } as Response);
+
+ const result = await getRouteDuration([
+ { lat: 40.0, lon: -3.0 },
+ { lat: 41.0, lon: -2.0 },
+ ]);
+ expect(result).toBe(1800);
+ });
+ });
+});
diff --git a/src/middleware.test.ts b/src/middleware.test.ts
new file mode 100644
index 0000000..5659c72
--- /dev/null
+++ b/src/middleware.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect, vi } from "vitest";
+
+// We need to mock next/server since middleware uses NextRequest/NextResponse
+vi.mock("next/server", () => {
+ class MockHeaders extends Map {
+ constructor(init?: Record | [string, string][]) {
+ super();
+ if (init && !Array.isArray(init)) {
+ for (const [k, v] of Object.entries(init)) {
+ this.set(k.toLowerCase(), v);
+ }
+ }
+ }
+ // next/server Headers uses .get() which Map already provides
+ }
+
+ class MockNextRequest {
+ nextUrl: { pathname: string; clone: () => MockNextRequest["nextUrl"] };
+ headers: MockHeaders;
+ cookies: { get: (name: string) => { value: string } | undefined };
+
+ constructor(
+ url: string,
+ options?: {
+ headers?: Record;
+ cookies?: Record;
+ },
+ ) {
+ const parsed = new URL(url, "http://localhost");
+ const self = this;
+ this.nextUrl = {
+ pathname: parsed.pathname,
+ clone() {
+ return { ...self.nextUrl, pathname: self.nextUrl.pathname };
+ },
+ };
+ this.headers = new MockHeaders(options?.headers);
+ const cookieStore = options?.cookies ?? {};
+ this.cookies = {
+ get: (name: string) =>
+ name in cookieStore ? { value: cookieStore[name] } : undefined,
+ };
+ }
+ }
+
+ const responses: Array<{ type: string; args: unknown[] }> = [];
+
+ class MockNextResponse {
+ static _responses = responses;
+
+ cookies = {
+ set: vi.fn(),
+ };
+
+ static next(opts?: { request?: { headers?: MockHeaders } }) {
+ const res = new MockNextResponse();
+ responses.push({ type: "next", args: [opts] });
+ return res;
+ }
+
+ static rewrite(url: unknown, opts?: unknown) {
+ const res = new MockNextResponse();
+ responses.push({ type: "rewrite", args: [url, opts] });
+ return res;
+ }
+
+ static json(data: unknown, init?: unknown) {
+ return { data, init };
+ }
+ }
+
+ return {
+ NextRequest: MockNextRequest,
+ NextResponse: MockNextResponse,
+ };
+});
+
+// Import after mock
+import { NextRequest, NextResponse } from "next/server";
+import { middleware, config } from "./middleware";
+
+function makeRequest(
+ pathname: string,
+ opts?: { headers?: Record; cookies?: Record },
+) {
+ return new (NextRequest as any)(`http://localhost${pathname}`, opts);
+}
+
+describe("middleware", () => {
+ it("skips API routes", () => {
+ const req = makeRequest("/api/stations");
+ const res = middleware(req);
+ // Should call NextResponse.next() without locale rewrite
+ expect(res).toBeDefined();
+ });
+
+ it("skips Next.js internals", () => {
+ const req = makeRequest("/_next/static/chunk.js");
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ });
+
+ it("skips paths with file extensions", () => {
+ const req = makeRequest("/icon.svg");
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ });
+
+ it("passes through when locale is already in path", () => {
+ const req = makeRequest("/en");
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ // Should set cookie for the detected locale
+ expect(res.cookies.set).toHaveBeenCalledWith(
+ "pumperly-locale",
+ "en",
+ expect.objectContaining({ path: "/" }),
+ );
+ });
+
+ it("detects locale from cookie", () => {
+ const req = makeRequest("/", { cookies: { "pumperly-locale": "fr" } });
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ expect(res.cookies.set).toHaveBeenCalledWith(
+ "pumperly-locale",
+ "fr",
+ expect.objectContaining({ path: "/" }),
+ );
+ });
+
+ it("detects locale from accept-language header", () => {
+ const req = makeRequest("/", {
+ headers: { "accept-language": "de-DE,de;q=0.9,en;q=0.8" },
+ });
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ expect(res.cookies.set).toHaveBeenCalledWith(
+ "pumperly-locale",
+ "de",
+ expect.objectContaining({ path: "/" }),
+ );
+ });
+
+ it("falls back to es when no locale detected", () => {
+ const req = makeRequest("/", {
+ headers: { "accept-language": "zh-CN" },
+ });
+ const res = middleware(req);
+ expect(res).toBeDefined();
+ expect(res.cookies.set).toHaveBeenCalledWith(
+ "pumperly-locale",
+ "es",
+ expect.objectContaining({ path: "/" }),
+ );
+ });
+});
+
+describe("middleware config", () => {
+ it("has matcher pattern", () => {
+ expect(config.matcher).toBeDefined();
+ expect(config.matcher).toHaveLength(1);
+ });
+});
diff --git a/src/scrapers/spain.test.ts b/src/scrapers/spain.test.ts
new file mode 100644
index 0000000..c1ec396
--- /dev/null
+++ b/src/scrapers/spain.test.ts
@@ -0,0 +1,253 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock the base class DB dependencies so we only test fetch()
+vi.mock("@prisma/adapter-pg", () => ({
+ PrismaPg: vi.fn(),
+}));
+
+vi.mock("../generated/prisma/client", () => ({
+ PrismaClient: vi.fn(),
+}));
+
+describe("SpainScraper", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ });
+
+ it("has correct country and source", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+ expect(scraper.country).toBe("ES");
+ expect(scraper.source).toBe("miteco");
+ });
+
+ it("parses MITECO API response into stations and prices", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ const mockMitecoResponse = {
+ Fecha: "01/04/2026 08:00:00",
+ ResultadoConsulta: "OK",
+ Nota: "",
+ ListaEESSPrecio: [
+ {
+ IDEESS: "1234",
+ "Rótulo": "REPSOL",
+ "Dirección": "CALLE MAYOR 1",
+ Municipio: "MADRID",
+ Provincia: "MADRID",
+ "C.P.": "28001",
+ Latitud: "40,416775",
+ "Longitud (WGS84)": "-3,703790",
+ "Tipo Venta": "D",
+ "Precio Gasoleo A": "1,459",
+ "Precio Gasolina 95 E5": "1,599",
+ "Precio Gasolina 98 E5": "1,789",
+ "Precio Gases licuados del petróleo": "",
+ "Precio Gasoleo Premium": "1,529",
+ "Precio Gasoleo B": "",
+ "Precio Diésel Renovable": "",
+ "Precio Gasolina 95 E5 Premium": "",
+ "Precio Gasolina 95 E10": "",
+ "Precio Gasolina 98 E10": "",
+ "Precio Gas Natural Comprimido": "",
+ "Precio Gas Natural Licuado": "",
+ "Precio Hidrogeno": "",
+ "Precio Adblue": "",
+ },
+ {
+ IDEESS: "5678",
+ "Rótulo": "CEPSA",
+ "Dirección": "AV LIBERTAD 5",
+ Municipio: "MURCIA",
+ Provincia: "MURCIA",
+ "C.P.": "30001",
+ Latitud: "37,983810",
+ "Longitud (WGS84)": "-1,129812",
+ "Tipo Venta": "R",
+ "Precio Gasoleo A": "1,439",
+ "Precio Gasolina 95 E5": "1,579",
+ "Precio Gasolina 98 E5": "",
+ "Precio Gases licuados del petróleo": "0,699",
+ "Precio Gasoleo Premium": "",
+ "Precio Gasoleo B": "",
+ "Precio Diésel Renovable": "",
+ "Precio Gasolina 95 E5 Premium": "",
+ "Precio Gasolina 95 E10": "",
+ "Precio Gasolina 98 E10": "",
+ "Precio Gas Natural Comprimido": "",
+ "Precio Gas Natural Licuado": "",
+ "Precio Hidrogeno": "",
+ "Precio Adblue": "",
+ },
+ ],
+ };
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => mockMitecoResponse,
+ } as Response);
+
+ const { stations, prices } = await scraper.fetch();
+
+ // Should parse both stations
+ expect(stations).toHaveLength(2);
+
+ // First station
+ expect(stations[0].externalId).toBe("1234");
+ expect(stations[0].brand).toBe("Repsol");
+ expect(stations[0].city).toBe("MADRID");
+ expect(stations[0].latitude).toBeCloseTo(40.416775, 4);
+ expect(stations[0].longitude).toBeCloseTo(-3.70379, 4);
+ expect(stations[0].stationType).toBe("fuel");
+
+ // Second station
+ expect(stations[1].externalId).toBe("5678");
+ expect(stations[1].brand).toBe("Cepsa");
+
+ // Prices: station 1 has B7, E5, E5_98, B7_PREMIUM = 4 prices
+ // Station 2 has B7, E5, LPG = 3 prices
+ expect(prices).toHaveLength(7);
+
+ const station1Prices = prices.filter((p) => p.stationExternalId === "1234");
+ expect(station1Prices).toHaveLength(4);
+
+ const dieselPrice = station1Prices.find((p) => p.fuelType === "B7");
+ expect(dieselPrice).toBeDefined();
+ expect(dieselPrice!.price).toBeCloseTo(1.459, 3);
+ expect(dieselPrice!.currency).toBe("EUR");
+
+ const lpgPrice = prices.find((p) => p.fuelType === "LPG");
+ expect(lpgPrice).toBeDefined();
+ expect(lpgPrice!.price).toBeCloseTo(0.699, 3);
+ });
+
+ it("skips stations with invalid coordinates", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ Fecha: "01/04/2026",
+ ResultadoConsulta: "OK",
+ Nota: "",
+ ListaEESSPrecio: [
+ {
+ IDEESS: "9999",
+ "Rótulo": "TEST",
+ "Dirección": "TEST",
+ Municipio: "TEST",
+ Provincia: "TEST",
+ "C.P.": "00000",
+ Latitud: "", // empty
+ "Longitud (WGS84)": "-3,5",
+ "Tipo Venta": "D",
+ "Precio Gasoleo A": "1,500",
+ },
+ {
+ IDEESS: "8888",
+ "Rótulo": "TEST2",
+ "Dirección": "TEST2",
+ Municipio: "TEST2",
+ Provincia: "TEST2",
+ "C.P.": "00001",
+ Latitud: "90,000", // out of Spain bounds
+ "Longitud (WGS84)": "0,000",
+ "Tipo Venta": "D",
+ "Precio Gasoleo A": "1,500",
+ },
+ ],
+ }),
+ } as Response);
+
+ const { stations } = await scraper.fetch();
+ expect(stations).toHaveLength(0);
+ });
+
+ it("throws on non-OK API response", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: "Internal Server Error",
+ } as Response);
+
+ await expect(scraper.fetch()).rejects.toThrow("HTTP 500");
+ });
+
+ it("throws on ResultadoConsulta != OK", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ Fecha: "01/04/2026",
+ ResultadoConsulta: "ERROR",
+ Nota: "",
+ ListaEESSPrecio: [],
+ }),
+ } as Response);
+
+ await expect(scraper.fetch()).rejects.toThrow("ResultadoConsulta");
+ });
+
+ it("throws on empty ListaEESSPrecio", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ Fecha: "01/04/2026",
+ ResultadoConsulta: "OK",
+ Nota: "",
+ ListaEESSPrecio: [],
+ }),
+ } as Response);
+
+ await expect(scraper.fetch()).rejects.toThrow("empty");
+ });
+
+ it("ignores prices with zero or missing values", async () => {
+ const { SpainScraper } = await import("./spain");
+ const scraper = new SpainScraper();
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ Fecha: "01/04/2026",
+ ResultadoConsulta: "OK",
+ Nota: "",
+ ListaEESSPrecio: [
+ {
+ IDEESS: "1111",
+ "Rótulo": "TEST",
+ "Dirección": "TEST",
+ Municipio: "TEST",
+ Provincia: "TEST",
+ "C.P.": "28001",
+ Latitud: "40,416",
+ "Longitud (WGS84)": "-3,703",
+ "Tipo Venta": "D",
+ "Precio Gasoleo A": "0,000", // zero price
+ "Precio Gasolina 95 E5": "1,599",
+ },
+ ],
+ }),
+ } as Response);
+
+ const { prices } = await scraper.fetch();
+ // Zero price should be filtered out, only E5 remains
+ expect(prices).toHaveLength(1);
+ expect(prices[0].fuelType).toBe("E5");
+ });
+});
diff --git a/src/types/fuel.test.ts b/src/types/fuel.test.ts
new file mode 100644
index 0000000..a9bfbc3
--- /dev/null
+++ b/src/types/fuel.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import { FUEL_TYPES, FUEL_TYPE_MAP, FUEL_CATEGORIES } from "./fuel";
+
+describe("FUEL_TYPES", () => {
+ it("contains expected fuel types", () => {
+ const codes = FUEL_TYPES.map((f) => f.code);
+ expect(codes).toContain("B7");
+ expect(codes).toContain("E5");
+ expect(codes).toContain("E10");
+ expect(codes).toContain("LPG");
+ expect(codes).toContain("EV");
+ expect(codes).toContain("H2");
+ expect(codes).toContain("ADBLUE");
+ });
+
+ it("all entries have label and valid category", () => {
+ const validCategories = ["gasoline", "diesel", "gas", "hydrogen", "electric", "other"];
+ for (const ft of FUEL_TYPES) {
+ expect(ft.label).toBeTruthy();
+ expect(validCategories).toContain(ft.category);
+ }
+ });
+
+ it("has no duplicate codes", () => {
+ const codes = FUEL_TYPES.map((f) => f.code);
+ expect(new Set(codes).size).toBe(codes.length);
+ });
+});
+
+describe("FUEL_TYPE_MAP", () => {
+ it("maps all fuel type codes", () => {
+ for (const ft of FUEL_TYPES) {
+ expect(FUEL_TYPE_MAP.get(ft.code)).toBe(ft);
+ }
+ });
+
+ it("returns undefined for unknown codes", () => {
+ expect(FUEL_TYPE_MAP.get("UNKNOWN" as any)).toBeUndefined();
+ });
+});
+
+describe("FUEL_CATEGORIES", () => {
+ it("covers all categories used in FUEL_TYPES", () => {
+ const usedCategories = new Set(FUEL_TYPES.map((f) => f.category));
+ const definedCategories = new Set(FUEL_CATEGORIES.map((c) => c.key));
+ for (const cat of usedCategories) {
+ expect(definedCategories.has(cat), `category "${cat}" missing from FUEL_CATEGORIES`).toBe(true);
+ }
+ });
+
+ it("each category has a label", () => {
+ for (const cat of FUEL_CATEGORIES) {
+ expect(cat.label).toBeTruthy();
+ }
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..cb5ba0e
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vitest/config";
+import path from "path";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ },
+ },
+ test: {
+ globals: true,
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/lib/**", "src/scrapers/**", "src/middleware.ts", "src/app/api/**"],
+ exclude: ["src/generated/**", "src/**/*.test.ts", "src/scrapers/cli.ts"],
+ },
+ },
+});