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:
chris 2026-09-10 11:33:27 -04:00
parent 39bffcab32
commit 3a54cdcc53
14 changed files with 1338 additions and 21 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.
--- ---

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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 85100% 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
});
});

View 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 groups 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
View 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
View 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
View 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
View 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
View 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'],
},
});