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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user