diff --git a/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts new file mode 100644 index 0000000..dc65212 --- /dev/null +++ b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts @@ -0,0 +1,184 @@ +import { computeApplicableSteps } from './compute-applicable-steps'; + +describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => { + it('at L5 returns [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review]', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toEqual([ + 'class-features', + 'boost', + 'skill-increase', + 'feat-class', + 'feat-skill', + 'feat-ancestry', + 'review', + ]); + }); + + it('at L4 (not a boost level) does NOT contain boost', () => { + const steps = computeApplicableSteps({ + targetLevel: 4, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('boost'); + }); + + it('at L4 (even level) contains feat-class AND feat-skill', () => { + const steps = computeApplicableSteps({ + targetLevel: 4, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-class'); + expect(steps).toContain('feat-skill'); + }); + + it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => { + const steps = computeApplicableSteps({ + targetLevel: 3, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('skill-increase'); + expect(steps).toContain('feat-general'); + expect(steps).not.toContain('feat-class'); + }); + + it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => { + const steps = computeApplicableSteps({ + targetLevel: 2, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-class'); + expect(steps).toContain('feat-skill'); + expect(steps).not.toContain('skill-increase'); + }); +}); + +describe('computeApplicableSteps — Free Archetype', () => { + it('with FA enabled at L5 includes feat-archetype', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: true, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-archetype'); + }); + + it('without FA never includes feat-archetype', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('feat-archetype'); + }); +}); + +describe('computeApplicableSteps — Spellcaster', () => { + it('with isCaster=true includes spellcaster step', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Wizard', + hasFreeArchetype: false, + isCaster: true, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('spellcaster'); + }); + + it('with isCaster=false never includes spellcaster step', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('spellcaster'); + }); +}); + +describe('computeApplicableSteps — class-feature-choice (D-19)', () => { + it('includes class-feature-choice when ClassProgression carries choiceType', () => { + const steps = computeApplicableSteps({ + targetLevel: 1, + className: 'Cleric', + hasFreeArchetype: false, + isCaster: true, + isSpontaneousCaster: false, + classProgressionHasChoiceType: true, + }); + expect(steps).toContain('class-feature-choice'); + }); + + it('does NOT include class-feature-choice when no choiceType', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('class-feature-choice'); + }); +}); + +describe('computeApplicableSteps — invariants', () => { + it('always starts with class-features', () => { + for (const level of [1, 2, 3, 5, 10, 15, 20]) { + const steps = computeApplicableSteps({ + targetLevel: level, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps[0]).toBe('class-features'); + } + }); + + it('always ends with review', () => { + for (const level of [1, 2, 3, 5, 10, 15, 20]) { + const steps = computeApplicableSteps({ + targetLevel: level, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps[steps.length - 1]).toBe('review'); + } + }); +});