From c2c1ced387fb467a75aa62035a5d4e284a2cbcba Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 30 Aug 2026 09:26:02 +0700 Subject: [PATCH] test: cover the lib layer against a fake and a real Redis Scoring, the Upstash key adapter, sessions and the leaderboards had no coverage, and they carry the anti-cheat model: server-held coordinates, session TTLs, rank arithmetic. One suite runs against two backing stores. By default @upstash/redis is mocked with an in-memory fake, so `npm test` needs no service and finishes well under a second. With TEST_REDIS=real the mock steps aside and the same files run against a real Redis behind SRH, the proxy Upstash recommends for local work, since the SDK speaks HTTP REST rather than RESP and a plain Redis container cannot serve it alone. Running both is what keeps the fake honest: a semantic it gets wrong shows up as a green unit run and a red integration run. Two tests skip against real Redis, one asserting a response shape only an older SDK produces and one fast-forwarding half an hour to watch a session expire. The suite was checked by mutation rather than by passing alone. Breaking a score band, the key prefix and the session TTL each failed a test; reversing the global trim window did not, because only the city leaderboard was asserted, so that test now covers both scopes. The compose stack doubles as a local Redis for `npm run dev`, which had no credentials to reach one before. package.json also loses the eight unused runtime dependencies removed in the cleanup that follows; they cannot be split from the same file. --- CLAUDE.md | 4 +- docker-compose.yml | 36 + docs/development.md | 52 +- package-lock.json | 1652 +++++++++++++++++++++------------ package.json | 20 +- tests/fake-upstash-redis.js | 147 +++ tests/game.test.js | 108 +++ tests/leaderboard.test.js | 191 ++++ tests/mock-upstash.js | 11 + tests/redis-harness.js | 55 ++ tests/session.test.js | 89 ++ tests/upstash.test.js | 198 ++++ tests/wait-for-srh.js | 36 + vitest.config.mjs | 17 + vitest.integration.config.mjs | 25 + 15 files changed, 2018 insertions(+), 623 deletions(-) create mode 100644 docker-compose.yml create mode 100644 tests/fake-upstash-redis.js create mode 100644 tests/game.test.js create mode 100644 tests/leaderboard.test.js create mode 100644 tests/mock-upstash.js create mode 100644 tests/redis-harness.js create mode 100644 tests/session.test.js create mode 100644 tests/upstash.test.js create mode 100644 tests/wait-for-srh.js create mode 100644 vitest.config.mjs create mode 100644 vitest.integration.config.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 33f23d7..7f219c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,13 +20,15 @@ For detailed information about this project, refer to the documentation files in - `npm run build` - Build the application for production - `npm start` - Start production server - `npm run lint` - Run ESLint +- `npm test` - Run tests against the in-memory Redis fake (no service needed) +- `npm run test:integration` - Same tests against local Redis (`npm run redis:up` first) **Key Guidelines:** - **JavaScript Only**: No TypeScript files (.ts, .tsx) - **Function Parameters**: Use individual parameters, not object destructuring - **File Modifications**: Only modify source code, `/docs`, and `/plans` - **Security**: Never expose secrets or keys -- **Testing**: Inform user when complete - they handle manual testing +- **Testing**: Run `npm test` for `src/lib/` changes; inform user when complete - they handle manual testing of the UI ## Critical Instructions diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8cbaec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# Local stand-in for Upstash Redis. +# +# Upstash itself is cloud-only, but @upstash/redis talks HTTP REST rather than +# RESP, so any server exposing that REST surface is indistinguishable to the +# app. SRH is the proxy Upstash's own docs recommend for this: it accepts the +# Upstash REST API and forwards to a real Redis. +# +# npm run redis:up start it +# npm run redis:down stop it and discard the data +# +# Used by `npm run test:integration`, and usable for `npm run dev` by pointing +# UPSTASH_REDIS_REST_URL at http://localhost:8079 with the token below. + +services: + redis: + image: redis:7-alpine + # No volume: this data is disposable by design, and tests flush it anyway. + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 15 + + srh: + image: hiett/serverless-redis-http:latest + ports: + - "8079:80" + environment: + SRH_MODE: env + # Local-only credential. SRH accepts any token it is configured with, and + # this one never reaches a real Upstash database. + SRH_TOKEN: vngeoguessr-local-token + SRH_CONNECTION_STRING: "redis://redis:6379" + depends_on: + redis: + condition: service_healthy diff --git a/docs/development.md b/docs/development.md index abced5d..306744a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,6 +6,10 @@ - `npm run build` - Build the application for production - `npm start` - Start production server - `npm run lint` - Run ESLint +- `npm test` - Run the test suite against the in-memory Redis fake +- `npm run test:watch` - Re-run tests on change +- `npm run test:integration` - Run the same suite against a local Redis +- `npm run redis:up` / `npm run redis:down` - Start/stop that local Redis ## Important Development Guidelines @@ -40,10 +44,52 @@ **Mapillary (required)**: - `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching +For local Redis without an Upstash account, see *Running Upstash locally* below. + ### Testing & Completion -- After completing implementation tasks, inform the user that work is complete -- Ready for manual testing - do NOT attempt to run test commands -- Do NOT start development servers - user handles testing manually + +Tests live in `tests/` and cover the logic in `src/lib/`: scoring and distance, +the Upstash key adapter, game sessions, and the leaderboards. Run `npm test` +after changing anything under `src/lib/`. + +The suite runs against two backing stores, from one set of test files: + +- `npm test` uses `tests/fake-upstash-redis.js`, an in-memory stand-in mocked in + at the `@upstash/redis` boundary. No service, no Docker, well under a second. + This is the default. +- `npm run test:integration` runs the same files against a real Redis. Two of + them skip: one asserts a response shape only an older SDK produces, and one + fast-forwards half an hour to watch a session expire. + +Running both is what keeps the fake honest. A behaviour the fake gets wrong +shows up as a green unit run and a red integration run. + +Everything above `src/lib/` (UI, panorama viewer, map interaction, Mapillary +calls) is still manual: inform the user when work is complete. Do NOT start +development servers - user handles testing manually. + +### Running Upstash locally + +Upstash itself is cloud-only, but `@upstash/redis` speaks HTTP REST, so any +server exposing that REST surface is indistinguishable to the app. +`docker-compose.yml` runs the proxy Upstash's own docs recommend, SRH +(`hiett/serverless-redis-http`), in front of a real Redis: + +```bash +npm run redis:up # redis + SRH on http://localhost:8079 +npm run redis:down # stop and discard the data +``` + +`npm run test:integration` points at it automatically. To use it for `npm run +dev` as well, set in `.env`: + +``` +UPSTASH_REDIS_REST_URL=http://localhost:8079 +UPSTASH_REDIS_REST_TOKEN=vngeoguessr-local-token +``` + +SRH accepts commands only as a JSON array POSTed to `/`, which is what the SDK +sends; the path form Upstash also supports (`GET /set/key/value`) returns 404. ## shadcn/ui Configuration diff --git a/package-lock.json b/package-lock.json index ca4693e..3c48a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,11 @@ "name": "vngeoguessr", "version": "0.1.0", "dependencies": { - "@hookform/resolvers": "^5.4.0", "@photo-sphere-viewer/core": "^5.14.3", - "@radix-ui/react-avatar": "^1.2.2", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-label": "^2.1.11", - "@radix-ui/react-popover": "^1.1.19", - "@radix-ui/react-progress": "^1.1.12", - "@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.17", - "@radix-ui/react-tooltip": "^1.2.12", "@turf/turf": "^7.2.0", "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.5.0", @@ -30,10 +24,8 @@ "next": "15.5.18", "react": "19.2.7", "react-dom": "19.2.7", - "react-hook-form": "^7.81.0", "tailwind-merge": "^3.3.1", - "uuid": "^14.0.1", - "zod": "^4.0.16" + "uuid": "^14.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", @@ -41,7 +33,8 @@ "eslint": "^9.39.4", "eslint-config-next": "15.4.6", "tailwindcss": "^4.3.3", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "vitest": "^4.1.11" }, "engines": { "node": ">=24" @@ -261,154 +254,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.8.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" - }, - "node_modules/@hookform/resolvers": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.0.tgz", - "integrity": "sha512-8sNo1IklGaONrsKzXjwZJNsPbccAXLXGX//G+VFEcQOviMtbvR8/fM6HWyA0qrjgpOYauwoodda53CQIWOZ7Ag==", - "license": "MIT", - "dependencies": { - "@standard-schema/utils": "^0.3.0" - }, - "peerDependencies": { - "@sinclair/typebox": ">=0.25.24", - "@standard-schema/spec": "^1.0.0", - "@typeschema/main": ">=0.13.7", - "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", - "ajv": "^8.12.0", - "ajv-errors": "^3.0.0", - "ajv-formats": "^2.1.1", - "arktype": "^2.0.0", - "ata-validator": "^1.2.0", - "class-transformer": ">=0.4.0", - "class-validator": ">=0.12.0", - "computed-types": "^1.0.0", - "effect": "^3.10.3", - "fluentvalidation-ts": "^3.0.0", - "fp-ts": "^2.7.0", - "io-ts": "^2.0.0", - "joi": "^17.0.0 || ^18.0.0", - "nope-validator": ">=0.12.0", - "react-hook-form": "^7.55.0", - "superstruct": ">=0.12.0", - "typanion": "^3.3.2", - "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", - "vest": ">=6.0.0", - "yup": "^1.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "@sinclair/typebox": { - "optional": true - }, - "@standard-schema/spec": { - "optional": true - }, - "@typeschema/main": { - "optional": true - }, - "@vinejs/vine": { - "optional": true - }, - "ajv": { - "optional": true - }, - "ajv-errors": { - "optional": true - }, - "ajv-formats": { - "optional": true - }, - "arktype": { - "optional": true - }, - "ata-validator": { - "optional": true - }, - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - }, - "computed-types": { - "optional": true - }, - "effect": { - "optional": true - }, - "fluentvalidation-ts": { - "optional": true - }, - "fp-ts": { - "optional": true - }, - "io-ts": { - "optional": true - }, - "joi": { - "optional": true - }, - "nope-validator": { - "optional": true - }, - "superstruct": { - "optional": true - }, - "typanion": { - "optional": true - }, - "valibot": { - "optional": true - }, - "vest": { - "optional": true - }, - "yup": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -568,9 +413,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -587,9 +429,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -606,9 +445,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -625,9 +461,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -644,9 +477,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -663,9 +493,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,9 +509,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -701,9 +525,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -720,9 +541,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -745,9 +563,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -770,9 +585,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -795,9 +607,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -820,9 +629,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -845,9 +651,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -870,9 +673,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -895,9 +695,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1116,9 +913,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1135,9 +929,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1154,9 +945,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1173,9 +961,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1265,6 +1050,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@photo-sphere-viewer/core": { "version": "5.15.1", "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.15.1.tgz", @@ -1283,57 +1078,6 @@ "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", - "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", - "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-is-hydrated": "0.1.3", - "@radix-ui/react-use-layout-effect": "1.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", @@ -1550,75 +1294,6 @@ } } }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.23", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", - "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.1.16", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", - "@radix-ui/react-use-controllable-state": "1.2.6", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.15", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-use-rect": "1.1.4", - "@radix-ui/react-use-size": "1.1.4", - "@radix-ui/rect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-portal": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", @@ -1689,30 +1364,6 @@ } } }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", - "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", @@ -1746,29 +1397,6 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", - "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", @@ -1817,41 +1445,6 @@ } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", - "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.19", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.7", - "@radix-ui/react-portal": "1.1.17", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-slot": "1.3.3", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.11" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", @@ -1935,69 +1528,266 @@ } } }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", - "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", - "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", - "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@radix-ui/rect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", - "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "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.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "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.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "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.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "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.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, "license": "MIT" }, "node_modules/@rtsao/scc": { @@ -2014,10 +1804,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2162,9 +1953,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2182,9 +1970,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2202,9 +1987,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2222,9 +2004,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4381,12 +4160,30 @@ "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/d3-voronoi": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.12.tgz", "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/earcut": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", @@ -4814,9 +4611,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4831,9 +4625,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4848,9 +4639,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4865,9 +4653,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4882,9 +4667,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4899,9 +4681,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4916,9 +4695,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4933,9 +4709,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4950,9 +4723,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4967,9 +4737,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5143,6 +4910,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "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.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5166,23 +5046,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5396,6 +5259,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-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5569,6 +5442,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", @@ -5658,6 +5541,13 @@ "tinyqueue": "^2.0.3" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6026,6 +5916,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -6523,6 +6420,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", @@ -6533,6 +6440,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6583,23 +6500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "extraneous": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6690,6 +6590,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7568,13 +7483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "extraneous": true, - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7813,9 +7721,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7837,9 +7742,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7861,9 +7763,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7885,9 +7784,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8307,6 +8203,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8416,6 +8326,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8593,22 +8510,6 @@ "react": "^19.2.7" } }, - "node_modules/react-hook-form": { - "version": "7.85.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz", - "integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8729,16 +8630,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -8800,6 +8691,40 @@ "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", "license": "Unlicense" }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9091,6 +9016,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/skmeans": { "version": "0.9.7", "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", @@ -9119,6 +9051,20 @@ "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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9356,6 +9302,23 @@ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9410,6 +9373,16 @@ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "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", @@ -9726,6 +9699,461 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "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.4.0 || ^0.5.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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "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.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "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.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9831,6 +10259,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", @@ -9853,15 +10298,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 8402edd..9f98390 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,19 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.mjs", + "redis:up": "docker compose up -d --wait", + "redis:down": "docker compose down -v" }, "dependencies": { - "@hookform/resolvers": "^5.4.0", "@photo-sphere-viewer/core": "^5.14.3", - "@radix-ui/react-avatar": "^1.2.2", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-label": "^2.1.11", - "@radix-ui/react-popover": "^1.1.19", - "@radix-ui/react-progress": "^1.1.12", - "@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.17", - "@radix-ui/react-tooltip": "^1.2.12", "@turf/turf": "^7.2.0", "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.5.0", @@ -31,10 +30,8 @@ "next": "15.5.18", "react": "19.2.7", "react-dom": "19.2.7", - "react-hook-form": "^7.81.0", "tailwind-merge": "^3.3.1", - "uuid": "^14.0.1", - "zod": "^4.0.16" + "uuid": "^14.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", @@ -42,7 +39,8 @@ "eslint": "^9.39.4", "eslint-config-next": "15.4.6", "tailwindcss": "^4.3.3", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "vitest": "^4.1.11" }, "engines": { "node": ">=24" diff --git a/tests/fake-upstash-redis.js b/tests/fake-upstash-redis.js new file mode 100644 index 0000000..8a7dc32 --- /dev/null +++ b/tests/fake-upstash-redis.js @@ -0,0 +1,147 @@ +// In-memory stand-in for the @upstash/redis client. +// +// The real client speaks HTTP REST, not RESP, so a plain Redis container cannot +// serve it without an extra proxy in front. The app only touches nine commands +// over strings and sorted sets, so a faithful in-memory implementation is both +// simpler to run and enough to exercise every path in lib/upstash.js. +// +// Semantics deliberately mirror Redis where the app depends on them: +// - sorted sets order by score ascending, ties broken by member lexicographically +// - zrange/zremrangebyrank take inclusive indices and accept negative offsets +// - zrange REV reverses the index space, so 0 is the highest score +// - keys with an elapsed TTL read back as missing + +const instances = []; + +/** Resolve a possibly-negative inclusive index pair against a list length. */ +function resolveRange(length, start, stop) { + let from = start < 0 ? length + start : start; + let to = stop < 0 ? length + stop : stop; + if (from < 0) from = 0; + if (to >= length) to = length - 1; + return { from, to }; +} + +/** Order sorted-set entries the way Redis does: by score, then by member. */ +function sortEntries(entries) { + return [...entries].sort((a, b) => + a.score === b.score ? a.member.localeCompare(b.member) : a.score - b.score + ); +} + +export class FakeRedis { + constructor(config = {}) { + this.config = config; + // key -> { value, expireAt } for strings, key -> Map for zsets. + this.strings = new Map(); + this.zsets = new Map(); + // Set by tests that need zrange to answer in the older array-of-objects + // shape instead of the flat [member, score, ...] the current SDK returns. + this.withScoresShape = 'flat'; + instances.push(this); + } + + /** Clear every store handed out so far. Call between tests. */ + static resetAll() { + for (const instance of instances) { + instance.strings.clear(); + instance.zsets.clear(); + instance.withScoresShape = 'flat'; + } + } + + /** The client most recently constructed, for store-level assertions. */ + static latest() { + return instances[instances.length - 1]; + } + + async get(key) { + const entry = this.strings.get(key); + if (!entry) return null; + if (entry.expireAt != null && Date.now() >= entry.expireAt) { + this.strings.delete(key); + return null; + } + return entry.value; + } + + async set(key, value, opts) { + const expireAt = opts?.ex != null ? Date.now() + opts.ex * 1000 : null; + this.strings.set(key, { value, expireAt }); + return 'OK'; + } + + /** Remaining TTL in seconds, mirroring Redis: -2 missing, -1 no expiry. */ + async ttl(key) { + const entry = this.strings.get(key); + if (!entry) return -2; + if (entry.expireAt == null) return -1; + return Math.ceil((entry.expireAt - Date.now()) / 1000); + } + + async del(key) { + const had = this.strings.delete(key); + const hadZset = this.zsets.delete(key); + return had || hadZset ? 1 : 0; + } + + async zadd(key, { score, member }) { + let zset = this.zsets.get(key); + if (!zset) { + zset = new Map(); + this.zsets.set(key, zset); + } + const isNew = !zset.has(member); + zset.set(member, score); + return isNew ? 1 : 0; + } + + async zscore(key, member) { + const zset = this.zsets.get(key); + if (!zset || !zset.has(member)) return null; + return zset.get(member); + } + + async zrange(key, start, stop, opts = {}) { + const ordered = this.#ordered(key, opts.rev); + const { from, to } = resolveRange(ordered.length, start, stop); + const slice = from > to ? [] : ordered.slice(from, to + 1); + + if (!opts.withScores) return slice.map((entry) => entry.member); + if (this.withScoresShape === 'objects') { + return slice.map((entry) => ({ member: entry.member, score: entry.score })); + } + return slice.flatMap((entry) => [entry.member, entry.score]); + } + + async zrank(key, member) { + const index = this.#ordered(key, false).findIndex((e) => e.member === member); + return index === -1 ? null : index; + } + + async zrevrank(key, member) { + const index = this.#ordered(key, true).findIndex((e) => e.member === member); + return index === -1 ? null : index; + } + + async zremrangebyrank(key, start, stop) { + const ordered = this.#ordered(key, false); + const { from, to } = resolveRange(ordered.length, start, stop); + if (from > to) return 0; + + const zset = this.zsets.get(key); + const doomed = ordered.slice(from, to + 1); + for (const entry of doomed) zset.delete(entry.member); + return doomed.length; + } + + /** Sorted-set contents in rank order, newest sort applied on every read. */ + #ordered(key, rev) { + const zset = this.zsets.get(key); + if (!zset) return []; + const ordered = sortEntries( + [...zset.entries()].map(([member, score]) => ({ member, score })) + ); + return rev ? ordered.reverse() : ordered; + } +} diff --git a/tests/game.test.js b/tests/game.test.js new file mode 100644 index 0000000..cdbac29 --- /dev/null +++ b/tests/game.test.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { + CITIES, + cities, + cityDeltas, + calculateDistance, + calculateScore, + formatDistance, +} from '../src/lib/game.js'; + +describe('calculateScore', () => { + // The bands are the whole scoring rule, so each boundary is pinned on both + // sides: an off-by-one here silently rewrites what players are rewarded for. + it.each([ + [0, 5], + [50, 5], + [51, 4], + [100, 4], + [101, 3], + [200, 3], + [201, 2], + [500, 2], + [501, 1], + [1000, 1], + [1001, 0], + [50000, 0], + ])('scores %dm as %d', (distance, expected) => { + expect(calculateScore(distance)).toBe(expected); + }); +}); + +describe('calculateDistance', () => { + it('returns zero for identical coordinates', () => { + expect(calculateDistance(10.8231, 106.6297, 10.8231, 106.6297)).toBe(0); + }); + + it('measures one degree of latitude as roughly 111km', () => { + const metres = calculateDistance(10, 106, 11, 106); + // Turf uses a spherical earth, so the exact figure depends on its radius + // constant. Assert the magnitude, not the constant. + expect(metres).toBeGreaterThan(110_000); + expect(metres).toBeLessThan(112_000); + }); + + it('is symmetric', () => { + const there = calculateDistance(10.8231, 106.6297, 21.0285, 105.8542); + const back = calculateDistance(21.0285, 105.8542, 10.8231, 106.6297); + expect(there).toBe(back); + }); + + it('returns whole metres', () => { + const metres = calculateDistance(10.8231, 106.6297, 10.8241, 106.6307); + expect(Number.isInteger(metres)).toBe(true); + }); + + it('grows with separation', () => { + const near = calculateDistance(10.82, 106.62, 10.83, 106.62); + const far = calculateDistance(10.82, 106.62, 10.9, 106.62); + expect(far).toBeGreaterThan(near); + }); +}); + +describe('formatDistance', () => { + it.each([ + [0, '0m'], + [999, '999m'], + [1000, '1.00km'], + [1500, '1.50km'], + [12300, '12.30km'], + ])('formats %dm as %s', (distance, expected) => { + expect(formatDistance(distance)).toBe(expected); + }); +}); + +describe('city configuration', () => { + it('exposes only enabled cities to the UI', () => { + const exposed = cities.map((city) => city.code).sort(); + const expected = Object.values(CITIES) + .filter((city) => city.enabled) + .map((city) => city.code) + .sort(); + expect(exposed).toEqual(expected); + }); + + it('uppercases city names for the UI', () => { + for (const city of cities) { + expect(city.name).toBe(city.name.toUpperCase()); + } + }); + + it('gives every city a Mapillary delta and a well-formed bbox', () => { + for (const city of Object.values(CITIES)) { + expect(cityDeltas[city.code]).toBeGreaterThan(0); + + const [minLng, minLat, maxLng, maxLat] = city.bbox; + expect(maxLng).toBeGreaterThan(minLng); + expect(maxLat).toBeGreaterThan(minLat); + + // The centre must sit inside the box it labels, or the map opens on one + // city while the panorama comes from another. + const [centreLat, centreLng] = city.center; + expect(centreLat).toBeGreaterThanOrEqual(minLat); + expect(centreLat).toBeLessThanOrEqual(maxLat); + expect(centreLng).toBeGreaterThanOrEqual(minLng); + expect(centreLng).toBeLessThanOrEqual(maxLng); + } + }); +}); diff --git a/tests/leaderboard.test.js b/tests/leaderboard.test.js new file mode 100644 index 0000000..ce500ec --- /dev/null +++ b/tests/leaderboard.test.js @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +vi.mock('@upstash/redis', async (importOriginal) => { + const { upstashModule } = await import('./mock-upstash.js'); + return upstashModule(importOriginal); +}); + +import { + getLeaderboard, + submitScore, + submitDistanceRecord, +} from '../src/lib/leaderboard.js'; +import { resetStore, storedKeys } from './redis-harness.js'; + +const MAX_LEADERBOARD_SIZE = 200; + +describe('score leaderboard', () => { + beforeEach(async () => { + await resetStore(); + }); + + it('accumulates repeat submissions rather than replacing them', async () => { + await submitScore('mai', 3, 'TPHCM'); + const second = await submitScore('mai', 4, 'TPHCM'); + expect(second.global.score).toBe(7); + expect(second.city.score).toBe(7); + }); + + it('keeps city totals independent of each other', async () => { + await submitScore('mai', 5, 'TPHCM'); + const inHanoi = await submitScore('mai', 2, 'HN'); + expect(inHanoi.city.score).toBe(2); + // The global total spans both cities. + expect(inHanoi.global.score).toBe(7); + }); + + it('lowercases the city key', async () => { + await submitScore('mai', 1, 'TPHCM'); + expect(await storedKeys()).toContain('vngeoguessr:leaderboard:city:tphcm'); + }); + + it('ranks the highest total first, counting from one', async () => { + await submitScore('anh', 2, 'HN'); + await submitScore('binh', 5, 'HN'); + const chi = await submitScore('chi', 4, 'HN'); + expect(chi.city.rank).toBe(2); + + const leaderboard = await getLeaderboard('HN', 10, 'score'); + expect(leaderboard).toEqual([ + { username: 'binh', score: 5, rank: 1 }, + { username: 'chi', score: 4, rank: 2 }, + { username: 'anh', score: 2, rank: 3 }, + ]); + }); + + it('moves a player up as their total grows', async () => { + await submitScore('anh', 5, 'HN'); + await submitScore('binh', 3, 'HN'); + expect((await submitScore('binh', 4, 'HN')).city.rank).toBe(1); + }); + + it('honours the requested limit', async () => { + for (const name of ['anh', 'binh', 'chi', 'dung']) { + await submitScore(name, 1, 'HN'); + } + expect(await getLeaderboard('HN', 2, 'score')).toHaveLength(2); + }); + + it('returns an empty leaderboard before anyone plays', async () => { + expect(await getLeaderboard('HN', 10, 'score')).toEqual([]); + }); + + it('keeps only the top entries once the cap is passed', async () => { + // Trimming is the only thing bounding this key's growth, and it uses a + // negative rank window that is easy to get backwards. + const overflow = MAX_LEADERBOARD_SIZE + 5; + for (let i = 0; i < overflow; i++) { + await submitScore(`player${String(i).padStart(3, '0')}`, i + 1, 'HN'); + } + + const strongest = `player${String(overflow - 1).padStart(3, '0')}`; + + // Both the city key and the global key are trimmed, by separate calls with + // separate rank windows, so both need asserting. + for (const scope of ['HN', null]) { + const all = await getLeaderboard(scope, overflow, 'score'); + expect(all).toHaveLength(MAX_LEADERBOARD_SIZE); + // The five weakest players are the ones dropped, not the strongest. + expect(all[0].username).toBe(strongest); + expect(all.map((e) => e.username)).not.toContain('player000'); + } + }); + + it.each([ + ['username', ['', 3, 'HN']], + ['cityCode', ['mai', 3, '']], + ])('rejects a submission missing %s', async (_field, args) => { + await expect(submitScore(...args)).rejects.toThrow(/Missing required fields/); + }); + + it('trims surrounding whitespace from the username', async () => { + const result = await submitScore(' mai ', 3, 'HN'); + expect(result.global.username).toBe('mai'); + expect((await getLeaderboard('HN', 10, 'score'))[0].username).toBe('mai'); + }); +}); + +describe('distance leaderboard', () => { + beforeEach(async () => { + await resetStore(); + }); + + it('ranks the shortest distance first', async () => { + await submitDistanceRecord('anh', 900, 'DL'); + await submitDistanceRecord('binh', 120, 'DL'); + const chi = await submitDistanceRecord('chi', 400, 'DL'); + expect(chi.cityDistance.rank).toBe(2); + + const leaderboard = await getLeaderboard('DL', 10, 'distance'); + expect(leaderboard.map((e) => [e.username, e.distance])).toEqual([ + ['binh', 120], + ['chi', 400], + ['anh', 900], + ]); + expect(leaderboard.map((e) => e.rank)).toEqual([1, 2, 3]); + }); + + it('keeps every attempt instead of overwriting the previous one', async () => { + // Unlike scores, each distance attempt gets its own slot, so a player can + // hold several rows. + await submitDistanceRecord('mai', 500, 'DL'); + await submitDistanceRecord('mai', 80, 'DL'); + const leaderboard = await getLeaderboard('DL', 10, 'distance'); + expect(leaderboard).toHaveLength(2); + expect(leaderboard.map((e) => e.distance)).toEqual([80, 500]); + }); + + it('decodes the username, distance and timestamp from the entry id', async () => { + const before = Date.now(); + await submitDistanceRecord('mai', 250, 'DL'); + const [entry] = await getLeaderboard('DL', 10, 'distance'); + expect(entry.username).toBe('mai'); + expect(entry.distance).toBe(250); + expect(entry.timestamp).toBeGreaterThanOrEqual(before); + }); + + it('writes to both the city and the global key', async () => { + await submitDistanceRecord('mai', 250, 'DL'); + const keys = await storedKeys(); + expect(keys).toContain('vngeoguessr:distance:city:dl'); + expect(keys).toContain('vngeoguessr:distance:vietnam'); + expect(await getLeaderboard(null, 10, 'distance')).toHaveLength(1); + }); + + it('keeps only the closest entries once the cap is passed', async () => { + const overflow = MAX_LEADERBOARD_SIZE + 5; + for (let i = 0; i < overflow; i++) { + await submitDistanceRecord('mai', (i + 1) * 10, 'DL'); + } + + const all = await getLeaderboard('DL', overflow, 'distance'); + expect(all).toHaveLength(MAX_LEADERBOARD_SIZE); + expect(all[0].distance).toBe(10); + // The furthest five attempts are the ones dropped. + expect(all[all.length - 1].distance).toBe(MAX_LEADERBOARD_SIZE * 10); + }); + + it.each([ + ['username', ['', 100, 'DL']], + ['cityCode', ['mai', 100, '']], + ])('rejects a submission missing %s', async (_field, args) => { + await expect(submitDistanceRecord(...args)).rejects.toThrow(/Missing required fields/); + }); +}); + +describe('global leaderboard', () => { + beforeEach(async () => { + await resetStore(); + }); + + it('spans every city', async () => { + await submitScore('anh', 5, 'HN'); + await submitScore('binh', 3, 'TPHCM'); + const global = await getLeaderboard(null, 10, 'score'); + expect(global.map((e) => e.username)).toEqual(['anh', 'binh']); + }); + + it('defaults to the score leaderboard', async () => { + await submitScore('anh', 5, 'HN'); + expect(await getLeaderboard()).toEqual([{ username: 'anh', score: 5, rank: 1 }]); + }); +}); diff --git a/tests/mock-upstash.js b/tests/mock-upstash.js new file mode 100644 index 0000000..302690b --- /dev/null +++ b/tests/mock-upstash.js @@ -0,0 +1,11 @@ +// Shared vi.mock factory for @upstash/redis. +// +// Each test file hoists its own vi.mock call, but they all delegate here so the +// fake-vs-real switch lives in one place. With TEST_REDIS=real the factory hands +// back the untouched SDK, which then talks to SRH. + +export async function upstashModule(importOriginal) { + if (process.env.TEST_REDIS === 'real') return importOriginal(); + const { FakeRedis } = await import('./fake-upstash-redis.js'); + return { Redis: FakeRedis }; +} diff --git a/tests/redis-harness.js b/tests/redis-harness.js new file mode 100644 index 0000000..d106851 --- /dev/null +++ b/tests/redis-harness.js @@ -0,0 +1,55 @@ +// Lets one set of tests run against either backing store. +// +// By default the suite mocks @upstash/redis with the in-memory fake, so it +// needs no service at all. With TEST_REDIS=real the mock steps aside and the +// real SDK talks to SRH, which serves the Upstash REST API on top of a real +// Redis (see docker-compose.yml). Running the same assertions both ways is what +// keeps the fake honest. +// +// Test bodies must go through this module rather than reaching into the fake's +// internals, or they only work in one mode. + +import { getUpstash } from '../src/lib/upstash.js'; +import { FakeRedis } from './fake-upstash-redis.js'; + +export const isFake = process.env.TEST_REDIS !== 'real'; + +/** Skip helper for assertions that only make sense against the fake. */ +export const fakeOnly = { skip: !isFake }; + +/** Empty the store so each test starts from nothing. */ +export async function resetStore() { + if (isFake) { + FakeRedis.resetAll(); + return; + } + const h = getUpstash(); + const existing = await h.client.keys(`${h.prefix}*`); + if (existing.length > 0) await h.client.del(...existing); +} + +/** Every physical key currently stored, prefix included, sorted. */ +export async function storedKeys() { + if (isFake) { + const client = FakeRedis.latest(); + if (!client) return []; + return [...client.strings.keys(), ...client.zsets.keys()].sort(); + } + const h = getUpstash(); + return (await h.client.keys(`${h.prefix}*`)).sort(); +} + +/** Remaining TTL in seconds for a physical key: -2 missing, -1 no expiry. */ +export async function ttlOf(physicalKey) { + if (isFake) return FakeRedis.latest().ttl(physicalKey); + return await getUpstash().client.ttl(physicalKey); +} + +/** Write a raw value, bypassing the adapter's JSON encoding. */ +export async function writeRaw(physicalKey, value) { + if (isFake) { + FakeRedis.latest().strings.set(physicalKey, { value, expireAt: null }); + return; + } + await getUpstash().client.set(physicalKey, value); +} diff --git a/tests/session.test.js b/tests/session.test.js new file mode 100644 index 0000000..84f3254 --- /dev/null +++ b/tests/session.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +vi.mock('@upstash/redis', async (importOriginal) => { + const { upstashModule } = await import('./mock-upstash.js'); + return upstashModule(importOriginal); +}); + +import { + storeGameSession, + getGameSession, + deleteGameSession, +} from '../src/lib/session.js'; +import { fakeOnly, resetStore, storedKeys, ttlOf } from './redis-harness.js'; + +const SESSION = { + sessionId: 'sess-1', + cityCode: 'TPHCM', + exactLocation: { lat: 10.7769, lng: 106.7009 }, + imageId: '123456789', + createdAt: 1_700_000_000_000, +}; + +describe('game sessions', () => { + beforeEach(async () => { + await resetStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('round-trips a session', async () => { + await storeGameSession('sess-1', SESSION); + expect(await getGameSession('sess-1')).toEqual(SESSION); + }); + + it('returns null for an unknown session', async () => { + expect(await getGameSession('never-existed')).toBeNull(); + }); + + it('namespaces the key under session:', async () => { + await storeGameSession('sess-1', SESSION); + expect(await storedKeys()).toEqual(['vngeoguessr:session:sess-1']); + }); + + it('keeps sessions separate', async () => { + await storeGameSession('sess-1', SESSION); + await storeGameSession('sess-2', { ...SESSION, sessionId: 'sess-2', cityCode: 'HN' }); + expect((await getGameSession('sess-1')).cityCode).toBe('TPHCM'); + expect((await getGameSession('sess-2')).cityCode).toBe('HN'); + }); + + it('overwrites on re-store', async () => { + await storeGameSession('sess-1', SESSION); + await storeGameSession('sess-1', { ...SESSION, imageId: 'replaced' }); + expect((await getGameSession('sess-1')).imageId).toBe('replaced'); + }); + + it('deletes a session', async () => { + await storeGameSession('sess-1', SESSION); + await deleteGameSession('sess-1'); + expect(await getGameSession('sess-1')).toBeNull(); + }); + + it('tolerates deleting a session that is already gone', async () => { + await expect(deleteGameSession('never-existed')).resolves.toBe(true); + }); + + it('sets a thirty minute expiry', async () => { + // The TTL is what stops an abandoned round's coordinates from lingering in + // Redis, so it is worth pinning rather than trusting the constant. + await storeGameSession('sess-1', SESSION); + const ttl = await ttlOf('vngeoguessr:session:sess-1'); + expect(ttl).toBeGreaterThan(29 * 60); + expect(ttl).toBeLessThanOrEqual(30 * 60); + }); + + it('stops returning a session once the expiry passes', fakeOnly, async () => { + // Only the fake can be fast-forwarded; against real Redis this would mean + // sleeping half an hour. + vi.useFakeTimers(); + await storeGameSession('sess-1', SESSION); + + vi.advanceTimersByTime(29 * 60 * 1000); + expect(await getGameSession('sess-1')).toEqual(SESSION); + + vi.advanceTimersByTime(2 * 60 * 1000); + expect(await getGameSession('sess-1')).toBeNull(); + }); +}); diff --git a/tests/upstash.test.js b/tests/upstash.test.js new file mode 100644 index 0000000..b623476 --- /dev/null +++ b/tests/upstash.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +vi.mock('@upstash/redis', async (importOriginal) => { + const { upstashModule } = await import('./mock-upstash.js'); + return upstashModule(importOriginal); +}); + +import { + getUpstash, + getJson, + putJson, + del, + zAdd, + zScore, + zRangeWithScores, + zRank, + zRevRank, + zRemRangeByRank, +} from '../src/lib/upstash.js'; +import { fakeOnly, resetStore, storedKeys, ttlOf, writeRaw } from './redis-harness.js'; + +describe('upstash adapter', () => { + beforeEach(async () => { + await resetStore(); + }); + + describe('handle', () => { + it('is a singleton', () => { + expect(getUpstash()).toBe(getUpstash()); + }); + + it('carries the default key prefix', () => { + expect(getUpstash().prefix).toBe('vngeoguessr:'); + }); + }); + + describe('key prefixing', () => { + // Every physical key must carry the prefix so the project can share an + // Upstash database with others without colliding. Callers pass logical keys + // and never see it, so only a store-level assertion can catch a regression. + it('prefixes string keys', async () => { + const h = getUpstash(); + await putJson(h, 'session:abc', { city: 'HN' }, null); + expect(await storedKeys()).toEqual(['vngeoguessr:session:abc']); + }); + + it('prefixes sorted set keys', async () => { + const h = getUpstash(); + await zAdd(h, 'leaderboard:vietnam', 5, 'mai'); + expect(await storedKeys()).toEqual(['vngeoguessr:leaderboard:vietnam']); + }); + }); + + describe('getJson / putJson / del', () => { + it('round-trips an object', async () => { + const h = getUpstash(); + const session = { sessionId: 'abc', exactLocation: { lat: 10.8, lng: 106.6 } }; + await putJson(h, 'session:abc', session, null); + expect(await getJson(h, 'session:abc')).toEqual(session); + }); + + it('returns null for a missing key', async () => { + expect(await getJson(getUpstash(), 'session:nope')).toBeNull(); + }); + + it('reads a value written outside the adapter', async () => { + // Some SDK versions parse JSON before the adapter sees it, others hand + // back the raw string; getJson has to cope with either without + // double-parsing. + const h = getUpstash(); + await writeRaw('vngeoguessr:session:raw', JSON.stringify({ city: 'DL' })); + expect(await getJson(h, 'session:raw')).toEqual({ city: 'DL' }); + }); + + it('applies a TTL when one is given', async () => { + const h = getUpstash(); + await putJson(h, 'session:ttl', { a: 1 }, 60); + const ttl = await ttlOf('vngeoguessr:session:ttl'); + expect(ttl).toBeGreaterThan(55); + expect(ttl).toBeLessThanOrEqual(60); + }); + + it('leaves a key persistent when no TTL is given', async () => { + const h = getUpstash(); + await putJson(h, 'session:forever', { a: 1 }, null); + expect(await ttlOf('vngeoguessr:session:forever')).toBe(-1); + }); + + it('deletes a key', async () => { + const h = getUpstash(); + await putJson(h, 'session:gone', { a: 1 }, null); + await del(h, 'session:gone'); + expect(await getJson(h, 'session:gone')).toBeNull(); + }); + }); + + describe('sorted sets', () => { + async function seed(h) { + await zAdd(h, 'lb', 10, 'anh'); + await zAdd(h, 'lb', 30, 'binh'); + await zAdd(h, 'lb', 20, 'chi'); + } + + it('reads back a score', async () => { + const h = getUpstash(); + await seed(h); + expect(await zScore(h, 'lb', 'binh')).toBe(30); + }); + + it('returns null for an absent member', async () => { + expect(await zScore(getUpstash(), 'lb', 'ghost')).toBeNull(); + }); + + it('overwrites rather than accumulating on re-add', async () => { + const h = getUpstash(); + await zAdd(h, 'lb', 10, 'anh'); + await zAdd(h, 'lb', 25, 'anh'); + expect(await zScore(h, 'lb', 'anh')).toBe(25); + }); + + it('ranges ascending', async () => { + const h = getUpstash(); + await seed(h); + expect(await zRangeWithScores(h, 'lb', 0, -1, false)).toEqual([ + { value: 'anh', score: 10 }, + { value: 'chi', score: 20 }, + { value: 'binh', score: 30 }, + ]); + }); + + it('ranges descending', async () => { + const h = getUpstash(); + await seed(h); + expect(await zRangeWithScores(h, 'lb', 0, -1, true)).toEqual([ + { value: 'binh', score: 30 }, + { value: 'chi', score: 20 }, + { value: 'anh', score: 10 }, + ]); + }); + + it('honours a limit', async () => { + const h = getUpstash(); + await seed(h); + const top = await zRangeWithScores(h, 'lb', 0, 1, true); + expect(top.map((e) => e.value)).toEqual(['binh', 'chi']); + }); + + it('reshapes the object form the older SDK returns', fakeOnly, async () => { + // lib/upstash.js normalises two different withScores shapes; the flat one + // is covered by every other case here, so pin the object one too. + const h = getUpstash(); + await seed(h); + const { FakeRedis } = await import('./fake-upstash-redis.js'); + FakeRedis.latest().withScoresShape = 'objects'; + expect(await zRangeWithScores(h, 'lb', 0, -1, true)).toEqual([ + { value: 'binh', score: 30 }, + { value: 'chi', score: 20 }, + { value: 'anh', score: 10 }, + ]); + }); + + it('returns an empty list for a missing key', async () => { + expect(await zRangeWithScores(getUpstash(), 'lb:missing', 0, -1, true)).toEqual([]); + }); + + it('ranks ascending and descending', async () => { + const h = getUpstash(); + await seed(h); + expect(await zRank(h, 'lb', 'anh')).toBe(0); + expect(await zRevRank(h, 'lb', 'anh')).toBe(2); + expect(await zRevRank(h, 'lb', 'binh')).toBe(0); + }); + + it('returns a null rank for an absent member', async () => { + const h = getUpstash(); + await seed(h); + expect(await zRank(h, 'lb', 'ghost')).toBeNull(); + expect(await zRevRank(h, 'lb', 'ghost')).toBeNull(); + }); + + it('trims by rank', async () => { + const h = getUpstash(); + await seed(h); + const removed = await zRemRangeByRank(h, 'lb', 0, 0); + expect(removed).toBe(1); + const remaining = await zRangeWithScores(h, 'lb', 0, -1, false); + expect(remaining.map((e) => e.value)).toEqual(['chi', 'binh']); + }); + + it('removes nothing when the trim window falls outside the set', async () => { + // This is the shape submitScore uses: with fewer members than the cap, the + // negative stop resolves below the start and must be a no-op. + const h = getUpstash(); + await seed(h); + expect(await zRemRangeByRank(h, 'lb', 0, -201)).toBe(0); + expect(await zRangeWithScores(h, 'lb', 0, -1, false)).toHaveLength(3); + }); + }); +}); diff --git a/tests/wait-for-srh.js b/tests/wait-for-srh.js new file mode 100644 index 0000000..ee4eab8 --- /dev/null +++ b/tests/wait-for-srh.js @@ -0,0 +1,36 @@ +// Global setup for the integration run: fail fast and legibly when the local +// Upstash stand-in is not up, rather than letting every test time out. + +// globalSetup runs before the config's `env` block reaches the process, so +// these fall back to the values docker-compose.yml serves. +const URL = process.env.UPSTASH_REDIS_REST_URL ?? 'http://localhost:8079'; +const TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? 'vngeoguessr-local-token'; +const ATTEMPTS = 20; +const DELAY_MS = 500; + +export async function setup() { + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + // A command posted as a JSON array is exactly what the SDK sends, so a + // PONG here means SRH is up and its Redis connection works. SRH serves + // only this form, not the path form Upstash also accepts. + const response = await fetch(URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(['PING']), + }); + if (response.ok) return; + } catch { + // Not listening yet; fall through to the retry. + } + await new Promise((resolve) => setTimeout(resolve, DELAY_MS)); + } + + throw new Error( + `No Upstash-compatible server answered at ${URL} after ${ATTEMPTS} attempts. ` + + 'Start it with `npm run redis:up`.' + ); +} diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..a37418a --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.js'], + // lib/upstash.js refuses to build a client without these. The tests mock the + // SDK itself, so the values are never dialled -- they only have to exist. + env: { + UPSTASH_REDIS_REST_URL: 'http://fake-upstash.test', + UPSTASH_REDIS_REST_TOKEN: 'fake-token', + // Pinned so the assertions on physical keys hold regardless of what the + // developer's .env sets. + KEY_PREFIX: 'vngeoguessr:', + }, + }, +}); diff --git a/vitest.integration.config.mjs b/vitest.integration.config.mjs new file mode 100644 index 0000000..bd40a18 --- /dev/null +++ b/vitest.integration.config.mjs @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; + +// Runs the same suite as vitest.config.mjs, but against the local SRH container +// instead of the in-memory fake, so real Redis answers every command. Start the +// stack first: `npm run redis:up`. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.js'], + globalSetup: ['tests/wait-for-srh.js'], + // Real network round-trips, and the overflow tests issue a few hundred of + // them, so the default timeout is too tight. + testTimeout: 30_000, + // One file at a time: they all share a single Redis and each flushes the + // keyspace between tests. + fileParallelism: false, + env: { + TEST_REDIS: 'real', + UPSTASH_REDIS_REST_URL: 'http://localhost:8079', + // Matches SRH_TOKEN in docker-compose.yml. + UPSTASH_REDIS_REST_TOKEN: 'vngeoguessr-local-token', + KEY_PREFIX: 'vngeoguessr:', + }, + }, +});