From 21d401d441a8542c7f02ce472fc521ccdf3f93f8 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:59:25 -0400 Subject: [PATCH 1/5] Report: one schema for stored reports reportSchema.ts holds the stored shape of a v3 report's provenance and record, a schemaVersion (2; unversioned is 1), and parsers that upgrade any older shape to the current one with safe defaults, or answer a typed ReportSchemaError. It also reads meta.baseline from the custom-baseline work, so either can merge first. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/reportSchema.ts | 478 ++++++++++++++++++ .../libs/flagship/reportSchemaMocks.ts | 304 +++++++++++ .../unit/libs/flagship/reportSchema.test.ts | 240 +++++++++ 3 files changed, 1022 insertions(+) create mode 100644 app/src/libs/flagship/reportSchema.ts create mode 100644 app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts create mode 100644 app/src/tests/unit/libs/flagship/reportSchema.test.ts diff --git a/app/src/libs/flagship/reportSchema.ts b/app/src/libs/flagship/reportSchema.ts new file mode 100644 index 000000000..f02906a02 --- /dev/null +++ b/app/src/libs/flagship/reportSchema.ts @@ -0,0 +1,478 @@ +/** + * The stored shape of a v3 report, in one place. + * + * A report run stores two things: + * - its provenance (`FlagshipReportMeta`): the header's title, source + * note and provisions, kept in this browser's localStorage under the + * API report id; and + * - its record (`FlagshipReportRecord`): the pointer to the API report + * plus the same provenance, kept by the report store — the + * calculator-app routes (Postgres), or this browser's localStorage + * when they are unconfigured. + * + * Fields were added over time without a version, so every read goes + * through a parser here. It upgrades an older shape to the current one, + * fills safe defaults for fields older writes lacked, and answers a + * typed error for anything else, instead of a bad field throwing deep + * in the UI. Every write stamps `schemaVersion`. + * + * Versions: + * 1 — unversioned: everything written before this module. + * Meta: title, sourceNote, provisions, createdAt; later provisions + * gained `intervals`, and the meta `budgetWindow` and `baseline`. + * Record: id, userId, countryId, apiReportId, title, sourceNote, + * provisions, year, createdAt; later `reformId` and `validation`, + * and then `userId` was dropped. + * 2 — the same fields, stamped, with defaults filled and no `userId`. + * + * To add a field: add it to the type and give it a rule below (the rule + * maps do not compile until every field has one). A missing field takes + * the rule's default; when old records need more than a default, bump + * REPORT_SCHEMA_VERSION and add the upgrade step. A field this version + * does not know is dropped on read, so a field is only safe to write + * once its rule is here. + * + * Central rows have no version column: their columns are versioned by + * the database migrations, and the routes always serve the current + * columns. The rows' JSON fields (provisions, validation) follow the + * same rules as the browser's records, on the way in and on the way out. + */ +import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { CountryId, countryIds } from '@/libs/countries'; +import type { CalibrationRing } from '@/libs/flagship/calibrationMatching'; +import { + validationFromWire, + type ReportValidationMatch, + type ReportValidationSnapshot, +} from '@/libs/flagship/reportValidation'; +import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; +import type { ValueInterval } from '@/types/subIngredients/valueInterval'; + +export type { FlagshipReportMeta, FlagshipReportRecord, RunReportProvision }; + +/** The version every write stamps. */ +export const REPORT_SCHEMA_VERSION = 2; + +/** The version of anything stored without a `schemaVersion`. */ +export const UNVERSIONED_REPORT_SCHEMA_VERSION = 1; + +/** + * What a report compared the reform with, when not current law: a saved + * reform run as the baseline policy. Named here as well as on the meta + * type, so the schema keeps it whether or not that type names it yet. + */ +export interface ReportMetaBaseline { + /** The saved reform, when the run came from one. */ + reformId: string | null; + /** The baseline policy on the API. */ + policyId: string; + /** Its name, for the report's labels. */ + label: string; +} + +/** The provenance with every field the schema reads. */ +export type ReportMetaFields = FlagshipReportMeta & { baseline?: ReportMetaBaseline }; + +export type StoredReportMeta = ReportMetaFields & { schemaVersion: number }; +export type StoredReportRecord = FlagshipReportRecord & { schemaVersion: number }; + +export type ReportSchemaErrorCode = + /** Not a JSON object at all. */ + | 'not-an-object' + /** A field is present with the wrong type, or a required one is missing. */ + | 'invalid-field' + /** Stamped by a newer version of the app than this one. */ + | 'newer-version'; + +/** Why a stored report can't be read. */ +export class ReportSchemaError extends Error { + readonly code: ReportSchemaErrorCode; + /** The offending field's path, e.g. "provisions[0].path". */ + readonly field?: string; + + constructor(code: ReportSchemaErrorCode, message: string, field?: string) { + super(message); + this.name = 'ReportSchemaError'; + this.code = code; + this.field = field; + } +} + +export type ReportParseResult = { ok: true; value: T } | { ok: false; error: ReportSchemaError }; + +/** The parsed value; throws its ReportSchemaError when it could not be read. */ +export function unwrapReport(result: ReportParseResult): T { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** The values that parsed, in order; the rest are left out. */ +export function readableReports(results: Array>): T[] { + return results.flatMap((result) => (result.ok ? [result.value] : [])); +} + +/* Field rules. A rule returns the field's value in the current shape, or + throws FieldError; `undefined` leaves an optional field out. */ + +class FieldError extends Error { + constructor( + readonly field: string, + message: string + ) { + super(message); + } +} + +type Rule = (value: unknown, field: string) => T; + +/** One rule per field, optional fields included. */ +type FieldRules = { [K in keyof T]-?: Rule }; + +function fail(field: string, expected: string): never { + throw new FieldError(field, `${field} must be ${expected}`); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const isAbsent = (value: unknown): value is null | undefined => + value === undefined || value === null; + +const requiredText: Rule = (value, field) => + typeof value === 'string' && value.length > 0 ? value : fail(field, 'a non-empty string'); + +/** A string; absent, the fallback. */ +function text(fallback: string): Rule { + return (value, field) => + isAbsent(value) ? fallback : typeof value === 'string' ? value : fail(field, 'a string'); +} + +const nullableText: Rule = (value, field) => + isAbsent(value) ? null : typeof value === 'string' ? value : fail(field, 'a string or null'); + +const integer: Rule = (value, field) => + Number.isInteger(value) ? (value as number) : fail(field, 'an integer'); + +const finiteNumber: Rule = (value, field) => + typeof value === 'number' && Number.isFinite(value) ? value : fail(field, 'a number'); + +const country: Rule = (value, field) => + countryIds.includes(value as CountryId) + ? (value as CountryId) + : fail(field, `one of ${countryIds.join(', ')}`); + +const ring: Rule = (value, field) => + value === 'primary' || value === 'mechanism' || value === 'downstream' + ? value + : fail(field, 'primary, mechanism or downstream'); + +/** Parameter values are any JSON; kept exactly as stored. */ +const anyValue: Rule = (value) => value; + +function optional(rule: Rule): Rule { + return (value, field) => (isAbsent(value) ? undefined : rule(value, field)); +} + +function nullable(rule: Rule): Rule { + return (value, field) => (isAbsent(value) ? null : rule(value, field)); +} + +function arrayOf(rule: Rule): Rule { + return (value, field) => + Array.isArray(value) + ? value.map((item, index) => rule(item, `${field}[${index}]`)) + : fail(field, 'an array'); +} + +/** An object with exactly these fields; anything else on it is dropped. */ +function shape(rules: FieldRules): Rule { + return (value, field) => { + if (!isJsonObject(value)) { + return fail(field || 'the record', 'an object'); + } + const parsed: Record = {}; + for (const [key, rule] of Object.entries(rules) as Array<[string, Rule]>) { + const fieldValue = rule(value[key], field ? `${field}.${key}` : key); + if (fieldValue !== undefined) { + parsed[key] = fieldValue; + } + } + return parsed as T; + }; +} + +/** A rule whose failure means "leave it out" rather than "unreadable". */ +function orNull(rule: Rule): Rule { + return (value, field) => { + try { + return rule(value, field); + } catch (error) { + if (error instanceof FieldError) { + return null; + } + throw error; + } + }; +} + +const intervalRule = shape({ + parameter: optional(text('')), + startDate: requiredText, + endDate: requiredText, + value: anyValue, +}); + +const provisionFields = shape({ + path: requiredText, + breadcrumb: text(''), + unit: nullableText, + baselineValue: anyValue, + value: anyValue, + intervals: optional(arrayOf(intervalRule)), +}); + +/** A provision; one stored without a breadcrumb is labelled by its path. */ +const provisionRule: Rule = (value, field) => { + const provision = provisionFields(value, field); + return provision.breadcrumb ? provision : { ...provision, breadcrumb: provision.path }; +}; + +const provisionsRule = arrayOf(provisionRule); + +const budgetWindowRule = shape>({ + startYear: integer, + windowSize: (value, field) => + Number.isInteger(value) && (value as number) >= 1 + ? (value as number) + : fail(field, 'a whole number of years'), +}); + +const baselineRule = shape({ + reformId: nullableText, + policyId: requiredText, + label: text(''), +}); + +const validationMatchRule = shape({ + variable: text(''), + depth: finiteNumber, + ring, + targetCount: finiteNumber, + meanAbsRelativeError: finiteNumber, + worst: shape({ + name: text(''), + source: text(''), + geography: text(''), + relativeError: nullable(finiteNumber), + }), +}); + +const validationRule = shape({ + matchedAt: requiredText, + mapModelVersion: text(''), + calibration: nullable( + shape>({ + releaseId: nullableText, + geography: text(''), + reachedCount: finiteNumber, + matches: arrayOf(validationMatchRule), + }) + ), + scorecard: nullable( + shape>({ + programs: arrayOf(text('')), + }) + ), +}); + +const metaRule = shape({ + title: text(''), + sourceNote: text(''), + provisions: provisionsRule, + createdAt: text(''), + budgetWindow: optional(budgetWindowRule), + // Absent, the report compares with current law. + baseline: optional(baselineRule), +}); + +const recordRule = shape({ + id: requiredText, + countryId: country, + apiReportId: requiredText, + title: nullableText, + sourceNote: nullableText, + provisions: provisionsRule, + year: requiredText, + createdAt: text(''), + reformId: nullableText, + // The pin is a cache of what validation matched: a damaged one is + // dropped, and the report pins again, rather than the record failing. + validation: orNull(validationRule), +}); + +/* Upgrades, keyed by the version they upgrade from. */ + +type Upgrade = (stored: Record) => Record; + +const META_UPGRADES: Record = { + // Unversioned meta has today's fields; the rules fill what it lacks. + 1: (stored) => stored, +}; + +const RECORD_UPGRADES: Record = { + // Records saved before ownership moved to edit tokens carry the shared user id. + 1: ({ userId: _userId, ...stored }) => stored, +}; + +function parseVersioned( + stored: unknown, + upgrades: Record, + rule: Rule +): ReportParseResult { + if (!isJsonObject(stored)) { + return { + ok: false, + error: new ReportSchemaError('not-an-object', 'The stored report is not an object'), + }; + } + const version = stored.schemaVersion ?? UNVERSIONED_REPORT_SCHEMA_VERSION; + if (!Number.isInteger(version) || (version as number) < UNVERSIONED_REPORT_SCHEMA_VERSION) { + return { + ok: false, + error: new ReportSchemaError( + 'invalid-field', + 'schemaVersion must be a positive integer', + 'schemaVersion' + ), + }; + } + if ((version as number) > REPORT_SCHEMA_VERSION) { + return { + ok: false, + error: new ReportSchemaError( + 'newer-version', + `Saved with report schema ${version}; this version reads up to ${REPORT_SCHEMA_VERSION}`, + 'schemaVersion' + ), + }; + } + let current = stored; + for (let from = version as number; from < REPORT_SCHEMA_VERSION; from++) { + current = upgrades[from](current); + } + try { + return { ok: true, value: rule(current, '') }; + } catch (error) { + if (error instanceof FieldError) { + return { + ok: false, + error: new ReportSchemaError('invalid-field', error.message, error.field), + }; + } + throw error; + } +} + +/** Reads a report's stored provenance, of any version, in the current shape. */ +export function parseReportMeta(stored: unknown): ReportParseResult { + return parseVersioned(stored, META_UPGRADES, metaRule); +} + +/** Reads a stored report record, of any version, in the current shape. */ +export function parseReportRecord(stored: unknown): ReportParseResult { + return parseVersioned(stored, RECORD_UPGRADES, recordRule); +} + +/** Provisions as the central store speaks them (snake_case), in the record's names. */ +function provisionsFromWire(provisions: unknown): unknown { + if (!Array.isArray(provisions)) { + return provisions; + } + return provisions.map((provision) => + isJsonObject(provision) + ? { + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit, + baselineValue: provision.baseline_value, + value: provision.value, + } + : provision + ); +} + +/** A validation pin from the wire; one too damaged to convert reads as none. */ +function validationFromWireOrNull(wire: unknown): ReportValidationSnapshot | null { + try { + return validationFromWire(wire); + } catch { + return null; + } +} + +/** Reads a central store row (the routes' snake_case wire form) as a record. */ +export function parseReportRow(row: unknown): ReportParseResult { + if (!isJsonObject(row)) { + return parseReportRecord(row); + } + return parseReportRecord({ + // The routes serve the current columns (see above). + schemaVersion: REPORT_SCHEMA_VERSION, + id: row.id, + countryId: row.country_id, + apiReportId: row.api_report_id, + title: row.title, + sourceNote: row.source_note, + provisions: provisionsFromWire(row.provisions), + year: row.year, + createdAt: row.created_at, + reformId: row.reform_id, + validation: validationFromWireOrNull(row.validation), + }); +} + +/** The rule's error message for this value, or null when it fits. */ +function wireError(rule: Rule, value: unknown, field: string): string | null { + try { + rule(value, field); + return null; + } catch (error) { + if (error instanceof FieldError) { + return error.message; + } + throw error; + } +} + +/** + * Why a create body's provisions (snake_case) break the report schema, + * or null when they fit: the same rules the client reads them back with. + */ +export function reportProvisionsWireError(provisions: unknown): string | null { + return wireError(provisionsRule, provisionsFromWire(provisions), 'provisions'); +} + +/** + * Why a validation pin (snake_case) breaks the report schema, or null + * when it fits: the same rules the client reads it back with. + */ +export function reportValidationWireError(validation: unknown): string | null { + const snapshot = validationFromWireOrNull(validation); + if (!snapshot) { + return 'validation must be a snapshot with matched_at'; + } + return wireError(validationRule, snapshot, 'validation'); +} + +/** Provenance as it is written: stamped with the current version. */ +export function stampReportMeta(meta: FlagshipReportMeta): StoredReportMeta { + return { ...meta, schemaVersion: REPORT_SCHEMA_VERSION }; +} + +/** A record as it is written: stamped with the current version. */ +export function stampReportRecord(record: FlagshipReportRecord): StoredReportRecord { + return { ...record, schemaVersion: REPORT_SCHEMA_VERSION }; +} diff --git a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts new file mode 100644 index 000000000..fc57b14cc --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts @@ -0,0 +1,304 @@ +/** + * Report records and provenance in every shape the app has stored, from + * git history (`git log -p` on runReport.ts, flagshipReportStore.ts and + * calculator-app/src/db/serialize.ts), with what each reads as today. + */ +import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import type { ReportMetaFields } from '@/libs/flagship/reportSchema'; +import { validationToWire } from '@/libs/flagship/reportValidation'; +import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; +import { + API_REPORT_ID, + CENTRAL_REFORM_ID, + CENTRAL_REPORT_ID, +} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +export const REPORT_META_KEY = 'pe-flagship-report-meta'; +export const REPORTS_KEY = 'pe-flagship-reports'; + +/** The version this app stamps; a later one is "newer". */ +export const CURRENT_SCHEMA_VERSION = 2; +export const NEWER_SCHEMA_VERSION = 3; + +export const LOCAL_REPORT_ID = 'lfr-mfz1abcdef'; +export const OTHER_LOCAL_REPORT_ID = 'lfr-mfz2ghijkl'; +export const CREATED_AT = '2026-09-03T00:00:00.000Z'; + +export const CTC_PROVISION: RunReportProvision = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, +}; + +export const DATED_CTC_PROVISION: RunReportProvision = { + ...CTC_PROVISION, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 2500 }, + { startDate: '2027-01-01', endDate: '2100-12-31', value: 3000 }, + ], +}; + +/* Provenance meta, stored under REPORT_META_KEY by report id. */ + +/** 3673cf94 "Build the run bridge": the first shape. */ +export const metaFromRunBridge = { + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + createdAt: CREATED_AT, +}; + +/** ebb805b5 "Build: make the reform the page": provisions with dated changes. */ +export const metaWithIntervals = { ...metaFromRunBridge, provisions: [DATED_CTC_PROVISION] }; + +/** aaf850a6 "Build: choose the budget window before the run". */ +export const metaWithBudgetWindow = { + ...metaWithIntervals, + budgetWindow: { startYear: 2026, windowSize: 10 }, +}; + +/** What a report compared the reform with, when not current law (PR #1265). */ +export const CUSTOM_BASELINE = { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }; + +/** e0cee74a "Build: choose what the reform is compared with" (feat/v3-custom-baseline). */ +export const metaWithBaseline = { ...metaWithBudgetWindow, baseline: CUSTOM_BASELINE }; + +/** A sparse unversioned stash: what the defaults are for. */ +export const sparseMeta = { + provisions: [{ path: CTC_PROVISION.path, value: 2500 }], +}; + +export const sparseMetaExpected: FlagshipReportMeta = { + title: '', + sourceNote: '', + provisions: [ + // No baseline was stored, so none is made up. + { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.path, + unit: null, + baselineValue: undefined, + value: 2500, + }, + ], + createdAt: '', +}; + +/** The current shape, as runFlagshipReport writes it. */ +export const currentMeta: FlagshipReportMeta = { + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [DATED_CTC_PROVISION], + createdAt: CREATED_AT, + budgetWindow: { startYear: 2026, windowSize: 10 }, +}; + +export const historicMeta: Array<[string, unknown, ReportMetaFields]> = [ + ['the run bridge', metaFromRunBridge, metaFromRunBridge], + ['provisions with dated changes', metaWithIntervals, metaWithIntervals], + ['a budget window', metaWithBudgetWindow, metaWithBudgetWindow], + ['a custom baseline', metaWithBaseline, metaWithBaseline], + [ + 'a custom baseline without a saved reform', + { ...metaWithBaseline, baseline: { policyId: '88', label: 'Plan B' } }, + { ...metaWithBaseline, baseline: { reformId: null, policyId: '88', label: 'Plan B' } }, + ], +]; + +/* Report records, stored under REPORTS_KEY by the localStorage fallback. */ + +/** 6c23805e "Beat 1 of storage unification": the first record, with the shared user id. */ +export const recordFromStorageBeat = { + id: LOCAL_REPORT_ID, + userId: 'anonymous', + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + year: '2026', + createdAt: CREATED_AT, +}; + +/** b2a09635 "Adjust panel: … reform reconciliation": the saved reform it ran from. */ +export const recordWithReformLink = { ...recordFromStorageBeat, reformId: CENTRAL_REFORM_ID }; + +/** f8294f90 "Pin what each report's validation was matched against". */ +export const recordWithValidation = { + ...recordWithReformLink, + validation: mockReportValidationSnapshot, +}; + +/** ec399555 "Flagship: store reforms and reports without a user id". */ +const { userId: _userId, ...recordWithoutUserIdFields } = recordWithValidation; +export const recordWithoutUserId = recordWithoutUserIdFields; + +const recordBase: FlagshipReportRecord = { + id: LOCAL_REPORT_ID, + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + year: '2026', + createdAt: CREATED_AT, + reformId: null, + validation: null, +}; + +export const currentRecord: FlagshipReportRecord = { + ...recordBase, + reformId: CENTRAL_REFORM_ID, + validation: mockReportValidationSnapshot, +}; + +export const historicRecords: Array<[string, unknown, FlagshipReportRecord]> = [ + ['the first record, with the shared user id', recordFromStorageBeat, recordBase], + ['a reform link', recordWithReformLink, { ...recordBase, reformId: CENTRAL_REFORM_ID }], + ['a validation pin', recordWithValidation, currentRecord], + ['no user id', recordWithoutUserId, currentRecord], +]; + +/* Central rows, as the calculator-app routes serve them (snake_case). */ + +const CTC_PROVISION_WIRE = { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.breadcrumb, + unit: CTC_PROVISION.unit, + baseline_value: CTC_PROVISION.baselineValue, + value: CTC_PROVISION.value, +}; + +/** 6c23805e: user_id, and no validation column yet. */ +export const rowFromStorageBeat = { + id: CENTRAL_REPORT_ID, + user_id: 'anonymous', + country_id: 'us', + api_report_id: API_REPORT_ID, + title: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: [CTC_PROVISION_WIRE], + reform_id: null, + year: '2026', + created_at: CREATED_AT, +}; + +/** f8294f90: the validation column. */ +export const rowWithValidation = { + ...rowFromStorageBeat, + reform_id: CENTRAL_REFORM_ID, + validation: validationToWire(mockReportValidationSnapshot), +}; + +/** ec399555: no user_id; today's row. */ +const { user_id: _userIdColumn, ...rowWithoutUserIdFields } = rowWithValidation; +export const rowWithoutUserId = rowWithoutUserIdFields; + +const rowRecordBase: FlagshipReportRecord = { ...recordBase, id: CENTRAL_REPORT_ID }; + +export const historicRows: Array<[string, unknown, FlagshipReportRecord]> = [ + ['the first row, without a validation column', rowFromStorageBeat, rowRecordBase], + [ + 'a validation column', + rowWithValidation, + { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, + ], + [ + 'no user_id', + rowWithoutUserId, + { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, + ], +]; + +/* Unreadable: [name, stored, error code, offending field]. */ + +export const malformedMeta: Array<[string, unknown, string, string | undefined]> = [ + ['a string', 'CTC to $2,500', 'not-an-object', undefined], + ['an array', [metaFromRunBridge], 'not-an-object', undefined], + ['null', null, 'not-an-object', undefined], + ['no provisions', { title: 'CTC' }, 'invalid-field', 'provisions'], + ['provisions as an object', { provisions: { 0: CTC_PROVISION } }, 'invalid-field', 'provisions'], + [ + 'a provision without a path', + { provisions: [{ ...CTC_PROVISION, path: '' }] }, + 'invalid-field', + 'provisions[0].path', + ], + ['a provision that is not an object', { provisions: [42] }, 'invalid-field', 'provisions[0]'], + [ + 'a numeric unit', + { provisions: [{ ...CTC_PROVISION, unit: 7 }] }, + 'invalid-field', + 'provisions[0].unit', + ], + [ + 'a dated change without a start', + { + provisions: [{ ...CTC_PROVISION, intervals: [{ endDate: '2026-12-31', value: 2500 }] }], + }, + 'invalid-field', + 'provisions[0].intervals[0].startDate', + ], + ['a numeric title', { ...metaFromRunBridge, title: 42 }, 'invalid-field', 'title'], + [ + 'a budget window of no years', + { ...metaFromRunBridge, budgetWindow: { startYear: 2026, windowSize: 0 } }, + 'invalid-field', + 'budgetWindow.windowSize', + ], + [ + 'a baseline without its policy', + { ...metaFromRunBridge, baseline: { reformId: null, label: 'Plan B' } }, + 'invalid-field', + 'baseline.policyId', + ], + [ + 'a baseline that is a name', + { ...metaFromRunBridge, baseline: 'Plan B' }, + 'invalid-field', + 'baseline', + ], + [ + 'a text schema version', + { ...metaFromRunBridge, schemaVersion: '2' }, + 'invalid-field', + 'schemaVersion', + ], + [ + 'a newer schema version', + { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }, + 'newer-version', + 'schemaVersion', + ], +]; + +export const malformedRecords: Array<[string, unknown, string, string | undefined]> = [ + ['no id', { ...recordWithoutUserId, id: undefined }, 'invalid-field', 'id'], + ['an unknown country', { ...recordWithoutUserId, countryId: 'fr' }, 'invalid-field', 'countryId'], + ['no API report id', { ...recordWithoutUserId, apiReportId: '' }, 'invalid-field', 'apiReportId'], + ['a numeric year', { ...recordWithoutUserId, year: 2026 }, 'invalid-field', 'year'], + ['a numeric reform id', { ...recordWithoutUserId, reformId: 7 }, 'invalid-field', 'reformId'], + [ + 'a newer schema version', + { ...recordWithoutUserId, schemaVersion: NEWER_SCHEMA_VERSION }, + 'newer-version', + 'schemaVersion', + ], +]; + +/** A record whose validation pin is damaged: the record still reads, without the pin. */ +export const recordWithDamagedPin = { + ...recordWithoutUserId, + validation: { matchedAt: 7, calibration: 'release' }, +}; + +/** A record a newer version of the app saved, with a field this one doesn't know. */ +export const recordFromNewerVersion = { + ...recordWithoutUserId, + id: OTHER_LOCAL_REPORT_ID, + schemaVersion: NEWER_SCHEMA_VERSION, + futureField: { kept: true }, +}; diff --git a/app/src/tests/unit/libs/flagship/reportSchema.test.ts b/app/src/tests/unit/libs/flagship/reportSchema.test.ts new file mode 100644 index 000000000..f112ec859 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportSchema.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from 'vitest'; +import { + parseReportMeta, + parseReportRecord, + parseReportRow, + readableReports, + REPORT_SCHEMA_VERSION, + reportProvisionsWireError, + ReportSchemaError, + reportValidationWireError, + stampReportMeta, + stampReportRecord, + unwrapReport, +} from '@/libs/flagship/reportSchema'; +import { validationToWire } from '@/libs/flagship/reportValidation'; +import { CENTRAL_REPORT_ID } from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { + CTC_PROVISION, + CURRENT_SCHEMA_VERSION, + currentMeta, + currentRecord, + historicMeta, + historicRecords, + historicRows, + malformedMeta, + malformedRecords, + recordFromNewerVersion, + recordWithDamagedPin, + recordWithoutUserId, + rowWithoutUserId, + sparseMeta, + sparseMetaExpected, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; +import { + MATCHED_AT, + mockReportValidationSnapshot, +} from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +/** What a stored value reads back as after a trip through localStorage. */ +function throughStorage(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)); +} + +describe('REPORT_SCHEMA_VERSION', () => { + test('given the current schema then writes stamp version 2', () => { + expect(REPORT_SCHEMA_VERSION).toBe(CURRENT_SCHEMA_VERSION); + }); +}); + +describe('parseReportMeta', () => { + test.each(historicMeta)( + 'given unversioned meta from %s then it reads as today', + (_n, stored, expected) => { + const result = parseReportMeta(throughStorage(stored)); + + expect(result).toEqual({ ok: true, value: expected }); + } + ); + + test('given sparse unversioned meta then the defaults fill what it lacks', () => { + expect(parseReportMeta(sparseMeta)).toEqual({ ok: true, value: sparseMetaExpected }); + }); + + test('given meta written today then it round trips unchanged', () => { + const stored = throughStorage(stampReportMeta(currentMeta)); + + expect(parseReportMeta(stored)).toEqual({ ok: true, value: currentMeta }); + }); + + test('given meta with fields no version wrote then they are dropped', () => { + const result = parseReportMeta({ ...currentMeta, stray: 'value' }); + + expect(result.ok && result.value).not.toHaveProperty('stray'); + }); + + test.each(malformedMeta)( + 'given meta with %s then it reads as a typed error', + (_name, stored, code, field) => { + const result = parseReportMeta(stored); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(ReportSchemaError); + expect(result.error.code).toBe(code); + expect(result.error.field).toBe(field); + } + } + ); + + test.each([undefined, 0, true, {}, { provisions: [null] }, { provisions: [{ path: 1 }] }])( + 'given %j then parsing answers instead of throwing', + (stored) => { + expect(() => parseReportMeta(stored)).not.toThrow(); + } + ); +}); + +describe('parseReportRecord', () => { + test.each(historicRecords)( + 'given a record from %s then it reads as today', + (_name, stored, expected) => { + expect(parseReportRecord(throughStorage(stored))).toEqual({ ok: true, value: expected }); + } + ); + + test('given a record with the shared user id then the id is gone', () => { + const result = parseReportRecord(historicRecords[0][1]); + + expect(result.ok && result.value).not.toHaveProperty('userId'); + }); + + test('given a record written today then it round trips unchanged', () => { + const stored = throughStorage(stampReportRecord(currentRecord)); + + expect(parseReportRecord(stored)).toEqual({ ok: true, value: currentRecord }); + }); + + test('given a damaged validation pin then the record reads without it', () => { + const result = parseReportRecord(recordWithDamagedPin); + + expect(result).toEqual({ ok: true, value: { ...currentRecord, validation: null } }); + }); + + test.each(malformedRecords)( + 'given a record with %s then it reads as a typed error', + (_name, stored, code, field) => { + const result = parseReportRecord(stored); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(code); + expect(result.error.field).toBe(field); + } + } + ); + + test('given a record a newer version saved then it is not read as this version', () => { + const result = parseReportRecord(recordFromNewerVersion); + + expect(result.ok ? null : result.error.code).toBe('newer-version'); + }); +}); + +describe('parseReportRow', () => { + test.each(historicRows)( + 'given a central row with %s then it reads as a record', + (_n, row, expected) => { + expect(parseReportRow(row)).toEqual({ ok: true, value: expected }); + } + ); + + test('given a row with provisions that are not a list then it reads as a typed error', () => { + const result = parseReportRow({ ...rowWithoutUserId, provisions: 'oops' }); + + expect(result.ok ? null : result.error.field).toBe('provisions'); + }); + + test('given a row whose pin is too damaged to convert then it reads without the pin', () => { + const row = { + ...rowWithoutUserId, + validation: { matched_at: MATCHED_AT, calibration: { matches: 'x' } }, + }; + + expect(parseReportRow(row)).toEqual({ + ok: true, + value: { ...currentRecord, id: CENTRAL_REPORT_ID, validation: null }, + }); + }); + + test('given a row that is not an object then it reads as a typed error', () => { + const result = parseReportRow('row'); + + expect(result.ok ? null : result.error.code).toBe('not-an-object'); + }); +}); + +describe('reportProvisionsWireError', () => { + test('given provisions as the client sends them then there is no error', () => { + const wire = [ + { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.breadcrumb, + unit: CTC_PROVISION.unit, + baseline_value: CTC_PROVISION.baselineValue, + value: CTC_PROVISION.value, + }, + ]; + + expect(reportProvisionsWireError(wire)).toBeNull(); + }); + + test('given a provision without a path then the error names it', () => { + expect(reportProvisionsWireError([{ breadcrumb: 'CTC', value: 1 }])).toBe( + 'provisions[0].path must be a non-empty string' + ); + }); + + test('given a provision that is not an object then the error names it', () => { + expect(reportProvisionsWireError(['CTC'])).toBe('provisions[0] must be an object'); + }); +}); + +describe('reportValidationWireError', () => { + test('given a pin as the client sends it then there is no error', () => { + expect(reportValidationWireError(validationToWire(mockReportValidationSnapshot))).toBeNull(); + }); + + test.each([ + ['no pin', undefined, 'validation must be a snapshot with matched_at'], + [ + 'scorecard programs that are not a list', + { ...validationToWire(mockReportValidationSnapshot), scorecard: { programs: 'ctc' } }, + 'validation.scorecard.programs must be an array', + ], + [ + 'a match depth that is not a number', + { + ...validationToWire(mockReportValidationSnapshot), + calibration: { geography: 'US', reached_count: 1, matches: [{ depth: 'deep' }] }, + }, + 'validation.calibration.matches[0].depth must be a number', + ], + ])('given %s then the error names it', (_name, wire, message) => { + expect(reportValidationWireError(wire)).toBe(message); + }); +}); + +describe('unwrapReport and readableReports', () => { + test('given an unreadable record then unwrapping throws its ReportSchemaError', () => { + expect(() => unwrapReport(parseReportRecord({ ...recordWithoutUserId, id: 7 }))).toThrow( + ReportSchemaError + ); + }); + + test('given readable and unreadable records then only the readable are kept, in order', () => { + const results = [recordWithoutUserId, 'junk', recordFromNewerVersion].map(parseReportRecord); + + expect(readableReports(results)).toEqual([currentRecord]); + }); +}); From bb25f3aed99fed7f9647d728a63b9f4809e82b80 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:59:33 -0400 Subject: [PATCH 2/5] Report: read and write stored reports through the schema Every read of a report record or its provenance goes through the parser, and every write stamps the current schemaVersion. A list leaves out a record it can't read; entries this version can't read stay as stored, so a newer version's records survive. Co-Authored-By: Claude Opus 5.5 --- app/src/api/flagshipReportStore.ts | 96 ++++++----- app/src/libs/flagship/runReport.ts | 26 ++- .../api/flagshipReportStoreSchema.test.ts | 154 ++++++++++++++++++ .../hooks/useReportValidationSnapshot.test.ts | 3 +- .../libs/flagship/reportMetaStorage.test.ts | 93 +++++++++++ 5 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 app/src/tests/unit/api/flagshipReportStoreSchema.test.ts create mode 100644 app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index f0dfb7f43..661c85882 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -8,10 +8,13 @@ import { readOwnedItems, } from '@/libs/flagship/ownedItems'; import { - ReportValidationSnapshot, - validationFromWire, - validationToWire, -} from '@/libs/flagship/reportValidation'; + parseReportRecord, + parseReportRow, + readableReports, + stampReportRecord, + unwrapReport, +} from '@/libs/flagship/reportSchema'; +import { ReportValidationSnapshot, validationToWire } from '@/libs/flagship/reportValidation'; import { RunReportProvision } from '@/libs/flagship/runReport'; import { clampStoreText } from '@/libs/flagship/storeText'; @@ -81,25 +84,9 @@ function newestFirst(a: FlagshipReportRecord, b: FlagshipReportRecord): number { return b.createdAt.localeCompare(a.createdAt); } -function fromMetadata(data: any): FlagshipReportRecord { - return { - id: data.id, - countryId: data.country_id, - apiReportId: data.api_report_id, - title: data.title, - sourceNote: data.source_note, - provisions: (data.provisions ?? []).map((p: any) => ({ - path: p.path, - breadcrumb: p.breadcrumb, - unit: p.unit ?? null, - baselineValue: p.baseline_value, - value: p.value, - })), - year: data.year, - createdAt: data.created_at, - reformId: data.reform_id ?? null, - validation: validationFromWire(data.validation), - }; +/** A central row as a record (see reportSchema); throws ReportSchemaError when unreadable. */ +function fromMetadata(data: unknown): FlagshipReportRecord { + return unwrapReport(parseReportRow(data)); } export class ApiFlagshipReportStore implements FlagshipReportStore { @@ -158,7 +145,9 @@ export class ApiFlagshipReportStore implements FlagshipReportStore { throwIfUnavailable(response); throw new Error('Failed to load reports'); } - records.push(...((await response.json()) as any[]).map(fromMetadata)); + // A row that can't be read is left out of the list, not fatal to it. + const rows: unknown = await response.json(); + records.push(...readableReports((Array.isArray(rows) ? rows : []).map(parseReportRow))); } return records.sort(newestFirst); } @@ -215,7 +204,11 @@ const LOCAL_KEY = 'pe-flagship-reports'; * central store dropped user ids, which still carry a userId field. */ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { - private read(): FlagshipReportRecord[] { + /** + * The entries as stored, of any schema version. Writes keep the ones + * this version can't read, so a record a newer version saved survives. + */ + private readStored(): unknown[] { try { const parsed = JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]'); return Array.isArray(parsed) ? parsed : []; @@ -224,21 +217,43 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } } - private write(records: FlagshipReportRecord[]): void { + private write(entries: unknown[]): void { try { - localStorage.setItem(LOCAL_KEY, JSON.stringify(records)); + localStorage.setItem(LOCAL_KEY, JSON.stringify(entries)); } catch { // Reports simply won't persist locally. } } + /** The readable records, in the current shape; an unreadable entry is left out. */ + private read(): FlagshipReportRecord[] { + return readableReports(this.readStored().map(parseReportRecord)); + } + + /** The first entry with this field value, parsed; throws ReportSchemaError when unreadable. */ + private findStored( + field: 'id' | 'apiReportId', + value: string + ): { entries: unknown[]; index: number; record: FlagshipReportRecord } | null { + const entries = this.readStored(); + const index = entries.findIndex( + (entry) => !!entry && typeof entry === 'object' && (entry as any)[field] === value + ); + if (index === -1) { + return null; + } + return { entries, index, record: unwrapReport(parseReportRecord(entries[index])) }; + } + async create(report: NewFlagshipReport): Promise { - const record: FlagshipReportRecord = { - ...report, - id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, - createdAt: new Date().toISOString(), - }; - this.write([record, ...this.read()]); + const record = unwrapReport( + parseReportRecord({ + ...report, + id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + }) + ); + this.write([stampReportRecord(record), ...this.readStored()]); return record; } @@ -249,25 +264,24 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } async findById(id: string): Promise { - return this.read().find((record) => record.id === id) ?? null; + return this.findStored('id', id)?.record ?? null; } async findByApiReportId(apiReportId: string): Promise { - return this.read().find((record) => record.apiReportId === apiReportId) ?? null; + return this.findStored('apiReportId', apiReportId)?.record ?? null; } async saveValidation( id: string, snapshot: ReportValidationSnapshot ): Promise { - const records = this.read(); - const index = records.findIndex((record) => record.id === id); - if (index === -1) { + const found = this.findStored('id', id); + if (!found) { throw new Error('Report not found'); } - const updated = { ...records[index], validation: snapshot }; - records[index] = updated; - this.write(records); + const updated = { ...found.record, validation: snapshot }; + found.entries[found.index] = stampReportRecord(updated); + this.write(found.entries); return updated; } } diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 9fa87a298..c20bca755 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -6,6 +6,7 @@ import { fetchSocietyWideCalculation } from '@/api/societyWideCalculation'; import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { CountryId } from '@/libs/countries'; import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import { parseReportMeta, stampReportMeta, type ReportParseResult } from './reportSchema'; /** * The run bridge: turns a set of provisions (from a draft reform or a @@ -38,7 +39,8 @@ export interface FlagshipReportMeta { const META_KEY = 'pe-flagship-report-meta'; -function readAllMeta(): Record { +/** Every report's provenance as stored, of any schema version (see reportSchema). */ +function readAllMeta(): Record { try { return JSON.parse(localStorage.getItem(META_KEY) ?? '{}'); } catch { @@ -46,7 +48,8 @@ function readAllMeta(): Record { } } -export function saveReportMeta(userReportId: string, meta: FlagshipReportMeta): void { +export function saveReportMeta(userReportId: string, unstamped: FlagshipReportMeta): void { + const meta = stampReportMeta(unstamped); try { localStorage.setItem(META_KEY, JSON.stringify({ ...readAllMeta(), [userReportId]: meta })); } catch { @@ -54,8 +57,25 @@ export function saveReportMeta(userReportId: string, meta: FlagshipReportMeta): } } +/** + * The report's stored provenance, parsed: null when none is stored, + * otherwise the current shape or why it can't be read. + */ +export function readReportMetaResult( + userReportId: string +): ReportParseResult | null { + const all: unknown = readAllMeta(); + // A stash that is not a map holds no report's provenance. + if (!all || typeof all !== 'object' || Array.isArray(all) || !Object.hasOwn(all, userReportId)) { + return null; + } + return parseReportMeta((all as Record)[userReportId]); +} + +/** The report's provenance in the current shape; null when none is stored or it can't be read. */ export function readReportMeta(userReportId: string): FlagshipReportMeta | null { - return readAllMeta()[userReportId] ?? null; + const result = readReportMetaResult(userReportId); + return result?.ok ? result.value : null; } export interface RunFlagshipReportArgs { diff --git a/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts new file mode 100644 index 000000000..24f30cf9c --- /dev/null +++ b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { mockJsonResponse, mockNewReport } from '@/tests/fixtures/api/flagshipStoreMocks'; +import { + CENTRAL_REPORT_ID, + OWNED_REPORTS_KEY, + ownedReport, + seedOwnedItems, +} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { + CURRENT_SCHEMA_VERSION, + currentRecord, + historicRecords, + LOCAL_REPORT_ID, + recordFromNewerVersion, + recordWithoutUserId, + REPORTS_KEY, + rowWithoutUserId, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; +import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +const UNREADABLE_ID = 'lfr-unreadable'; +const unreadableEntry = { ...recordWithoutUserId, id: UNREADABLE_ID, provisions: 'oops' }; + +function storedEntries(): any[] { + return JSON.parse(localStorage.getItem(REPORTS_KEY) ?? '[]'); +} + +function seedEntries(entries: unknown[]): void { + localStorage.setItem(REPORTS_KEY, JSON.stringify(entries)); +} + +describe('LocalStorageFlagshipReportStore and the report schema', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given a new report then the stored entry is stamped with the current version', async () => { + await new LocalStorageFlagshipReportStore().create(mockNewReport); + + expect(storedEntries()[0].schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + }); + + test('given records in every earlier shape then each is listed in the current shape', async () => { + seedEntries( + historicRecords.map(([_name, stored], index) => ({ + ...(stored as object), + id: `lfr-${index}`, + })) + ); + + const mine = await new LocalStorageFlagshipReportStore().findMine('us'); + + expect(mine).toEqual( + historicRecords.map(([_name, _stored, expected], index) => ({ + ...expected, + id: `lfr-${index}`, + })) + ); + }); + + test('given an unreadable entry then the list leaves it out', async () => { + seedEntries([unreadableEntry, recordWithoutUserId]); + + const mine = await new LocalStorageFlagshipReportStore().findMine(); + + expect(mine).toEqual([currentRecord]); + }); + + test('given an unreadable entry then finding it throws a ReportSchemaError', async () => { + seedEntries([unreadableEntry]); + + await expect(new LocalStorageFlagshipReportStore().findById(UNREADABLE_ID)).rejects.toThrow( + ReportSchemaError + ); + }); + + test('given an unversioned record then pinning a validation writes it in the current shape', async () => { + seedEntries([recordWithoutUserId]); + + await new LocalStorageFlagshipReportStore().saveValidation( + LOCAL_REPORT_ID, + mockReportValidationSnapshot + ); + + expect(storedEntries()[0]).toEqual({ + ...currentRecord, + validation: mockReportValidationSnapshot, + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + }); + + test('given entries this version can not read then writes keep them as stored', async () => { + seedEntries([recordFromNewerVersion, unreadableEntry]); + + await new LocalStorageFlagshipReportStore().create(mockNewReport); + + expect(storedEntries().slice(1)).toEqual([recordFromNewerVersion, unreadableEntry]); + }); + + test('given a record a newer version saved then pinning it fails and leaves it as stored', async () => { + seedEntries([recordFromNewerVersion]); + + await expect( + new LocalStorageFlagshipReportStore().saveValidation( + recordFromNewerVersion.id, + mockReportValidationSnapshot + ) + ).rejects.toThrow(ReportSchemaError); + expect(storedEntries()).toEqual([recordFromNewerVersion]); + }); +}); + +describe('ApiFlagshipReportStore and the report schema', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + localStorage.clear(); + seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given a central row then it reads in the current shape', async () => { + fetchMock.mockResolvedValue(mockJsonResponse(rowWithoutUserId)); + + const record = await new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID); + + expect(record).toEqual({ ...currentRecord, id: CENTRAL_REPORT_ID }); + }); + + test('given an unreadable row then the list leaves it out', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse([rowWithoutUserId, { ...rowWithoutUserId, country_id: 'fr' }]) + ); + + const mine = await new ApiFlagshipReportStore().findMine(); + + expect(mine.map((record) => record.id)).toEqual([CENTRAL_REPORT_ID]); + }); + + test('given an unreadable row then reading it throws a ReportSchemaError', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ ...rowWithoutUserId, provisions: 'oops' })); + + await expect(new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID)).rejects.toThrow( + ReportSchemaError + ); + }); +}); diff --git a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts index 2190c1b07..503c59ae2 100644 --- a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts +++ b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts @@ -74,7 +74,8 @@ describe('useReportValidationSnapshot', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.snapshot).toBeNull(); const stored = await new LocalStorageFlagshipReportStore().findById(record.id); - expect(stored?.validation).toBeUndefined(); + // No pin reads as null, the report schema's default. + expect(stored?.validation).toBeNull(); }); test('given no store record for the report then nothing is pinned', async () => { diff --git a/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts new file mode 100644 index 000000000..65035c443 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { readReportMeta, readReportMetaResult, saveReportMeta } from '@/libs/flagship/runReport'; +import { + CURRENT_SCHEMA_VERSION, + currentMeta, + CUSTOM_BASELINE, + metaFromRunBridge, + metaWithBudgetWindow, + NEWER_SCHEMA_VERSION, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +const REPORT_ID = '4512'; +const OTHER_REPORT_ID = '4513'; + +function seedMeta(byReportId: Record): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(byReportId)); +} + +function storedMeta(): Record { + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); +} + +describe('report provenance in localStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given saved provenance then it is stamped with the current version', () => { + saveReportMeta(REPORT_ID, currentMeta); + + expect(storedMeta()[REPORT_ID]).toEqual({ + ...currentMeta, + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + }); + + test('given saved provenance then it reads back unchanged', () => { + saveReportMeta(REPORT_ID, currentMeta); + + expect(readReportMeta(REPORT_ID)).toEqual(currentMeta); + }); + + test('given provenance an older version stashed then it reads in the current shape', () => { + seedMeta({ [REPORT_ID]: metaWithBudgetWindow }); + + expect(readReportMeta(REPORT_ID)).toEqual(metaWithBudgetWindow); + }); + + test('given no provenance for the report then there is no result', () => { + seedMeta({ [OTHER_REPORT_ID]: metaFromRunBridge }); + + expect(readReportMetaResult(REPORT_ID)).toBeNull(); + expect(readReportMetaResult('constructor')).toBeNull(); + }); + + test('given unreadable provenance then the result says why and the plain read is null', () => { + seedMeta({ [REPORT_ID]: { ...metaFromRunBridge, provisions: 'oops' } }); + + const result = readReportMetaResult(REPORT_ID); + + expect(result?.ok ? null : result?.error.field).toBe('provisions'); + expect(readReportMeta(REPORT_ID)).toBeNull(); + }); + + test('given provenance a newer version saved then saving another keeps it as stored', () => { + const newer = { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }; + seedMeta({ [OTHER_REPORT_ID]: newer }); + + saveReportMeta(REPORT_ID, currentMeta); + + expect(storedMeta()[OTHER_REPORT_ID]).toEqual(newer); + }); + + test.each([ + ['a string', '4512'], + ['a list', [metaFromRunBridge]], + ['null', null], + ])('given a stash that is %s then no report reads provenance from it', (_name, stash) => { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(stash)); + + expect(readReportMetaResult('0')).toBeNull(); + expect(readReportMeta('0')).toBeNull(); + }); + + test('given meta compared with a saved reform then the baseline reads back', () => { + const compared = { ...currentMeta, baseline: CUSTOM_BASELINE }; + + saveReportMeta(REPORT_ID, compared); + + expect(readReportMeta(REPORT_ID)).toEqual(compared); + }); +}); From 28e75f01b9355e309ee81ecaeb6991063549cfc2 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:59:38 -0400 Subject: [PATCH 3/5] Report: say when the saved details can't be read A report whose stored provenance the schema can't read shows an error in place of the report: reload when a newer version saved it, open the report without it, or go back to Build. Co-Authored-By: Claude Opus 5.5 --- .../flagship/report/ReportRecordError.tsx | 113 +++++++++++++++++ app/src/hooks/useUnreadableReportMeta.ts | 26 ++++ app/src/pages/flagship/Report.page.tsx | 16 +++ .../report/ReportRecordError.test.tsx | 40 ++++++ .../hooks/useUnreadableReportMeta.test.ts | 69 ++++++++++ .../flagship/ReportUnreadableMeta.test.tsx | 119 ++++++++++++++++++ 6 files changed, 383 insertions(+) create mode 100644 app/src/components/flagship/report/ReportRecordError.tsx create mode 100644 app/src/hooks/useUnreadableReportMeta.ts create mode 100644 app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx create mode 100644 app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts create mode 100644 app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx diff --git a/app/src/components/flagship/report/ReportRecordError.tsx b/app/src/components/flagship/report/ReportRecordError.tsx new file mode 100644 index 000000000..5753b3651 --- /dev/null +++ b/app/src/components/flagship/report/ReportRecordError.tsx @@ -0,0 +1,113 @@ +import { IconAlertTriangle } from '@tabler/icons-react'; +import { Button } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; + +/** + * A report whose stored details (title, source note, provisions) can't + * be read: saved by a newer version of the app, or damaged. The results + * can still open without them, rebuilt from the report's policy. + */ +export default function ReportRecordError({ + error, + onOpenWithout, +}: { + error: ReportSchemaError; + /** Opens the report without the stored details. */ + onOpenWithout: () => void; +}) { + const nav = useAppNavigate(); + const countryId = useCurrentCountry(); + const newer = error.code === 'newer-version'; + + return ( +
+
+ + + +
+

+ This report's saved details can't be read +

+

+ {newer + ? 'A newer version of PolicyEngine saved them. Reload to update.' + : 'They are damaged in this browser.'} +

+
+
+ +
+ {newer && } + + +
+ +
+ + Technical details + +
+          {error.message}
+        
+
+
+ ); +} diff --git a/app/src/hooks/useUnreadableReportMeta.ts b/app/src/hooks/useUnreadableReportMeta.ts new file mode 100644 index 000000000..06cc27d01 --- /dev/null +++ b/app/src/hooks/useUnreadableReportMeta.ts @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { readReportMetaResult } from '@/libs/flagship/runReport'; + +export interface UnreadableReportMeta { + /** Why the stored provenance can't be read; null when it can, none is stored, or it is set aside. */ + error: ReportSchemaError | null; + /** Opens the report without it: the page rebuilds the provenance from the policy. */ + setAside: () => void; +} + +/** + * The provenance this browser stored when it ran the report, when the + * report schema can't read it. `readReportMeta` answers null for it, so + * once set aside the page carries on as for a report with none stored. + * Nothing is deleted: setting it aside only stops this page asking. + */ +export function useUnreadableReportMeta(userReportId: string): UnreadableReportMeta { + const [setAsideId, setSetAsideId] = useState(null); + const setAside = () => setSetAsideId(userReportId); + if (setAsideId === userReportId) { + return { error: null, setAside }; + } + const result = readReportMetaResult(userReportId); + return { error: result && !result.ok ? result.error : null, setAside }; +} diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 93fe14623..c099e7a36 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -5,6 +5,7 @@ import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/society import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; +import ReportRecordError from '@/components/flagship/report/ReportRecordError'; import ReportView from '@/components/flagship/report/ReportView'; import RunProgress from '@/components/flagship/report/RunProgress'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; @@ -16,6 +17,7 @@ import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; +import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; import { readReportMeta } from '@/libs/flagship/runReport'; @@ -53,6 +55,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); + // Stored provenance that can't be read: readReportMeta answers none for it. + const unreadableMeta = useUnreadableReportMeta(userReportId); // The run handed its draft over to this report; Build starts fresh. useEffect(() => { @@ -127,6 +131,18 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep
{main}
); + // Unreadable stored provenance says so, until the report is opened without it. + if (unreadableMeta.error) { + return layout( + + + {title} + + + + ); + } + if (!output || isLoading || (!meta && !!reformPolicy)) { if (unresolvable) { return layout( diff --git a/app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx new file mode 100644 index 000000000..a66dc7188 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import ReportRecordError from '@/components/flagship/report/ReportRecordError'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; + +const DAMAGED = new ReportSchemaError('invalid-field', 'provisions must be an array', 'provisions'); +const NEWER = new ReportSchemaError( + 'newer-version', + 'Saved with report schema 3; this version reads up to 2', + 'schemaVersion' +); + +describe('ReportRecordError', () => { + test('given damaged details then it says so, with the reason kept for debugging', () => { + render(); + + expect( + screen.getByRole('heading', { name: "This report's saved details can't be read" }) + ).toBeInTheDocument(); + expect(screen.getByText('They are damaged in this browser.')).toBeInTheDocument(); + expect(screen.getByText('provisions must be an array')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reload' })).not.toBeInTheDocument(); + }); + + test('given details a newer version saved then reloading is offered', () => { + render(); + + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + }); + + test('given the user opens without the details then the callback runs', async () => { + const user = userEvent.setup(); + const onOpenWithout = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + + expect(onOpenWithout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts new file mode 100644 index 000000000..658712746 --- /dev/null +++ b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { readReportMeta } from '@/libs/flagship/runReport'; +import { + metaFromRunBridge, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +const REPORT_ID = '4512'; +const OTHER_REPORT_ID = '4513'; +const DAMAGED_META = { provisions: 'oops' }; + +function seedMeta(byReportId: Record): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(byReportId)); +} + +describe('useUnreadableReportMeta', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given readable provenance then there is no error', () => { + seedMeta({ [REPORT_ID]: metaFromRunBridge }); + + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeNull(); + }); + + test('given no stored provenance then there is no error', () => { + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeNull(); + }); + + test('given unreadable provenance then the error says why, and the plain read is none', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META }); + + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeInstanceOf(ReportSchemaError); + expect(result.current.error?.field).toBe('provisions'); + expect(readReportMeta(REPORT_ID)).toBeNull(); + }); + + test('given unreadable provenance set aside then there is no error, and it stays stored', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META }); + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + act(() => result.current.setAside()); + + expect(result.current.error).toBeNull(); + expect(JSON.parse(localStorage.getItem(REPORT_META_KEY)!)[REPORT_ID]).toEqual(DAMAGED_META); + }); + + test('given a set-aside report then another report still answers its own error', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META, [OTHER_REPORT_ID]: DAMAGED_META }); + const { result, rerender } = renderHook(({ id }) => useUnreadableReportMeta(id), { + initialProps: { id: REPORT_ID }, + }); + + act(() => result.current.setAside()); + rerender({ id: OTHER_REPORT_ID }); + + expect(result.current.error).toBeInstanceOf(ReportSchemaError); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx new file mode 100644 index 000000000..c8b23ebbd --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx @@ -0,0 +1,119 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { + metaFromRunBridge, + NEWER_SCHEMA_VERSION, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +// The report page reading its stored provenance through the report +// schema, from real localStorage (Report.test.tsx stubs the read). + +const REPORT_ID = 'r1'; +const UNREADABLE_HEADING = "This report's saved details can't be read"; +const WAITING_TEXT = 'Calculating the nationwide results'; + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => ({ + report: { id: 'r1', countryId: 'us', year: '2026', label: 'Test Reform' }, + simulations: [ + { id: 's1', populationId: 'us', policyId: '2' }, + { id: 's2', populationId: 'us', policyId: '9' }, + ], + policies: [], + isLoading: false, + }), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => ({ + status: 'pending', + isComplete: false, + isError: false, + progress: 20, + result: null, + }), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +vi.mock('@/utils/reportSimulations', () => ({ + allSimulationsLoaded: () => true, +})); + +function seedMeta(stored: unknown): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify({ [REPORT_ID]: stored })); +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage with stored provenance it cannot read', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given damaged provenance then the page says so instead of loading', () => { + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Test Reform' })).toBeInTheDocument(); + expect(screen.queryByText(WAITING_TEXT)).not.toBeInTheDocument(); + }); + + test('given damaged provenance opened without it then the report carries on', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); + + test('given provenance a newer version saved then reloading is offered', () => { + seedMeta({ ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + }); + + test('given provenance from before the schema then the report opens as usual', () => { + seedMeta(metaFromRunBridge); + + renderPage(); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); +}); From eab84c045586790f762f1255f6935c358cb8948d Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 14:59:43 -0400 Subject: [PATCH 4/5] Reports API: check provisions and pins against the report schema POST checks each provision, and PATCH the validation pin, with the rules the client reads them back with, and names the bad field in the 400. Body limits and the schema guard are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../integration/api/reportRoutes.test.ts | 44 +++++++++++++++++++ .../src/app/api/reports/[reportId]/route.ts | 6 +++ calculator-app/src/app/api/reports/route.ts | 6 +++ 3 files changed, 56 insertions(+) diff --git a/app/src/tests/integration/api/reportRoutes.test.ts b/app/src/tests/integration/api/reportRoutes.test.ts index 2ed598eec..64bdcd46e 100644 --- a/app/src/tests/integration/api/reportRoutes.test.ts +++ b/app/src/tests/integration/api/reportRoutes.test.ts @@ -136,6 +136,12 @@ describe('POST /api/reports', () => { ['invalid country', { ...VALID_PAYLOAD, country_id: 'fr' }], ['missing api_report_id', { ...VALID_PAYLOAD, api_report_id: undefined }], ['provisions not an array', { ...VALID_PAYLOAD, provisions: 'oops' }], + ['a provision that is not an object', { ...VALID_PAYLOAD, provisions: ['oops'] }], + ['a provision without a path', { ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }], + [ + 'a provision with a numeric unit', + { ...VALID_PAYLOAD, provisions: [{ ...VALID_PAYLOAD.provisions[0], unit: 7 }] }, + ], ['missing year', { ...VALID_PAYLOAD, year: undefined }], ['a local fallback reform_id', { ...VALID_PAYLOAD, reform_id: 'rf-mfz1abcd' }], ['a non-string reform_id', { ...VALID_PAYLOAD, reform_id: 42 }], @@ -144,6 +150,27 @@ describe('POST /api/reports', () => { expect(response.status).toBe(400); }); + it('given a provision without a path then the error names the field', async () => { + const response = await createReport( + postRequest({ ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }) + ); + + expect(await response.json()).toEqual({ + error: 'provisions[0].path must be a non-empty string', + }); + }); + + it('given provisions without units or baselines then the record is stored', async () => { + const { path, breadcrumb, value } = VALID_PAYLOAD.provisions[0]; + + const created = await createAndParse({ + ...VALID_PAYLOAD, + provisions: [{ path, breadcrumb, value }], + }); + + expect(created.provisions).toEqual([{ path, breadcrumb, value }]); + }); + it('given a central reform_id then it is stored on the record', async () => { const created = await createAndParse({ ...VALID_PAYLOAD, reform_id: CENTRAL_REFORM_ID }); @@ -275,6 +302,23 @@ describe('GET/PATCH /api/reports/:id', () => { expect((await reread.json()).validation).toBeNull(); }); + it('given a pin the report schema can not read then PATCH returns 400 and nothing is pinned', async () => { + const created = await createAndParse(); + const damaged = { ...VALIDATION_BODY.validation, scorecard: { programs: 'ctc_refund' } }; + + const response = await patchReport( + patchRequest({ validation: damaged }, created.edit_token), + itemContext(created.id) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'validation.scorecard.programs must be an array', + }); + const reread = await getReport(new Request('http://test'), itemContext(created.id)); + expect((await reread.json()).validation).toBeNull(); + }); + it('given an unknown record then PATCH returns 404', async () => { const response = await patchReport( patchRequest(VALIDATION_BODY, 'a'.repeat(64)), diff --git a/calculator-app/src/app/api/reports/[reportId]/route.ts b/calculator-app/src/app/api/reports/[reportId]/route.ts index 9b4cb36dd..523b6b08c 100644 --- a/calculator-app/src/app/api/reports/[reportId]/route.ts +++ b/calculator-app/src/app/api/reports/[reportId]/route.ts @@ -3,6 +3,7 @@ import { isFlagshipApiEnabled, } from "@/libs/flagship/apiGate"; import { eq } from "drizzle-orm"; +import { reportValidationWireError } from "@/libs/flagship/reportSchema"; import { getDb, isDbConfigured } from "../../../../db"; import { readJsonBody } from "../../../../db/limits"; import { guardSchema } from "../../../../db/errors"; @@ -95,6 +96,11 @@ async function handlePATCH( if (!isValidationPayload(payload?.validation)) { return json({ error: "validation snapshot is required" }, 400); } + // The snapshot as the client's report schema reads it back. + const validationError = reportValidationWireError(payload.validation); + if (validationError) { + return json({ error: validationError }, 400); + } const [row] = await getDb() .update(reports) diff --git a/calculator-app/src/app/api/reports/route.ts b/calculator-app/src/app/api/reports/route.ts index bda40cde5..da804e012 100644 --- a/calculator-app/src/app/api/reports/route.ts +++ b/calculator-app/src/app/api/reports/route.ts @@ -4,6 +4,7 @@ import { } from "@/libs/flagship/apiGate"; import { desc, inArray } from "drizzle-orm"; import { countryIds } from "@/libs/countries"; +import { reportProvisionsWireError } from "@/libs/flagship/reportSchema"; import { getDb, isDbConfigured } from "../../../db"; import { invalidText, @@ -59,6 +60,11 @@ function validateCreationPayload(payload: any): string | null { if (!Array.isArray(payload.provisions)) { return "provisions must be an array"; } + // Each provision as the client's report schema reads it back. + const provisionsError = reportProvisionsWireError(payload.provisions); + if (provisionsError) { + return provisionsError; + } if (typeof payload.year !== "string" || !payload.year) { return "year is required"; } From f1d48b41e9802c0849ca541365b2b7c8ede7179f Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 14 Oct 2026 15:23:25 -0400 Subject: [PATCH 5/5] Report schema: keep growth settings, repair damaged details - A provision's growth, growthStart, growthBase, growthRoundTo and growthRound read back, so "Edit in Build" keeps how a value grows. - Opening a report without damaged saved details removes them, so the next visit opens without asking; details a newer version saved stay. - The page reads the saved details once per report for the error. - ReportRecordError is now ReportDetailsError. Co-Authored-By: Claude Opus 5.5 --- ...RecordError.tsx => ReportDetailsError.tsx} | 2 +- app/src/hooks/useUnreadableReportMeta.ts | 27 ++++--- app/src/libs/flagship/reportSchema.ts | 15 +++- app/src/libs/flagship/runReport.ts | 37 ++++++++-- app/src/pages/flagship/Report.page.tsx | 4 +- .../libs/flagship/reportSchemaMocks.ts | 61 +++++++++++++++- .../flagship/ReportEditInBuild.test.tsx | 70 +++++++++++++++++++ ...r.test.tsx => ReportDetailsError.test.tsx} | 10 +-- .../hooks/useUnreadableReportMeta.test.ts | 41 +++++++++-- .../libs/flagship/reportMetaStorage.test.ts | 41 ++++++++++- .../unit/libs/flagship/reportSchema.test.ts | 20 ++++++ .../flagship/ReportUnreadableMeta.test.tsx | 25 +++++++ 12 files changed, 322 insertions(+), 31 deletions(-) rename app/src/components/flagship/report/{ReportRecordError.tsx => ReportDetailsError.tsx} (98%) create mode 100644 app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx rename app/src/tests/unit/components/flagship/report/{ReportRecordError.test.tsx => ReportDetailsError.test.tsx} (79%) diff --git a/app/src/components/flagship/report/ReportRecordError.tsx b/app/src/components/flagship/report/ReportDetailsError.tsx similarity index 98% rename from app/src/components/flagship/report/ReportRecordError.tsx rename to app/src/components/flagship/report/ReportDetailsError.tsx index 5753b3651..9b0cbf321 100644 --- a/app/src/components/flagship/report/ReportRecordError.tsx +++ b/app/src/components/flagship/report/ReportDetailsError.tsx @@ -10,7 +10,7 @@ import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; * be read: saved by a newer version of the app, or damaged. The results * can still open without them, rebuilt from the report's policy. */ -export default function ReportRecordError({ +export default function ReportDetailsError({ error, onOpenWithout, }: { diff --git a/app/src/hooks/useUnreadableReportMeta.ts b/app/src/hooks/useUnreadableReportMeta.ts index 06cc27d01..d49971766 100644 --- a/app/src/hooks/useUnreadableReportMeta.ts +++ b/app/src/hooks/useUnreadableReportMeta.ts @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; -import { readReportMetaResult } from '@/libs/flagship/runReport'; +import { readReportMetaResult, removeReportMeta } from '@/libs/flagship/runReport'; export interface UnreadableReportMeta { /** Why the stored provenance can't be read; null when it can, none is stored, or it is set aside. */ @@ -13,14 +13,23 @@ export interface UnreadableReportMeta { * The provenance this browser stored when it ran the report, when the * report schema can't read it. `readReportMeta` answers null for it, so * once set aside the page carries on as for a report with none stored. - * Nothing is deleted: setting it aside only stops this page asking. + * + * Setting aside damaged provenance removes it, since no version of the + * app can read it, so the report opens without asking next time. + * Provenance a newer version saved stays stored for that version. */ export function useUnreadableReportMeta(userReportId: string): UnreadableReportMeta { const [setAsideId, setSetAsideId] = useState(null); - const setAside = () => setSetAsideId(userReportId); - if (setAsideId === userReportId) { - return { error: null, setAside }; - } - const result = readReportMetaResult(userReportId); - return { error: result && !result.ok ? result.error : null, setAside }; + // Read once per report: the page reads the provenance on every render. + const error = useMemo(() => { + const result = readReportMetaResult(userReportId); + return result && !result.ok ? result.error : null; + }, [userReportId]); + const setAside = () => { + if (error && error.code !== 'newer-version') { + removeReportMeta(userReportId); + } + setSetAsideId(userReportId); + }; + return { error: setAsideId === userReportId ? null : error, setAside }; } diff --git a/app/src/libs/flagship/reportSchema.ts b/app/src/libs/flagship/reportSchema.ts index f02906a02..55e50ff77 100644 --- a/app/src/libs/flagship/reportSchema.ts +++ b/app/src/libs/flagship/reportSchema.ts @@ -19,7 +19,8 @@ * Versions: * 1 — unversioned: everything written before this module. * Meta: title, sourceNote, provisions, createdAt; later provisions - * gained `intervals`, and the meta `budgetWindow` and `baseline`. + * gained `intervals` and the `growth` settings, and the meta + * `budgetWindow` and `baseline`. * Record: id, userId, countryId, apiReportId, title, sourceNote, * provisions, year, createdAt; later `reformId` and `validation`, * and then `userId` was dropped. @@ -225,6 +226,11 @@ const intervalRule = shape({ value: anyValue, }); +const growthRound: Rule> = (value, field) => + value === 'nearest' || value === 'down' || value === 'up' + ? value + : fail(field, 'nearest, down or up'); + const provisionFields = shape({ path: requiredText, breadcrumb: text(''), @@ -232,6 +238,13 @@ const provisionFields = shape({ baselineValue: anyValue, value: anyValue, intervals: optional(arrayOf(intervalRule)), + // How a value set from one year on grows after it: Build reads these + // back when the report is edited there. + growth: optional(text('')), + growthStart: optional(finiteNumber), + growthBase: optional(finiteNumber), + growthRoundTo: optional(finiteNumber), + growthRound: optional(growthRound), }); /** A provision; one stored without a breadcrumb is labelled by its path. */ diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index c20bca755..c5ec879b6 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -24,6 +24,12 @@ export interface RunReportProvision { value: any; /** Dated changes, when the provision varies over time (see DraftProvision). */ intervals?: ValueInterval[]; + /** How a value set from one year on grows, from when, and how it rounds (see DraftProvision). */ + growth?: string; + growthStart?: number; + growthBase?: number; + growthRoundTo?: number; + growthRound?: 'nearest' | 'down' | 'up'; } /** Provenance shown in the flagship report header, stashed per report. */ @@ -57,6 +63,15 @@ export function saveReportMeta(userReportId: string, unstamped: FlagshipReportMe } } +/** The stash, when it holds this report's provenance; a stash that is not a map holds none. */ +function stashWith(userReportId: string): Record | null { + const all: unknown = readAllMeta(); + if (!all || typeof all !== 'object' || Array.isArray(all) || !Object.hasOwn(all, userReportId)) { + return null; + } + return all as Record; +} + /** * The report's stored provenance, parsed: null when none is stored, * otherwise the current shape or why it can't be read. @@ -64,12 +79,8 @@ export function saveReportMeta(userReportId: string, unstamped: FlagshipReportMe export function readReportMetaResult( userReportId: string ): ReportParseResult | null { - const all: unknown = readAllMeta(); - // A stash that is not a map holds no report's provenance. - if (!all || typeof all !== 'object' || Array.isArray(all) || !Object.hasOwn(all, userReportId)) { - return null; - } - return parseReportMeta((all as Record)[userReportId]); + const all = stashWith(userReportId); + return all ? parseReportMeta(all[userReportId]) : null; } /** The report's provenance in the current shape; null when none is stored or it can't be read. */ @@ -78,6 +89,20 @@ export function readReportMeta(userReportId: string): FlagshipReportMeta | null return result?.ok ? result.value : null; } +/** Drops the report's stored provenance; the other reports' stays as stored. */ +export function removeReportMeta(userReportId: string): void { + const all = stashWith(userReportId); + if (!all) { + return; + } + const { [userReportId]: _removed, ...rest } = all; + try { + localStorage.setItem(META_KEY, JSON.stringify(rest)); + } catch { + // It stays stored; the report still opens without it. + } +} + export interface RunFlagshipReportArgs { countryId: CountryId; title: string; diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index c099e7a36..9e0caa016 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -5,7 +5,7 @@ import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/society import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; -import ReportRecordError from '@/components/flagship/report/ReportRecordError'; +import ReportDetailsError from '@/components/flagship/report/ReportDetailsError'; import ReportView from '@/components/flagship/report/ReportView'; import RunProgress from '@/components/flagship/report/RunProgress'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; @@ -138,7 +138,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep {title} - + ); } diff --git a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts index fc57b14cc..46d7012e8 100644 --- a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts @@ -41,6 +41,24 @@ export const DATED_CTC_PROVISION: RunReportProvision = { ], }; +/** A value set from 2026 that grows with CPI-U after it, as Build's one-value editor saves it. */ +export const GROWING_PROVISION: RunReportProvision = { + path: 'gov.irs.deductions.standard.amount.SINGLE', + breadcrumb: 'IRS → Deductions → Standard deduction → Single', + unit: 'currency-USD', + baselineValue: 16100, + value: 18000, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 18000 }, + { startDate: '2027-01-01', endDate: '2027-12-31', value: 18450 }, + ], + growth: 'gov.bls.cpi.cpi_u', + growthStart: 2027, + growthBase: 2026, + growthRoundTo: 50, + growthRound: 'down', +}; + /* Provenance meta, stored under REPORT_META_KEY by report id. */ /** 3673cf94 "Build the run bridge": the first shape. */ @@ -54,9 +72,28 @@ export const metaFromRunBridge = { /** ebb805b5 "Build: make the reform the page": provisions with dated changes. */ export const metaWithIntervals = { ...metaFromRunBridge, provisions: [DATED_CTC_PROVISION] }; +/** 42c22f3a "Build: let a value grow with inflation, …": a provision's growth. */ +const { + growthStart: _growthStart, + growthBase: _growthBase, + growthRoundTo: _growthRoundTo, + growthRound: _growthRound, + ...provisionWithGrowth +} = GROWING_PROVISION; +export const metaWithGrowth = { + ...metaWithIntervals, + provisions: [DATED_CTC_PROVISION, provisionWithGrowth], +}; + +/** e02a17ef "Build: … set when and how they grow and round": its timing and rounding. */ +export const metaWithGrowthTiming = { + ...metaWithIntervals, + provisions: [DATED_CTC_PROVISION, GROWING_PROVISION], +}; + /** aaf850a6 "Build: choose the budget window before the run". */ export const metaWithBudgetWindow = { - ...metaWithIntervals, + ...metaWithGrowthTiming, budgetWindow: { startYear: 2026, windowSize: 10 }, }; @@ -91,7 +128,7 @@ export const sparseMetaExpected: FlagshipReportMeta = { export const currentMeta: FlagshipReportMeta = { title: 'CTC to $2,500', sourceNote: 'Hand-built draft', - provisions: [DATED_CTC_PROVISION], + provisions: [DATED_CTC_PROVISION, GROWING_PROVISION], createdAt: CREATED_AT, budgetWindow: { startYear: 2026, windowSize: 10 }, }; @@ -99,6 +136,8 @@ export const currentMeta: FlagshipReportMeta = { export const historicMeta: Array<[string, unknown, ReportMetaFields]> = [ ['the run bridge', metaFromRunBridge, metaFromRunBridge], ['provisions with dated changes', metaWithIntervals, metaWithIntervals], + ['a provision that grows', metaWithGrowth, metaWithGrowth], + ['when and how it grows and rounds', metaWithGrowthTiming, metaWithGrowthTiming], ['a budget window', metaWithBudgetWindow, metaWithBudgetWindow], ['a custom baseline', metaWithBaseline, metaWithBaseline], [ @@ -242,6 +281,24 @@ export const malformedMeta: Array<[string, unknown, string, string | undefined]> 'invalid-field', 'provisions[0].intervals[0].startDate', ], + [ + 'a numeric growth', + { provisions: [{ ...GROWING_PROVISION, growth: 0.03 }] }, + 'invalid-field', + 'provisions[0].growth', + ], + [ + 'a growth start as text', + { provisions: [{ ...GROWING_PROVISION, growthStart: '2027' }] }, + 'invalid-field', + 'provisions[0].growthStart', + ], + [ + 'an unknown rounding', + { provisions: [{ ...GROWING_PROVISION, growthRound: 'sideways' }] }, + 'invalid-field', + 'provisions[0].growthRound', + ], ['a numeric title', { ...metaFromRunBridge, title: 42 }, 'invalid-field', 'title'], [ 'a budget window of no years', diff --git a/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx b/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx new file mode 100644 index 000000000..52bebb09a --- /dev/null +++ b/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; +import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; +import { readReportMeta, saveReportMeta } from '@/libs/flagship/runReport'; +import { currentMeta, GROWING_PROVISION } from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +// A report's stored provenance, read back through the report schema, +// going back to Build from the Adjust panel (ReportAdjustPanel.test.tsx +// covers the panel with provisions handed to it). + +const REPORT_ID = '4512'; + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: vi.fn(), isRunning: false, error: null }), +})); + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useParams: () => ({ countryId: 'us' }) }; +}); + +describe('ReportAdjustPanel with stored provenance', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + clearDraftReform(); + }); + + test('given a stored provision that grows then Edit in Build keeps its growth', async () => { + const user = userEvent.setup(); + saveReportMeta(REPORT_ID, currentMeta); + const meta = readReportMeta(REPORT_ID)!; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + await user.click(screen.getByRole('button', { name: /edit in build/i })); + + expect(mockPush).toHaveBeenCalledWith('/us/build'); + const growing = getDraftReform()?.provisions.find( + (provision) => provision.path === GROWING_PROVISION.path + ); + expect(growing).toMatchObject({ + growth: GROWING_PROVISION.growth, + growthStart: GROWING_PROVISION.growthStart, + growthBase: GROWING_PROVISION.growthBase, + growthRoundTo: GROWING_PROVISION.growthRoundTo, + growthRound: GROWING_PROVISION.growthRound, + intervals: GROWING_PROVISION.intervals, + }); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx similarity index 79% rename from app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx rename to app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx index a66dc7188..cfabeed0b 100644 --- a/app/src/tests/unit/components/flagship/report/ReportRecordError.test.tsx +++ b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx @@ -1,6 +1,6 @@ import { render, screen, userEvent } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; -import ReportRecordError from '@/components/flagship/report/ReportRecordError'; +import ReportDetailsError from '@/components/flagship/report/ReportDetailsError'; import { ReportSchemaError } from '@/libs/flagship/reportSchema'; const DAMAGED = new ReportSchemaError('invalid-field', 'provisions must be an array', 'provisions'); @@ -10,9 +10,9 @@ const NEWER = new ReportSchemaError( 'schemaVersion' ); -describe('ReportRecordError', () => { +describe('ReportDetailsError', () => { test('given damaged details then it says so, with the reason kept for debugging', () => { - render(); + render(); expect( screen.getByRole('heading', { name: "This report's saved details can't be read" }) @@ -23,7 +23,7 @@ describe('ReportRecordError', () => { }); test('given details a newer version saved then reloading is offered', () => { - render(); + render(); expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); }); @@ -31,7 +31,7 @@ describe('ReportRecordError', () => { test('given the user opens without the details then the callback runs', async () => { const user = userEvent.setup(); const onOpenWithout = vi.fn(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'Open without saved details' })); diff --git a/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts index 658712746..04c757bcb 100644 --- a/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts +++ b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts @@ -1,21 +1,27 @@ import { act, renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; import { ReportSchemaError } from '@/libs/flagship/reportSchema'; import { readReportMeta } from '@/libs/flagship/runReport'; import { metaFromRunBridge, + NEWER_SCHEMA_VERSION, REPORT_META_KEY, } from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; const REPORT_ID = '4512'; const OTHER_REPORT_ID = '4513'; const DAMAGED_META = { provisions: 'oops' }; +const NEWER_META = { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }; function seedMeta(byReportId: Record): void { localStorage.setItem(REPORT_META_KEY, JSON.stringify(byReportId)); } +function storedMeta(): Record { + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); +} + describe('useUnreadableReportMeta', () => { beforeEach(() => { localStorage.clear(); @@ -45,14 +51,41 @@ describe('useUnreadableReportMeta', () => { expect(readReportMeta(REPORT_ID)).toBeNull(); }); - test('given unreadable provenance set aside then there is no error, and it stays stored', () => { - seedMeta({ [REPORT_ID]: DAMAGED_META }); + test('given damaged provenance set aside then it is removed, and the next visit does not ask', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META, [OTHER_REPORT_ID]: metaFromRunBridge }); const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); act(() => result.current.setAside()); expect(result.current.error).toBeNull(); - expect(JSON.parse(localStorage.getItem(REPORT_META_KEY)!)[REPORT_ID]).toEqual(DAMAGED_META); + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + expect(renderHook(() => useUnreadableReportMeta(REPORT_ID)).result.current.error).toBeNull(); + }); + + test('given provenance a newer version saved set aside then it stays stored for that version', () => { + seedMeta({ [REPORT_ID]: NEWER_META }); + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + act(() => result.current.setAside()); + + expect(result.current.error).toBeNull(); + expect(storedMeta()[REPORT_ID]).toEqual(NEWER_META); + expect(renderHook(() => useUnreadableReportMeta(REPORT_ID)).result.current.error?.code).toBe( + 'newer-version' + ); + }); + + test('given re-renders then the stored provenance is read once', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META }); + const getItem = vi.spyOn(localStorage, 'getItem'); + const { rerender } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + const reads = getItem.mock.calls.length; + + rerender(); + rerender(); + + expect(getItem.mock.calls.length).toBe(reads); + getItem.mockRestore(); }); test('given a set-aside report then another report still answers its own error', () => { diff --git a/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts index 65035c443..bf37a55d8 100644 --- a/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts +++ b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { readReportMeta, readReportMetaResult, saveReportMeta } from '@/libs/flagship/runReport'; +import { getDraftReform, loadProvisionsIntoDraft } from '@/libs/draftReform'; +import { + readReportMeta, + readReportMetaResult, + removeReportMeta, + saveReportMeta, +} from '@/libs/flagship/runReport'; import { CURRENT_SCHEMA_VERSION, currentMeta, CUSTOM_BASELINE, + GROWING_PROVISION, metaFromRunBridge, metaWithBudgetWindow, NEWER_SCHEMA_VERSION, @@ -83,6 +90,38 @@ describe('report provenance in localStorage', () => { expect(readReportMeta('0')).toBeNull(); }); + test('given a saved provision that grows then Build gets it back with its growth', () => { + saveReportMeta(REPORT_ID, currentMeta); + + loadProvisionsIntoDraft('us', readReportMeta(REPORT_ID)!.provisions); + + expect(getDraftReform()?.provisions).toEqual(currentMeta.provisions); + expect(getDraftReform()?.provisions[1]).toMatchObject({ + growth: GROWING_PROVISION.growth, + growthStart: GROWING_PROVISION.growthStart, + growthBase: GROWING_PROVISION.growthBase, + growthRoundTo: GROWING_PROVISION.growthRoundTo, + growthRound: GROWING_PROVISION.growthRound, + }); + }); + + test('given provenance removed then only that report loses it', () => { + const damaged = { ...metaFromRunBridge, provisions: 'oops' }; + seedMeta({ [REPORT_ID]: damaged, [OTHER_REPORT_ID]: metaFromRunBridge }); + + removeReportMeta(REPORT_ID); + + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + }); + + test('given no provenance for the report then removing it leaves the stash alone', () => { + seedMeta({ [OTHER_REPORT_ID]: metaFromRunBridge }); + + removeReportMeta(REPORT_ID); + + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + }); + test('given meta compared with a saved reform then the baseline reads back', () => { const compared = { ...currentMeta, baseline: CUSTOM_BASELINE }; diff --git a/app/src/tests/unit/libs/flagship/reportSchema.test.ts b/app/src/tests/unit/libs/flagship/reportSchema.test.ts index f112ec859..6874c5e2f 100644 --- a/app/src/tests/unit/libs/flagship/reportSchema.test.ts +++ b/app/src/tests/unit/libs/flagship/reportSchema.test.ts @@ -19,6 +19,7 @@ import { CURRENT_SCHEMA_VERSION, currentMeta, currentRecord, + GROWING_PROVISION, historicMeta, historicRecords, historicRows, @@ -67,6 +68,14 @@ describe('parseReportMeta', () => { expect(parseReportMeta(stored)).toEqual({ ok: true, value: currentMeta }); }); + test('given a provision that grows then its growth, timing and rounding read back', () => { + const result = parseReportMeta( + throughStorage({ ...currentMeta, provisions: [GROWING_PROVISION] }) + ); + + expect(result.ok && result.value.provisions).toEqual([GROWING_PROVISION]); + }); + test('given meta with fields no version wrote then they are dropped', () => { const result = parseReportMeta({ ...currentMeta, stray: 'value' }); @@ -115,6 +124,17 @@ describe('parseReportRecord', () => { expect(parseReportRecord(stored)).toEqual({ ok: true, value: currentRecord }); }); + test('given a record whose provision grows then its growth reads back', () => { + const stored = throughStorage( + stampReportRecord({ ...currentRecord, provisions: [GROWING_PROVISION] }) + ); + + expect(parseReportRecord(stored)).toEqual({ + ok: true, + value: { ...currentRecord, provisions: [GROWING_PROVISION] }, + }); + }); + test('given a damaged validation pin then the record reads without it', () => { const result = parseReportRecord(recordWithDamagedPin); diff --git a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx index c8b23ebbd..de78cfa57 100644 --- a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx @@ -99,6 +99,31 @@ describe('FlagshipReportPage with stored provenance it cannot read', () => { expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); }); + test('given damaged provenance opened without it then the next visit does not ask', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + const { unmount } = renderPage(); + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + unmount(); + + renderPage(); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); + + test('given provenance a newer version saved opened without it then the next visit asks again', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }); + const { unmount } = renderPage(); + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + unmount(); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + }); + test('given provenance a newer version saved then reloading is offered', () => { seedMeta({ ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION });