From 9c021e3b100a041f3287d6f4da5036aa80f10e2f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Sat, 18 Apr 2026 00:24:19 +0200 Subject: [PATCH] Send explicit report specs from app v2 --- app/src/adapters/ReportAdapter.ts | 14 +- app/src/adapters/SimulationAdapter.ts | 19 +- app/src/api/report.ts | 15 +- app/src/api/simulation.ts | 24 +- app/src/libs/calculations/ResultPersister.ts | 58 ++++- .../economy/SocietyWideCalcStrategy.ts | 2 + .../household/HouseholdReportOrchestrator.ts | 38 +++- .../household/HouseholdSimCalculator.ts | 4 + app/src/libs/calculations/runMetadata.ts | 72 ++++++ .../strategies/HouseholdCalcStrategy.ts | 10 +- .../hooks/useModifyReportSubmission.ts | 9 +- .../hooks/useReportSubmission.ts | 13 +- .../buildExplicitReportCreationPayload.ts | 145 ++++++++++++ .../reportBuilder/useReportSubmissionMocks.ts | 65 +++++- app/src/tests/unit/api/report.test.ts | 28 +++ app/src/tests/unit/api/simulation.test.ts | 34 +++ .../libs/calculations/ResultPersister.test.ts | 44 +++- .../strategies/HouseholdCalcStrategy.test.ts | 41 +++- .../SocietyWideCalcStrategy.test.ts | 14 ++ ...buildExplicitReportCreationPayload.test.ts | 131 +++++++++++ .../hooks/useModifyReportSubmission.test.tsx | 210 +++++++++++++++++- .../hooks/useReportSubmission.test.tsx | 170 +++++++++++++- app/src/types/calculation/CalcStatus.ts | 7 + .../types/payloads/ReportCreationPayload.ts | 4 + .../types/payloads/ReportSetOutputPayload.ts | 4 +- .../payloads/SimulationSetOutputPayload.ts | 4 +- app/src/types/reportSpec.ts | 31 +++ app/src/types/runMetadata.ts | 7 + 28 files changed, 1143 insertions(+), 74 deletions(-) create mode 100644 app/src/libs/calculations/runMetadata.ts create mode 100644 app/src/pages/reportBuilder/utils/buildExplicitReportCreationPayload.ts create mode 100644 app/src/tests/unit/pages/reportBuilder/buildExplicitReportCreationPayload.test.ts create mode 100644 app/src/types/reportSpec.ts create mode 100644 app/src/types/runMetadata.ts diff --git a/app/src/adapters/ReportAdapter.ts b/app/src/adapters/ReportAdapter.ts index 4945ee729..6f5b75d7b 100644 --- a/app/src/adapters/ReportAdapter.ts +++ b/app/src/adapters/ReportAdapter.ts @@ -2,6 +2,7 @@ import { Report } from '@/types/ingredients/Report'; import { ReportMetadata } from '@/types/metadata/reportMetadata'; import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload'; import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload'; +import type { RunMetadata } from '@/types/runMetadata'; import { convertJsonToReportOutput, convertReportOutputToJson } from './conversionHelpers'; /** @@ -65,7 +66,10 @@ export class ReportAdapter { /** * Creates payload for marking a report as completed with output */ - static toCompletedReportPayload(report: Report): ReportSetOutputPayload { + static toCompletedReportPayload( + report: Report, + runMetadata?: RunMetadata + ): ReportSetOutputPayload { if (!report.id) { throw new Error('Report ID is required to create completed report payload'); } @@ -73,13 +77,18 @@ export class ReportAdapter { id: parseInt(report.id, 10), status: 'complete', output: report.output ? convertReportOutputToJson(report.output as any) : null, + ...runMetadata, }; } /** * Creates payload for marking a report as errored */ - static toErrorReportPayload(report: Report, errorMessage?: string): ReportSetOutputPayload { + static toErrorReportPayload( + report: Report, + errorMessage?: string, + runMetadata?: RunMetadata + ): ReportSetOutputPayload { if (!report.id) { throw new Error('Report ID is required to create error report payload'); } @@ -87,6 +96,7 @@ export class ReportAdapter { id: parseInt(report.id, 10), status: 'error', output: null, + ...runMetadata, }; if (errorMessage) { payload.error_message = errorMessage; diff --git a/app/src/adapters/SimulationAdapter.ts b/app/src/adapters/SimulationAdapter.ts index e481a56ee..6631e4ec7 100644 --- a/app/src/adapters/SimulationAdapter.ts +++ b/app/src/adapters/SimulationAdapter.ts @@ -1,6 +1,7 @@ import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload, SimulationSetOutputPayload } from '@/types/payloads'; +import type { RunMetadata } from '@/types/runMetadata'; /** * Adapter for converting between Simulation and API formats @@ -111,12 +112,14 @@ export class SimulationAdapter { static toUpdatePayload( id: number, output: unknown, - status: 'pending' | 'complete' | 'error' + status: 'pending' | 'complete' | 'error', + runMetadata?: RunMetadata ): SimulationSetOutputPayload { return { id, output: typeof output === 'string' ? output : output ? JSON.stringify(output) : null, status, + ...runMetadata, }; } @@ -124,22 +127,32 @@ export class SimulationAdapter { * Creates payload for marking a simulation as completed with output * Note: Output is NOT stringified here - it's stringified when the entire payload is JSON.stringified */ - static toCompletedPayload(id: number, output: unknown): SimulationSetOutputPayload { + static toCompletedPayload( + id: number, + output: unknown, + runMetadata?: RunMetadata + ): SimulationSetOutputPayload { return { id, output: typeof output === 'string' ? output : JSON.stringify(output), status: 'complete', + ...runMetadata, }; } /** * Creates payload for marking a simulation as errored */ - static toErrorPayload(id: number, errorMessage?: string): SimulationSetOutputPayload { + static toErrorPayload( + id: number, + errorMessage?: string, + runMetadata?: RunMetadata + ): SimulationSetOutputPayload { const payload: SimulationSetOutputPayload = { id, output: null, status: 'error', + ...runMetadata, }; if (errorMessage) { payload.error_message = errorMessage; diff --git a/app/src/api/report.ts b/app/src/api/report.ts index e7af68fef..8c9d1221c 100644 --- a/app/src/api/report.ts +++ b/app/src/api/report.ts @@ -6,6 +6,7 @@ import { Report } from '@/types/ingredients/Report'; import { UserReport } from '@/types/ingredients/UserReport'; import { ReportMetadata } from '@/types/metadata/reportMetadata'; import { ReportCreationPayload, ReportSetOutputPayload } from '@/types/payloads'; +import type { RunMetadata } from '@/types/runMetadata'; export type CountryId = (typeof countryIds)[number]; @@ -105,9 +106,10 @@ async function updateReport( export async function markReportCompleted( countryId: (typeof countryIds)[number], reportId: string, - report: Report + report: Report, + runMetadata?: RunMetadata ): Promise { - const data = ReportAdapter.toCompletedReportPayload(report); + const data = ReportAdapter.toCompletedReportPayload(report, runMetadata); return updateReport(countryId, reportId, data); } @@ -115,9 +117,14 @@ export async function markReportError( countryId: (typeof countryIds)[number], reportId: string, report: Report, - errorMessage?: string + errorMessage?: string, + runMetadata?: RunMetadata ): Promise { - const data = ReportAdapter.toErrorReportPayload(report, errorMessage); + const data = ReportAdapter.toErrorReportPayload( + report, + errorMessage, + runMetadata + ); return updateReport(countryId, reportId, data); } diff --git a/app/src/api/simulation.ts b/app/src/api/simulation.ts index 7530ebb2e..15987eb7c 100644 --- a/app/src/api/simulation.ts +++ b/app/src/api/simulation.ts @@ -3,6 +3,7 @@ import { BASE_URL } from '@/constants'; import { countryIds } from '@/libs/countries'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload } from '@/types/payloads'; +import type { RunMetadata } from '@/types/runMetadata'; export async function fetchSimulationById( countryId: (typeof countryIds)[number], @@ -83,11 +84,16 @@ export async function createSimulation( export async function updateSimulationOutput( countryId: (typeof countryIds)[number], simulationId: string, - output: unknown + output: unknown, + runMetadata?: RunMetadata ): Promise { const url = `${BASE_URL}/${countryId}/simulation`; - const payload = SimulationAdapter.toCompletedPayload(parseInt(simulationId, 10), output); + const payload = SimulationAdapter.toCompletedPayload( + parseInt(simulationId, 10), + output, + runMetadata + ); const response = await fetch(url, { method: 'PATCH', @@ -120,9 +126,10 @@ export async function updateSimulationOutput( export async function markSimulationCompleted( countryId: (typeof countryIds)[number], simulationId: string, - output: unknown + output: unknown, + runMetadata?: RunMetadata ): Promise { - return updateSimulationOutput(countryId, simulationId, output); + return updateSimulationOutput(countryId, simulationId, output, runMetadata); } /** @@ -132,11 +139,16 @@ export async function markSimulationCompleted( export async function markSimulationError( countryId: (typeof countryIds)[number], simulationId: string, - errorMessage?: string + errorMessage?: string, + runMetadata?: RunMetadata ): Promise { const url = `${BASE_URL}/${countryId}/simulation`; - const payload = SimulationAdapter.toErrorPayload(parseInt(simulationId, 10), errorMessage); + const payload = SimulationAdapter.toErrorPayload( + parseInt(simulationId, 10), + errorMessage, + runMetadata + ); const response = await fetch(url, { method: 'PATCH', diff --git a/app/src/libs/calculations/ResultPersister.ts b/app/src/libs/calculations/ResultPersister.ts index 69111a61d..604ba7350 100644 --- a/app/src/libs/calculations/ResultPersister.ts +++ b/app/src/libs/calculations/ResultPersister.ts @@ -1,9 +1,11 @@ import { QueryClient } from '@tanstack/react-query'; import { markReportCompleted } from '@/api/report'; import { updateSimulationOutput } from '@/api/simulation'; +import { mergeConsistentRunMetadata } from '@/libs/calculations/runMetadata'; import { calculationKeys, reportKeys, simulationKeys } from '@/libs/queryKeys'; import type { CalcStatus } from '@/types/calculation'; import type { Report } from '@/types/ingredients/Report'; +import type { RunMetadata } from '@/types/runMetadata'; /** * Persists calculation results to the appropriate backend resource @@ -26,13 +28,20 @@ export class ResultPersister { try { if (status.metadata.targetType === 'report') { - await this.persistToReport(status.metadata.calcId, status.result, countryId, year); + await this.persistToReport( + status.metadata.calcId, + status.result, + countryId, + year, + status.runMetadata + ); } else { await this.persistToSimulation( status.metadata.calcId, status.result, countryId, - status.metadata.reportId // Pass parent reportId for household sim-level calcs + status.metadata.reportId, // Pass parent reportId for household sim-level calcs + status.runMetadata ); } } catch (error) { @@ -41,13 +50,20 @@ export class ResultPersister { await this.sleep(1000); try { if (status.metadata.targetType === 'report') { - await this.persistToReport(status.metadata.calcId, status.result, countryId, year); + await this.persistToReport( + status.metadata.calcId, + status.result, + countryId, + year, + status.runMetadata + ); } else { await this.persistToSimulation( status.metadata.calcId, status.result, countryId, - status.metadata.reportId // Pass parent reportId for household sim-level calcs + status.metadata.reportId, // Pass parent reportId for household sim-level calcs + status.runMetadata ); } } catch (retryError) { @@ -66,7 +82,8 @@ export class ResultPersister { reportId: string, result: any, countryId: string, - year: string + year: string, + runMetadata?: RunMetadata ): Promise { // Create a Report object with the result const report: Report = { @@ -80,7 +97,7 @@ export class ResultPersister { }; // Use existing markReportCompleted API - await markReportCompleted(countryId as any, reportId, report); + await markReportCompleted(countryId as any, reportId, report, runMetadata); // Invalidate report metadata cache so Reports page shows updated status // WHY: Reports page reads from reportKeys.byId(), not calculation cache. @@ -101,10 +118,11 @@ export class ResultPersister { simulationId: string, result: any, countryId: string, - reportId?: string + reportId?: string, + runMetadata?: RunMetadata ): Promise { // Use new updateSimulationOutput API - await updateSimulationOutput(countryId as any, simulationId, result); + await updateSimulationOutput(countryId as any, simulationId, result, runMetadata); // Invalidate simulation metadata cache so Reports page shows updated status // WHY: Reports page may display simulation info, and we need fresh data after persistence. @@ -128,11 +146,33 @@ export class ResultPersister { const aggregatedOutput = await this.aggregateSimulationOutputs(reportId); // Mark report as complete with aggregated output - await this.persistToReport(reportId, aggregatedOutput, countryId, report.year); + await this.persistToReport( + reportId, + aggregatedOutput, + countryId, + report.year, + this.aggregateRunMetadata(reportId) + ); } } } + private aggregateRunMetadata(reportId: string): RunMetadata | undefined { + const report = this.queryClient.getQueryData(reportKeys.byId(reportId)); + if (!report) { + return undefined; + } + + const runMetadataItems = report.simulationIds.map((simId) => { + const simStatus = this.queryClient.getQueryData( + calculationKeys.bySimulationId(simId) + ); + return simStatus?.runMetadata; + }); + + return mergeConsistentRunMetadata(runMetadataItems); + } + /** * Check if all simulations for a report are complete * @param reportId - Parent report ID diff --git a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts index 35e684e52..39ecdf99f 100644 --- a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts +++ b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts @@ -4,6 +4,7 @@ import { SocietyWideCalculationParams, SocietyWideCalculationResponse, } from '@/api/societyWideCalculation'; +import { buildRunMetadataFromSocietyWideOutput } from '@/libs/calculations/runMetadata'; import { getDurationForCountry } from '@/constants/calculationDurations'; import { CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; import { CalcExecutionStrategy, RefetchConfig } from '../strategies/types'; @@ -105,6 +106,7 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { status: 'complete', result: response.result, metadata, + runMetadata: buildRunMetadataFromSocietyWideOutput(response.result), }; } diff --git a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts index 773e7e169..021ff45e5 100644 --- a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts +++ b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts @@ -1,8 +1,12 @@ import type { QueryClient } from '@tanstack/react-query'; +import type { HouseholdCalculationResult } from '@/api/householdCalculation'; import { markReportCompleted, markReportError as persistReportError } from '@/api/report'; import { markSimulationError, updateSimulationOutput } from '@/api/simulation'; +import { + buildRunMetadataFromPolicyEngineBundle, + mergeConsistentRunMetadata, +} from '@/libs/calculations/runMetadata'; import { reportKeys, simulationKeys } from '@/libs/queryKeys'; -import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculation/household'; import type { Report } from '@/types/ingredients/Report'; import { cacheMonitor } from '@/utils/cacheMonitor'; @@ -30,7 +34,7 @@ export class HouseholdReportOrchestrator { private queryClient: QueryClient; private activeCalculations: Set; // Track which simulations are running - private simulationResults: Map>; // reportId -> (simId -> result) + private simulationResults: Map>; // reportId -> (simId -> result) private progressCoordinators: Map< string, { coordinator: HouseholdProgressCoordinator; timer: NodeJS.Timeout } @@ -193,10 +197,15 @@ export class HouseholdReportOrchestrator { private async persistSimulation( countryId: string, simulationId: string, - result: any + calculation: HouseholdCalculationResult ): Promise { // Persist output — this is the critical step. If it fails, throw. - await updateSimulationOutput(countryId as any, simulationId, result); + await updateSimulationOutput( + countryId as any, + simulationId, + calculation.result, + buildRunMetadataFromPolicyEngineBundle(calculation.policyengine_bundle ?? null) + ); // Cache warming — best effort only. Failure here should NOT // cascade into markSimulationError since the output is already persisted. @@ -237,7 +246,19 @@ export class HouseholdReportOrchestrator { } // Build aggregated output: object mapping sim IDs (alphabetically sorted) to their outputs - const householdReportOutput = buildHouseholdReportOutput(reportResults); + const householdReportOutput = buildHouseholdReportOutput( + new Map( + Array.from(reportResults.entries()).map(([simulationId, calculation]) => [ + simulationId, + calculation.result, + ]) + ) + ); + const runMetadata = mergeConsistentRunMetadata( + Array.from(reportResults.values()).map((calculation) => + buildRunMetadataFromPolicyEngineBundle(calculation.policyengine_bundle ?? null) + ) + ); const completedReport: Report = { ...report, @@ -247,7 +268,12 @@ export class HouseholdReportOrchestrator { }; try { - await markReportCompleted(countryId as any, report.id!, completedReport); + await markReportCompleted( + countryId as any, + report.id!, + completedReport, + runMetadata + ); // Invalidate report cache to refetch with new status this.queryClient.invalidateQueries({ diff --git a/app/src/libs/calculations/household/HouseholdSimCalculator.ts b/app/src/libs/calculations/household/HouseholdSimCalculator.ts index 136787ef2..ee48bb8bd 100644 --- a/app/src/libs/calculations/household/HouseholdSimCalculator.ts +++ b/app/src/libs/calculations/household/HouseholdSimCalculator.ts @@ -3,6 +3,7 @@ import { fetchHouseholdCalculationWithBundle, type HouseholdCalculationResult, } from '@/api/householdCalculation'; +import { buildRunMetadataFromPolicyEngineBundle } from '@/libs/calculations/runMetadata'; import { calculationKeys } from '@/libs/queryKeys'; import type { CalcStatus } from '@/types/calculation'; @@ -79,6 +80,9 @@ export class HouseholdSimCalculator { startedAt: initialStatus.metadata.startedAt, reportId: this.reportId, }, + runMetadata: buildRunMetadataFromPolicyEngineBundle( + calculation.policyengine_bundle ?? null + ), }; this.queryClient.setQueryData(calcKey, completeStatus); diff --git a/app/src/libs/calculations/runMetadata.ts b/app/src/libs/calculations/runMetadata.ts new file mode 100644 index 000000000..0132d8c6a --- /dev/null +++ b/app/src/libs/calculations/runMetadata.ts @@ -0,0 +1,72 @@ +import type { PolicyEngineBundle, SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import type { RunMetadata } from '@/types/runMetadata'; + +export const APP_RUNTIME_NAME = 'policyengine-app-v2'; + +function normalizeNullableString(value: unknown): string | null | undefined { + if (value == null) { + return null; + } + return typeof value === 'string' ? value : undefined; +} + +export function buildRunMetadataFromPolicyEngineBundle( + bundle?: PolicyEngineBundle | null +): RunMetadata { + return { + country_package_version: normalizeNullableString(bundle?.model_version), + policyengine_version: normalizeNullableString(bundle?.policyengine_version), + data_version: normalizeNullableString(bundle?.data_version), + runtime_app_name: APP_RUNTIME_NAME, + resolved_dataset: normalizeNullableString(bundle?.dataset), + }; +} + +export function buildRunMetadataFromSocietyWideOutput( + output?: SocietyWideReportOutput | null +): RunMetadata { + if (!output) { + return { + runtime_app_name: APP_RUNTIME_NAME, + }; + } + + return { + country_package_version: normalizeNullableString(output.model_version), + policyengine_version: normalizeNullableString(output.policyengine_version), + data_version: normalizeNullableString(output.data_version), + runtime_app_name: APP_RUNTIME_NAME, + resolved_dataset: normalizeNullableString(output.dataset), + }; +} + +export function mergeConsistentRunMetadata( + metadataItems: Array +): RunMetadata | undefined { + const populatedItems = metadataItems.filter( + (metadata): metadata is RunMetadata => metadata != null + ); + if (populatedItems.length === 0) { + return undefined; + } + + const merged: RunMetadata = {}; + const keys: Array = [ + 'country_package_version', + 'policyengine_version', + 'data_version', + 'runtime_app_name', + 'resolved_dataset', + ]; + + for (const key of keys) { + const firstValue = populatedItems[0][key]; + if ( + populatedItems.every((metadata) => (metadata[key] ?? null) === (firstValue ?? null)) + ) { + merged[key] = firstValue ?? null; + } + } + + return merged; +} diff --git a/app/src/libs/calculations/strategies/HouseholdCalcStrategy.ts b/app/src/libs/calculations/strategies/HouseholdCalcStrategy.ts index b26b001ba..8a22d0b6e 100644 --- a/app/src/libs/calculations/strategies/HouseholdCalcStrategy.ts +++ b/app/src/libs/calculations/strategies/HouseholdCalcStrategy.ts @@ -1,4 +1,5 @@ -import { fetchHouseholdCalculation } from '@/api/householdCalculation'; +import { fetchHouseholdCalculationWithBundle } from '@/api/householdCalculation'; +import { buildRunMetadataFromPolicyEngineBundle } from '@/libs/calculations/runMetadata'; import { CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; import { CalcExecutionStrategy, RefetchConfig } from './types'; @@ -26,7 +27,7 @@ export class HouseholdCalcStrategy implements CalcExecutionStrategy { try { // Call API once and await the full result - const result = await fetchHouseholdCalculation( + const calculation = await fetchHouseholdCalculationWithBundle( params.countryId, params.populationId, policyId @@ -35,8 +36,11 @@ export class HouseholdCalcStrategy implements CalcExecutionStrategy { // Return complete status with result and PROVIDED metadata (includes reportId!) return { status: 'complete', - result, + result: calculation.result, metadata, // Use the metadata passed in, which has reportId for household sim-level calcs + runMetadata: buildRunMetadataFromPolicyEngineBundle( + calculation.policyengine_bundle ?? null + ), }; } catch (error) { console.error('[HouseholdCalcStrategy.execute] Calculation failed:', error); diff --git a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts index c5e784963..0df1bc688 100644 --- a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts @@ -21,10 +21,10 @@ import { useCalcOrchestratorManager } from '@/contexts/CalcOrchestratorContext'; import { useUpdateReportAssociation } from '@/hooks/useUserReportAssociations'; import { reportAssociationKeys, reportKeys } from '@/libs/queryKeys'; import { RootState } from '@/store'; -import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; import { toApiPolicyId } from '../currentLaw'; import { ReportBuilderState } from '../types'; +import { buildExplicitReportCreationPayload } from '../utils/buildExplicitReportCreationPayload'; interface UseModifyReportSubmissionArgs { reportState: ReportBuilderState; @@ -124,12 +124,13 @@ export function useModifyReportSubmission({ throw new Error('No simulations created'); } - const reportPayload = ReportAdapter.toCreationPayload({ + const reportPayload = buildExplicitReportCreationPayload({ countryId, year: reportState.year, simulationIds, - apiVersion: null, - } as Report); + simulation1: simulations[0], + simulation2: simulations[1] || null, + }); return { simulationIds, simulations, reportPayload }; }, [reportState, countryId, currentLawId]); diff --git a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts index 699962f2b..e79eeaf98 100644 --- a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts @@ -12,18 +12,18 @@ */ import { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; -import { ReportAdapter, SimulationAdapter } from '@/adapters'; +import { SimulationAdapter } from '@/adapters'; import { createSimulation } from '@/api/simulation'; import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import { useCreateReport } from '@/hooks/useCreateReport'; import { RootState } from '@/store'; -import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationStateProps } from '@/types/pathwayState'; import { trackReportStarted } from '@/utils/analytics'; import { toApiPolicyId } from '../currentLaw'; import { ReportBuilderState } from '../types'; +import { buildExplicitReportCreationPayload } from '../utils/buildExplicitReportCreationPayload'; interface UseReportSubmissionArgs { reportState: ReportBuilderState; @@ -159,14 +159,13 @@ export function useReportSubmission({ return; } - const reportData: Partial = { + const serializedPayload = buildExplicitReportCreationPayload({ countryId, year: reportState.year, simulationIds, - apiVersion: null, - }; - - const serializedPayload = ReportAdapter.toCreationPayload(reportData as Report); + simulation1: simulations[0], + simulation2: simulations[1] || null, + }); await createReport( { diff --git a/app/src/pages/reportBuilder/utils/buildExplicitReportCreationPayload.ts b/app/src/pages/reportBuilder/utils/buildExplicitReportCreationPayload.ts new file mode 100644 index 000000000..c558620a4 --- /dev/null +++ b/app/src/pages/reportBuilder/utils/buildExplicitReportCreationPayload.ts @@ -0,0 +1,145 @@ +import { countryIds } from '@/libs/countries'; +import { Simulation } from '@/types/ingredients/Simulation'; +import { ReportCreationPayload } from '@/types/payloads'; +import { + EconomyReportSpec, + ExplicitReportSpec, + HouseholdReportSpec, + REPORT_SPEC_SCHEMA_VERSION, + ReportSimulationInput, +} from '@/types/reportSpec'; + +interface BuildExplicitReportCreationPayloadArgs { + countryId: (typeof countryIds)[number]; + year: string; + simulationIds: string[]; + simulation1: Simulation | null; + simulation2?: Simulation | null; +} + +function toSimulationId(simulationId: string, fieldName: string): number { + const parsedId = Number.parseInt(simulationId, 10); + if (Number.isNaN(parsedId)) { + throw new Error(`[buildExplicitReportCreationPayload] ${fieldName} must be numeric`); + } + + return parsedId; +} + +function toPolicyId(simulation: Simulation, fieldName: string): number { + if (!simulation.policyId) { + throw new Error(`[buildExplicitReportCreationPayload] ${fieldName} is missing policyId`); + } + + const policyId = Number.parseInt(simulation.policyId, 10); + if (Number.isNaN(policyId)) { + throw new Error( + `[buildExplicitReportCreationPayload] ${fieldName} policyId must be numeric` + ); + } + + return policyId; +} + +function toReportSimulationInput( + simulation: Simulation, + fieldName: string +): ReportSimulationInput { + if (!simulation.populationId || !simulation.populationType) { + throw new Error( + `[buildExplicitReportCreationPayload] ${fieldName} is missing population configuration` + ); + } + + return { + population_type: simulation.populationType, + population_id: simulation.populationId, + policy_id: toPolicyId(simulation, fieldName), + }; +} + +export function buildExplicitReportSpec({ + countryId, + year, + simulation1, + simulation2 = null, +}: Omit): ExplicitReportSpec { + if (!simulation1) { + throw new Error('[buildExplicitReportCreationPayload] simulation1 is required'); + } + + if (!simulation1.populationType || !simulation1.populationId) { + throw new Error( + '[buildExplicitReportCreationPayload] simulation1 must include population data' + ); + } + + if (simulation2 && simulation2.populationType !== simulation1.populationType) { + throw new Error( + '[buildExplicitReportCreationPayload] simulation population types must match' + ); + } + + if (simulation2 && simulation2.populationId !== simulation1.populationId) { + throw new Error( + '[buildExplicitReportCreationPayload] comparison reports require matching population IDs' + ); + } + + if (simulation1.populationType === 'household') { + const reportSpec: HouseholdReportSpec = { + country_id: countryId, + report_kind: simulation2 ? 'household_comparison' : 'household_single', + time_period: year, + simulation_1: toReportSimulationInput(simulation1, 'simulation1'), + simulation_2: simulation2 ? toReportSimulationInput(simulation2, 'simulation2') : null, + }; + + return reportSpec; + } + + const baselinePolicyId = toPolicyId(simulation1, 'simulation1'); + const reformPolicyId = simulation2 + ? toPolicyId(simulation2, 'simulation2') + : baselinePolicyId; + + const reportSpec: EconomyReportSpec = { + country_id: countryId, + report_kind: simulation2 ? 'economy_comparison' : 'economy_single', + time_period: year, + region: simulation1.populationId, + baseline_policy_id: baselinePolicyId, + reform_policy_id: reformPolicyId, + dataset: 'default', + target: 'general', + options: {}, + }; + + return reportSpec; +} + +export function buildExplicitReportCreationPayload({ + countryId, + year, + simulationIds, + simulation1, + simulation2 = null, +}: BuildExplicitReportCreationPayloadArgs): ReportCreationPayload { + const [simulation1Id, simulation2Id] = simulationIds; + if (!simulation1Id) { + throw new Error('[buildExplicitReportCreationPayload] simulationIds[0] is required'); + } + + return { + simulation_1_id: toSimulationId(simulation1Id, 'simulationIds[0]'), + simulation_2_id: simulation2Id ? toSimulationId(simulation2Id, 'simulationIds[1]') : null, + year, + report_spec: buildExplicitReportSpec({ + countryId, + year, + simulation1, + simulation2, + }), + report_spec_schema_version: REPORT_SPEC_SCHEMA_VERSION, + }; +} diff --git a/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts b/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts index 985390c1d..cb8c77d40 100644 --- a/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts +++ b/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts @@ -5,12 +5,12 @@ import metadataReducer from '@/reducers/metadataReducer'; // Test constants export const TEST_SIMULATION_IDS = { - SIM_NEW_1: 'new-sim-1', - SIM_NEW_2: 'new-sim-2', + SIM_NEW_1: '1', + SIM_NEW_2: '2', } as const; export const TEST_POLICY_IDS = { - REFORM_POLICY: 'policy-reform-1', + REFORM_POLICY: '101', CURRENT_LAW: 'current-law', } as const; @@ -56,6 +56,65 @@ export const mockSingleSimReportState: ReportBuilderState = { ], }; +export const mockHouseholdSingleReportState: ReportBuilderState = { + label: TEST_LABELS.REPORT, + year: '2026', + simulations: [ + { + label: TEST_LABELS.BASELINE, + policy: { id: TEST_POLICY_IDS.CURRENT_LAW, label: 'Current law', parameters: [] }, + population: { + label: 'Household ABC', + type: 'household', + geography: null, + household: { + id: TEST_POPULATION.HOUSEHOLD_ID, + householdId: TEST_POPULATION.HOUSEHOLD_ID, + countryId: 'us', + label: 'Household ABC', + } as any, + }, + }, + ], +}; + +export const mockHouseholdComparisonReportState: ReportBuilderState = { + label: TEST_LABELS.REPORT, + year: '2026', + simulations: [ + { + label: TEST_LABELS.BASELINE, + policy: { id: TEST_POLICY_IDS.CURRENT_LAW, label: 'Current law', parameters: [] }, + population: { + label: 'Household ABC', + type: 'household', + geography: null, + household: { + id: TEST_POPULATION.HOUSEHOLD_ID, + householdId: TEST_POPULATION.HOUSEHOLD_ID, + countryId: 'us', + label: 'Household ABC', + } as any, + }, + }, + { + label: TEST_LABELS.REFORM, + policy: { id: TEST_POLICY_IDS.REFORM_POLICY, label: 'Reform', parameters: [] }, + population: { + label: 'Household ABC', + type: 'household', + geography: null, + household: { + id: TEST_POPULATION.HOUSEHOLD_ID, + householdId: TEST_POPULATION.HOUSEHOLD_ID, + countryId: 'us', + label: 'Household ABC', + } as any, + }, + }, + ], +}; + // Mock ReportBuilderState for a two-simulation report export const mockTwoSimReportState: ReportBuilderState = { label: TEST_LABELS.REPORT, diff --git a/app/src/tests/unit/api/report.test.ts b/app/src/tests/unit/api/report.test.ts index c0f95f001..e9f63a453 100644 --- a/app/src/tests/unit/api/report.test.ts +++ b/app/src/tests/unit/api/report.test.ts @@ -150,6 +150,34 @@ describe('report API', () => { 'Failed to update report report-123' ); }); + + test('given run metadata then includes it in the PATCH payload', async () => { + const countryId = 'us'; + const reportId = 'report-123'; + const runMetadata = { + country_package_version: '1.620.0', + policyengine_version: '0.94.2', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: 'enhanced_us_household', + }; + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ status: 'ok', result: mockReportMetadata }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + await markReportCompleted(countryId, reportId, mockReport, runMetadata); + + expect(global.fetch).toHaveBeenCalledWith(`${BASE_URL}/${countryId}/report`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...mockCompletedReportPayload, + ...runMetadata, + }), + }); + }); }); describe('markReportError', () => { diff --git a/app/src/tests/unit/api/simulation.test.ts b/app/src/tests/unit/api/simulation.test.ts index 3c43a8c0e..eb5ce776c 100644 --- a/app/src/tests/unit/api/simulation.test.ts +++ b/app/src/tests/unit/api/simulation.test.ts @@ -480,6 +480,40 @@ describe('updateSimulationOutput', () => { updateSimulationOutput(TEST_COUNTRIES.US, SIMULATION_IDS.VALID, mockSimulationOutput) ).rejects.toThrow(networkError); }); + + test('given run metadata then includes it in the PATCH payload', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce( + mockSuccessResponse(mockUpdateSimulationOutputSuccessResponse) as any + ); + const runMetadata = { + country_package_version: '1.602.0', + policyengine_version: '0.93.1', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + }; + + await updateSimulationOutput( + TEST_COUNTRIES.US, + SIMULATION_IDS.VALID, + mockSimulationOutput, + runMetadata + ); + + 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.stringify(mockSimulationOutput), + status: 'complete', + ...runMetadata, + }), + }); + }); }); describe('markSimulationCompleted', () => { diff --git a/app/src/tests/unit/libs/calculations/ResultPersister.test.ts b/app/src/tests/unit/libs/calculations/ResultPersister.test.ts index 9bb35316d..c410fcd63 100644 --- a/app/src/tests/unit/libs/calculations/ResultPersister.test.ts +++ b/app/src/tests/unit/libs/calculations/ResultPersister.test.ts @@ -39,6 +39,13 @@ describe('ResultPersister', () => { it('given complete report status then persists to report', async () => { // Given const status = mockCompleteSocietyWideStatus(); + status.runMetadata = { + country_package_version: '1.620.0', + policyengine_version: '0.94.2', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: 'enhanced_us_household', + }; (markReportCompleted as any).mockResolvedValue(undefined); // When @@ -52,7 +59,8 @@ describe('ResultPersister', () => { id: TEST_CALC_IDS.REPORT_123, status: 'complete', output: status.result, - }) + }), + status.runMetadata ); }); @@ -135,6 +143,12 @@ describe('ResultPersister', () => { const status: CalcStatus = { status: 'complete', result, + runMetadata: { + country_package_version: '1.602.0', + policyengine_version: '0.93.1', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + }, metadata: { calcId: 'sim-456', targetType: 'simulation', @@ -148,7 +162,12 @@ describe('ResultPersister', () => { await persister.persist(status, TEST_COUNTRIES.US, TEST_YEARS.DEFAULT); // Then - expect(updateSimulationOutput).toHaveBeenCalledWith(TEST_COUNTRIES.US, 'sim-456', result); + expect(updateSimulationOutput).toHaveBeenCalledWith( + TEST_COUNTRIES.US, + 'sim-456', + result, + status.runMetadata + ); }); it('given simulation persistence then invalidates simulation cache', async () => { @@ -254,6 +273,12 @@ describe('ResultPersister', () => { queryClient.setQueryData(calculationKeys.bySimulationId(TEST_CALC_IDS.SIM_1), { status: 'complete', result: result1, + runMetadata: { + country_package_version: '1.700.0', + policyengine_version: '0.95.0', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + }, metadata: { calcId: TEST_CALC_IDS.SIM_1, targetType: 'simulation', @@ -264,6 +289,12 @@ describe('ResultPersister', () => { queryClient.setQueryData(calculationKeys.bySimulationId(TEST_CALC_IDS.SIM_2), { status: 'complete', result: result2, + runMetadata: { + country_package_version: '1.700.0', + policyengine_version: '0.95.0', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + }, metadata: status.metadata, }); @@ -281,7 +312,14 @@ describe('ResultPersister', () => { id: TEST_CALC_IDS.REPORT_123, status: 'complete', output: [result1, result2], - }) + }), + { + country_package_version: '1.700.0', + policyengine_version: '0.95.0', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: null, + } ); }); }); diff --git a/app/src/tests/unit/libs/calculations/strategies/HouseholdCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/HouseholdCalcStrategy.test.ts index 56b876099..ef6387594 100644 --- a/app/src/tests/unit/libs/calculations/strategies/HouseholdCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/HouseholdCalcStrategy.test.ts @@ -5,18 +5,19 @@ import { mockHouseholdCalcParams } from '@/tests/fixtures/types/calculationFixtu // Mock the household API vi.mock('@/api/householdCalculation', () => ({ - fetchHouseholdCalculation: vi.fn(), + fetchHouseholdCalculationWithBundle: vi.fn(), })); describe('HouseholdCalcStrategy', () => { let strategy: HouseholdCalcStrategy; - let mockFetchHouseholdCalculation: any; + let mockFetchHouseholdCalculationWithBundle: any; beforeEach(async () => { strategy = new HouseholdCalcStrategy(); const householdModule = await import('@/api/householdCalculation'); - mockFetchHouseholdCalculation = householdModule.fetchHouseholdCalculation as any; + mockFetchHouseholdCalculationWithBundle = + householdModule.fetchHouseholdCalculationWithBundle as any; vi.clearAllMocks(); }); @@ -25,7 +26,10 @@ describe('HouseholdCalcStrategy', () => { it('given valid params then calls API with correct parameters', async () => { // Given const params = mockHouseholdCalcParams(); - mockFetchHouseholdCalculation.mockResolvedValue(mockHouseholdSuccessResponse()); + mockFetchHouseholdCalculationWithBundle.mockResolvedValue({ + result: mockHouseholdSuccessResponse(), + policyengine_bundle: null, + }); // When await strategy.execute(params, { @@ -36,7 +40,7 @@ describe('HouseholdCalcStrategy', () => { }); // Then - expect(mockFetchHouseholdCalculation).toHaveBeenCalledWith( + expect(mockFetchHouseholdCalculationWithBundle).toHaveBeenCalledWith( params.countryId, params.populationId, params.policyIds.baseline @@ -47,7 +51,14 @@ describe('HouseholdCalcStrategy', () => { // Given const params = mockHouseholdCalcParams(); const mockResult = mockHouseholdSuccessResponse(); - mockFetchHouseholdCalculation.mockResolvedValue(mockResult); + mockFetchHouseholdCalculationWithBundle.mockResolvedValue({ + result: mockResult, + policyengine_bundle: { + model_version: '1.602.0', + policyengine_version: '0.93.1', + data_version: '2026.04.17', + }, + }); // When const result = await strategy.execute(params, { @@ -62,13 +73,20 @@ describe('HouseholdCalcStrategy', () => { expect(result.result).toEqual(mockResult); expect(result.metadata.calcType).toBe('household'); expect(result.metadata.targetType).toBe('simulation'); + expect(result.runMetadata).toEqual({ + country_package_version: '1.602.0', + policyengine_version: '0.93.1', + data_version: '2026.04.17', + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: null, + }); }); it('given API error then returns error status', async () => { // Given const params = mockHouseholdCalcParams(); const mockError = new Error('API request failed'); - mockFetchHouseholdCalculation.mockRejectedValue(mockError); + mockFetchHouseholdCalculationWithBundle.mockRejectedValue(mockError); // When const result = await strategy.execute(params, { @@ -93,7 +111,10 @@ describe('HouseholdCalcStrategy', () => { const params = mockHouseholdCalcParams({ policyIds: { baseline: '1', reform: '2' }, }); - mockFetchHouseholdCalculation.mockResolvedValue(mockHouseholdSuccessResponse()); + mockFetchHouseholdCalculationWithBundle.mockResolvedValue({ + result: mockHouseholdSuccessResponse(), + policyengine_bundle: null, + }); // When await strategy.execute(params, { @@ -104,7 +125,7 @@ describe('HouseholdCalcStrategy', () => { }); // Then - expect(mockFetchHouseholdCalculation).toHaveBeenCalledWith( + expect(mockFetchHouseholdCalculationWithBundle).toHaveBeenCalledWith( params.countryId, params.populationId, '2' // reform policy @@ -114,7 +135,7 @@ describe('HouseholdCalcStrategy', () => { it('given non-Error rejection then wraps in CalcError', async () => { // Given const params = mockHouseholdCalcParams(); - mockFetchHouseholdCalculation.mockRejectedValue('String error'); + mockFetchHouseholdCalculationWithBundle.mockRejectedValue('String error'); // When const result = await strategy.execute(params, { diff --git a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts index 4ffcdf1e7..568de2c41 100644 --- a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts @@ -94,6 +94,13 @@ describe('SocietyWideCalcStrategy', () => { expect(result.status).toBe('complete'); expect(result.result).toBeDefined(); expect(result.error).toBeUndefined(); + expect(result.runMetadata).toEqual({ + country_package_version: apiResponse.result?.model_version ?? null, + policyengine_version: apiResponse.result?.policyengine_version ?? null, + data_version: apiResponse.result?.data_version ?? null, + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: apiResponse.result?.dataset ?? null, + }); }); it('given error response then returns error status', async () => { @@ -448,6 +455,13 @@ describe('SocietyWideCalcStrategy', () => { expect(result.status).toBe('complete'); expect(result.progress).toBeUndefined(); expect(result.result).toBeDefined(); + expect(result.runMetadata).toEqual({ + country_package_version: apiResponse.result?.model_version ?? null, + policyengine_version: apiResponse.result?.policyengine_version ?? null, + data_version: apiResponse.result?.data_version ?? null, + runtime_app_name: 'policyengine-app-v2', + resolved_dataset: apiResponse.result?.dataset ?? null, + }); }); }); }); diff --git a/app/src/tests/unit/pages/reportBuilder/buildExplicitReportCreationPayload.test.ts b/app/src/tests/unit/pages/reportBuilder/buildExplicitReportCreationPayload.test.ts new file mode 100644 index 000000000..a18ac03aa --- /dev/null +++ b/app/src/tests/unit/pages/reportBuilder/buildExplicitReportCreationPayload.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'vitest'; +import { buildExplicitReportCreationPayload } from '@/pages/reportBuilder/utils/buildExplicitReportCreationPayload'; +import { Simulation } from '@/types/ingredients/Simulation'; + +describe('buildExplicitReportCreationPayload', () => { + test('given household single simulation then builds an explicit household report spec', () => { + const simulation1: Simulation = { + id: '10', + countryId: 'us', + policyId: '42', + populationId: 'household-123', + populationType: 'household', + label: 'Baseline', + isCreated: true, + status: 'pending', + output: null, + }; + + const payload = buildExplicitReportCreationPayload({ + countryId: 'us', + year: '2026', + simulationIds: ['10'], + simulation1, + simulation2: null, + }); + + expect(payload).toEqual({ + simulation_1_id: 10, + simulation_2_id: null, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'household_single', + time_period: '2026', + simulation_1: { + population_type: 'household', + population_id: 'household-123', + policy_id: 42, + }, + simulation_2: null, + }, + }); + }); + + test('given economy comparison simulations then builds an explicit economy report spec', () => { + const simulation1: Simulation = { + id: '11', + countryId: 'us', + policyId: '0', + populationId: 'us', + populationType: 'geography', + label: 'Baseline', + isCreated: true, + status: 'pending', + output: null, + }; + const simulation2: Simulation = { + id: '12', + countryId: 'us', + policyId: '101', + populationId: 'us', + populationType: 'geography', + label: 'Reform', + isCreated: true, + status: 'pending', + output: null, + }; + + const payload = buildExplicitReportCreationPayload({ + countryId: 'us', + year: '2026', + simulationIds: ['11', '12'], + simulation1, + simulation2, + }); + + expect(payload).toEqual({ + simulation_1_id: 11, + simulation_2_id: 12, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_comparison', + time_period: '2026', + region: 'us', + baseline_policy_id: 0, + reform_policy_id: 101, + dataset: 'default', + target: 'general', + options: {}, + }, + }); + }); + + test('given mismatched comparison populations then throws', () => { + const simulation1: Simulation = { + id: '11', + countryId: 'us', + policyId: '0', + populationId: 'us', + populationType: 'geography', + label: 'Baseline', + isCreated: true, + status: 'pending', + output: null, + }; + const simulation2: Simulation = { + id: '12', + countryId: 'us', + policyId: '101', + populationId: 'ca', + populationType: 'geography', + label: 'Reform', + isCreated: true, + status: 'pending', + output: null, + }; + + expect(() => + buildExplicitReportCreationPayload({ + countryId: 'us', + year: '2026', + simulationIds: ['11', '12'], + simulation1, + simulation2, + }) + ).toThrow('comparison reports require matching population IDs'); + }); +}); diff --git a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx index 571c57ff3..4374169d5 100644 --- a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx +++ b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx @@ -4,16 +4,22 @@ import { renderHook, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createReport, createReportAndAssociateWithUser } from '@/api/report'; import { useModifyReportSubmission } from '@/pages/reportBuilder/hooks/useModifyReportSubmission'; import { createTestStore, CURRENT_LAW_ID, mockCreateSimulationFn, + mockHouseholdComparisonReportState, + mockHouseholdSingleReportState, mockLocalStorageCreateFn, mockOnSuccess, + mockSingleSimReportState, mockTwoSimReportState, setupDefaultMocks, TEST_LABELS, + TEST_POLICY_IDS, + TEST_POPULATION, TEST_SIMULATION_IDS, } from '@/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks'; @@ -55,11 +61,6 @@ vi.mock('@/adapters', () => ({ }), }, ReportAdapter: { - toCreationPayload: (data: any) => ({ - country_id: data.countryId, - year: data.year, - simulation_ids: data.simulationIds, - }), fromMetadata: (metadata: any) => ({ id: metadata.id, countryId: metadata.country_id, @@ -169,6 +170,170 @@ describe('useModifyReportSubmission', () => { isCreated: true, }); }); + + test('given two simulations when saving as new then sends explicit economy report spec', async () => { + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: mockTwoSimReportState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSaveAsNew('New Report Label'); + + await waitFor(() => { + expect(createReportAndAssociateWithUser).toHaveBeenCalledWith({ + countryId: 'us', + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: Number(TEST_SIMULATION_IDS.SIM_NEW_2), + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_comparison', + time_period: '2026', + region: TEST_POPULATION.GEOGRAPHY_ID, + baseline_policy_id: CURRENT_LAW_ID, + reform_policy_id: Number(TEST_POLICY_IDS.REFORM_POLICY), + dataset: 'default', + target: 'general', + options: {}, + }, + }, + userId: 'anonymous', + label: 'New Report Label', + }); + }); + }); + + test('given one geography simulation when saving as new then sends explicit economy single spec', async () => { + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: mockSingleSimReportState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSaveAsNew('New Report Label'); + + await waitFor(() => { + expect(createReportAndAssociateWithUser).toHaveBeenCalledWith({ + countryId: 'us', + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: null, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_single', + time_period: '2026', + region: TEST_POPULATION.GEOGRAPHY_ID, + baseline_policy_id: CURRENT_LAW_ID, + reform_policy_id: CURRENT_LAW_ID, + dataset: 'default', + target: 'general', + options: {}, + }, + }, + userId: 'anonymous', + label: 'New Report Label', + }); + }); + }); + + test('given one household simulation when saving as new then sends explicit household single spec', async () => { + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: mockHouseholdSingleReportState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSaveAsNew('New Report Label'); + + await waitFor(() => { + expect(createReportAndAssociateWithUser).toHaveBeenCalledWith({ + countryId: 'us', + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: null, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'household_single', + time_period: '2026', + simulation_1: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: CURRENT_LAW_ID, + }, + simulation_2: null, + }, + }, + userId: 'anonymous', + label: 'New Report Label', + }); + }); + }); + + test('given two household simulations when saving as new then sends explicit household comparison spec', async () => { + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: mockHouseholdComparisonReportState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSaveAsNew('New Report Label'); + + await waitFor(() => { + expect(createReportAndAssociateWithUser).toHaveBeenCalledWith({ + countryId: 'us', + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: Number(TEST_SIMULATION_IDS.SIM_NEW_2), + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'household_comparison', + time_period: '2026', + simulation_1: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: CURRENT_LAW_ID, + }, + simulation_2: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: Number(TEST_POLICY_IDS.REFORM_POLICY), + }, + }, + }, + userId: 'anonymous', + label: 'New Report Label', + }); + }); + }); }); describe('localStorage association creation via handleReplace', () => { @@ -231,5 +396,40 @@ describe('useModifyReportSubmission', () => { expect(mockOnSuccess).toHaveBeenCalledWith(EXISTING_USER_REPORT_ID); }); }); + + test('given two simulations when replacing then sends explicit economy report spec', async () => { + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: mockTwoSimReportState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleReplace(); + + await waitFor(() => { + expect(createReport).toHaveBeenCalledWith('us', { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: Number(TEST_SIMULATION_IDS.SIM_NEW_2), + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_comparison', + time_period: '2026', + region: TEST_POPULATION.GEOGRAPHY_ID, + baseline_policy_id: CURRENT_LAW_ID, + reform_policy_id: Number(TEST_POLICY_IDS.REFORM_POLICY), + dataset: 'default', + target: 'general', + options: {}, + }, + }); + }); + }); }); }); diff --git a/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx b/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx index ec2e1f9d0..ca4f405e4 100644 --- a/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx +++ b/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx @@ -11,7 +11,10 @@ import { mockCreateReportFn, mockCreateSimulationFn, mockLocalStorageCreateFn, + mockHouseholdComparisonReportState, + mockHouseholdSingleReportState, mockOnSuccess, + TEST_POLICY_IDS, mockSingleSimReportState, mockTwoSimReportState, setupDefaultMocks, @@ -45,13 +48,6 @@ vi.mock('@/adapters', () => ({ population_type: data.populationType, }), }, - ReportAdapter: { - toCreationPayload: (data: any) => ({ - country_id: data.countryId, - year: data.year, - simulation_ids: data.simulationIds, - }), - }, })); vi.mock('@/constants', () => ({ @@ -219,6 +215,166 @@ describe('useReportSubmission', () => { ); }); }); + + test('given two simulations when submitted then creates report with explicit economy spec', async () => { + const { result } = renderHook( + () => + useReportSubmission({ + reportState: mockTwoSimReportState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: Number(TEST_SIMULATION_IDS.SIM_NEW_2), + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_comparison', + time_period: '2026', + region: TEST_POPULATION.GEOGRAPHY_ID, + baseline_policy_id: CURRENT_LAW_ID, + reform_policy_id: Number(TEST_POLICY_IDS.REFORM_POLICY), + dataset: 'default', + target: 'general', + options: {}, + }, + }, + }), + expect.any(Object) + ); + }); + }); + + test('given one geography simulation when submitted then creates report with explicit economy single spec', async () => { + const { result } = renderHook( + () => + useReportSubmission({ + reportState: mockSingleSimReportState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: null, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'economy_single', + time_period: '2026', + region: TEST_POPULATION.GEOGRAPHY_ID, + baseline_policy_id: CURRENT_LAW_ID, + reform_policy_id: CURRENT_LAW_ID, + dataset: 'default', + target: 'general', + options: {}, + }, + }, + }), + expect.any(Object) + ); + }); + }); + + test('given one household simulation when submitted then creates report with explicit household single spec', async () => { + const { result } = renderHook( + () => + useReportSubmission({ + reportState: mockHouseholdSingleReportState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: null, + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'household_single', + time_period: '2026', + simulation_1: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: CURRENT_LAW_ID, + }, + simulation_2: null, + }, + }, + }), + expect.any(Object) + ); + }); + }); + + test('given two household simulations when submitted then creates report with explicit household comparison spec', async () => { + const { result } = renderHook( + () => + useReportSubmission({ + reportState: mockHouseholdComparisonReportState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + simulation_1_id: Number(TEST_SIMULATION_IDS.SIM_NEW_1), + simulation_2_id: Number(TEST_SIMULATION_IDS.SIM_NEW_2), + year: '2026', + report_spec_schema_version: 1, + report_spec: { + country_id: 'us', + report_kind: 'household_comparison', + time_period: '2026', + simulation_1: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: CURRENT_LAW_ID, + }, + simulation_2: { + population_type: 'household', + population_id: TEST_POPULATION.HOUSEHOLD_ID, + policy_id: Number(TEST_POLICY_IDS.REFORM_POLICY), + }, + }, + }, + }), + expect.any(Object) + ); + }); + }); }); describe('isReportConfigured', () => { diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index 1ad15c6dc..0cbda2ebb 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -2,6 +2,7 @@ import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import type { AppHouseholdInputData as HouseholdData } from '@/models/household/appTypes'; import { CalcError } from './CalcError'; import { CalcMetadata } from './CalcMetadata'; +import type { RunMetadata } from '@/types/runMetadata'; /** * Union type for all possible calculation results @@ -94,4 +95,10 @@ export interface CalcStatus { * Metadata about this calculation */ metadata: CalcMetadata; + + /** + * Optional run metadata captured from the calculation runtime. + * This is persisted into API v1 run rows during the stage 6 bridge. + */ + runMetadata?: RunMetadata; } diff --git a/app/src/types/payloads/ReportCreationPayload.ts b/app/src/types/payloads/ReportCreationPayload.ts index 6e757af45..3eff2257c 100644 --- a/app/src/types/payloads/ReportCreationPayload.ts +++ b/app/src/types/payloads/ReportCreationPayload.ts @@ -1,3 +1,5 @@ +import { ExplicitReportSpec, REPORT_SPEC_SCHEMA_VERSION } from '@/types/reportSpec'; + /** * Payload format for creating a report via the API * Backend expects simulation IDs and year, generates ID and timestamps @@ -6,4 +8,6 @@ export interface ReportCreationPayload { simulation_1_id: number; simulation_2_id: number | null; year: string; // Report calculation year (e.g., '2025') + report_spec?: ExplicitReportSpec; + report_spec_schema_version?: typeof REPORT_SPEC_SCHEMA_VERSION; } diff --git a/app/src/types/payloads/ReportSetOutputPayload.ts b/app/src/types/payloads/ReportSetOutputPayload.ts index a13a5d13d..41d70af38 100644 --- a/app/src/types/payloads/ReportSetOutputPayload.ts +++ b/app/src/types/payloads/ReportSetOutputPayload.ts @@ -1,8 +1,10 @@ +import type { RunMetadata } from '@/types/runMetadata'; + /** * Payload format for updating a report's output via the API * Note: Report PATCH takes id in body, not URL path */ -export interface ReportSetOutputPayload { +export interface ReportSetOutputPayload extends RunMetadata { id: number; status: 'pending' | 'complete' | 'error'; output?: string | null; // JSON-stringified output or null diff --git a/app/src/types/payloads/SimulationSetOutputPayload.ts b/app/src/types/payloads/SimulationSetOutputPayload.ts index cae9e0eee..7f73a1d09 100644 --- a/app/src/types/payloads/SimulationSetOutputPayload.ts +++ b/app/src/types/payloads/SimulationSetOutputPayload.ts @@ -1,9 +1,11 @@ +import type { RunMetadata } from '@/types/runMetadata'; + /** * Payload format for updating a simulation's output via the API * Note: Simulation PATCH takes id in body, not URL * Note: Now accepts status field (matching report PATCH format) */ -export interface SimulationSetOutputPayload { +export interface SimulationSetOutputPayload extends RunMetadata { id: number; status: 'pending' | 'complete' | 'error'; output?: string | null; // JSON-stringified output or null diff --git a/app/src/types/reportSpec.ts b/app/src/types/reportSpec.ts new file mode 100644 index 000000000..2db14111c --- /dev/null +++ b/app/src/types/reportSpec.ts @@ -0,0 +1,31 @@ +import { countryIds } from '@/libs/countries'; + +export const REPORT_SPEC_SCHEMA_VERSION = 1 as const; + +export interface ReportSimulationInput { + population_type: 'household' | 'geography'; + population_id: string; + policy_id: number; +} + +export interface HouseholdReportSpec { + country_id: (typeof countryIds)[number]; + report_kind: 'household_single' | 'household_comparison'; + time_period: string; + simulation_1: ReportSimulationInput; + simulation_2: ReportSimulationInput | null; +} + +export interface EconomyReportSpec { + country_id: (typeof countryIds)[number]; + report_kind: 'economy_single' | 'economy_comparison'; + time_period: string; + region: string; + baseline_policy_id: number; + reform_policy_id: number; + dataset: string; + target: 'general' | 'cliff'; + options: Record; +} + +export type ExplicitReportSpec = HouseholdReportSpec | EconomyReportSpec; diff --git a/app/src/types/runMetadata.ts b/app/src/types/runMetadata.ts new file mode 100644 index 000000000..f08868340 --- /dev/null +++ b/app/src/types/runMetadata.ts @@ -0,0 +1,7 @@ +export interface RunMetadata { + country_package_version?: string | null; + policyengine_version?: string | null; + data_version?: string | null; + runtime_app_name?: string | null; + resolved_dataset?: string | null; +}