diff --git a/frontend/src/components/scenario/TimelinePage.test.tsx b/frontend/src/components/scenario/TimelinePage.test.tsx new file mode 100644 index 00000000..6ae92414 --- /dev/null +++ b/frontend/src/components/scenario/TimelinePage.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const clientMocks = vi.hoisted(() => ({ + fetchColIndex: vi.fn(), +})); + +vi.mock('../../api/client', () => ({ + fetchColIndex: clientMocks.fetchColIndex, +})); + +import type { ConfigDetail, SimulationConfigPayload, WithdrawalOrder } from '../../api/types'; +import { TimelinePage } from './TimelinePage'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + clientMocks.fetchColIndex.mockImplementation(() => new Promise(() => {})); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +function buildSimulation(overrides: Partial = {}): SimulationConfigPayload { + return { + initial_year: 2026, + initial_net_worth: 940_000, + baseline_annual_expenses: 80_000, + additional_expenses: [], + additional_income: [], + country: 'US', + citizenship: 'US', + avg_return: 0.07, + volatility: 0.15, + approximate_yield_rate: 0.02, + taxable_balance: 940_000, + tax_deferred_balance: 0, + tax_free_balance: 0, + taxable_cost_basis: 470_000, + withdrawal_order: 'taxable_first' as WithdrawalOrder, + tax_filing_status: 'single', + wealth_tax_region: 'default', + horizon: 53, + n_scenarios: 1000, + inflation: 0.02, + skewness_start: 5, + skewness_factor: 2, + minimum_ending_balance: 0, + retirement_year: 2035, + annual_savings: 85_000, + age: 42, + plan_to_age: 95, + ...overrides, + }; +} + +function buildConfig(simulationOverrides: Partial = {}): ConfigDetail { + return { + id: 1, + name: 'reddit-bridge-years', + label: 'Reddit bridge years', + notes: '', + display_order: 0, + last_failure_probability: null, + simulation: buildSimulation(simulationOverrides), + }; +} + +function renderTimeline( + config: ConfigDetail, + onSave: (payload: { + label?: string | null; + notes?: string | null; + simulation: SimulationConfigPayload; + }) => void, +) { + act(() => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [`/scenario/${config.name}/timeline`] }, + createElement(TimelinePage, { + config, + onSave, + onSaveAndRun: () => undefined, + }), + ), + ); + }); +} + +function getByTestId(testId: string): T { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Unable to find element with data-testid="${testId}"`); + } + return element as T; +} + +function click(element: Element) { + act(() => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +function changeInput(input: HTMLInputElement, value: string) { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!setValue) { + throw new Error('Unable to update input value in test environment'); + } + act(() => { + setValue.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +describe('TimelinePage benefit editing', () => { + it('saves edited benefit claim age and derived begin year without requiring blur', () => { + const onSave = vi.fn(); + const config = buildConfig({ + additional_income: [ + { + description: 'Other government pension', + income: 23_000, + begin_year: 2026, + end_year: 2078, + type: 'public_pension', + source_country: 'US', + claim_age: null, + benefit_source: 'Other government pension', + }, + ], + }); + + renderTimeline(config, onSave); + + click(getByTestId('timeline-card-toggle-income-0')); + const claimAgeInput = getByTestId('timeline-card-claim-age-income-0'); + expect(claimAgeInput.value).toBe(''); + + changeInput(claimAgeInput, '55'); + click(getByTestId('timeline-save-btn')); + + expect(onSave).toHaveBeenCalledTimes(1); + const [payload] = onSave.mock.calls[0]; + const [income] = payload.simulation.additional_income; + expect(income.claim_age).toBe(55); + expect(income.begin_year).toBe(2039); + }); +}); diff --git a/frontend/src/components/scenario/TimelinePage.tsx b/frontend/src/components/scenario/TimelinePage.tsx index d05b97ef..49515a41 100644 --- a/frontend/src/components/scenario/TimelinePage.tsx +++ b/frontend/src/components/scenario/TimelinePage.tsx @@ -1375,8 +1375,8 @@ function BenefitFormFields({ { + value={income.claim_age ?? ''} + onChange={(e) => { const raw = e.target.value; const claimAge = raw === '' ? null : Number(raw); // Recalc begin_year from claim age so the simulation reflects