Add a Vitest unit suite for the pure-logic modules
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
39bffcab32
commit
3a54cdcc53
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -23,6 +23,8 @@ jobs:
|
|||||||
|
|
||||||
- run: npm run format:check
|
- run: npm run format:check
|
||||||
|
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
# The build's `prebuild` step fetches a data snapshot from PokéAPI.
|
# 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
|
# 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.
|
# cost; CI only needs *a* snapshot to prove the build compiles.
|
||||||
|
|||||||
23
AGENTS.md
23
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 dev # dev server, http://localhost:5173
|
||||||
npm run build # runs `snapshot` (prebuild) then builds to dist/
|
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 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+**.
|
- Node **20+**.
|
||||||
- Automated checks (both run in CI, both must pass):
|
- CI runs three checks, all must pass: `npm run format:check` (Prettier),
|
||||||
`npm run format:check` (Prettier) and `npm run build` (esbuild transforms
|
`npm test` (Vitest — pure-logic unit tests in `test/`), and
|
||||||
every module, so it catches syntax and import errors). Run `npm run format`
|
`npm run build` (esbuild transforms every module, catching syntax and
|
||||||
before finishing. There is **no test suite** and no ESLint.
|
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.
|
- `npm run snapshot -- --force` rebuilds the snapshot even if it's fresh.
|
||||||
|
|
||||||
## Project structure
|
## 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/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`, …) |
|
| `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 |
|
| `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/tokens.css` | palette, type colours, the five themes |
|
||||||
| `src/styles/layout.css` | everything else |
|
| `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
|
## Verifying a change
|
||||||
|
|
||||||
`npm run format` then `npm run build` — both must pass. Then sanity-check
|
`npm run format`, `npm test`, `npm run build` — all must pass. Then
|
||||||
by hand: the dex grid
|
sanity-check by hand: the dex grid (filters, sort, catching from a card),
|
||||||
(filters, sort, catching from a card), switching games (numbers + typings
|
switching games (numbers + typings update), a detail page (all tabs, form
|
||||||
update), a detail page (all tabs, form switch), and `npm run preview`
|
switch), and `npm run preview` offline (DevTools → Network → Offline →
|
||||||
offline (DevTools → Network → Offline → reload). Check light and dark
|
reload). Check light and dark
|
||||||
theme and a mobile-width viewport.
|
theme and a mobile-width viewport.
|
||||||
|
|||||||
@ -90,15 +90,22 @@ before it can render.
|
|||||||
|
|
||||||
## Testing your change
|
## 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
|
- The **dex grid** — filters, sort, the progress ring, catching from a
|
||||||
card.
|
card.
|
||||||
- **Switching games** (the Games sheet) — regional numbers, era typings,
|
- **Switching games** (the Games sheet) — regional numbers, era typings,
|
||||||
and per-game seen/caught all update.
|
and per-game seen/caught all update.
|
||||||
- A **detail page** — every tab, form switching, the track buttons.
|
- A **detail page** — every tab, form switching, the track buttons.
|
||||||
- **`npm run build`** succeeds, and `npm run preview` still works offline
|
- `npm run preview` still works offline (DevTools → Network → Offline,
|
||||||
(DevTools → Network → Offline, then reload).
|
then reload).
|
||||||
- Light **and** dark theme, and a narrow (mobile) viewport.
|
- Light **and** dark theme, and a narrow (mobile) viewport.
|
||||||
|
|
||||||
Describe what you tested in the PR.
|
Describe what you tested in the PR.
|
||||||
|
|||||||
@ -248,10 +248,10 @@ BASE_PATH=/pocketdex/ npm run build
|
|||||||
**Docker** (multi-stage Node → nginx):
|
**Docker** (multi-stage Node → nginx):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build # → http://localhost:8080
|
docker compose up --build # → http://localhost:4753
|
||||||
# or
|
# or
|
||||||
docker build -t pocketdex .
|
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
|
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)**.
|
**[AGENTS.md](AGENTS.md)**.
|
||||||
|
|
||||||
Known gaps: no interactive region map (PokéAPI has no map imagery or
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -6,5 +6,5 @@ services:
|
|||||||
BASE_PATH: /
|
BASE_PATH: /
|
||||||
image: pocketdex
|
image: pocketdex
|
||||||
ports:
|
ports:
|
||||||
- '8080:80'
|
- '4753:80'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
902
package-lock.json
generated
902
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
@ -20,7 +22,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-cacheable-response": "^7.4.1",
|
"workbox-cacheable-response": "^7.4.1",
|
||||||
|
|||||||
83
test/damage-calc.test.js
Normal file
83
test/damage-calc.test.js
Normal file
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
80
test/pokedex-resolver.test.js
Normal file
80
test/pokedex-resolver.test.js
Normal file
@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
test/savedex.test.js
Normal file
61
test/savedex.test.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
88
test/selection.test.js
Normal file
88
test/selection.test.js
Normal file
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
15
test/setup.js
Normal file
15
test/setup.js
Normal file
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
65
test/type-chart.test.js
Normal file
65
test/type-chart.test.js
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
13
vitest.config.js
Normal file
13
vitest.config.js
Normal file
@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user