dex/test/selection.test.js
chris 3a54cdcc53 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
2026-09-10 11:33:27 -04:00

89 lines
3.0 KiB
JavaScript

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 });
});
});