import { describe, expect, it } from 'vitest'; import { defaultScenarios, runScenario, scenarioInputs, type ScenarioParams } from './scenarios'; const accounts = [ { id: 1, name: 'Revolut', kind: 'checking' }, { id: 4, name: 'Joint', kind: 'checking' }, { id: 5, name: 'Broker', kind: 'investment' }, { id: 9, name: 'Savings', kind: 'savings', includeInTotal: false } ]; const inputs = scenarioInputs({ accounts, balances: new Map([ [1, 200000], [4, 100000], [5, 50000], [9, 999999] ]), recurring: [ { name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 }, { name: 'Gym', amountCents: -3000, cadence: 'monthly', accountId: 1 }, { name: 'Benefit', amountCents: 10000, cadence: 'monthly', accountId: 1, endsOn: '2026-12-31' }, { name: 'Rent + utilities (estimate)', amountCents: -180000, cadence: 'monthly', accountId: 4, startsOn: '2027-03-01' }, { name: 'Partner income', amountCents: 250000, cadence: 'monthly', accountId: 4 }, { name: 'Hidden', amountCents: -5000, cadence: 'monthly', accountId: 9 } ], forecasts: [ { date: '2026-11-15', amountCents: 28000, description: 'Side job', accountId: 1 }, { date: '2027-09-30', amountCents: 250000, description: 'Bonus Cargill', accountId: 1 } ], futureTransactions: [], today: '2026-10-24', endDate: '2030-12-31' }); describe('scenarioInputs', () => { it('splits tracker data into balances, salary, personal and household items', () => { expect(inputs.spendableCents).toBe(300000); expect(inputs.investedCents).toBe(50000); expect(inputs.netSalaryCents).toBe(300000); expect(inputs.personal.map((item) => item.name)).toEqual(['Gym', 'Benefit']); expect(inputs.household.map((item) => item.name)).toEqual(['Rent + utilities (estimate)']); expect(inputs.moveDate).toBe('2027-03-01'); // Salary extras are modelled by the scenarios, other one-offs pass through. expect(inputs.oneOffs.map((item) => item.description)).toEqual(['Side job']); }); }); describe('runScenario', () => { const flat: ScenarioParams = { ...defaultScenarios.realistic, returnPercent: 0, recoveryPercent: 0, cashRatePercent: 0, sideStartEuros: 0, sideEndEuros: 0, spendGrowthPercent: 0, otherSpendEuros: 0, householdExtraEuros: 0, healthInsuranceEuros: 0, moveCostEuros: 0, bonusPercentOfTarget: 0 }; it('runs monthly to the end date and starts from today', () => { const result = runScenario(inputs, flat); expect(result.points[0]).toMatchObject({ date: '2026-10-24', total: 350000 }); expect(result.end.date).toBe('2030-12-31'); expect(result.points).toHaveLength(51); expect(result.years.map((row) => row.year)).toEqual([2026, 2027, 2028, 2029, 2030]); }); it('fills the buffer before investing and counts recurring income and costs', () => { const result = runScenario(inputs, flat); const november = result.points[1]; expect(november.cash).toBe(500000); // 300k salary + 10k benefit + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household. expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0); }); it('pays contract extras in May (holiday pay) and August (bonus)', () => { const delta = (points: { total: number }[], index: number) => points[index].total - points[index - 1].total; const flatRun = runScenario(inputs, flat).points; // Index 7 is May 2027: holiday pay 8/12 of €1,950. expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0); const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points; // Index 10 is August 2027: the first bonus, €850. expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 0); }); it('orders the default scenarios from pessimistic to optimistic', () => { const [pessimistic, realistic, optimistic] = ( ['pessimistic', 'realistic', 'optimistic'] as const ).map((id) => runScenario(inputs, defaultScenarios[id]).end.total); expect(pessimistic).toBeLessThan(realistic); expect(realistic).toBeLessThan(optimistic); }); it('applies a crash in its year and a job gap after the contract', () => { const calm = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5 }); const crash = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5, crashPercent: -30, crashYear: 2027 }); expect(crash.end.total).toBeLessThan(calm.end.total); const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 }); expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total); }); });