diff --git a/app/src/adapters/SimulationAdapter.ts b/app/src/adapters/SimulationAdapter.ts index d449dbfb..25bf6d85 100644 --- a/app/src/adapters/SimulationAdapter.ts +++ b/app/src/adapters/SimulationAdapter.ts @@ -1,3 +1,4 @@ +import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload } from '@/types/payloads'; @@ -24,6 +25,20 @@ export class SimulationAdapter { throw new Error('Simulation metadata missing population_type'); } + // Parse output_json if present + let output: Household | null = null; + if (metadata.output_json) { + try { + const householdData = JSON.parse(metadata.output_json); + output = { + countryId: metadata.country_id, + householdData, + }; + } catch (error) { + console.error('[SimulationAdapter] Failed to parse output_json:', error); + } + } + return { id: String(metadata.id), countryId: metadata.country_id, @@ -33,6 +48,7 @@ export class SimulationAdapter { populationType, label: null, isCreated: true, + output, }; } diff --git a/app/src/api/reportCalculations.ts b/app/src/api/reportCalculations.ts index ac729b06..a7f18d76 100644 --- a/app/src/api/reportCalculations.ts +++ b/app/src/api/reportCalculations.ts @@ -4,7 +4,7 @@ import { EconomyCalculationParams, fetchEconomyCalculation } from './economy'; import { fetchHouseholdCalculation } from './householdCalculation'; /** - * Metadata needed to fetch a calculation + * Metadata needed to fetch a calculation and store results * This is stored alongside the calculation in the cache when a report is created */ export interface CalculationMeta { @@ -16,6 +16,7 @@ export interface CalculationMeta { }; populationId: string; region?: string; + simulationIds: string[]; // Track which simulations to update with calculation results } /** diff --git a/app/src/api/simulation.ts b/app/src/api/simulation.ts index 86f459bc..be4c2b8e 100644 --- a/app/src/api/simulation.ts +++ b/app/src/api/simulation.ts @@ -1,5 +1,6 @@ import { BASE_URL } from '@/constants'; import { countryIds } from '@/libs/countries'; +import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload } from '@/types/payloads'; @@ -76,3 +77,55 @@ export async function createSimulation( }, }; } + +/** + * Update a simulation with calculation output + * NOTE: Follows the same pattern as report PATCH endpoint (ID in payload, not URL) + * + * @param countryId - The country ID + * @param simulationId - The simulation ID + * @param simulation - The simulation object with output + * @returns The updated simulation metadata + */ +export async function updateSimulationOutput( + countryId: (typeof countryIds)[number], + simulationId: string, + simulation: Simulation +): Promise { + const url = `${BASE_URL}/${countryId}/simulation`; + + console.log('[updateSimulationOutput] Updating simulation:', simulationId, 'with output'); + + const payload = { + id: parseInt(simulationId, 10), + output_json: simulation.output ? JSON.stringify(simulation.output.householdData) : null, + }; + + const response = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error( + `Failed to update simulation ${simulationId}: ${response.status} ${response.statusText}` + ); + } + + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error(`Failed to parse simulation update response: ${error}`); + } + + if (json.status !== 'ok') { + throw new Error(json.message || `Failed to update simulation ${simulationId}`); + } + + return json.result; +} diff --git a/app/src/libs/calculations/manager.ts b/app/src/libs/calculations/manager.ts index 390fcc77..ea6d50d9 100644 --- a/app/src/libs/calculations/manager.ts +++ b/app/src/libs/calculations/manager.ts @@ -1,8 +1,10 @@ import { QueryClient } from '@tanstack/react-query'; import { markReportCompleted, markReportError } from '@/api/report'; import { CalculationMeta } from '@/api/reportCalculations'; +import { updateSimulationOutput } from '@/api/simulation'; import { countryIds } from '@/libs/countries'; import { Report, ReportOutput } from '@/types/ingredients/Report'; +import { Simulation } from '@/types/ingredients/Simulation'; import { HouseholdProgressUpdater } from './progressUpdater'; import { CalculationService, getCalculationService } from './service'; import { CalculationStatusResponse } from './status'; @@ -32,35 +34,47 @@ export class CalculationManager { reportId: string, meta: CalculationMeta ): Promise { - // Create completion callback for household calculations - const onComplete = async (completedReportId: string, status: 'ok' | 'error', result?: any) => { - console.log( - '[CalculationManager] Household calculation completed:', - completedReportId, - status - ); - - // Check if we haven't already updated this report - if (!this.reportStatusTracking.get(completedReportId)) { - this.reportStatusTracking.set(completedReportId, true); - await this.updateReportStatus( - completedReportId, - status === 'ok' ? 'complete' : 'error', - meta.countryId, - result - ); - } + // Create callbacks + const callbacks = { + onComplete: async (completedReportId: string, status: 'ok' | 'error', result?: any) => { + console.log('[CalculationManager] Calculation completed:', completedReportId, status); + + if (!this.reportStatusTracking.get(completedReportId)) { + this.reportStatusTracking.set(completedReportId, true); + await this.updateReportStatus( + completedReportId, + status === 'ok' ? 'complete' : 'error', + meta.countryId, + result, + meta + ); + } + }, + onSimulationComplete: + meta.type === 'household' + ? async (simulationId: string, result: any, policyId: string) => { + console.log('[CalculationManager] Simulation completed:', simulationId); + + const simulation: Simulation = { + id: simulationId, + countryId: meta.countryId, + policyId, + populationId: meta.populationId, + populationType: 'household', + label: null, + isCreated: true, + output: result, + }; + + await updateSimulationOutput(meta.countryId, simulationId, simulation); + } + : undefined, }; - // Execute calculation with callback for household - const result = await this.service.executeCalculation( - reportId, - meta, - meta.type === 'household' ? onComplete : undefined - ); + // Execute calculation with callbacks + const result = await this.service.executeCalculation(reportId, meta, callbacks); // For economy calculations, update immediately if complete - // (household updates happen via callback) if (meta.type === 'economy' && !this.reportStatusTracking.get(reportId)) { if (result.status === 'ok' || result.status === 'error') { this.reportStatusTracking.set(reportId, true); @@ -68,7 +82,8 @@ export class CalculationManager { reportId, result.status === 'ok' ? 'complete' : 'error', meta.countryId, - result.result + result.result, + meta ); } } @@ -85,35 +100,44 @@ export class CalculationManager { this.reportStatusTracking.delete(reportId); if (meta.type === 'household') { - // For household, check if already running const handler = this.service.getHandler('household'); if (!handler.isActive(reportId)) { - // Create completion callback for household - const onComplete = async ( - completedReportId: string, - status: 'ok' | 'error', - result?: any - ) => { - console.log( - '[CalculationManager.startCalculation] Household completed:', - completedReportId, - status - ); + // Create callbacks + const callbacks = { + onComplete: async (completedReportId: string, status: 'ok' | 'error', result?: any) => { + console.log('[CalculationManager] Calculation completed:', completedReportId, status); + + if (!this.reportStatusTracking.get(completedReportId)) { + this.reportStatusTracking.set(completedReportId, true); + await this.updateReportStatus( + completedReportId, + status === 'ok' ? 'complete' : 'error', + meta.countryId, + result, + meta + ); + } + }, + onSimulationComplete: async (simulationId: string, result: any, policyId: string) => { + console.log('[CalculationManager] Simulation completed:', simulationId); + + const simulation: Simulation = { + id: simulationId, + countryId: meta.countryId, + policyId, + populationId: meta.populationId, + populationType: 'household', + label: null, + isCreated: true, + output: result, + }; - // Check if we haven't already updated this report - if (!this.reportStatusTracking.get(completedReportId)) { - this.reportStatusTracking.set(completedReportId, true); - await this.updateReportStatus( - completedReportId, - status === 'ok' ? 'complete' : 'error', - meta.countryId, - result - ); - } + await updateSimulationOutput(meta.countryId, simulationId, simulation); + }, }; - // Start the calculation with callback - await this.service.executeCalculation(reportId, meta, onComplete); + // Start the calculation with callbacks + await this.service.executeCalculation(reportId, meta, callbacks); // Start progress updates this.progressUpdater.startProgressUpdates(reportId, handler as any); } @@ -152,19 +176,24 @@ export class CalculationManager { /** * Update the report status in the database when a calculation completes or errors + * For household calculations, output is null (stored in Simulations) + * For economy calculations, output contains the comparison results */ async updateReportStatus( reportId: string, status: 'complete' | 'error', countryId: (typeof countryIds)[number], - result?: ReportOutput + result?: ReportOutput, + calculationMeta?: CalculationMeta ): Promise { // Create a minimal Report object with just the necessary fields - // Both household and society-wide results are stored in the output field + // For household: output is null (stored in Simulation) + // For economy: output contains the comparison results const report: Report = { id: reportId, status, - output: status === 'complete' ? result || null : null, + output: + status === 'complete' && calculationMeta?.type !== 'household' ? result || null : null, countryId, apiVersion: '', simulationIds: [], diff --git a/app/src/libs/calculations/service.ts b/app/src/libs/calculations/service.ts index a8c77d4a..5da5f81c 100644 --- a/app/src/libs/calculations/service.ts +++ b/app/src/libs/calculations/service.ts @@ -65,6 +65,15 @@ export class CalculationService { ? geography.geographyId : undefined; + // Collect simulation IDs to update with calculation results + const simulationIds: string[] = []; + if (simulation1.id) { + simulationIds.push(simulation1.id); + } + if (simulation2?.id) { + simulationIds.push(simulation2.id); + } + return { type, countryId: countryId as any, @@ -74,6 +83,7 @@ export class CalculationService { }, populationId, region, + simulationIds, }; } @@ -113,15 +123,49 @@ export class CalculationService { * Execute a calculation through the appropriate handler * @param reportId - The report ID * @param meta - The calculation metadata - * @param onComplete - Optional callback for household calculation completion + * @param callbacks - Optional callbacks for completion events */ async executeCalculation( reportId: string, meta: CalculationMeta, - onComplete?: (reportId: string, status: 'ok' | 'error', result?: any) => Promise + callbacks?: { + onComplete?: (reportId: string, status: 'ok' | 'error', result?: any) => Promise; + onSimulationComplete?: (simulationId: string, result: any, policyId: string) => Promise; + } ): Promise { if (meta.type === 'household') { - return this.householdHandler.execute(reportId, meta, onComplete); + // Loop through each simulation and run calculation + for (let index = 0; index < meta.simulationIds.length; index++) { + const simulationId = meta.simulationIds[index]; + const policyId = index === 0 ? meta.policyIds.baseline : meta.policyIds.reform; + + if (!policyId) { + continue; + } + + const singleSimMeta: CalculationMeta = { + ...meta, + policyIds: { baseline: policyId, reform: undefined }, + simulationIds: [simulationId], + }; + + await this.householdHandler.execute( + `${reportId}-sim-${simulationId}`, + singleSimMeta, + async (_, status, res) => { + if (status === 'ok' && callbacks?.onSimulationComplete) { + await callbacks.onSimulationComplete(simulationId, res, policyId); + } + } + ); + } + + // Notify overall completion + if (callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'ok', null); + } + + return { status: 'ok', result: null }; } return this.economyHandler.execute(reportId, meta); } diff --git a/app/src/libs/queryOptions/calculations.ts b/app/src/libs/queryOptions/calculations.ts index ffcd8398..87b22af3 100644 --- a/app/src/libs/queryOptions/calculations.ts +++ b/app/src/libs/queryOptions/calculations.ts @@ -78,6 +78,11 @@ async function getOrReconstructMetadata( sim1.population_type === 'geography' && sim1.population_id !== report.country_id ? String(sim1.population_id) : undefined, + // Simulation IDs needed for storing household calculation outputs + simulationIds: [ + String(report.simulation_1_id), + ...(report.simulation_2_id ? [String(report.simulation_2_id)] : []), + ], }; console.log( diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index 7489acb0..5073b61b 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -86,6 +86,8 @@ function useReportData(reportId: string) { outputType: 'economy' as ReportOutputType, error: undefined, normalizedReport, + baseline: null, // Economy doesn't use baseline/reform (uses Report.output) + reform: null, progress: undefined, message: undefined, queuePosition: undefined, @@ -98,12 +100,27 @@ function useReportData(reportId: string) { const userId = MOCK_USER_ID.toString(); const normalizedReport = useUserReportById(userId, reportId); + // Extract baseline and reform from demo report's simulations + const { report, simulations } = normalizedReport; + let baseline: Household | null = null; + let reform: Household | null = null; + + if (report && simulations) { + const baselineSim = simulations.find((s) => s.id === report.simulationIds?.[0]); + const reformSim = simulations.find((s) => s.id === report.simulationIds?.[1]); + + baseline = baselineSim?.output || null; + reform = reformSim?.output || null; + } + return { status: 'complete' as const, output: MOCK_HOUSEHOLD_OUTPUT, outputType: 'household' as ReportOutputType, error: undefined, normalizedReport, + baseline, + reform, progress: undefined, message: undefined, queuePosition: undefined, @@ -142,12 +159,28 @@ function useReportData(reportId: string) { output = wrappedOutput; } + // Extract baseline and reform simulation outputs for household calculations + // Position 0 = baseline, Position 1 = reform (if exists) + const { report, simulations } = normalizedReport; + let baseline: Household | null = null; + let reform: Household | null = null; + + if (outputType === 'household' && report && simulations) { + const baselineSim = simulations.find((s) => s.id === report.simulationIds?.[0]); + const reformSim = simulations.find((s) => s.id === report.simulationIds?.[1]); + + baseline = baselineSim?.output || null; + reform = reformSim?.output || null; + } + return { status, output, outputType, error, normalizedReport, + baseline, + reform, progress, message, queuePosition, @@ -176,6 +209,8 @@ export default function ReportOutputPage() { outputType, error, normalizedReport, + baseline, + reform, progress, message, queuePosition, @@ -234,7 +269,12 @@ export default function ReportOutputPage() { switch (activeTab) { case 'overview': return output && outputType ? ( - + ) : ( ); diff --git a/app/src/pages/report-output/subpages/HouseholdOverview.tsx b/app/src/pages/report-output/subpages/HouseholdOverview.tsx index 636cb416..ab3fbb0a 100644 --- a/app/src/pages/report-output/subpages/HouseholdOverview.tsx +++ b/app/src/pages/report-output/subpages/HouseholdOverview.tsx @@ -14,7 +14,8 @@ import { } from '@/utils/householdValues'; interface HouseholdOverviewProps { - output: Household; + baseline: Household | null; + reform: Household | null; } /** @@ -22,16 +23,15 @@ interface HouseholdOverviewProps { * Based on the v1 NetIncomeBreakdown.jsx component with recursive expansion logic * * Structure: - * - Title showing total net income at top + * - Title showing total net income at top (or comparison if reform exists) * - Recursive breakdown of income components * - Each component can expand to show its children * - Up arrows (blue) for additions, down arrows (gray) for subtractions + * - Shows comparison "rises by" / "falls by" when reform is present */ -export default function HouseholdOverview({ output }: HouseholdOverviewProps) { +export default function HouseholdOverview({ baseline, reform }: HouseholdOverviewProps) { const metadata = useSelector((state: RootState) => state.metadata); - console.log(metadata); - // Get the root variable (household_net_income) const rootVariable = metadata.variables.household_net_income; if (!rootVariable) { @@ -42,17 +42,50 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { ); } - // Calculate the net income value - const netIncome = getValueFromHousehold('household_net_income', null, null, output, metadata); - const netIncomeValue = typeof netIncome === 'number' ? netIncome : 0; + if (!baseline) { + return ( + + Error: Baseline household data not found + + ); + } + + const hasReform = reform !== null && reform !== undefined; + + // Calculate baseline net income + const baselineNetIncome = getValueFromHousehold( + 'household_net_income', + null, + null, + baseline, + metadata + ); + const baselineValue = typeof baselineNetIncome === 'number' ? baselineNetIncome : 0; + + // Calculate reform net income and difference if reform exists + let reformValue = 0; + let netIncomeDiff = 0; + if (hasReform) { + const reformNetIncome = getValueFromHousehold( + 'household_net_income', + null, + null, + reform, + metadata + ); + reformValue = typeof reformNetIncome === 'number' ? reformNetIncome : 0; + netIncomeDiff = reformValue - baselineValue; + } /** * Recursive component that renders a variable and its children * Based on the v1 app's VariableArithmetic component + * Now supports comparison between baseline and reform */ interface VariableArithmeticProps { variableName: string; - household: Household; + householdBaseline: Household; + householdReform: Household | null; isAdd: boolean; defaultExpanded?: boolean; childrenOnly?: boolean; @@ -60,7 +93,8 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { const VariableArithmetic = ({ variableName, - household, + householdBaseline, + householdReform, isAdd, defaultExpanded = false, childrenOnly = false, @@ -72,10 +106,78 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { return null; } - // Get the value for this variable - const value = getValueFromHousehold(variableName, null, null, household, metadata); + // Get baseline value + const value = getValueFromHousehold(variableName, null, null, householdBaseline, metadata); const numericValue = typeof value === 'number' ? value : 0; + // Check if reform exists and calculate comparison + const hasReformForVar = householdReform !== null && householdReform !== undefined; + let valueStr: React.ReactNode; + let nodeSign = isAdd; + + if (hasReformForVar) { + // Get reform value and calculate difference + const reformValue = getValueFromHousehold( + variableName, + null, + null, + householdReform, + metadata + ); + const reformNumeric = typeof reformValue === 'number' ? reformValue : 0; + const diff = reformNumeric - numericValue; + + // Adjust sign based on whether reform increases or decreases value (XOR logic from v1) + if (!childrenOnly) { + nodeSign = nodeSign !== diff < 0; + } + + // Format the difference text + if (diff > 0) { + valueStr = ( + <> + Your {variable.label} rise{variable.label.endsWith('s') ? '' : 's'} by{' '} + + {formatVariableValue(variable, diff, 0)} + + + ); + } else if (diff < 0) { + valueStr = ( + <> + Your {variable.label} fall{variable.label.endsWith('s') ? '' : 's'} by{' '} + + {formatVariableValue(variable, Math.abs(diff), 0)} + + + ); + } else { + valueStr = `Your ${variable.label} ${variable.label.endsWith('s') ? "don't" : "doesn't"} change`; + } + } else { + // No reform - show baseline only + valueStr = ( + <> + Your {variable.label} {variable.label.endsWith('s') ? 'are' : 'is'}{' '} + + {formatVariableValue(variable, numericValue, 0)} + + + ); + } + // Get child variables (adds and subtracts) let addsArray: string[] = []; let subtractsArray: string[] = []; @@ -104,12 +206,12 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { } } - // Filter child variables to only show non-zero ones + // Filter child variables to only show non-zero ones (in baseline or reform) const visibleAdds = addsArray.filter((v) => - shouldShowVariable(v, household, null, metadata, false) + shouldShowVariable(v, householdBaseline, householdReform, metadata, false) ); const visibleSubtracts = subtractsArray.filter((v) => - shouldShowVariable(v, household, null, metadata, false) + shouldShowVariable(v, householdBaseline, householdReform, metadata, false) ); // Recursively render children @@ -117,7 +219,8 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { @@ -127,7 +230,8 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { @@ -141,8 +245,8 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { return <>{childNodes}; } - // Determine colors and icons based on isAdd - const Arrow = isAdd ? ( + // Determine colors and icons based on nodeSign (adjusted for comparison) + const Arrow = nodeSign ? ( ) : ( ); - const valueColor = isAdd ? colors.primary[700] : colors.text.secondary; - const borderColor = isAdd ? colors.primary[700] : colors.text.secondary; + const borderColor = nodeSign ? colors.primary[700] : colors.text.secondary; return ( @@ -176,12 +279,9 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { > - - Your {variable.label} {variable.label.endsWith('s') ? 'are' : 'is'} - {Arrow} - - {formatVariableValue(variable, numericValue, 0)} + + {valueStr} {expandable && ( @@ -222,7 +322,23 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { {/* Main Title */} - Your net income is {formatVariableValue(rootVariable, netIncomeValue, 0)} + {hasReform ? ( + netIncomeDiff > 0 ? ( + <> + Your net income increases by{' '} + {formatVariableValue(rootVariable, netIncomeDiff, 0)} + + ) : netIncomeDiff < 0 ? ( + <> + Your net income decreases by{' '} + {formatVariableValue(rootVariable, Math.abs(netIncomeDiff), 0)} + + ) : ( + <>Your net income doesn't change + ) + ) : ( + <>Your net income is {formatVariableValue(rootVariable, baselineValue, 0)} + )} @@ -234,7 +350,8 @@ export default function HouseholdOverview({ output }: HouseholdOverviewProps) { > ; } - return ; + // For household, pass baseline and reform for comparison + return ; } diff --git a/app/src/tests/fixtures/api/simulationMocks.ts b/app/src/tests/fixtures/api/simulationMocks.ts index 5a781a94..f75df723 100644 --- a/app/src/tests/fixtures/api/simulationMocks.ts +++ b/app/src/tests/fixtures/api/simulationMocks.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import { HouseholdData } from '@/types/ingredients/Household'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload } from '@/types/payloads'; @@ -42,6 +43,28 @@ export const mockSimulationPayloadMinimal: SimulationCreationPayload = { policy_id: 1, }; +// Household output data for testing +export const mockHouseholdData: HouseholdData = { + people: { + person1: { + age: { '2024': 30 }, + employment_income: { '2024': 50000 }, + }, + person2: { + age: { '2024': 28 }, + employment_income: { '2024': 45000 }, + }, + }, + households: { + household1: { + members: ['person1', 'person2'], + state_name: { '2024': 'California' }, + }, + }, +}; + +export const mockHouseholdOutputJson = JSON.stringify(mockHouseholdData); + // API response structures export const mockSimulationMetadata: SimulationMetadata = { id: parseInt(SIMULATION_IDS.VALID, 10), @@ -52,6 +75,11 @@ export const mockSimulationMetadata: SimulationMetadata = { policy_id: mockSimulationPayload.policy_id.toString(), }; +export const mockSimulationMetadataWithOutput: SimulationMetadata = { + ...mockSimulationMetadata, + output_json: mockHouseholdOutputJson, +}; + export const mockCreateSimulationSuccessResponse = { status: 'ok', message: 'Simulation created successfully', @@ -104,6 +132,18 @@ export const mockNonJsonResponse = () => ({ json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token < in JSON')), }); +export const mockUpdateSimulationSuccessResponse = { + status: 'ok', + message: 'Simulation updated successfully', + result: mockSimulationMetadataWithOutput, +}; + +export const mockUpdateSimulationErrorResponse = { + status: 'error', + message: 'Failed to update simulation', + result: null, +}; + // Error messages that match the implementation export const ERROR_MESSAGES = { CREATE_FAILED: 'Failed to create simulation', @@ -113,4 +153,8 @@ export const ERROR_MESSAGES = { FETCH_FAILED: (id: string) => `Failed to fetch simulation ${id}`, FETCH_FAILED_WITH_STATUS: (id: string, status: number, statusText: string) => `Failed to fetch simulation ${id}: ${status} ${statusText}`, + UPDATE_FAILED: (id: string) => `Failed to update simulation ${id}`, + UPDATE_FAILED_WITH_STATUS: (id: string, status: number, statusText: string) => + `Failed to update simulation ${id}: ${status} ${statusText}`, + UPDATE_PARSE_FAILED: (error: any) => `Failed to parse simulation update response: ${error}`, } as const; diff --git a/app/src/tests/fixtures/hooks/calculationManagerMocks.ts b/app/src/tests/fixtures/hooks/calculationManagerMocks.ts index 7be7848e..ec24edb0 100644 --- a/app/src/tests/fixtures/hooks/calculationManagerMocks.ts +++ b/app/src/tests/fixtures/hooks/calculationManagerMocks.ts @@ -41,6 +41,7 @@ export const MOCK_HOUSEHOLD_META: CalculationMeta = { reform: 'policy-2', }, populationId: 'household-123', + simulationIds: ['sim-1', 'sim-2'], }; export const MOCK_ECONOMY_META_NATIONAL: CalculationMeta = { @@ -51,6 +52,7 @@ export const MOCK_ECONOMY_META_NATIONAL: CalculationMeta = { reform: 'policy-3', }, populationId: 'us', + simulationIds: ['sim-3', 'sim-4'], }; export const MOCK_ECONOMY_META_SUBNATIONAL: CalculationMeta = { @@ -62,6 +64,7 @@ export const MOCK_ECONOMY_META_SUBNATIONAL: CalculationMeta = { }, populationId: 'us-california', region: 'california', + simulationIds: ['sim-5', 'sim-6'], }; // Mock calculation manager diff --git a/app/src/tests/fixtures/libs/calculations/handlerMocks.ts b/app/src/tests/fixtures/libs/calculations/handlerMocks.ts index dea173b8..d8e0cb9c 100644 --- a/app/src/tests/fixtures/libs/calculations/handlerMocks.ts +++ b/app/src/tests/fixtures/libs/calculations/handlerMocks.ts @@ -2,7 +2,7 @@ import { QueryClient } from '@tanstack/react-query'; import { vi } from 'vitest'; import { EconomyCalculationResponse } from '@/api/economy'; import { CalculationMeta } from '@/api/reportCalculations'; -import { Household } from '@/types/ingredients/Household'; +import { HouseholdData } from '@/types/ingredients/Household'; // Report IDs export const TEST_REPORT_ID = 'report-123'; @@ -17,6 +17,7 @@ export const HOUSEHOLD_CALCULATION_META: CalculationMeta = { reform: 'policy-reform-456', }, populationId: 'household-789', + simulationIds: ['sim-baseline-1', 'sim-reform-1'], }; export const ECONOMY_CALCULATION_META: CalculationMeta = { @@ -28,6 +29,7 @@ export const ECONOMY_CALCULATION_META: CalculationMeta = { }, populationId: 'us', region: 'ca', + simulationIds: ['sim-baseline-2', 'sim-reform-2'], }; export const ECONOMY_NATIONAL_META: CalculationMeta = { @@ -37,18 +39,15 @@ export const ECONOMY_NATIONAL_META: CalculationMeta = { baseline: 'policy-baseline-uk', }, populationId: 'uk', + simulationIds: ['sim-baseline-3'], }; // Household calculation results -export const MOCK_HOUSEHOLD_RESULT: Household = { - id: 'household-789', - countryId: 'us', - householdData: { - people: { - you: { - age: { 2025: 35 }, - employment_income: { 2025: 50000 }, - }, +export const MOCK_HOUSEHOLD_RESULT: HouseholdData = { + people: { + you: { + age: { 2025: 35 }, + employment_income: { 2025: 50000 }, }, }, }; diff --git a/app/src/tests/fixtures/libs/calculations/managerMocks.ts b/app/src/tests/fixtures/libs/calculations/managerMocks.ts index 1c59dc90..1b4f1f27 100644 --- a/app/src/tests/fixtures/libs/calculations/managerMocks.ts +++ b/app/src/tests/fixtures/libs/calculations/managerMocks.ts @@ -17,6 +17,7 @@ export const MANAGER_HOUSEHOLD_META: CalculationMeta = { reform: 'manager-policy-reform', }, populationId: 'manager-household-001', + simulationIds: ['manager-sim-1', 'manager-sim-2'], }; export const MANAGER_ECONOMY_META: CalculationMeta = { @@ -27,6 +28,7 @@ export const MANAGER_ECONOMY_META: CalculationMeta = { }, populationId: 'uk', region: 'london', + simulationIds: ['manager-sim-uk-1'], }; // Invalid metadata for testing error cases @@ -37,6 +39,7 @@ export const INVALID_TYPE_META: CalculationMeta = { baseline: 'invalid-policy', }, populationId: 'invalid-001', + simulationIds: ['invalid-sim-1'], }; // Mock status responses diff --git a/app/src/tests/fixtures/libs/calculations/serviceMocks.ts b/app/src/tests/fixtures/libs/calculations/serviceMocks.ts index 7a9a8ae9..5e3fc655 100644 --- a/app/src/tests/fixtures/libs/calculations/serviceMocks.ts +++ b/app/src/tests/fixtures/libs/calculations/serviceMocks.ts @@ -61,11 +61,13 @@ const mockHousehold: Household = { export const HOUSEHOLD_BUILD_PARAMS: BuildMetadataParams = { simulation1: { ...mockSimulation, + id: 'sim-1', populationType: 'household', policyId: 'policy-baseline', }, simulation2: { ...mockSimulation, + id: 'sim-2', populationType: 'household', policyId: 'policy-reform', }, @@ -77,11 +79,13 @@ export const HOUSEHOLD_BUILD_PARAMS: BuildMetadataParams = { export const ECONOMY_BUILD_PARAMS: BuildMetadataParams = { simulation1: { ...mockSimulation, + id: 'sim-1', populationType: 'geography', policyId: 'policy-baseline', }, simulation2: { ...mockSimulation, + id: 'sim-2', populationType: 'geography', policyId: 'policy-reform', }, @@ -100,6 +104,7 @@ export const HOUSEHOLD_META: CalculationMeta = { }, populationId: 'household-123', region: undefined, + simulationIds: ['sim-1', 'sim-2'], }; export const ECONOMY_META: CalculationMeta = { @@ -111,6 +116,7 @@ export const ECONOMY_META: CalculationMeta = { }, populationId: 'us', region: undefined, + simulationIds: ['sim-1', 'sim-2'], }; // Status responses diff --git a/app/src/tests/fixtures/libs/queryOptions/calculationMocks.ts b/app/src/tests/fixtures/libs/queryOptions/calculationMocks.ts index b4332595..b8b7b9b1 100644 --- a/app/src/tests/fixtures/libs/queryOptions/calculationMocks.ts +++ b/app/src/tests/fixtures/libs/queryOptions/calculationMocks.ts @@ -74,6 +74,7 @@ export const HOUSEHOLD_META_WITH_REFORM: CalculationMeta = { }, populationId: 'household123', region: undefined, + simulationIds: ['sim1', 'sim2'], }; export const ECONOMY_META_SUBNATIONAL: CalculationMeta = { @@ -85,6 +86,7 @@ export const ECONOMY_META_SUBNATIONAL: CalculationMeta = { }, populationId: 'ca', region: 'ca', + simulationIds: ['sim1'], }; export const ECONOMY_META_NATIONAL: CalculationMeta = { @@ -96,6 +98,7 @@ export const ECONOMY_META_NATIONAL: CalculationMeta = { }, populationId: 'us', region: undefined, + simulationIds: ['sim1'], }; // Test calculation results diff --git a/app/src/tests/unit/adapters/SimulationAdapter.test.ts b/app/src/tests/unit/adapters/SimulationAdapter.test.ts new file mode 100644 index 00000000..e31a3780 --- /dev/null +++ b/app/src/tests/unit/adapters/SimulationAdapter.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test, vi } from 'vitest'; +import { SimulationAdapter } from '@/adapters/SimulationAdapter'; +import { + mockHouseholdData, + mockSimulationMetadata, + mockSimulationMetadataWithOutput, +} from '@/tests/fixtures/api/simulationMocks'; +import { Simulation } from '@/types/ingredients/Simulation'; +import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; + +describe('SimulationAdapter', () => { + describe('fromMetadata', () => { + test('given metadata without output then converts to Simulation correctly', () => { + // Given + const metadata = mockSimulationMetadata; + + // When + const result = SimulationAdapter.fromMetadata(metadata); + + // Then + expect(result).toEqual({ + id: String(metadata.id), + countryId: metadata.country_id, + apiVersion: metadata.api_version, + policyId: metadata.policy_id, + populationId: metadata.population_id, + populationType: metadata.population_type, + label: null, + isCreated: true, + output: null, + }); + }); + + test('given metadata with household output then parses output correctly', () => { + // Given + const metadata = mockSimulationMetadataWithOutput; + + // When + const result = SimulationAdapter.fromMetadata(metadata); + + // Then + expect(result.output).not.toBeNull(); + expect(result.output).toEqual({ + countryId: metadata.country_id, + householdData: mockHouseholdData, + }); + }); + + test('given metadata with output then preserves other fields', () => { + // Given + const metadata = mockSimulationMetadataWithOutput; + + // When + const result = SimulationAdapter.fromMetadata(metadata); + + // Then + expect(result.id).toBe(String(metadata.id)); + expect(result.countryId).toBe(metadata.country_id); + expect(result.policyId).toBe(metadata.policy_id); + expect(result.populationId).toBe(metadata.population_id); + expect(result.populationType).toBe(metadata.population_type); + }); + + test('given metadata with invalid JSON output then logs error and sets output to null', () => { + // Given + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const metadataWithInvalidJson: SimulationMetadata = { + ...mockSimulationMetadata, + output_json: 'invalid json {{{', + }; + + // When + const result = SimulationAdapter.fromMetadata(metadataWithInvalidJson); + + // Then + expect(result.output).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SimulationAdapter] Failed to parse output_json:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + test('given metadata with empty string output then sets output to null', () => { + // Given + const metadataWithEmptyOutput: SimulationMetadata = { + ...mockSimulationMetadata, + output_json: '', + }; + + // When + const result = SimulationAdapter.fromMetadata(metadataWithEmptyOutput); + + // Then + expect(result.output).toBeNull(); + }); + + test('given metadata with null output_json then sets output to null', () => { + // Given + const metadataWithNullOutput: SimulationMetadata = { + ...mockSimulationMetadata, + output_json: null, + }; + + // When + const result = SimulationAdapter.fromMetadata(metadataWithNullOutput); + + // Then + expect(result.output).toBeNull(); + }); + + test('given metadata with undefined output_json then sets output to null', () => { + // Given + const metadataWithUndefinedOutput: SimulationMetadata = { + ...mockSimulationMetadata, + output_json: undefined, + }; + + // When + const result = SimulationAdapter.fromMetadata(metadataWithUndefinedOutput); + + // Then + expect(result.output).toBeNull(); + }); + + test('given metadata without population_id then throws error', () => { + // Given + const invalidMetadata = { + ...mockSimulationMetadata, + population_id: '', + } as SimulationMetadata; + + // When/Then + expect(() => SimulationAdapter.fromMetadata(invalidMetadata)).toThrow( + 'Simulation metadata missing population_id' + ); + }); + + test('given metadata without population_type then throws error', () => { + // Given + const invalidMetadata = { + ...mockSimulationMetadata, + population_type: undefined, + } as any; + + // When/Then + expect(() => SimulationAdapter.fromMetadata(invalidMetadata)).toThrow( + 'Simulation metadata missing population_type' + ); + }); + + test('given geography simulation metadata then converts correctly', () => { + // Given + const geographyMetadata: SimulationMetadata = { + ...mockSimulationMetadata, + population_type: 'geography', + population_id: 'california', + }; + + // When + const result = SimulationAdapter.fromMetadata(geographyMetadata); + + // Then + expect(result.populationType).toBe('geography'); + expect(result.populationId).toBe('california'); + expect(result.output).toBeNull(); // Geography simulations don't have outputs + }); + }); + + describe('toCreationPayload', () => { + test('given valid simulation then converts to creation payload', () => { + // Given + const simulation: Partial = { + populationId: '123', + populationType: 'household', + policyId: '456', + }; + + // When + const result = SimulationAdapter.toCreationPayload(simulation); + + // Then + expect(result).toEqual({ + population_id: '123', + population_type: 'household', + policy_id: 456, + }); + }); + + test('given simulation without populationId then throws error', () => { + // Given + const simulation: Partial = { + populationType: 'household', + policyId: '456', + }; + + // When/Then + expect(() => SimulationAdapter.toCreationPayload(simulation)).toThrow( + 'Simulation must have a populationId' + ); + }); + + test('given simulation without policyId then throws error', () => { + // Given + const simulation: Partial = { + populationId: '123', + populationType: 'household', + }; + + // When/Then + expect(() => SimulationAdapter.toCreationPayload(simulation)).toThrow( + 'Simulation must have a policyId' + ); + }); + + test('given simulation without populationType then throws error', () => { + // Given + const simulation: Partial = { + populationId: '123', + policyId: '456', + }; + + // When/Then + expect(() => SimulationAdapter.toCreationPayload(simulation)).toThrow( + 'Simulation must have a populationType' + ); + }); + + test('given geography simulation then converts correctly', () => { + // Given + const simulation: Partial = { + populationId: 'california', + populationType: 'geography', + policyId: '789', + }; + + // When + const result = SimulationAdapter.toCreationPayload(simulation); + + // Then + expect(result).toEqual({ + population_id: 'california', + population_type: 'geography', + policy_id: 789, + }); + }); + + test('given policyId as string then converts to integer', () => { + // Given + const simulation: Partial = { + populationId: '123', + populationType: 'household', + policyId: '999', + }; + + // When + const result = SimulationAdapter.toCreationPayload(simulation); + + // Then + expect(result.policy_id).toBe(999); + expect(typeof result.policy_id).toBe('number'); + }); + }); +}); diff --git a/app/src/tests/unit/api/simulation.test.ts b/app/src/tests/unit/api/simulation.test.ts index 63ef16b9..b452d29e 100644 --- a/app/src/tests/unit/api/simulation.test.ts +++ b/app/src/tests/unit/api/simulation.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { createSimulation, fetchSimulationById } from '@/api/simulation'; +import { createSimulation, fetchSimulationById, updateSimulationOutput } from '@/api/simulation'; import { BASE_URL } from '@/constants'; import { ERROR_MESSAGES, @@ -9,8 +9,10 @@ import { mockErrorResponse, mockFetchSimulationNotFoundResponse, mockFetchSimulationSuccessResponse, + mockHouseholdData, mockNonJsonResponse, mockSimulationMetadata, + mockSimulationMetadataWithOutput, mockSimulationPayload, mockSimulationPayloadGeography, mockSimulationPayloadMinimal, @@ -18,6 +20,7 @@ import { SIMULATION_IDS, TEST_COUNTRIES, } from '@/tests/fixtures/api/simulationMocks'; +import { Simulation } from '@/types/ingredients/Simulation'; // Mock fetch globally global.fetch = vi.fn(); @@ -356,3 +359,251 @@ describe('fetchSimulationById', () => { expect(result.population_type).toBe('geography'); }); }); + +describe('updateSimulationOutput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given valid simulation with output then updates simulation successfully', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ status: 'ok', result: mockSimulationMetadataWithOutput }), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.US, + householdData: mockHouseholdData, + }, + }; + + // When + const result = await updateSimulationOutput( + TEST_COUNTRIES.US, + SIMULATION_IDS.VALID, + simulation + ); + + // Then + expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/${TEST_COUNTRIES.US}/simulation`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + id: parseInt(SIMULATION_IDS.VALID, 10), + output_json: JSON.stringify(mockHouseholdData), + }), + }); + expect(result).toEqual(mockSimulationMetadataWithOutput); + }); + + test('given simulation without output then updates with null output', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ status: 'ok', result: mockSimulationMetadata }), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: null, + }; + + // When + const result = await updateSimulationOutput( + TEST_COUNTRIES.US, + SIMULATION_IDS.VALID, + simulation + ); + + // Then + expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/${TEST_COUNTRIES.US}/simulation`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + id: parseInt(SIMULATION_IDS.VALID, 10), + output_json: null, + }), + }); + expect(result).toEqual(mockSimulationMetadata); + }); + + test('given different country ID then uses correct endpoint', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ status: 'ok', result: mockSimulationMetadataWithOutput }), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.UK, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.UK, + householdData: mockHouseholdData, + }, + }; + + // When + await updateSimulationOutput(TEST_COUNTRIES.UK, SIMULATION_IDS.VALID, simulation); + + // Then + expect(mockFetch).toHaveBeenCalledWith( + `${BASE_URL}/${TEST_COUNTRIES.UK}/simulation`, + expect.any(Object) + ); + }); + + test('given HTTP error response then throws error with status', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce( + mockErrorResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error') as any + ); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.US, + householdData: mockHouseholdData, + }, + }; + + // When/Then + await expect( + updateSimulationOutput(TEST_COUNTRIES.US, SIMULATION_IDS.VALID, simulation) + ).rejects.toThrow( + ERROR_MESSAGES.UPDATE_FAILED_WITH_STATUS( + SIMULATION_IDS.VALID, + HTTP_STATUS.INTERNAL_SERVER_ERROR, + 'Internal Server Error' + ) + ); + }); + + test('given 404 not found then throws error with status', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.NOT_FOUND, 'Not Found') as any); + + const simulation: Simulation = { + id: SIMULATION_IDS.NON_EXISTENT, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.US, + householdData: mockHouseholdData, + }, + }; + + // When/Then + await expect( + updateSimulationOutput(TEST_COUNTRIES.US, SIMULATION_IDS.NON_EXISTENT, simulation) + ).rejects.toThrow( + ERROR_MESSAGES.UPDATE_FAILED_WITH_STATUS( + SIMULATION_IDS.NON_EXISTENT, + HTTP_STATUS.NOT_FOUND, + 'Not Found' + ) + ); + }); + + test('given non-JSON response then throws parse error', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce(mockNonJsonResponse() as any); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.US, + householdData: mockHouseholdData, + }, + }; + + // When/Then + await expect( + updateSimulationOutput(TEST_COUNTRIES.US, SIMULATION_IDS.VALID, simulation) + ).rejects.toThrow(/Failed to parse simulation update response/); + }); + + test('given network failure then throws error', async () => { + // Given + const mockFetch = vi.mocked(global.fetch); + const networkError = new Error('Network error'); + mockFetch.mockRejectedValueOnce(networkError); + + const simulation: Simulation = { + id: SIMULATION_IDS.VALID, + countryId: TEST_COUNTRIES.US, + apiVersion: '1.0.0', + policyId: '1', + populationId: '123', + populationType: 'household', + label: null, + isCreated: true, + output: { + countryId: TEST_COUNTRIES.US, + householdData: mockHouseholdData, + }, + }; + + // When/Then + await expect( + updateSimulationOutput(TEST_COUNTRIES.US, SIMULATION_IDS.VALID, simulation) + ).rejects.toThrow(networkError); + }); +}); diff --git a/app/src/tests/unit/libs/calculations/handlers/household.test.ts b/app/src/tests/unit/libs/calculations/handlers/household.test.ts index c9b72c86..e43c7d99 100644 --- a/app/src/tests/unit/libs/calculations/handlers/household.test.ts +++ b/app/src/tests/unit/libs/calculations/handlers/household.test.ts @@ -28,9 +28,7 @@ describe('HouseholdCalculationHandler', () => { describe('execute', () => { test('given new calculation request then starts calculation and returns computing status', async () => { // Given - vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( - MOCK_HOUSEHOLD_RESULT.householdData - ); + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue(MOCK_HOUSEHOLD_RESULT); // When const result = await handler.execute(TEST_REPORT_ID, HOUSEHOLD_CALCULATION_META); @@ -68,9 +66,7 @@ describe('HouseholdCalculationHandler', () => { test('given completed calculation then returns ok status with result', async () => { // Given - vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( - MOCK_HOUSEHOLD_RESULT.householdData - ); + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue(MOCK_HOUSEHOLD_RESULT); await handler.execute(TEST_REPORT_ID, HOUSEHOLD_CALCULATION_META); // Wait for completion @@ -82,7 +78,7 @@ describe('HouseholdCalculationHandler', () => { // Then expect(result).toEqual({ status: 'ok', - result: MOCK_HOUSEHOLD_RESULT.householdData, + result: MOCK_HOUSEHOLD_RESULT, }); }); @@ -163,9 +159,7 @@ describe('HouseholdCalculationHandler', () => { test('given completed calculation then returns result', async () => { // Given - vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( - MOCK_HOUSEHOLD_RESULT.householdData - ); + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue(MOCK_HOUSEHOLD_RESULT); await handler.execute(TEST_REPORT_ID, HOUSEHOLD_CALCULATION_META); await advanceTimeAndFlush(0); @@ -175,7 +169,7 @@ describe('HouseholdCalculationHandler', () => { // Then expect(status).toEqual({ status: 'ok', - result: MOCK_HOUSEHOLD_RESULT.householdData, + result: MOCK_HOUSEHOLD_RESULT, }); }); @@ -205,9 +199,7 @@ describe('HouseholdCalculationHandler', () => { test('given completed calculation then returns true until cleanup', async () => { // Given - vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( - MOCK_HOUSEHOLD_RESULT.householdData - ); + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue(MOCK_HOUSEHOLD_RESULT); await handler.execute(TEST_REPORT_ID, HOUSEHOLD_CALCULATION_META); await advanceTimeAndFlush(0); @@ -220,9 +212,7 @@ describe('HouseholdCalculationHandler', () => { test('given completed calculation after cleanup then returns false', async () => { // Given - vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( - MOCK_HOUSEHOLD_RESULT.householdData - ); + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue(MOCK_HOUSEHOLD_RESULT); await handler.execute(TEST_REPORT_ID, HOUSEHOLD_CALCULATION_META); await advanceTimeAndFlush(0); diff --git a/app/src/tests/unit/libs/calculations/manager.test.ts b/app/src/tests/unit/libs/calculations/manager.test.ts index 2ecb1feb..98b21ad9 100644 --- a/app/src/tests/unit/libs/calculations/manager.test.ts +++ b/app/src/tests/unit/libs/calculations/manager.test.ts @@ -103,10 +103,10 @@ describe('CalculationManager', () => { test('given successful household calculation then updates report status', async () => { // Given mockService.executeCalculation.mockImplementation( - async (reportId: any, meta: any, onComplete: any) => { + async (reportId: any, meta: any, callbacks: any) => { // Simulate the callback being invoked for household calculations - if (meta.type === 'household' && onComplete) { - await onComplete(reportId, 'ok', OK_STATUS_HOUSEHOLD.result); + if (meta.type === 'household' && callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'ok', null); } return OK_STATUS_HOUSEHOLD; } @@ -123,7 +123,7 @@ describe('CalculationManager', () => { expect.objectContaining({ id: TEST_REPORT_ID, status: 'complete', - output: OK_STATUS_HOUSEHOLD.result, + output: null, // Household calculations have null output }) ); expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ @@ -134,10 +134,10 @@ describe('CalculationManager', () => { test('given failed calculation then marks report as error', async () => { // Given mockService.executeCalculation.mockImplementation( - async (reportId: any, meta: any, onComplete: any) => { + async (reportId: any, meta: any, callbacks: any) => { // Simulate the callback being invoked for household calculations - if (meta.type === 'household' && onComplete) { - await onComplete(reportId, 'error', undefined); + if (meta.type === 'household' && callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'error', undefined); } return ERROR_STATUS; } @@ -175,10 +175,10 @@ describe('CalculationManager', () => { test('given already updated report then skips duplicate update', async () => { // Given mockService.executeCalculation.mockImplementation( - async (reportId: any, meta: any, onComplete: any) => { + async (reportId: any, meta: any, callbacks: any) => { // Simulate the callback being invoked for household calculations - if (meta.type === 'household' && onComplete) { - await onComplete(reportId, 'ok', OK_STATUS_HOUSEHOLD.result); + if (meta.type === 'household' && callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'ok', null); } return OK_STATUS_HOUSEHOLD; } @@ -199,10 +199,10 @@ describe('CalculationManager', () => { // Given vi.useFakeTimers(); mockService.executeCalculation.mockImplementation( - async (reportId: any, meta: any, onComplete: any) => { + async (reportId: any, meta: any, callbacks: any) => { // Simulate the callback being invoked for household calculations - if (meta.type === 'household' && onComplete) { - await onComplete(reportId, 'ok', OK_STATUS_HOUSEHOLD.result); + if (meta.type === 'household' && callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'ok', null); } return OK_STATUS_HOUSEHOLD; } @@ -238,7 +238,10 @@ describe('CalculationManager', () => { expect(mockService.executeCalculation).toHaveBeenCalledWith( TEST_REPORT_ID, HOUSEHOLD_META, - expect.any(Function) // The callback function + expect.objectContaining({ + onComplete: expect.any(Function), + onSimulationComplete: expect.any(Function), + }) ); expect(mockProgressUpdater.startProgressUpdates).toHaveBeenCalledWith( TEST_REPORT_ID, @@ -271,10 +274,10 @@ describe('CalculationManager', () => { test('given new calculation then resets report tracking', async () => { // Given mockService.executeCalculation.mockImplementation( - async (reportId: any, meta: any, onComplete: any) => { + async (reportId: any, meta: any, callbacks: any) => { // Simulate the callback being invoked for household calculations - if (meta.type === 'household' && onComplete) { - await onComplete(reportId, 'ok', OK_STATUS_HOUSEHOLD.result); + if (meta.type === 'household' && callbacks?.onComplete) { + await callbacks.onComplete(reportId, 'ok', null); } return OK_STATUS_HOUSEHOLD; } @@ -336,7 +339,8 @@ describe('CalculationManager', () => { TEST_REPORT_ID, 'complete', 'us', - OK_STATUS_HOUSEHOLD.result + OK_STATUS_HOUSEHOLD.result, + HOUSEHOLD_META ); // Then @@ -357,7 +361,8 @@ describe('CalculationManager', () => { TEST_REPORT_ID, 'complete', 'us', - OK_STATUS_HOUSEHOLD.result + OK_STATUS_HOUSEHOLD.result, + HOUSEHOLD_META ); // Advance time for retry @@ -384,7 +389,8 @@ describe('CalculationManager', () => { TEST_REPORT_ID, 'complete', 'us', - OK_STATUS_HOUSEHOLD.result + OK_STATUS_HOUSEHOLD.result, + HOUSEHOLD_META ); // Advance time for retry diff --git a/app/src/tests/unit/libs/calculations/service.test.ts b/app/src/tests/unit/libs/calculations/service.test.ts index 6cafafd8..6b9a74bb 100644 --- a/app/src/tests/unit/libs/calculations/service.test.ts +++ b/app/src/tests/unit/libs/calculations/service.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import * as economyApi from '@/api/economy'; import * as householdApi from '@/api/householdCalculation'; +import { CalculationMeta } from '@/api/reportCalculations'; import { CalculationService } from '@/libs/calculations/service'; import { ECONOMY_OK_RESPONSE } from '@/tests/fixtures/libs/calculations/handlerMocks'; import { @@ -160,19 +161,29 @@ describe('CalculationService', () => { }); describe('executeCalculation', () => { - test('given household calculation request then starts calculation and returns computing', async () => { + test('given household calculation request then executes calculations for each simulation', async () => { // Given vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( MOCK_HOUSEHOLD_RESULT.householdData ); + const onSimulationComplete = vi.fn(); + const onComplete = vi.fn(); // When - const result = await service.executeCalculation(TEST_REPORT_ID, HOUSEHOLD_META); + const result = await service.executeCalculation(TEST_REPORT_ID, HOUSEHOLD_META, { + onSimulationComplete, + onComplete, + }); - // Then - household returns computing status initially - expect(result.status).toBe('computing'); - expect(result.progress).toBe(0); - expect(result.message).toBe('Initializing calculation...'); + // Then - loops through simulationIds and calls callbacks + expect(result.status).toBe('ok'); + expect(result.result).toBeNull(); // Report output is null for household + expect(onSimulationComplete).toHaveBeenCalledWith( + 'sim-1', + MOCK_HOUSEHOLD_RESULT.householdData, + 'policy-baseline' + ); + expect(onComplete).toHaveBeenCalledWith(TEST_REPORT_ID, 'ok', null); }); test('given economy calculation request then executes economy handler', async () => { @@ -187,27 +198,40 @@ describe('CalculationService', () => { expect(result.result).toBe(ECONOMY_OK_RESPONSE.result); }); - test('given existing household calculation then returns current status without new API call', async () => { - // Given - use a promise that doesn't resolve immediately - vi.mocked(householdApi.fetchHouseholdCalculation).mockImplementation( - () => - new Promise((resolve) => - setTimeout(() => resolve(MOCK_HOUSEHOLD_RESULT.householdData), 1000) - ) + test('given household calculation with multiple simulations then executes each separately', async () => { + // Given - metadata with 2 simulations + const multiSimMeta: CalculationMeta = { + ...HOUSEHOLD_META, + policyIds: { + baseline: 'policy-1', + reform: 'policy-2', + }, + simulationIds: ['sim-1', 'sim-2'], + }; + vi.mocked(householdApi.fetchHouseholdCalculation).mockResolvedValue( + MOCK_HOUSEHOLD_RESULT.householdData ); + const onSimulationComplete = vi.fn(); - // Start first calculation - const firstResult = await service.executeCalculation(TEST_REPORT_ID, HOUSEHOLD_META); - expect(firstResult.status).toBe('computing'); - - vi.clearAllMocks(); - - // When - execute again with same reportId (while still computing) - const result = await service.executeCalculation(TEST_REPORT_ID, HOUSEHOLD_META); + // When + await service.executeCalculation(TEST_REPORT_ID, multiSimMeta, { + onSimulationComplete, + }); - // Then - should return current status without new API call - expect(householdApi.fetchHouseholdCalculation).not.toHaveBeenCalled(); - expect(result.status).toBe('computing'); // Still computing + // Then - should call onSimulationComplete for each simulation + expect(onSimulationComplete).toHaveBeenCalledTimes(2); + expect(onSimulationComplete).toHaveBeenNthCalledWith( + 1, + 'sim-1', + MOCK_HOUSEHOLD_RESULT.householdData, + 'policy-1' + ); + expect(onSimulationComplete).toHaveBeenNthCalledWith( + 2, + 'sim-2', + MOCK_HOUSEHOLD_RESULT.householdData, + 'policy-2' + ); }); }); @@ -232,7 +256,7 @@ describe('CalculationService', () => { }); describe('getStatus', () => { - test('given household calculation then returns status from handler', async () => { + test('given household calculation then returns null (calculations use unique sim keys)', async () => { // Given vi.mocked(householdApi.fetchHouseholdCalculation).mockImplementation( () => new Promise(() => {}) // Never resolves @@ -244,12 +268,11 @@ describe('CalculationService', () => { // Allow promise to register await Promise.resolve(); - // When + // When - getStatus with reportId returns null because handler uses unique keys const status = service.getStatus(TEST_REPORT_ID, 'household'); - // Then - expect(status).toBeDefined(); - expect(status?.status).toBe('computing'); + // Then - Service doesn't track by reportId for household anymore + expect(status).toBeNull(); }); test('given economy calculation then returns null', () => { diff --git a/app/src/types/ingredients/Simulation.ts b/app/src/types/ingredients/Simulation.ts index 717c289d..4e9bf396 100644 --- a/app/src/types/ingredients/Simulation.ts +++ b/app/src/types/ingredients/Simulation.ts @@ -1,10 +1,14 @@ import { countryIds } from '@/libs/countries'; +import { Household } from './Household'; /** * Simulation type for position-based storage * ID is optional and only exists after API creation * The populationId can be either a household ID or geography ID * The Simulation is agnostic to which type of population it references + * + * For household simulations, the output field stores the calculated results. + * For geography simulations, outputs are stored in Report (economy calculations). */ export interface Simulation { id?: string; // Optional - only exists after API creation @@ -15,4 +19,5 @@ export interface Simulation { populationType?: 'household' | 'geography'; // Indicates the type of populationId label: string | null; // Always present, even if null isCreated: boolean; // Always present, defaults to false + output?: Household | null; // Calculation output for household simulations only } diff --git a/app/src/types/metadata/simulationMetadata.ts b/app/src/types/metadata/simulationMetadata.ts index 6b873a6c..fa4df7d9 100644 --- a/app/src/types/metadata/simulationMetadata.ts +++ b/app/src/types/metadata/simulationMetadata.ts @@ -7,4 +7,5 @@ export interface SimulationMetadata { population_id: string; population_type: 'household' | 'geography'; policy_id: string; + output_json?: string | null; // JSON string of household calculation output (household simulations only) }