test(01-02): RED — recompute-derived-stats spec (Pitfall #8/#9)

Task 4 RED phase: 5 failing tests covering pure recompute pipeline.
- hpMax = ancestryHP + (classHP + conMod) × newLevel
- boost-cap-at-18 for CON boost (Pitfall #8)
- proficiencyChanges from ClassProgression applied (fortitude EXPERT)
- Pitfall #9: result MUST NOT contain hpCurrent or hpTemp
- level passthrough

Verified failure: module './recompute-derived-stats' not found.
This commit is contained in:
2026-04-27 14:10:28 +02:00
parent da82d9bf82
commit 8dd55b6fa9

View File

@@ -0,0 +1,89 @@
import { recomputeDerivedStats } from './recompute-derived-stats';
import type { CharacterContext, ClassProgressionRow, WizardChoices, Proficiency } from './types';
type RecomputeInput = CharacterContext & {
ancestryHp: number;
classHp: number;
armorAc: number;
armorProficiency: Proficiency;
dexCap: number;
};
function baseCharacter(overrides: Partial<RecomputeInput> = {}): RecomputeInput {
return {
level: 4,
className: 'Fighter',
ancestryName: 'Human',
heritageName: undefined,
abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 },
skills: {},
feats: new Set(),
ancestryHp: 8,
classHp: 10,
armorAc: 4,
armorProficiency: 'EXPERT',
dexCap: 2,
...overrides,
};
}
function emptyProgression(level: number): ClassProgressionRow {
return {
className: 'Fighter',
level,
grants: [],
proficiencyChanges: {},
};
}
describe('recomputeDerivedStats — hpMax', () => {
it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => {
// CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73
const ch = baseCharacter();
const choices: WizardChoices = {};
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
expect(result.hpMax).toBe(73);
});
it('respects boost-cap-at-18 when CON is boosted from 18', () => {
// CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4.
// L5: 8 + (10+4)×5 = 78
const ch = baseCharacter({
abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 },
});
const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] };
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
expect(result.hpMax).toBe(78);
});
});
describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => {
it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => {
// Fighter L4 → L5 with progression bumping fort to EXPERT.
// CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12
const ch = baseCharacter();
const progression: ClassProgressionRow = {
...emptyProgression(5),
proficiencyChanges: { fortitude: 'EXPERT' },
};
const result = recomputeDerivedStats(ch, {}, progression);
expect(result.fortitude).toBe(12);
});
});
describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => {
it('does NOT include hpCurrent in the result object', () => {
const ch = baseCharacter();
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
expect(result).not.toHaveProperty('hpCurrent');
expect(result).not.toHaveProperty('hpTemp');
});
});
describe('recomputeDerivedStats — level passthrough', () => {
it('returns the new level in the output', () => {
const ch = baseCharacter();
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
expect(result.level).toBe(5);
});
});