From 3a54cdcc5349e00dee68464f2419ba1b89354058 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 11:33:27 -0400 Subject: [PATCH] Add a Vitest unit suite for the pure-logic modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 31 tests in test/, run in a plain Node env with a tiny localStorage shim (no jsdom — its Node-22.4 transitive-dep breakage isn't worth working around for logic tests): - type-chart: modern effectiveness, dual-type stacking, and the era rules (no Fairy pre-6, Steel resists Ghost/Dark pre-6, the Gen 1 Ghost→Psychic and Bug↔Poison quirks). - damage-calc: nature multipliers, the Gen 3+ stat formula (Garchomp reference values), STAB/effectiveness/immunity, pre/post-Gen-6 crit. - savedex: a synthetic Gen 1 SRAM (bitfields + checksum) round-trips; checksum-mismatch and size guards. - selection: normalizeSelection v2→v3 migration, per-game isolation, global favourite, stats vs statsNational. - pokedex-resolver: prettify, dex resolution against a mini snapshot. `npm test` wired into CI after format:check. Also: docker-compose host port 8080 → 4753 (README updated). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- .github/workflows/ci.yml | 2 + AGENTS.md | 23 +- CONTRIBUTING.md | 13 +- README.md | 7 +- docker-compose.yml | 2 +- package-lock.json | 902 +++++++++++++++++++++++++++++++++- package.json | 5 +- test/damage-calc.test.js | 83 ++++ test/pokedex-resolver.test.js | 80 +++ test/savedex.test.js | 61 +++ test/selection.test.js | 88 ++++ test/setup.js | 15 + test/type-chart.test.js | 65 +++ vitest.config.js | 13 + 14 files changed, 1338 insertions(+), 21 deletions(-) create mode 100644 test/damage-calc.test.js create mode 100644 test/pokedex-resolver.test.js create mode 100644 test/savedex.test.js create mode 100644 test/selection.test.js create mode 100644 test/setup.js create mode 100644 test/type-chart.test.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3ff06..870906e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: - run: npm run format:check + - run: npm test + # The build's `prebuild` step fetches a data snapshot from PokéAPI. # Cache it so only the first run (or a change to the script) pays that # cost; CI only needs *a* snapshot to prove the build compiles. diff --git a/AGENTS.md b/AGENTS.md index a369da0..294b72a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,17 @@ npm run snapshot # build src/data/snapshot.json from PokéAPI (git-ignore npm run dev # dev server, http://localhost:5173 npm run build # runs `snapshot` (prebuild) then builds to dist/ npm run preview # serve the production build — REQUIRED to test the service worker / offline / install +npm test # Vitest (pure-logic unit tests in test/) +npm run format # Prettier, write ``` - Node **20+**. -- Automated checks (both run in CI, both must pass): - `npm run format:check` (Prettier) and `npm run build` (esbuild transforms - every module, so it catches syntax and import errors). Run `npm run format` - before finishing. There is **no test suite** and no ESLint. +- CI runs three checks, all must pass: `npm run format:check` (Prettier), + `npm test` (Vitest — pure-logic unit tests in `test/`), and + `npm run build` (esbuild transforms every module, catching syntax and + import errors). Run `npm run format` before finishing. No ESLint. +- If you change type-chart / damage-calc / savedex / selection logic, + update or add a test in `test/`. - `npm run snapshot -- --force` rebuilds the snapshot even if it's fresh. ## Project structure @@ -39,6 +43,7 @@ npm run preview # serve the production build — REQUIRED to test the se | `src/data/*.js` | snapshot loader, pokedex resolver, lazy PokéAPI client, type chart, natures | | `src/lib/*.js` | app-agnostic helpers (`dom`, `anim`, `damage-calc`, `savedex`, `swipe`, …) | | `scripts/build-snapshot.mjs` | the only thing that calls PokéAPI at build time | +| `test/*.test.js` | Vitest unit tests (pure logic only) | | `src/styles/tokens.css` | palette, type colours, the five themes | | `src/styles/layout.css` | everything else | @@ -91,9 +96,9 @@ npm run preview # serve the production build — REQUIRED to test the se ## Verifying a change -`npm run format` then `npm run build` — both must pass. Then sanity-check -by hand: the dex grid -(filters, sort, catching from a card), switching games (numbers + typings -update), a detail page (all tabs, form switch), and `npm run preview` -offline (DevTools → Network → Offline → reload). Check light and dark +`npm run format`, `npm test`, `npm run build` — all must pass. Then +sanity-check by hand: the dex grid (filters, sort, catching from a card), +switching games (numbers + typings update), a detail page (all tabs, form +switch), and `npm run preview` offline (DevTools → Network → Offline → +reload). Check light and dark theme and a mobile-width viewport. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9bbf42..28b7caf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,15 +90,22 @@ before it can render. ## Testing your change -There's no automated suite. Before opening a PR, manually check: +There's a small [Vitest](https://vitest.dev/) suite in `test/` covering +pure logic — the type chart, damage formula, the save-file parser, and the +per-game selection store. Run it with `npm test` (watch mode: +`npm run test:watch`). If you change any of those modules, update or add a +test. There are no UI/integration tests. + +CI runs `npm run format:check`, `npm test`, and `npm run build`. Before +opening a PR also check by hand: - The **dex grid** — filters, sort, the progress ring, catching from a card. - **Switching games** (the Games sheet) — regional numbers, era typings, and per-game seen/caught all update. - A **detail page** — every tab, form switching, the track buttons. -- **`npm run build`** succeeds, and `npm run preview` still works offline - (DevTools → Network → Offline, then reload). +- `npm run preview` still works offline (DevTools → Network → Offline, + then reload). - Light **and** dark theme, and a narrow (mobile) viewport. Describe what you tested in the PR. diff --git a/README.md b/README.md index 9d80d39..9c302a4 100644 --- a/README.md +++ b/README.md @@ -248,10 +248,10 @@ BASE_PATH=/pocketdex/ npm run build **Docker** (multi-stage Node → nginx): ```bash -docker compose up --build # → http://localhost:8080 +docker compose up --build # → http://localhost:4753 # or docker build -t pocketdex . -docker run --rm -p 8080:80 pocketdex +docker run --rm -p 4753:80 pocketdex ``` The build stage's `prebuild` fetches the snapshot, so `docker build` needs @@ -271,7 +271,8 @@ what's in and out of scope. AI coding agents: there's an **[AGENTS.md](AGENTS.md)**. Known gaps: no interactive region map (PokéAPI has no map imagery or -coordinates), no automated test suite yet, and no formal Lighthouse pass. +coordinates), a thin unit-test suite (pure logic only — no UI/integration +tests), and no formal Lighthouse pass. --- diff --git a/docker-compose.yml b/docker-compose.yml index 3efb129..fa574e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,5 +6,5 @@ services: BASE_PATH: / image: pocketdex ports: - - '8080:80' + - '4753:80' restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index 83ab0aa..354b254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "devDependencies": { "prettier": "3.9.6", "vite": "^6.4.3", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "vitest": "^5.0.0" }, "engines": { "node": ">=20" @@ -42,6 +43,67 @@ "ajv": ">=8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1584,6 +1646,173 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "peer": true, + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2026,6 +2255,26 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2080,9 +2329,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -2600,6 +2849,24 @@ "node": ">=12" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2620,6 +2887,64 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "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/mocker/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/@vitest/mocker/node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2689,6 +3014,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/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2797,6 +3132,18 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2922,6 +3269,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/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -2988,6 +3345,55 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3060,6 +3466,15 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3156,6 +3571,21 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3264,6 +3694,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", @@ -3396,6 +3833,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "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", @@ -3823,6 +4270,21 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -4091,6 +4553,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4314,6 +4785,61 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4691,6 +5217,15 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0", + "optional": true, + "peer": true + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4797,6 +5332,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/own-keys": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", @@ -4823,6 +5372,21 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4955,6 +5519,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5190,6 +5766,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5358,6 +5949,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5422,6 +6020,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "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", @@ -5562,6 +6174,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -5610,6 +6231,26 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "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", @@ -5627,6 +6268,60 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -5737,6 +6432,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -5952,6 +6659,155 @@ } } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.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/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6057,6 +6913,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/workbox-background-sync": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", @@ -6270,6 +7143,27 @@ "workbox-core": "7.4.1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 9fb464d..09822c4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "format": "prettier --write .", "format:check": "prettier --check ." }, @@ -20,7 +22,8 @@ "devDependencies": { "prettier": "3.9.6", "vite": "^6.4.3", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "vitest": "^5.0.0" }, "dependencies": { "workbox-cacheable-response": "^7.4.1", diff --git a/test/damage-calc.test.js b/test/damage-calc.test.js new file mode 100644 index 0000000..431bd64 --- /dev/null +++ b/test/damage-calc.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { natureMultiplier, statAt, calcDamage } from '../src/lib/damage-calc.js'; + +describe('natureMultiplier', () => { + it('boosts, cuts, and no-ops', () => { + expect(natureMultiplier('Adamant', 'atk')).toBe(1.1); + expect(natureMultiplier('Adamant', 'spa')).toBe(0.9); + expect(natureMultiplier('Adamant', 'spe')).toBe(1); + expect(natureMultiplier('Hardy', 'atk')).toBe(1); // neutral nature + expect(natureMultiplier('Nonsense', 'atk')).toBe(1); // unknown nature + }); +}); + +describe('statAt (Gen 3+ formula)', () => { + it('HP and a normal stat at level 100, 31 IV / 0 EV / neutral', () => { + // Garchomp: 108 base HP -> 357 ; 130 base Atk -> 296 + expect(statAt(108, 100, { statKey: 'hp' })).toBe(357); + expect(statAt(130, 100, { statKey: 'atk' })).toBe(296); + }); + + it('applies nature to the raw stat', () => { + expect(statAt(130, 100, { statKey: 'atk', nature: 'Adamant' })).toBe(325); // floor(296 * 1.1) + expect(statAt(130, 100, { statKey: 'atk', nature: 'Modest' })).toBe(266); // floor(296 * 0.9) + }); + + it('Shedinja HP is always 1', () => { + expect(statAt(1, 100, { statKey: 'hp' })).toBe(1); + }); +}); + +describe('calcDamage', () => { + it('returns null for status / zero-power moves', () => { + expect(calcDamage({ power: 0, level: 50 })).toBeNull(); + expect(calcDamage({ power: null, level: 50 })).toBeNull(); + }); + + it('STAB + super effective, with the 85–100% roll', () => { + const r = calcDamage({ + level: 100, + power: 80, + moveType: 'water', + atkTypes: ['water'], + defTypes: ['ground', 'rock'], // 4x + atkStat: 200, + defStat: 100, + gen: 9, + }); + expect(r.stab).toBe(true); + expect(r.eff).toBe(4); + expect(r.max).toBeGreaterThan(r.min); + expect(r.min).toBeGreaterThanOrEqual(1); + }); + + it('immunity short-circuits to 0', () => { + const r = calcDamage({ + level: 100, + power: 100, + moveType: 'ground', + atkTypes: ['fire'], // no STAB, to keep the shape unambiguous + defTypes: ['flying'], + atkStat: 200, + defStat: 100, + gen: 9, + }); + expect(r).toEqual({ min: 0, max: 0, stab: false, eff: 0 }); + }); + + it('crit multiplier differs pre/post Gen 6', () => { + const base = { + level: 100, + power: 100, + moveType: 'normal', + atkTypes: ['normal'], + defTypes: ['normal'], + atkStat: 200, + defStat: 100, + crit: true, + }; + const g5 = calcDamage({ ...base, gen: 5 }); + const g6 = calcDamage({ ...base, gen: 6 }); + expect(g5.max).toBeGreaterThan(g6.max); // 2x vs 1.5x + }); +}); diff --git a/test/pokedex-resolver.test.js b/test/pokedex-resolver.test.js new file mode 100644 index 0000000..6768547 --- /dev/null +++ b/test/pokedex-resolver.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + prettify, + dexesForVersionGroup, + resolvePokedex, + dexRows, +} from '../src/data/pokedex-resolver.js'; + +describe('prettify', () => { + it('title-cases a hyphenated key', () => { + expect(prettify('heartgold-soulsilver')).toBe('Heartgold Soulsilver'); + expect(prettify('black-2-white-2')).toBe('Black 2 White 2'); + expect(prettify('national')).toBe('National'); + }); +}); + +// A hand-built mini snapshot with the Map indexes loadSnapshot() would add. +function miniSnap() { + const pokedexes = [ + { + key: 'national', + name: 'National', + entries: [ + [1, 1], + [4, 4], + [999, 7], + ], + }, + { + key: 'kanto', + name: 'Kanto', + entries: [ + [4, 1], + [1, 2], + ], + }, + ]; + const versionGroups = [ + { key: 'red-blue', name: 'Red Blue', generation: 1, pokedexKeys: ['kanto'] }, + { key: 'x-y', name: 'X Y', generation: 6, pokedexKeys: [] }, + ]; + return { + pokedexByKey: new Map(pokedexes.map((d) => [d.key, d])), + versionGroupByKey: new Map(versionGroups.map((v) => [v.key, v])), + speciesById: new Map([ + [1, { id: 1, name: 'bulbasaur' }], + [4, { id: 4, name: 'charmander' }], + ]), + }; +} + +describe('dexesForVersionGroup', () => { + it('returns the group’s regional dexes', () => { + expect(dexesForVersionGroup(miniSnap(), 'red-blue').map((d) => d.key)).toEqual(['kanto']); + }); + it('falls back to National when a group lists no dex', () => { + expect(dexesForVersionGroup(miniSnap(), 'x-y').map((d) => d.key)).toEqual(['national']); + }); +}); + +describe('resolvePokedex', () => { + it('honours settings.pokedex when it belongs to the game', () => { + const snap = miniSnap(); + // one-dex game: always that dex + expect(resolvePokedex(snap, { versionGroup: 'red-blue', pokedex: 'kanto' }).key).toBe('kanto'); + // stale pokedex from another game -> that game's first dex + expect(resolvePokedex(snap, { versionGroup: 'red-blue', pokedex: 'hoenn' }).key).toBe('kanto'); + }); +}); + +describe('dexRows', () => { + it('expands entries and drops species missing from the snapshot', () => { + const snap = miniSnap(); + const rows = dexRows(snap, snap.pokedexByKey.get('national')); + expect(rows).toEqual([ + { species: { id: 1, name: 'bulbasaur' }, number: 1 }, + { species: { id: 4, name: 'charmander' }, number: 4 }, + ]); + }); +}); diff --git a/test/savedex.test.js b/test/savedex.test.js new file mode 100644 index 0000000..bff0034 --- /dev/null +++ b/test/savedex.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { parseSaveDex } from '../src/lib/savedex.js'; + +// --- Synthetic Gen 1 (R/B/Y) SRAM ------------------------------------- +// 32 KB, with the owned/seen bitfields and the byte checksum the parser +// verifies. Offsets mirror src/lib/savedex.js `G1`. +const G1 = { + owned: 0x25a3, + seen: 0x25b6, + species: 151, + ckByte: 0x3523, + ckFrom: 0x2598, + ckTo: 0x3522, +}; + +function setBits(bytes, start, ids) { + for (const n of ids) bytes[start + ((n - 1) >> 3)] |= 1 << ((n - 1) & 7); +} + +function makeGen1Save({ caught, seen }) { + const bytes = new Uint8Array(0x8000); + setBits(bytes, G1.owned, caught); + setBits(bytes, G1.seen, seen); + let sum = 0; + for (let i = G1.ckFrom; i <= G1.ckTo; i++) sum = (sum + bytes[i]) >>> 0; + bytes[G1.ckByte] = ~sum & 0xff; + return bytes.buffer; +} + +describe('parseSaveDex — Gen 1', () => { + it('reads owned/seen and verifies the checksum', () => { + const caught = [1, 25, 151]; + const seen = [1, 4, 25, 151]; + const dex = parseSaveDex(makeGen1Save({ caught, seen })); + + expect(dex.gen).toBe(1); + expect(dex.game).toBe('Red / Blue / Yellow'); + expect(dex.versionGroups).toEqual(['red-blue', 'yellow']); + expect(dex.caught).toEqual(caught); + expect(dex.seen).toEqual(seen); + expect(dex.checksumOk).toBe(true); + expect(dex.nationalUnlocked).toBe(true); + }); + + it('still parses (checksumOk false) when the checksum byte is wrong', () => { + const buf = makeGen1Save({ caught: [1], seen: [1, 2] }); + new Uint8Array(buf)[G1.ckByte] ^= 0xff; + const dex = parseSaveDex(buf); + expect(dex.gen).toBe(1); + expect(dex.caught).toEqual([1]); + expect(dex.checksumOk).toBe(false); + }); + + it('rejects a file that is too small', () => { + expect(() => parseSaveDex(new Uint8Array(1024).buffer)).toThrow(/too small/i); + }); + + it('rejects a large file with no recognisable Pokédex', () => { + expect(() => parseSaveDex(new Uint8Array(0x20000).buffer)).toThrow(); + }); +}); diff --git a/test/selection.test.js b/test/selection.test.js new file mode 100644 index 0000000..c5de28e --- /dev/null +++ b/test/selection.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + selection, + normalizeSelection, + entry, + gameEntry, + toggle, + stats, + statsNational, + importFlags, +} from '../src/store/selection.js'; + +beforeEach(() => { + localStorage.clear(); + selection.replace({ version: 3, pokemon: {}, games: {} }); +}); + +describe('normalizeSelection', () => { + it('migrates a flat v2 store to per-game', () => { + const v2 = { + version: 2, + pokemon: { + 1: { seen: true, caught: true, favorite: true, note: '' }, + 4: { seen: true, caught: false }, + 150: { seen: true, caught: true, favorite: false, note: 'box 1' }, + 25: { favorite: true }, // favourite only, never seen + }, + }; + const out = normalizeSelection(v2); + expect(out.version).toBe(3); + // favourite / note lift to the global record + expect(out.pokemon['1']).toMatchObject({ favorite: true }); + expect(out.pokemon['150']).toMatchObject({ note: 'box 1' }); + expect(out.pokemon['25']).toMatchObject({ favorite: true }); + expect(out.pokemon['4']).toBeUndefined(); // nothing global to keep + // seen / caught drop into the 'all' bucket, not a real game + expect(out.games.all['1']).toMatchObject({ seen: true, caught: true }); + expect(out.games.all['4']).toMatchObject({ seen: true, caught: false }); + expect(out.games.all['25']).toBeUndefined(); + }); + + it('passes a v3 store through untouched', () => { + const v3 = { version: 3, pokemon: { 7: { favorite: true } }, games: { crystal: {} } }; + expect(normalizeSelection(v3)).toEqual(v3); + }); + + it('handles junk input', () => { + expect(normalizeSelection(null)).toEqual({ version: 3, pokemon: {}, games: {} }); + }); +}); + +describe('per-game seen/caught', () => { + it('a catch in one game does not appear in another', () => { + toggle(3, 'caught', 'crystal'); + expect(entry(3, 'crystal').caught).toBe(true); + expect(entry(3, 'red-blue').caught).toBe(false); + // catching implies seen + expect(gameEntry(3, 'crystal').seen).toBe(true); + }); + + it("'all' reads as the union across games", () => { + toggle(1, 'caught', 'crystal'); + toggle(4, 'caught', 'red-blue'); + expect(gameEntry(1, 'all').caught).toBe(true); + expect(gameEntry(4, 'all').caught).toBe(true); + expect(gameEntry(7, 'all').caught).toBe(false); + }); + + it('favorite is global, not per game', () => { + toggle(25, 'favorite', 'crystal'); + expect(entry(25, 'red-blue').favorite).toBe(true); + expect(entry(25, 'emerald').favorite).toBe(true); + }); + + it('stats counts one game; statsNational counts the union', () => { + importFlags({ seen: [1, 2, 3], caught: [1, 2], game: 'crystal' }); + importFlags({ seen: [4], caught: [4], game: 'red-blue' }); + + const cr = stats([1, 2, 3, 4], 'crystal'); + expect(cr).toEqual({ seen: 3, caught: 2, total: 4 }); + + const rb = stats([1, 2, 3, 4], 'red-blue'); + expect(rb).toEqual({ seen: 1, caught: 1, total: 4 }); + + const nat = statsNational([1, 2, 3, 4]); + expect(nat).toEqual({ seen: 4, caught: 3, total: 4 }); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..b5f3555 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,15 @@ +// Minimal localStorage for the store modules (createStore persists to it). +// Enough for get/set/remove/clear; synchronous, in-memory, per test process. +if (typeof globalThis.localStorage === 'undefined') { + const store = new Map(); + globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => void store.set(String(k), String(v)), + removeItem: (k) => void store.delete(k), + clear: () => void store.clear(), + key: (i) => [...store.keys()][i] ?? null, + get length() { + return store.size; + }, + }; +} diff --git a/test/type-chart.test.js b/test/type-chart.test.js new file mode 100644 index 0000000..0582e15 --- /dev/null +++ b/test/type-chart.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { multiplier, defensiveMatchups } from '../src/data/type-chart.js'; + +describe('multiplier — modern (Gen 9)', () => { + it('single-type effectiveness', () => { + expect(multiplier('fire', ['grass'])).toBe(2); + expect(multiplier('fire', ['water'])).toBe(0.5); + expect(multiplier('normal', ['ghost'])).toBe(0); + expect(multiplier('ghost', ['normal'])).toBe(0); + expect(multiplier('dragon', ['fairy'])).toBe(0); + }); + + it('stacks across a dual type', () => { + expect(multiplier('water', ['ground', 'rock'])).toBe(4); + expect(multiplier('grass', ['water', 'ground'])).toBe(4); + expect(multiplier('fighting', ['normal', 'flying'])).toBe(1); // 2 * 0.5 + expect(multiplier('ground', ['flying', 'steel'])).toBe(0); // immunity wins + }); + + it('ghost hits steel neutrally from Gen 6 on', () => { + expect(multiplier('ghost', ['steel'], 9)).toBe(1); + }); +}); + +describe('multiplier — era rules', () => { + it('Fairy does not exist before Gen 6 (defender ignored)', () => { + expect(multiplier('dragon', ['fairy'], 5)).toBe(1); + expect(multiplier('poison', ['fairy'], 5)).toBe(1); + // still applies in Gen 6+ + expect(multiplier('poison', ['fairy'], 6)).toBe(2); + }); + + it('Steel resisted Ghost and Dark before Gen 6', () => { + expect(multiplier('ghost', ['steel'], 5)).toBe(0.5); + expect(multiplier('dark', ['steel'], 5)).toBe(0.5); + expect(multiplier('ghost', ['steel'], 6)).toBe(1); + }); + + it('Gen 1 quirks', () => { + expect(multiplier('ghost', ['psychic'], 1)).toBe(0); // the famous bug + expect(multiplier('ghost', ['psychic'], 2)).toBe(2); // fixed in Gen 2 + expect(multiplier('bug', ['poison'], 1)).toBe(2); + expect(multiplier('poison', ['bug'], 1)).toBe(2); + expect(multiplier('bug', ['poison'], 2)).toBe(0.5); // modern value + }); +}); + +describe('defensiveMatchups', () => { + it('buckets a dual type by multiplier and omits absent attackers', () => { + // Sableye (Dark/Ghost) modern: immune to Normal/Fighting/Psychic, weak + // only to Fairy (added in Gen 6). + const m = defensiveMatchups(['dark', 'ghost'], 9); + expect(m['0'].sort()).toEqual(['fighting', 'normal', 'psychic'].sort()); + expect(m['2']).toEqual(['fairy']); + expect(m['4']).toEqual([]); + expect(m['0.5']).toContain('poison'); + + // Pre-Gen-2 there is no Dark/Steel attacker, so Fairy is absent too + const g1 = defensiveMatchups(['ghost'], 1); + const flat = Object.values(g1).flat(); + expect(flat).not.toContain('dark'); + expect(flat).not.toContain('steel'); + expect(flat).not.toContain('fairy'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..a5d3573 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// Standalone so the PWA plugin in vite.config.js stays out of the test run. +// The units under test are pure logic + localStorage-backed stores, so a +// plain Node environment plus a tiny localStorage shim is all that's needed +// (no jsdom/happy-dom). +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['test/setup.js'], + include: ['test/**/*.test.js'], + }, +});