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 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.
|
||||
|
||||
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 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -6,5 +6,5 @@ services:
|
||||
BASE_PATH: /
|
||||
image: pocketdex
|
||||
ports:
|
||||
- '8080:80'
|
||||
- '4753:80'
|
||||
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",
|
||||
"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",
|
||||
|
||||
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