dex/test/damage-calc.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

84 lines
2.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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