diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 606266d63..3ce668168 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,7 +2,7 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952) +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952), `Pick<>` is not a forcing function + caller-supplied monotonic seq reintroduces the ref + cascade tables smuggle behaviour changes + neutralised-trigger-left-in-code (#1947), tier factory only forces the cases that spread it (#1988) - [Dual-rail aggregation](dual-rail-aggregation.md) — Rail A/B tagged-deposit invariants (#1891/PR #1894), residual-denominator rule, isSplit UNION - [Source-report split inference](source-report-split-inference.md) — budgetLines[]/deposits[] are this-source-scoped, so †/‡ classification is a proxy; proposed `splitKind`; pdfmake `'2*'` width trap - [Story reviews](story-reviews.md) — per-story and per-PR review log diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index f05280593..4f5de792b 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -708,8 +708,8 @@ discriminates. Good pattern to reuse; also a reminder that a length assertion al ### A bumped submodule ref is not a pushed wiki commit (PR #1987) PR #1987 had the parent ref bumped to a wiki commit that was **never pushed** — the wiki remote was two -commits behind. `git -C wiki log --oneline` shows the commit as HEAD, so the wiki *looks* published, and -`git ls-tree HEAD wiki` matches it, so the ref *looks* correct. Anyone cloning the branch and running +commits behind. `git -C wiki log --oneline` shows the commit as HEAD, so the wiki _looks_ published, and +`git ls-tree HEAD wiki` matches it, so the ref _looks_ correct. Anyone cloning the branch and running `git submodule update` would fail on an unresolvable ref. Verify with `git -C wiki ls-remote origin master` compared against `git ls-tree HEAD wiki` — those are the @@ -730,5 +730,59 @@ and nothing else. A scoped ref-bump commit does not disturb an implementer mid-e ### Shell heredocs: a bare `cat >> file` with no redirect hangs the tool `cat >> a.md 2>/dev/null || true` followed by a second `cat >> b.md <<'EOF'` — the heredoc binds to the -*second* cat, so the first reads stdin and blocks until the 120s timeout. Prefer the Edit/Write tools for +_second_ cat, so the first reads stdin and blocks until the 120s timeout. Prefer the Edit/Write tools for appending to memory files; if you must use bash, one heredoc per command and never a redirect-less `cat`. + +### `Pick` is not a forcing function (#1947 action-set review) + +A reducer "tier factory" typed `function freshTier(): Pick` claims to make +"what does this transition clear" a compile-time decision. It does not: adding a field to `State` +produces **no error** — the key union just doesn't mention it, the spread leaves it untouched, and it +silently defaults to _kept_. The key union is a second hand-maintained list, i.e. the very thing being +replaced. The working version is a **named tier type** (`interface ReportTier {...}`) whose factory has an +**explicit return-type annotation** and returns a total object literal — missing property = compile error. +The annotation is load-bearing: an inferred return type re-derives the shape from the literal and the +error vanishes. Partition state as a flat intersection of tiers, not nested objects (nesting churns every +read site). Generalises to any "exhaustive mapping" claim made with `Pick`/`Omit`/`Record`. + +### Caller-supplied monotonic seq in an action payload reintroduces the ref it replaces (#1947) + +`dispatch({type:'SELECT_SOURCE', payload:{ newReportSeq }})` asks the caller to produce a value that must +stay **in sync with reducer-owned state** — only achievable with an out-of-reducer counter ref, so the +"staleness is enforced in the reducer" claim is false. Fix: **opaque nullable token** (`requestId: string | +null`) used as identity, never ordering — caller generates via a module-level `nextRequestId()`, echoes it +back in the completion action, reducer no-ops on mismatch. `null` then means "nothing in flight, discard +every outstanding response", so a reset invalidates in-flight work with no bump arithmetic. Monotonicity is +never needed when nothing compares generations for order. Corollary: an in-flight **boolean flag** +(`isGeneratingAi`) alongside such a token must be **derived** (`token !== null`), never stored — the two +disagreeing is exactly the bug class the token exists to kill. + +### A refactor's cascade table smuggles behaviour changes (#1947) + +Diff every row of a proposed reset/cascade table against the actual handler line-by-line. Two of three rows +in #1947's table cleared `aiError` where the code does not: one handler never clears it, and the other +clears it only inside `if (isGeneratingAi)`. Both were reachable, user-visible, and would have landed inside +a PR whose stated AC was "no user-visible change". Also watch for **generic setter actions** +(`SET_MAX_STEP`) — a setter wearing an action's clothes preserves the ad-hoc call it was meant to replace +and names nothing about what it invalidates. And check whether the _unfixed_ instances of the same race +exist elsewhere in the file (#1947 had a third, unguarded, in the Step-2 fan-out fetch). + +### The neutralised trigger left in the code (#1947 `deepLinkAppliedRef`) + +When a defect's trigger condition is _neutralised by a new guard_ rather than removed, the guarantee lives +in a comment. `if (… && !report && !appliedRef.current)` — `!report` was the AC8 trigger, kept alive behind +a ref and a nine-line comment. Removing the redundant condition also removes `report` from the effect's +dep array, making "clearing report cannot re-fire this" structural. Look for this shape in any fix that +_added_ a guard without deleting what it guards against. + +### A total-object tier factory only forces a decision in the cases that spread it (PR #1988 review) + +Follow-up to the `Pick<>` entry above: getting the factory right is necessary but not sufficient. A named +tier type + annotated total-literal factory produces the compile error, but **any reducer case that +hand-lists that tier's fields instead of spreading the factory keeps the hole** — the new field silently +defaults to _kept_ there. PR #1988 had `freshContentTier()` correct and then bypassed it in `SELECT_SOURCE` +and `DISCARD_EDITS`, the two cases that clear content state, because each needed one field _preserved_ +(`aiError`). Reviewing a tier-factory design: grep every case for the tier's field names appearing as +literal keys; each hit is an unenforced case. The fix is always the same shape — spread the factory, then +name the exception on the next line (`...freshContentTier(), aiError: state.aiError`), which is +behaviour-identical and makes the KEEP the thing that is written down rather than the CLEAR. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index e9c12ded1..a450b52fd 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -688,7 +688,7 @@ the empty-fallback makes it incapable of emptying a valid field), prompt rule 4 amended not deleted with the submodule ref bumped on-branch (AC 4.1). 193/193 + 187/187 green locally. Non-blocking: `futureDateStr` now triplicated (`timeline.test.ts:151` + 2 copies) while -`server/src/test-helpers/` exists — and the two new copies dropped the JSDoc that carries the *reason* +`server/src/test-helpers/` exists — and the two new copies dropped the JSDoc that carries the _reason_ (CPM today-floor on `not_started`), i.e. exactly the knowledge #1913 was filed to preserve. ### Round 2 (`857fcedd`) — APPROVED @@ -705,3 +705,30 @@ Accepted residuals, recorded so they are not rediscovered as bugs: `` stripped (INFO-1, AC 2.4's `Beträge < 500 EUR` safe via the space-after-`<` guard); a genuine German date list of ≥2 lines (`15. Mai: …\n16. Mai: …`) still loses its numbers, which is arguably correct (INFO-2). `futureDateStr` extraction to `server/src/test-helpers/dates.ts` deferred as a follow-up. + +## PR #1988 (#1967 dead `attachmentsNote` override + #1947 `ReportWizardPage` → `useReducer`) + +### Round 1 (`b503e496`) — CHANGES_REQUIRED, one HIGH + +The tier design I specified in the #1947 action-set review landed correctly (named tier interfaces, +annotated total-literal factories, opaque nullable request tokens, `isGeneratingAi` derived not stored, +the Step-2 fan-out race tokenized, `deepLinkAppliedRef` holding the applied id with `report` out of the +dep array). H1 was that `freshContentTier()` was **bypassed** in `SELECT_SOURCE` and `DISCARD_EDITS` — +the two cascades — because each needed `aiError` preserved, so a future 5th `ContentTier` field would +silently default to _kept_ in exactly the handler that produced #1943 and M2. See the recurring-patterns +entry; the fix shape is spread-the-factory-then-name-the-exception. + +Method note: for a behaviour-preserving refactor, **green CI is necessary but not the review**. What +settled AC3 here was walking each silently-changed semantic and proving it unreachable — `GO_TO_STEP` +now bumping `maxReachedStep` at every call site (no-op: step-1 Next only renders once `useCase` is set, +and `WizardStepper` gates clickability on `maxReachedStep`), the new `Math.min` step clamps (use case is +only selectable at step 1, source at step 2), `freshContentTier()` in `SELECT_USE_CASE` (no-op because +`isDirty` covers all three content fields, and the dirty path dispatches `DISCARD_EDITS` first). + +### Round 2 (`01d8ff12`) — APPROVED + +Both cascades now spread the factory and override `aiError` back; the M-I test passes unmodified, which is +the pin that matters. 57 tests, 100% on all four metrics. Also confirmed the one-file Prettier fix did not +drag repo-wide drift with it. Carried forward non-blocking: whole-`wizardState` in a `useCallback` dep +array, `REPORT_REFRESHED` as the last untokenized async write, `reportStatus` duplicating the page-local +`PageStatus` union, and a comma-operator exhaustiveness guard that invites deletion. diff --git a/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md b/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md new file mode 100644 index 000000000..0246e6616 --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md @@ -0,0 +1,21 @@ +--- +name: story-1947-wizardreducer +description: wizardReducer.ts pure unit test patterns — tier factories, staleness guards, M-I/M-J regression tests, exhaustiveness guard +metadata: + type: project +--- + +New pure unit test file `client/src/pages/ReportWizardPage/wizardReducer.test.ts` (57 tests, 100% coverage on wizardReducer.ts). + +**Why:** Story #1947 extracted inline reducer logic from ReportWizardPage into a pure module; QA owns the unit tests. + +**Key patterns used:** + +- `makeState(overrides?)` helper spreads `createInitialWizardState(null)` — keeps tests minimal and focused. +- Staleness guards tested with `toBe(state)` (same object reference) — proves the reducer short-circuits, not just "returns equivalent state". +- M-I regression (SELECT_SOURCE must NOT clear `aiError`): tested with a comment that the guard must fail if someone adds `aiError: ''` to that case. +- M-J regression (REPORT_REFRESHED no-op when report=null): `toBe(state)` reference check. +- Exhaustiveness guard (line 282, `action satisfies never`): cast to `any` to hit default branch. +- `nextRequestId` exported function: test that two consecutive calls produce strings where the second is +1 of the first (counter monotonically increases). + +**How to apply:** For future pure reducer modules: use the same `makeState()` pattern, test staleness guards with `toBe`, test exhaustiveness with `any` cast. diff --git a/client/src/lib/reportContent/applyOverrides.test.ts b/client/src/lib/reportContent/applyOverrides.test.ts index 1ced2be53..837b63033 100644 --- a/client/src/lib/reportContent/applyOverrides.test.ts +++ b/client/src/lib/reportContent/applyOverrides.test.ts @@ -4,7 +4,7 @@ * applyOverrides is a pure function: given a baseline ReportContent and a flat * ReportContentOverrides map, it returns a NEW ReportContent with the recognized override keys * applied, without mutating the input. Recognized keys: coverLetter.{sender,recipient,reference, - * subject,body} and row..{usageText,attachmentsNote}. Unknown keys are silently + * subject,body,signature} and row..usageText. Unknown keys are silently * ignored. Overriding coverLetter.sender recomputes coverLetter.signature. */ import { describe, it, expect } from '@jest/globals'; @@ -291,20 +291,6 @@ describe('applyOverrides — row overrides', () => { expect(result.rows.find((r) => r.invoiceId === 'inv-b')!.usageText).toBe('B baseline'); }); - it('overrides attachmentsNote with a non-empty string', () => { - const row = makeRow({ attachmentsNote: '1 attachment: Invoice' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': 'Edited note' }); - expect(result.rows[0]!.attachmentsNote).toBe('Edited note'); - }); - - it('overriding attachmentsNote with an empty string coerces it to null', () => { - const row = makeRow({ attachmentsNote: '1 attachment: Invoice' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': '' }); - expect(result.rows[0]!.attachmentsNote).toBeNull(); - }); - it('overriding usageText with an empty string coerces it to an empty string (never null)', () => { const content = makeContent(); const result = applyOverrides(content, { 'row.inv-1.usageText': '' }); @@ -318,15 +304,23 @@ describe('applyOverrides — row overrides', () => { expect(result.rows[0]!.usageText).toBe(content.rows[0]!.usageText); }); - it('applies both usageText and attachmentsNote overrides for the same row together', () => { - const row = makeRow({ attachmentsNote: 'baseline note' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { - 'row.inv-1.usageText': 'Edited usage', - 'row.inv-1.attachmentsNote': 'Edited note', - }); - expect(result.rows[0]!.usageText).toBe('Edited usage'); - expect(result.rows[0]!.attachmentsNote).toBe('Edited note'); + it('silently ignores the row.attachmentsNote key (dead since #1959)', () => { + // Arrange: content with an invoice row where attachmentsNote is null (the default) + const content = makeContent(); + // Act: call applyOverrides with the attachmentsNote key — no `in`-check exists for it in + // applyOverrides.ts, so it cannot update any row field + const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': 'some-override' }); + // Assert: attachmentsNote is still null — the key is silently ignored + expect(result.rows[0]!.attachmentsNote).toBeNull(); + }); + + it('still applies usageText override (positive control for remaining field coverage)', () => { + // Arrange: content with an invoice row + const content = makeContent(); + // Act: apply the usageText key — it IS in the `in`-check inside applyOverrides.ts + const result = applyOverrides(content, { 'row.inv-1.usageText': 'positive-control' }); + // Assert: the field was updated — proves the row-loop is still wired up correctly + expect(result.rows[0]!.usageText).toBe('positive-control'); }); }); diff --git a/client/src/lib/reportContent/applyOverrides.ts b/client/src/lib/reportContent/applyOverrides.ts index dfac50afe..9755c94c9 100644 --- a/client/src/lib/reportContent/applyOverrides.ts +++ b/client/src/lib/reportContent/applyOverrides.ts @@ -1,7 +1,7 @@ /** * Apply user overrides to baseline ReportContent. * Pure function: returns a new ReportContent without mutating the input. - * Recognized override keys: coverLetter.{sender,recipient,reference,subject,body,signature}, row..{usageText,attachmentsNote} + * Recognized override keys: coverLetter.{sender,recipient,reference,subject,body,signature}, row..usageText * Unknown keys are silently ignored. * When sender is overridden, signature is recomputed from it UNLESS signature has itself been * explicitly overridden — an explicit signature override always wins (AC 2.6). @@ -86,9 +86,6 @@ export function applyOverrides( if (rowKeys.usageText in overrides) { row.usageText = overrides[rowKeys.usageText] || ''; } - if (rowKeys.attachmentsNote in overrides) { - row.attachmentsNote = overrides[rowKeys.attachmentsNote] || null; - } } return result; diff --git a/client/src/lib/reportContent/overrideKeys.test.ts b/client/src/lib/reportContent/overrideKeys.test.ts index 5696bea4d..116612534 100644 --- a/client/src/lib/reportContent/overrideKeys.test.ts +++ b/client/src/lib/reportContent/overrideKeys.test.ts @@ -4,7 +4,7 @@ * `overrideKey` is a pure, side-effect-free builder for override map keys, decoupling * applyOverrides.ts and ReportContentEditor.tsx from manually-constructed string literals. * `overrideKey.coverLetter` is a fixed set of literal string constants; `overrideKey.row(id)` is a - * factory that interpolates a given invoiceId into two field-specific keys. + * factory that interpolates a given invoiceId into one field-specific key. */ import { describe, it, expect } from '@jest/globals'; import { overrideKey } from './overrideKeys.js'; @@ -41,10 +41,9 @@ describe('overrideKey.coverLetter — fixed literal keys', () => { }); describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { - it('interpolates a simple invoiceId into both the usageText and attachmentsNote keys', () => { + it('interpolates a simple invoiceId into the usageText key', () => { expect(overrideKey.row('inv-1')).toEqual({ usageText: 'row.inv-1.usageText', - attachmentsNote: 'row.inv-1.attachmentsNote', }); }); @@ -58,11 +57,10 @@ describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { it('correctly interpolates an invoiceId that itself contains a literal "." with no key ambiguity', () => { // A UUID-like or namespaced invoiceId containing dots must not be confused with the key's own - // "row." / ".usageText" / ".attachmentsNote" structural dot-separators — the whole id is used - // verbatim as the middle segment, however many dots it contains. + // "row." / ".usageText" structural dot-separators — the whole id is used verbatim as the + // middle segment, however many dots it contains. const keys = overrideKey.row('src.2026.inv-42'); expect(keys.usageText).toBe('row.src.2026.inv-42.usageText'); - expect(keys.attachmentsNote).toBe('row.src.2026.inv-42.attachmentsNote'); // Splitting on '.' yields more than 3 segments (proving the id's own dots survived verbatim, // rather than being collapsed/stripped), and the first/last segments are still the fixed @@ -77,8 +75,6 @@ describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { const keys = overrideKey.row('any-id-123'); expect(keys.usageText.startsWith('row.')).toBe(true); expect(keys.usageText.endsWith('.usageText')).toBe(true); - expect(keys.attachmentsNote.startsWith('row.')).toBe(true); - expect(keys.attachmentsNote.endsWith('.attachmentsNote')).toBe(true); }); it('returns a fresh object on each call (not a shared/mutated singleton)', () => { diff --git a/client/src/lib/reportContent/overrideKeys.ts b/client/src/lib/reportContent/overrideKeys.ts index f7a485337..f3e8010d7 100644 --- a/client/src/lib/reportContent/overrideKeys.ts +++ b/client/src/lib/reportContent/overrideKeys.ts @@ -14,6 +14,5 @@ export const overrideKey = { }, row: (invoiceId: string) => ({ usageText: `row.${invoiceId}.usageText`, - attachmentsNote: `row.${invoiceId}.attachmentsNote`, }), } as const; diff --git a/client/src/lib/reportContent/types.ts b/client/src/lib/reportContent/types.ts index ed69aee96..403adf479 100644 --- a/client/src/lib/reportContent/types.ts +++ b/client/src/lib/reportContent/types.ts @@ -19,10 +19,7 @@ export interface ReportContentRow { isRefund: boolean; refundNoteText: string; // shown only when isRefund usageText: string; // EDITABLE — key `row..usageText` - // READ-ONLY since #1959 moved it inline, with areaText, into the Usage cell's grey meta suffix: - // the editor renders both as static text and exposes no input for either. applyOverrides still - // honours `row..attachmentsNote`, but nothing can produce that key any more — it is - // unreachable from the UI. null = no docs, omitted entirely. + // READ-ONLY since #1959: rendered inline in the Usage cell's grey meta suffix. null = no attached documents. attachmentsNote: string | null; areaText: string | null; // read-only leaf area names, distinct comma-joined } diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx index 8950888ac..3bb2afe7d 100644 --- a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx +++ b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx @@ -1,15 +1,10 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef, useReducer } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import type { - BudgetSource, - SourceReportType, - HouseholdSettings, - GenerateReportContentResponse, -} from '@cornerstone/shared'; +import type { BudgetSource, SourceReportType, HouseholdSettings } from '@cornerstone/shared'; import i18n from '../../i18n/index.js'; import { useAuth } from '../../contexts/AuthContext.js'; -import { useLocale, type ResolvedLocale } from '../../contexts/LocaleContext.js'; +import { useLocale } from '../../contexts/LocaleContext.js'; import { fetchBudgetSources } from '../../lib/budgetSourcesApi.js'; import { fetchHouseholdSettings } from '../../lib/settingsApi.js'; import { fetchConfig } from '../../lib/configApi.js'; @@ -26,14 +21,12 @@ import { applyOverrides, applyAiContent, type ReportContent, - type ReportContentOverrides, } from '../../lib/reportContent/index.js'; import { generateReportPdf, downloadPdf, createPreviewUrl, uploadToPaperless, - type SkippedDocument, } from '../../lib/reportPdf/index.js'; import { ApiClientError } from '../../lib/apiClient.js'; import { translateApiError } from '../../lib/errorTranslation.js'; @@ -53,6 +46,15 @@ import { Step1UseCase } from './Step1UseCase.js'; import { Step2Source } from './Step2Source.js'; import { Step4Settings } from './Step4Settings.js'; import { Step5Actions } from './Step5Actions.js'; +import { + wizardReducer, + createInitialWizardState, + nextRequestId, + hasManualEdits, + isDirty, + isGeneratingOnly, + isGeneratingAi, +} from './wizardReducer.js'; import sharedStyles from '../../styles/shared.module.css'; import styles from './ReportWizardPage.module.css'; @@ -66,20 +68,42 @@ export function ReportWizardPage() { const { resolvedLocale, currency } = useLocale(); const [searchParams] = useSearchParams(); - // Step navigation - const [currentStep, setCurrentStep] = useState(1); - const [maxReachedStep, setMaxReachedStep] = useState(1); + const sourceIdFromQuery = searchParams.get('sourceId'); + const [wizardState, dispatch] = useReducer( + wizardReducer, + sourceIdFromQuery, + createInitialWizardState, + ); + + const { + useCase, + sourceId, + step2Amounts, + step2Loading, + report, + reportStatus, + excludedInvoiceIds, + excludedLineIds, + overrides, + aiContent, + aiError, + reportLanguageOverride, + attachDocuments, + includeCoverLetter, + currentStep, + maxReachedStep, + skippedDocuments, + } = wizardState; + + const isGeneratingAiValue = isGeneratingAi(wizardState); + const isDirtyValue = isDirty(wizardState); // Focus management for step headings const stepHeadingsRef = useRef<(HTMLHeadingElement | null)[]>([]); // Report language selection (derived default: override takes precedence, falls back to resolvedLocale) - const [reportLanguageOverride, setReportLanguageOverride] = useState(null); const reportLanguage = reportLanguageOverride ?? resolvedLocale; - // Use case selection - const [useCase, setUseCase] = useState(null); - // Budget sources const [budgetSources, setBudgetSources] = useState([]); const [sourcesStatus, setSourcesStatus] = useState('loading'); @@ -87,66 +111,25 @@ export function ReportWizardPage() { // LLM configuration const [llmEnabled, setLlmEnabled] = useState(false); - // AI generation state - const [aiContent, setAiContent] = useState(null); - const [isGeneratingAi, setIsGeneratingAi] = useState(false); + // AI generation state (UI-only, not wizard state) const [aiElapsed, setAiElapsed] = useState(0); - const [aiError, setAiError] = useState(''); const [showAiOverwriteConfirm, setShowAiOverwriteConfirm] = useState(false); const pendingAiGenerationRef = useRef<(() => void) | null>(null); - // Step 2 amounts - const [step2Amounts, setStep2Amounts] = useState>(new Map()); - const [step2Loading, setStep2Loading] = useState(false); - - // Source selection - const sourceIdFromQuery = searchParams.get('sourceId'); - const [sourceId, setSourceId] = useState(sourceIdFromQuery); + // Source selection (derived) const selectedSource = useMemo( () => budgetSources.find((s) => s.id === sourceId) || null, [budgetSources, sourceId], ); - // Report data - const [report, setReport] = useState> | null>(null); - const [reportStatus, setReportStatus] = useState('loading'); - - // Invoice selection - const [excludedInvoiceIds, setExcludedInvoiceIds] = useState>(new Set()); - - // Line-level exclusions - const [excludedLineIds, setExcludedLineIds] = useState>(new Set()); - - // PDF generation & options - const [attachDocuments, setAttachDocuments] = useState(true); - const [includeCoverLetter, setIncludeCoverLetter] = useState(false); - - // Editable content overrides - const [overrides, setOverrides] = useState({}); - // Discard confirmation modal const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); const pendingChangeRef = useRef<(() => void) | null>(null); - // #1943: the ?sourceId= deep link auto-selects a source AT MOST ONCE per page load. Without - // this guard, clearing `report` as part of a use-case change re-satisfies this effect's - // `!report` condition and silently re-fires handleSourceChange with the ORIGINAL query-string - // source id — re-selecting a source and pushing maxReachedStep back to 3, undoing the very - // reset handleUseCaseChange performs (see #1943 AC8). The ref persists for the component's - // full lifetime and is never reset: sourceIdFromQuery is derived from the URL's search params - // once and this page never calls setSearchParams, so the deep-link source id is immutable for - // as long as this component instance is mounted. - const deepLinkAppliedRef = useRef(false); - - // #1943 (M1): tokens the report-fetch race between handleUseCaseChange and handleSourceChange. - // Neither fetch aborts its predecessor, so an out-of-order resolution — a use-case-A fetch - // that settles AFTER a later use-case-B fetch for the same source — would let the stale A - // report win the `setReport`/`setReportStatus` write, reaching step 3 with a report from the - // wrong use case even though the reset above already cleared it. Bumping this token wherever - // a fetch starts and checking it in every callback before writing state discards any response - // that isn't from the most recently started fetch, in either the success or error path. - const reportRequestRef = useRef(0); - const aiGenerationTokenRef = useRef(0); + // Upgraded from useRef(false) per #1947 M-D decision. Holds the sourceId that was applied + // by the deep-link effect, or null if the effect has not yet fired. The ref is the sole guard; + // '!report' is dropped from the condition below, removing report from the effect's deps. + const deepLinkAppliedRef = useRef(null); // PDF preview modal const [showPdfPreviewModal, setShowPdfPreviewModal] = useState(false); @@ -156,7 +139,6 @@ export function ReportWizardPage() { const [actionError, setActionError] = useState(''); const modalPreviewUrlRef = useRef(null); const [modalPreviewUrl, setModalPreviewUrl] = useState(null); - const [skippedDocuments, setSkippedDocuments] = useState([]); // Household settings const [household, setHousehold] = useState(null); @@ -200,17 +182,9 @@ export function ReportWizardPage() { // Guard for mutations: if overrides, aiContent, or an in-flight generation exist, show confirm modal; else apply change immediately const guardedUpdate = useCallback( (applyChange: () => void) => { - const hasEdits = Object.keys(overrides).length > 0 || aiContent !== null; - const isDirty = hasEdits || isGeneratingAi; - if (isDirty) { + if (isDirtyValue) { pendingChangeRef.current = () => { - setOverrides({}); - setAiContent(null); - if (isGeneratingAi) { - aiGenerationTokenRef.current += 1; - setIsGeneratingAi(false); - setAiError(''); - } + dispatch({ type: 'DISCARD_EDITS' }); applyChange(); }; setShowDiscardConfirm(true); @@ -218,47 +192,29 @@ export function ReportWizardPage() { applyChange(); } }, - [overrides, aiContent, isGeneratingAi], + [isDirtyValue], ); // Handle use case selection const handleUseCaseChange = useCallback( (uc: SourceReportType) => { guardedUpdate(() => { - setUseCase(uc); - setMaxReachedStep(2); - setStep2Amounts(new Map()); - setStep2Loading(true); - - // #1943: a use-case change invalidates any report fetched under the previous use - // case (and the source-gated Step 2 Next control, which only checks `sourceId`). - // Clear both so the wizard can't carry a stale report into a later step. - // #1943 (M1): also bump the request token so an in-flight fetch from the previous - // use case can never win the race against a report fetched after this reset. - reportRequestRef.current += 1; - setReport(null); - setReportStatus('loading'); - setSourceId(null); - setExcludedInvoiceIds(new Set()); - setExcludedLineIds(new Set()); - setSkippedDocuments([]); - setAiError(''); - - // Fetch amounts for all sources in parallel - Promise.all( + const step2RequestId = nextRequestId(); + dispatch({ type: 'SELECT_USE_CASE', payload: { useCase: uc, step2RequestId } }); + + void Promise.all( budgetSources.map((source) => getSourceReport(uc, source.id) .then((r) => ({ sourceId: source.id, amount: r.totalAmount })) .catch(() => ({ sourceId: source.id, amount: 0 })), ), - ) - .then((results) => { - const map = new Map(results.map((r) => [r.sourceId, r.amount])); - setStep2Amounts(map); - }) - .finally(() => { - setStep2Loading(false); + ).then((results) => { + const amounts = new Map(results.map((r) => [r.sourceId, r.amount])); + dispatch({ + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: step2RequestId, amounts }, }); + }); }); }, [budgetSources, guardedUpdate], @@ -268,31 +224,17 @@ export function ReportWizardPage() { const handleSourceChange = useCallback( (sid: string) => { guardedUpdate(() => { - setSourceId(sid); - setExcludedInvoiceIds(new Set()); - setExcludedLineIds(new Set()); - setSkippedDocuments([]); - setMaxReachedStep(3); - setReportStatus('loading'); - - // #1943 (M1): bump the token before starting this fetch so it can only ever be the - // authoritative response for its own request generation — any earlier fetch (whether - // started under this use case or a previous one) is discarded below on resolution. - const requestId = ++reportRequestRef.current; + const requestId = nextRequestId(); + dispatch({ type: 'SELECT_SOURCE', payload: { sourceId: sid, requestId } }); if (useCase) { - getSourceReport(useCase, sid) + void getSourceReport(useCase, sid) .then((r) => { - if (reportRequestRef.current !== requestId) return; - setReport(r); - // Auto-enable cover letter based on source - setIncludeCoverLetter(Boolean(r.source.contactAddress || r.source.reference)); - setReportStatus('ready'); + dispatch({ type: 'REPORT_LOADED', payload: { requestId, report: r } }); }) .catch((err) => { - if (reportRequestRef.current !== requestId) return; console.error(err); - setReportStatus('error'); + dispatch({ type: 'REPORT_ERROR', payload: { requestId } }); }); } }); @@ -302,11 +244,11 @@ export function ReportWizardPage() { // Handle ?sourceId= query parameter deep link useEffect(() => { - if (useCase && sourceIdFromQuery && !report && !deepLinkAppliedRef.current) { - deepLinkAppliedRef.current = true; + if (useCase && sourceIdFromQuery && deepLinkAppliedRef.current !== sourceIdFromQuery) { + deepLinkAppliedRef.current = sourceIdFromQuery; handleSourceChange(sourceIdFromQuery); } - }, [useCase, sourceIdFromQuery, report, handleSourceChange]); + }, [useCase, sourceIdFromQuery, handleSourceChange]); // Report-language-specific translation and formatters const reportT = useMemo(() => i18n.getFixedT(reportLanguage, 'budget'), [reportLanguage]); @@ -376,7 +318,7 @@ export function ReportWizardPage() { reportT, ); - setSkippedDocuments(result.skippedDocuments); + dispatch({ type: 'PDF_GENERATED', payload: { skippedDocuments: result.skippedDocuments } }); return result; } catch (err) { console.error(err); @@ -541,15 +483,7 @@ export function ReportWizardPage() { if (useCase && sourceId) { try { const updated = await getSourceReport(useCase, sourceId); - setReport(updated); - // Reset excluded to only include still-present invoices - const stillPresent = new Set(); - for (const id of excludedInvoiceIds) { - if (updated.invoices.some((inv) => inv.invoiceId === id)) { - stillPresent.add(id); - } - } - setExcludedInvoiceIds(stillPresent); + dispatch({ type: 'REPORT_REFRESHED', payload: { report: updated } }); } catch { // Ignore refetch errors } @@ -568,17 +502,15 @@ export function ReportWizardPage() { // AI elapsed timer effect useEffect(() => { - if (!isGeneratingAi) { - setAiElapsed(0); - return; - } - + if (!isGeneratingAiValue) return; const id = setInterval(() => { setAiElapsed((n) => n + 1); }, 1000); - - return () => clearInterval(id); - }, [isGeneratingAi]); + return () => { + clearInterval(id); + setAiElapsed(0); + }; + }, [isGeneratingAiValue]); // Cleanup on unmount useEffect(() => { @@ -613,14 +545,12 @@ export function ReportWizardPage() { ); if (includedInvoiceIds.length === 0) { - setAiError(tErrors('EMPTY_SELECTION')); + dispatch({ type: 'AI_GENERATION_BLOCKED', payload: { error: tErrors('EMPTY_SELECTION') } }); return; } - // #1946: Capture token BEFORE setting isGeneratingAi - const token = ++aiGenerationTokenRef.current; - setIsGeneratingAi(true); - setAiError(''); + const requestId = nextRequestId(); + dispatch({ type: 'AI_GENERATION_STARTED', payload: { requestId } }); try { const result = await generateReportContent({ @@ -630,39 +560,28 @@ export function ReportWizardPage() { includedInvoiceIds, excludedLineIds: Array.from(excludedLineIds), }); - - // Token mismatch: user discarded this generation while in flight - if (aiGenerationTokenRef.current !== token) return; - - setAiContent(result); - setOverrides({}); + dispatch({ type: 'AI_GENERATION_COMPLETE', payload: { requestId, result } }); } catch (err) { - // Token mismatch: do not surface error for discarded generation - if (aiGenerationTokenRef.current !== token) return; - + let errorMessage: string; if (err instanceof ApiClientError) { - setAiError(translateApiError(err.error.code, tErrors)); + errorMessage = translateApiError(err.error.code, tErrors); } else { - setAiError(t('sourceReports.editable.aiGenerationFailed')); - } - } finally { - if (aiGenerationTokenRef.current === token) { - setIsGeneratingAi(false); + errorMessage = t('sourceReports.editable.aiGenerationFailed'); } + dispatch({ type: 'AI_GENERATION_ERROR', payload: { requestId, error: errorMessage } }); } }, [report, useCase, excludedLineIds, excludedInvoiceIds, sourceId, reportLanguage, t, tErrors]); // Handle generate with AI button click const handleGenerateWithAiClick = useCallback(() => { - const isDirty = Object.keys(overrides).length > 0; - - if (isDirty) { + const dirty = hasManualEdits(wizardState); + if (dirty) { pendingAiGenerationRef.current = runAiGeneration; setShowAiOverwriteConfirm(true); } else { void runAiGeneration(); } - }, [overrides, runAiGeneration]); + }, [wizardState, runAiGeneration]); const steps: WizardStep[] = [ { id: 'use-case', label: t('sourceReports.stepper.useCase') }, @@ -682,7 +601,7 @@ export function ReportWizardPage() { steps={steps} currentStep={currentStep} maxReachedStep={maxReachedStep} - onStepClick={(step) => setCurrentStep(step)} + onStepClick={(step) => dispatch({ type: 'GO_TO_STEP', payload: { step } })} ariaLabel={t('sourceReports.stepperAriaLabel')} mobileStepLabel={(current, total) => t('sourceReports.mobileStepLabel', { current, total })} /> @@ -705,7 +624,7 @@ export function ReportWizardPage() { @@ -736,14 +655,14 @@ export function ReportWizardPage() { @@ -921,10 +821,10 @@ export function ReportWizardPage() { type="button" className={sharedStyles.btnSecondary} onClick={handleGenerateWithAiClick} - disabled={isGeneratingAi} + disabled={isGeneratingAiValue} aria-describedby="enhanceWithAiDescription" > - {isGeneratingAi && ( + {isGeneratingAiValue && ( @@ -935,7 +835,7 @@ export function ReportWizardPage() { {t('sourceReports.editable.enhanceWithAiDescription')} - {isGeneratingAi && ( + {isGeneratingAiValue && (

{t('sourceReports.editable.generating', { seconds: aiElapsed })}

@@ -943,7 +843,7 @@ export function ReportWizardPage() { {aiError && } - {aiContent && !isGeneratingAi && ( + {aiContent && !isGeneratingAiValue && (

{t('sourceReports.editable.aiGeneratedNote')}

@@ -954,16 +854,10 @@ export function ReportWizardPage() { { - setOverrides((prev) => ({ ...prev, [key]: value })); - }} - onFieldReset={(key) => { - setOverrides((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); - }} + onFieldChange={(key, value) => + dispatch({ type: 'SET_OVERRIDE', payload: { key, value } }) + } + onFieldReset={(key) => dispatch({ type: 'RESET_OVERRIDE', payload: { key } })} t={t} /> @@ -1004,7 +898,7 @@ export function ReportWizardPage() { @@ -1017,7 +911,7 @@ export function ReportWizardPage() { {showDiscardConfirm && (

- {isGeneratingAi && Object.keys(overrides).length === 0 && aiContent === null + {isGeneratingOnly(wizardState) ? t('sourceReports.editable.discardConfirmBodyGenerating') : t('sourceReports.editable.discardConfirmBody')}

diff --git a/client/src/pages/ReportWizardPage/wizardReducer.test.ts b/client/src/pages/ReportWizardPage/wizardReducer.test.ts new file mode 100644 index 000000000..31bb62a25 --- /dev/null +++ b/client/src/pages/ReportWizardPage/wizardReducer.test.ts @@ -0,0 +1,893 @@ +/** + * Unit tests for wizardReducer.ts + * + * Pure unit tests — no React rendering, no jsdom, no module mocks. + * All tests operate on the reducer, factories, and selectors directly. + * + * Coverage: createInitialWizardState, wizardReducer (all 20 action types), + * isGeneratingAi, hasManualEdits, isDirty, isGeneratingOnly. + * + * Story #1947 / Bug #1943 regression tests are in Group 17. + */ +import { describe, it, expect } from '@jest/globals'; +import type { SourceReportResponse, GenerateReportContentResponse } from '@cornerstone/shared'; +import type { WizardState } from './wizardReducer.js'; +import { + createInitialWizardState, + wizardReducer, + nextRequestId, + isGeneratingAi, + hasManualEdits, + isDirty, + isGeneratingOnly, +} from './wizardReducer.js'; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeSourceSummary( + overrides: Partial = {}, +): SourceReportResponse['source'] { + return { + id: 'src-1', + name: 'Home Loan', + sourceType: 'bank_loan', + reference: null, + contactAddress: null, + ...overrides, + }; +} + +function makeInvoice(id: string): SourceReportResponse['invoices'][0] { + return { + invoiceId: id, + vendorId: 'vend-1', + vendorName: 'ACME', + invoiceNumber: `INV-${id}`, + date: '2026-01-10', + status: 'pending', + invoiceAmount: 1000, + allocatedAmount: 1000, + lineKind: 'invoice', + isSplit: false, + documents: [], + budgetLines: [ + { + id: `bl-${id}`, + description: 'Usage text', + allocatedPortion: 0, + linkedItem: null, + }, + ], + deposits: [], + }; +} + +function makeReport( + sourceId: string, + invoiceIds: string[] = ['inv-1'], + sourceOverrides: Partial = {}, +): SourceReportResponse { + return { + type: 'claim', + source: makeSourceSummary({ id: sourceId, ...sourceOverrides }), + invoices: invoiceIds.map((id) => makeInvoice(id)), + totalAmount: 1000, + unallocatedInvoices: [], + generatedAt: '2026-01-15T00:00:00.000Z', + }; +} + +function makeAiResult(): GenerateReportContentResponse { + return { + letterSubject: 'AI subject', + letterBody: 'AI body', + descriptions: { 'inv-1': 'AI description' }, + }; +} + +/** Build a WizardState by starting from createInitialWizardState(null) and spreading overrides. */ +function makeState(overrides: Partial = {}): WizardState { + return { ...createInitialWizardState(null), ...overrides }; +} + +// ─── nextRequestId ──────────────────────────────────────────────────────────── + +describe('nextRequestId', () => { + it('returns a string and increments on each call', () => { + const id1 = nextRequestId(); + const id2 = nextRequestId(); + expect(typeof id1).toBe('string'); + expect(id1.length).toBeGreaterThan(0); + expect(Number(id2)).toBe(Number(id1) + 1); + }); +}); + +// ─── Group 1: createInitialWizardState ──────────────────────────────────────── + +describe('createInitialWizardState', () => { + it('with null sourceId: sets sourceId null and all tier defaults', () => { + const state = createInitialWizardState(null); + expect(state.sourceId).toBeNull(); + expect(state.useCase).toBeNull(); + expect(state.currentStep).toBe(1); + expect(state.maxReachedStep).toBe(1); + expect(state.step2Loading).toBe(false); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.overrides).toEqual({}); + expect(state.aiRequestId).toBeNull(); + }); + + it("with 'src-42' sourceId: sets sourceId and all other fields match defaults", () => { + const state = createInitialWizardState('src-42'); + expect(state.sourceId).toBe('src-42'); + expect(state.useCase).toBeNull(); + expect(state.currentStep).toBe(1); + expect(state.maxReachedStep).toBe(1); + expect(state.step2Loading).toBe(false); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.overrides).toEqual({}); + expect(state.aiRequestId).toBeNull(); + }); +}); + +// ─── Group 2: Tier factories (via createInitialWizardState) ────────────────── + +describe('Tier factories (via createInitialWizardState)', () => { + it('freshReportTier shape: report=null, reportStatus=loading, reportRequestId=null, empty sets, []', () => { + const state = createInitialWizardState(null); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.reportRequestId).toBeNull(); + expect(state.excludedInvoiceIds).toBeInstanceOf(Set); + expect(state.excludedInvoiceIds.size).toBe(0); + expect(state.excludedLineIds).toBeInstanceOf(Set); + expect(state.excludedLineIds.size).toBe(0); + expect(state.skippedDocuments).toEqual([]); + }); + + it('freshContentTier shape: aiContent=null, aiRequestId=null, aiError="", overrides={}', () => { + const state = createInitialWizardState(null); + expect(state.aiContent).toBeNull(); + expect(state.aiRequestId).toBeNull(); + expect(state.aiError).toBe(''); + expect(state.overrides).toEqual({}); + }); +}); + +// ─── Group 3: SELECT_USE_CASE ───────────────────────────────────────────────── + +describe('SELECT_USE_CASE', () => { + it('resets ReportTier to fresh values', () => { + const state = makeState({ + report: makeReport('src-1'), + reportStatus: 'ready', + reportRequestId: 'req-old', + excludedInvoiceIds: new Set(['inv-1']), + excludedLineIds: new Set(['bl-1']), + skippedDocuments: [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed', + vendorName: 'ACME', + invoiceNumber: 'INV-001', + }, + ], + }); + + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + + expect(next.report).toBeNull(); + expect(next.reportStatus).toBe('loading'); + expect(next.reportRequestId).toBeNull(); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.excludedLineIds.size).toBe(0); + expect(next.skippedDocuments).toEqual([]); + }); + + it('resets SelectionTier: sourceId=null, useCase=action.payload.useCase', () => { + const state = makeState({ sourceId: 'src-1', useCase: 'claim' }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + expect(next.sourceId).toBeNull(); + expect(next.useCase).toBe('budget-overview'); + }); + + it('sets step2Loading=true and step2RequestId', () => { + const state = makeState(); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-42' }, + }); + expect(next.step2Loading).toBe(true); + expect(next.step2RequestId).toBe('req-42'); + }); + + it('sets maxReachedStep=2; currentStep=min(prev,2)', () => { + const stateAt1 = makeState({ currentStep: 1, maxReachedStep: 1 }); + const next1 = wizardReducer(stateAt1, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next1.maxReachedStep).toBe(2); + expect(next1.currentStep).toBe(1); // stayed at 1 + + const stateAt4 = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next4 = wizardReducer(stateAt4, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-2' }, + }); + expect(next4.maxReachedStep).toBe(2); + expect(next4.currentStep).toBe(2); // clamped to 2 + }); + + it('clears ContentTier: overrides={}, aiContent=null, aiRequestId=null, aiError=""', () => { + const state = makeState({ + overrides: { 'row.inv-1.usageText': 'edited' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-req-1', + aiError: 'some error', + }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe(''); + }); + + it('preserves SettingsTier fields', () => { + const state = makeState({ + attachDocuments: false, + includeCoverLetter: true, + reportLanguageOverride: 'de', + }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next.attachDocuments).toBe(false); + expect(next.includeCoverLetter).toBe(true); + expect(next.reportLanguageOverride).toBe('de'); + }); +}); + +// ─── Group 4: SELECT_SOURCE ─────────────────────────────────────────────────── + +describe('SELECT_SOURCE', () => { + it('sets sourceId, reportRequestId, reportStatus=loading', () => { + const state = makeState({ sourceId: null, reportStatus: 'error' }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-10' }, + }); + expect(next.sourceId).toBe('src-2'); + expect(next.reportRequestId).toBe('req-10'); + expect(next.reportStatus).toBe('loading'); + }); + + it('resets excludedInvoiceIds, excludedLineIds, skippedDocuments', () => { + const state = makeState({ + excludedInvoiceIds: new Set(['inv-1']), + excludedLineIds: new Set(['bl-1']), + skippedDocuments: [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed', + vendorName: 'ACME', + invoiceNumber: null, + }, + ], + }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.excludedLineIds.size).toBe(0); + expect(next.skippedDocuments).toEqual([]); + }); + + it('clears overrides, aiContent, aiRequestId', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-1', + }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + }); + + it('M-I regression: does NOT clear aiError — aiError is preserved after SELECT_SOURCE', () => { + const state = makeState({ aiError: 'some error' }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + // CRITICAL: this must fail if someone adds `aiError: ''` to the SELECT_SOURCE case + expect(next.aiError).toBe('some error'); + }); + + it('sets maxReachedStep=3; currentStep=min(prev,3)', () => { + const stateAt2 = makeState({ currentStep: 2, maxReachedStep: 2 }); + const next2 = wizardReducer(stateAt2, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-1' }, + }); + expect(next2.maxReachedStep).toBe(3); + expect(next2.currentStep).toBe(2); // stayed at 2 + + const stateAt4 = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next4 = wizardReducer(stateAt4, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-2' }, + }); + expect(next4.maxReachedStep).toBe(3); + expect(next4.currentStep).toBe(3); // clamped to 3 + }); +}); + +// ─── Group 5: STEP2_AMOUNTS_LOADED ─────────────────────────────────────────── + +describe('STEP2_AMOUNTS_LOADED', () => { + it('matching requestId: updates step2Amounts, clears step2Loading and step2RequestId', () => { + const state = makeState({ step2RequestId: 'req-1', step2Loading: true }); + const amounts = new Map([['src-1', 50000]]); + const next = wizardReducer(state, { + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: 'req-1', amounts }, + }); + expect(next.step2Amounts).toBe(amounts); + expect(next.step2Loading).toBe(false); + expect(next.step2RequestId).toBeNull(); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ step2RequestId: 'req-current', step2Loading: true }); + const next = wizardReducer(state, { + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: 'req-stale', amounts: new Map() }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 6: REPORT_LOADED ─────────────────────────────────────────────────── + +describe('REPORT_LOADED', () => { + it('matching requestId, source with contactAddress: sets report, status=ready, includeCoverLetter=true', () => { + const report = makeReport('src-1', ['inv-1'], { contactAddress: '123 Main St' }); + const state = makeState({ reportRequestId: 'req-1', includeCoverLetter: false }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-1', report }, + }); + expect(next.report).toBe(report); + expect(next.reportStatus).toBe('ready'); + expect(next.reportRequestId).toBeNull(); + expect(next.includeCoverLetter).toBe(true); + }); + + it('matching requestId, source with no contactAddress/reference: includeCoverLetter=false', () => { + const report = makeReport('src-1', ['inv-1'], { contactAddress: null, reference: null }); + const state = makeState({ reportRequestId: 'req-1', includeCoverLetter: true }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-1', report }, + }); + expect(next.includeCoverLetter).toBe(false); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ reportRequestId: 'req-current' }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-stale', report: makeReport('src-1') }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 7: REPORT_ERROR ──────────────────────────────────────────────────── + +describe('REPORT_ERROR', () => { + it('matching requestId: sets reportStatus=error, clears reportRequestId', () => { + const state = makeState({ reportRequestId: 'req-1', reportStatus: 'loading' }); + const next = wizardReducer(state, { + type: 'REPORT_ERROR', + payload: { requestId: 'req-1' }, + }); + expect(next.reportStatus).toBe('error'); + expect(next.reportRequestId).toBeNull(); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ reportRequestId: 'req-current' }); + const next = wizardReducer(state, { + type: 'REPORT_ERROR', + payload: { requestId: 'req-stale' }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 8: REPORT_REFRESHED ──────────────────────────────────────────────── + +describe('REPORT_REFRESHED', () => { + it('when state.report is null: returns same state reference unchanged (M-J no-op)', () => { + const state = makeState({ report: null }); + const next = wizardReducer(state, { + type: 'REPORT_REFRESHED', + payload: { report: makeReport('src-1') }, + }); + expect(next).toBe(state); + }); + + it('when state.report is non-null: updates report and prunes excludedInvoiceIds', () => { + const oldReport = makeReport('src-1', ['inv-1', 'inv-2', 'inv-3']); + const newReport = makeReport('src-1', ['inv-1', 'inv-3']); // inv-2 removed + const state = makeState({ + report: oldReport, + excludedInvoiceIds: new Set(['inv-1', 'inv-2']), // inv-2 no longer valid + }); + const next = wizardReducer(state, { + type: 'REPORT_REFRESHED', + payload: { report: newReport }, + }); + expect(next.report).toBe(newReport); + // inv-1 still valid → kept; inv-2 gone → pruned + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-2')).toBe(false); + expect(next.excludedInvoiceIds.has('inv-3')).toBe(false); // was never excluded + }); +}); + +// ─── Group 9: TOGGLE_INVOICE / TOGGLE_ALL_INVOICES / TOGGLE_LINE ───────────── + +describe('TOGGLE_INVOICE / TOGGLE_ALL_INVOICES / TOGGLE_LINE', () => { + it('TOGGLE_INVOICE excluded=true: adds invoiceId to excludedInvoiceIds', () => { + const state = makeState({ excludedInvoiceIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_INVOICE', + payload: { invoiceId: 'inv-1', excluded: true }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + }); + + it('TOGGLE_INVOICE excluded=false: removes invoiceId from excludedInvoiceIds', () => { + const state = makeState({ excludedInvoiceIds: new Set(['inv-1']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_INVOICE', + payload: { invoiceId: 'inv-1', excluded: false }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(false); + }); + + it('TOGGLE_ALL_INVOICES excludeAll=true: excludedInvoiceIds contains all invoice ids', () => { + const report = makeReport('src-1', ['inv-1', 'inv-2', 'inv-3']); + const state = makeState({ report, excludedInvoiceIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: true }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-2')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-3')).toBe(true); + expect(next.excludedInvoiceIds.size).toBe(3); + }); + + it('TOGGLE_ALL_INVOICES excludeAll=false: excludedInvoiceIds is empty', () => { + const report = makeReport('src-1', ['inv-1', 'inv-2']); + const state = makeState({ report, excludedInvoiceIds: new Set(['inv-1', 'inv-2']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: false }, + }); + expect(next.excludedInvoiceIds.size).toBe(0); + }); + + it('TOGGLE_ALL_INVOICES when report is null: returns same state reference unchanged', () => { + const state = makeState({ report: null }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: true }, + }); + expect(next).toBe(state); + }); + + it('TOGGLE_LINE excluded=true: adds lineId to excludedLineIds', () => { + const state = makeState({ excludedLineIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_LINE', + payload: { lineId: 'bl-1', excluded: true }, + }); + expect(next.excludedLineIds.has('bl-1')).toBe(true); + }); + + it('TOGGLE_LINE excluded=false: removes lineId from excludedLineIds', () => { + const state = makeState({ excludedLineIds: new Set(['bl-1']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_LINE', + payload: { lineId: 'bl-1', excluded: false }, + }); + expect(next.excludedLineIds.has('bl-1')).toBe(false); + }); +}); + +// ─── Group 10: Settings actions ─────────────────────────────────────────────── + +describe('Settings actions', () => { + it("SET_REPORT_LANGUAGE: updates reportLanguageOverride to 'de'", () => { + const state = makeState({ reportLanguageOverride: null }); + const next = wizardReducer(state, { + type: 'SET_REPORT_LANGUAGE', + payload: { lang: 'de' }, + }); + expect(next.reportLanguageOverride).toBe('de'); + }); + + it('SET_ATTACH_DOCUMENTS: toggles attachDocuments false→true and true→false', () => { + const stateOff = makeState({ attachDocuments: false }); + const nextOn = wizardReducer(stateOff, { + type: 'SET_ATTACH_DOCUMENTS', + payload: { value: true }, + }); + expect(nextOn.attachDocuments).toBe(true); + + const nextOff = wizardReducer(nextOn, { + type: 'SET_ATTACH_DOCUMENTS', + payload: { value: false }, + }); + expect(nextOff.attachDocuments).toBe(false); + }); + + it('SET_INCLUDE_COVER_LETTER: toggles includeCoverLetter', () => { + const stateOff = makeState({ includeCoverLetter: false }); + const nextOn = wizardReducer(stateOff, { + type: 'SET_INCLUDE_COVER_LETTER', + payload: { value: true }, + }); + expect(nextOn.includeCoverLetter).toBe(true); + + const nextOff = wizardReducer(nextOn, { + type: 'SET_INCLUDE_COVER_LETTER', + payload: { value: false }, + }); + expect(nextOff.includeCoverLetter).toBe(false); + }); +}); + +// ─── Group 11: Override actions ─────────────────────────────────────────────── + +describe('Override actions', () => { + it('SET_OVERRIDE: adds key/value, preserves other overrides', () => { + const state = makeState({ overrides: { 'row.inv-1.usageText': 'existing' } }); + const next = wizardReducer(state, { + type: 'SET_OVERRIDE', + payload: { key: 'row.inv-2.usageText', value: 'new value' }, + }); + expect(next.overrides['row.inv-1.usageText']).toBe('existing'); + expect(next.overrides['row.inv-2.usageText']).toBe('new value'); + }); + + it('RESET_OVERRIDE: removes specific key, other overrides remain', () => { + const state = makeState({ + overrides: { + 'row.inv-1.usageText': 'keep', + 'row.inv-2.usageText': 'remove', + }, + }); + const next = wizardReducer(state, { + type: 'RESET_OVERRIDE', + payload: { key: 'row.inv-2.usageText' }, + }); + expect(next.overrides['row.inv-1.usageText']).toBe('keep'); + expect('row.inv-2.usageText' in next.overrides).toBe(false); + }); +}); + +// ─── Group 12: AI generation lifecycle ─────────────────────────────────────── + +describe('AI generation lifecycle', () => { + it('AI_GENERATION_STARTED: sets aiRequestId=requestId, clears aiError', () => { + const state = makeState({ aiRequestId: null, aiError: 'previous error' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_STARTED', + payload: { requestId: 'ai-req-1' }, + }); + expect(next.aiRequestId).toBe('ai-req-1'); + expect(next.aiError).toBe(''); + }); + + it('AI_GENERATION_COMPLETE matching requestId: sets aiContent, clears overrides and aiRequestId', () => { + const result = makeAiResult(); + const state = makeState({ + aiRequestId: 'ai-req-1', + aiContent: null, + overrides: { 'row.inv-1.usageText': 'edited' }, + }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'ai-req-1', result }, + }); + expect(next.aiContent).toBe(result); + expect(next.overrides).toEqual({}); + expect(next.aiRequestId).toBeNull(); + expect(isGeneratingAi(next)).toBe(false); + }); + + it('AI_GENERATION_COMPLETE non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-current' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'ai-req-stale', result: makeAiResult() }, + }); + expect(next).toBe(state); + }); + + it('AI_GENERATION_ERROR matching requestId: sets aiError, clears aiRequestId', () => { + const state = makeState({ aiRequestId: 'ai-req-1' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_ERROR', + payload: { requestId: 'ai-req-1', error: 'LLM timeout' }, + }); + expect(next.aiError).toBe('LLM timeout'); + expect(next.aiRequestId).toBeNull(); + }); + + it('AI_GENERATION_ERROR non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-current' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_ERROR', + payload: { requestId: 'ai-req-stale', error: 'some error' }, + }); + expect(next).toBe(state); + }); + + it('AI_GENERATION_BLOCKED: sets aiError, leaves aiRequestId unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-in-flight', aiError: '' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_BLOCKED', + payload: { error: 'content policy block' }, + }); + expect(next.aiError).toBe('content policy block'); + // aiRequestId is preserved even if it was non-null + expect(next.aiRequestId).toBe('ai-req-in-flight'); + }); +}); + +// ─── Group 13: DISCARD_EDITS ────────────────────────────────────────────────── + +describe('DISCARD_EDITS', () => { + it('when aiRequestId is non-null: clears overrides, aiContent, aiRequestId, AND clears aiError', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-req-1', + aiError: 'some error', + }); + const next = wizardReducer(state, { type: 'DISCARD_EDITS' }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe(''); // cleared because aiRequestId was non-null + }); + + it('when aiRequestId is null: clears overrides, aiContent, aiRequestId, but PRESERVES aiError', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: null, + aiError: 'persistent error', + }); + const next = wizardReducer(state, { type: 'DISCARD_EDITS' }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe('persistent error'); // preserved because aiRequestId was null + }); +}); + +// ─── Group 14: GO_TO_STEP ───────────────────────────────────────────────────── + +describe('GO_TO_STEP', () => { + it('forward navigation (step > maxReachedStep): updates currentStep and maxReachedStep', () => { + const state = makeState({ currentStep: 2, maxReachedStep: 2 }); + const next = wizardReducer(state, { type: 'GO_TO_STEP', payload: { step: 4 } }); + expect(next.currentStep).toBe(4); + expect(next.maxReachedStep).toBe(4); + }); + + it('backward navigation (step < maxReachedStep): updates currentStep, maxReachedStep unchanged', () => { + const state = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next = wizardReducer(state, { type: 'GO_TO_STEP', payload: { step: 2 } }); + expect(next.currentStep).toBe(2); + expect(next.maxReachedStep).toBe(4); // unchanged + }); +}); + +// ─── Group 15: PDF_GENERATED ────────────────────────────────────────────────── + +describe('PDF_GENERATED', () => { + it('updates skippedDocuments to the payload value', () => { + const state = makeState({ skippedDocuments: [] }); + const skipped = [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed' as const, + vendorName: 'ACME', + invoiceNumber: 'INV-001', + }, + ]; + const next = wizardReducer(state, { + type: 'PDF_GENERATED', + payload: { skippedDocuments: skipped }, + }); + expect(next.skippedDocuments).toBe(skipped); + }); +}); + +// ─── Group 16: Selectors ────────────────────────────────────────────────────── + +describe('Selectors', () => { + it('isGeneratingAi: true when aiRequestId is non-null; false when null', () => { + expect(isGeneratingAi(makeState({ aiRequestId: 'req-1' }))).toBe(true); + expect(isGeneratingAi(makeState({ aiRequestId: null }))).toBe(false); + }); + + it('hasManualEdits: true when overrides has at least one key; false when empty', () => { + expect(hasManualEdits(makeState({ overrides: { key: 'val' } }))).toBe(true); + expect(hasManualEdits(makeState({ overrides: {} }))).toBe(false); + }); + + it('isDirty: true from overrides, aiContent, or generating; false when all clear', () => { + expect(isDirty(makeState({ overrides: { key: 'val' } }))).toBe(true); + expect(isDirty(makeState({ aiContent: makeAiResult() }))).toBe(true); + expect(isDirty(makeState({ aiRequestId: 'req-1' }))).toBe(true); + expect(isDirty(makeState({ overrides: {}, aiContent: null, aiRequestId: null }))).toBe(false); + }); + + it('isGeneratingOnly: true when generating and no manual edits and no aiContent', () => { + // Generating, no overrides, no aiContent → true + expect( + isGeneratingOnly(makeState({ aiRequestId: 'req-1', overrides: {}, aiContent: null })), + ).toBe(true); + + // Has manual edits → false + expect( + isGeneratingOnly( + makeState({ aiRequestId: 'req-1', overrides: { key: 'val' }, aiContent: null }), + ), + ).toBe(false); + + // Has aiContent → false + expect( + isGeneratingOnly( + makeState({ aiRequestId: 'req-1', overrides: {}, aiContent: makeAiResult() }), + ), + ).toBe(false); + + // Not generating → false + expect(isGeneratingOnly(makeState({ aiRequestId: null, overrides: {}, aiContent: null }))).toBe( + false, + ); + }); +}); + +// ─── Group 17: AC5 Regression tests ────────────────────────────────────────── + +describe('AC5 Regression tests', () => { + it('Test 52 — Bug #1943 shape: SELECT_USE_CASE cascade-resets all downstream state', () => { + const startState = makeState({ + useCase: 'claim', + sourceId: 'src-1', + report: makeReport('src-1', ['inv-1']), + excludedInvoiceIds: new Set(['inv-1']), + overrides: { 'row.inv-1.usageText': 'edited' }, + aiContent: makeAiResult(), + aiRequestId: null, + }); + + const next = wizardReducer(startState, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + + expect(next.report).toBeNull(); + expect(next.sourceId).toBeNull(); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.maxReachedStep).toBe(2); + }); + + it('Test 53 — Bug #1943 AC8 shape: SELECT_USE_CASE clears sourceId; SELECT_SOURCE re-applies it', () => { + const stateWithSource = makeState({ sourceId: 'src-1', useCase: 'budget-overview' }); + + const afterUseCaseChange = wizardReducer(stateWithSource, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-2' }, + }); + expect(afterUseCaseChange.sourceId).toBeNull(); + + const afterSourceSelect = wizardReducer(afterUseCaseChange, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-3' }, + }); + expect(afterSourceSelect.sourceId).toBe('src-1'); + }); + + it('Test 54 — Bug M1 shape: REPORT_LOADED is a no-op for stale requests', () => { + const state = makeState({ reportRequestId: 'req-current', report: null }); + + const afterStale = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-stale', report: makeReport('src-1') }, + }); + expect(afterStale.report).toBeNull(); + expect(afterStale.reportStatus).toBe('loading'); + + const freshReport = makeReport('src-1'); + const afterFresh = wizardReducer(afterStale, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-current', report: freshReport }, + }); + expect(afterFresh.report).toBe(freshReport); + expect(afterFresh.reportStatus).toBe('ready'); + }); + + it('Test 55 — Bug M2 shape: AI_GENERATION_COMPLETE is a no-op for stale requests', () => { + // Start generation + const stateStarted = wizardReducer(makeState(), { + type: 'AI_GENERATION_STARTED', + payload: { requestId: 'req-current' }, + }); + expect(stateStarted.aiRequestId).toBe('req-current'); + + // Stale completion: no-op + const afterStale = wizardReducer(stateStarted, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'req-stale', result: makeAiResult() as GenerateReportContentResponse }, + }); + expect(afterStale.aiContent).toBeNull(); + expect(isGeneratingAi(afterStale)).toBe(true); + + // Fresh completion: applies + const freshResult = makeAiResult(); + const afterFresh = wizardReducer(afterStale, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'req-current', result: freshResult }, + }); + expect(afterFresh.aiContent).toBe(freshResult); + expect(isGeneratingAi(afterFresh)).toBe(false); + }); +}); + +// ─── TypeScript exhaustiveness guard (default branch) ──────────────────────── + +describe('wizardReducer default/exhaustiveness guard', () => { + it('returns state unchanged for an unknown action type (runtime safety)', () => { + const state = makeState(); + // Cast to `any` to bypass the TypeScript discriminated union and hit the `default` branch. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const next = wizardReducer(state, { type: 'UNKNOWN_ACTION' } as any); + expect(next).toBe(state); + }); +}); diff --git a/client/src/pages/ReportWizardPage/wizardReducer.ts b/client/src/pages/ReportWizardPage/wizardReducer.ts new file mode 100644 index 000000000..33afdce99 --- /dev/null +++ b/client/src/pages/ReportWizardPage/wizardReducer.ts @@ -0,0 +1,297 @@ +import type { + SourceReportType, + SourceReportResponse, + GenerateReportContentResponse, +} from '@cornerstone/shared'; +import type { ResolvedLocale } from '../../contexts/LocaleContext.js'; +import type { ReportContentOverrides } from '../../lib/reportContent/index.js'; +import type { SkippedDocument } from '../../lib/reportPdf/index.js'; + +export interface SelectionTier { + useCase: SourceReportType | null; + sourceId: string | null; +} + +export interface SourcesTier { + step2Amounts: Map; + step2Loading: boolean; + step2RequestId: string | null; +} + +export interface ReportTier { + report: SourceReportResponse | null; + reportStatus: 'loading' | 'ready' | 'error'; + reportRequestId: string | null; + excludedInvoiceIds: Set; + excludedLineIds: Set; + skippedDocuments: SkippedDocument[]; +} + +export interface ContentTier { + overrides: ReportContentOverrides; + aiContent: GenerateReportContentResponse | null; + aiRequestId: string | null; + aiError: string; +} + +export interface SettingsTier { + reportLanguageOverride: ResolvedLocale | null; + attachDocuments: boolean; + includeCoverLetter: boolean; +} + +export interface NavTier { + currentStep: number; + maxReachedStep: number; +} + +export type WizardState = SelectionTier & + SourcesTier & + ReportTier & + ContentTier & + SettingsTier & + NavTier; + +/** + * Tier factories — each returns a complete, fresh object of its named tier type. + * Reducer cases build their next state by spreading the relevant factories plus + * explicit field writes, NEVER by ad-hoc spread of individually-cleared fields. + * Adding a field to a tier type is a compile error in its factory, which is AC4. + */ +function freshSelectionTier(): SelectionTier { + return { useCase: null, sourceId: null }; +} + +function freshSourcesTier(): SourcesTier { + return { step2Amounts: new Map(), step2Loading: false, step2RequestId: null }; +} + +function freshReportTier(): ReportTier { + return { + report: null, + reportStatus: 'loading', + reportRequestId: null, + excludedInvoiceIds: new Set(), + excludedLineIds: new Set(), + skippedDocuments: [], + }; +} + +function freshContentTier(): ContentTier { + return { overrides: {}, aiContent: null, aiRequestId: null, aiError: '' }; +} + +function freshSettingsTier(): SettingsTier { + return { reportLanguageOverride: null, attachDocuments: true, includeCoverLetter: false }; +} + +function freshNavTier(): NavTier { + return { currentStep: 1, maxReachedStep: 1 }; +} + +let _requestCounter = 0; +export function nextRequestId(): string { + return String(++_requestCounter); +} + +export type WizardAction = + | { type: 'SELECT_USE_CASE'; payload: { useCase: SourceReportType; step2RequestId: string } } + | { type: 'SELECT_SOURCE'; payload: { sourceId: string; requestId: string } } + | { type: 'STEP2_AMOUNTS_LOADED'; payload: { requestId: string; amounts: Map } } + | { type: 'REPORT_LOADED'; payload: { requestId: string; report: SourceReportResponse } } + | { type: 'REPORT_ERROR'; payload: { requestId: string } } + | { type: 'REPORT_REFRESHED'; payload: { report: SourceReportResponse } } + | { type: 'TOGGLE_INVOICE'; payload: { invoiceId: string; excluded: boolean } } + | { type: 'TOGGLE_ALL_INVOICES'; payload: { excludeAll: boolean } } + | { type: 'TOGGLE_LINE'; payload: { lineId: string; excluded: boolean } } + | { type: 'SET_REPORT_LANGUAGE'; payload: { lang: ResolvedLocale } } + | { type: 'SET_ATTACH_DOCUMENTS'; payload: { value: boolean } } + | { type: 'SET_INCLUDE_COVER_LETTER'; payload: { value: boolean } } + | { type: 'SET_OVERRIDE'; payload: { key: string; value: string } } + | { type: 'RESET_OVERRIDE'; payload: { key: string } } + | { type: 'AI_GENERATION_STARTED'; payload: { requestId: string } } + | { + type: 'AI_GENERATION_COMPLETE'; + payload: { requestId: string; result: GenerateReportContentResponse }; + } + | { type: 'AI_GENERATION_ERROR'; payload: { requestId: string; error: string } } + | { type: 'AI_GENERATION_BLOCKED'; payload: { error: string } } + | { type: 'DISCARD_EDITS' } + | { type: 'GO_TO_STEP'; payload: { step: number } } + | { type: 'PDF_GENERATED'; payload: { skippedDocuments: SkippedDocument[] } }; + +export function createInitialWizardState(sourceIdFromQuery: string | null): WizardState { + return { + ...freshSelectionTier(), + ...freshSourcesTier(), + ...freshReportTier(), + ...freshContentTier(), + ...freshSettingsTier(), + ...freshNavTier(), + sourceId: sourceIdFromQuery, + }; +} + +export function wizardReducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case 'SELECT_USE_CASE': + return { + ...state, + ...freshSelectionTier(), + ...freshSourcesTier(), + ...freshReportTier(), + ...freshContentTier(), + useCase: action.payload.useCase, + step2RequestId: action.payload.step2RequestId, + step2Loading: true, + currentStep: Math.min(state.currentStep, 2), + maxReachedStep: 2, + }; + + case 'SELECT_SOURCE': + // M-I: preserve aiError by spreading freshContentTier() then overriding aiError back. + // Adding a future ContentTier field will be caught here at compile time. + return { + ...state, + ...freshReportTier(), + reportRequestId: action.payload.requestId, + ...freshContentTier(), + aiError: state.aiError, + sourceId: action.payload.sourceId, + currentStep: Math.min(state.currentStep, 3), + maxReachedStep: 3, + }; + + case 'STEP2_AMOUNTS_LOADED': + if (action.payload.requestId !== state.step2RequestId) return state; + return { + ...state, + step2Amounts: action.payload.amounts, + step2Loading: false, + step2RequestId: null, + }; + + case 'REPORT_LOADED': + if (action.payload.requestId !== state.reportRequestId) return state; + return { + ...state, + report: action.payload.report, + reportStatus: 'ready', + reportRequestId: null, + includeCoverLetter: Boolean( + action.payload.report.source.contactAddress || action.payload.report.source.reference, + ), + }; + + case 'REPORT_ERROR': + if (action.payload.requestId !== state.reportRequestId) return state; + return { ...state, reportStatus: 'error', reportRequestId: null }; + + case 'REPORT_REFRESHED': { + if (state.report === null) return state; + const validInvoiceIds = new Set(action.payload.report.invoices.map((inv) => inv.invoiceId)); + return { + ...state, + report: action.payload.report, + excludedInvoiceIds: new Set( + [...state.excludedInvoiceIds].filter((id) => validInvoiceIds.has(id)), + ), + }; + } + + case 'TOGGLE_INVOICE': { + const next = new Set(state.excludedInvoiceIds); + if (action.payload.excluded) { + next.add(action.payload.invoiceId); + } else { + next.delete(action.payload.invoiceId); + } + return { ...state, excludedInvoiceIds: next }; + } + + case 'TOGGLE_ALL_INVOICES': + if (!state.report) return state; + return { + ...state, + excludedInvoiceIds: action.payload.excludeAll + ? new Set(state.report.invoices.map((inv) => inv.invoiceId)) + : new Set(), + }; + + case 'TOGGLE_LINE': { + const next = new Set(state.excludedLineIds); + if (action.payload.excluded) { + next.add(action.payload.lineId); + } else { + next.delete(action.payload.lineId); + } + return { ...state, excludedLineIds: next }; + } + + case 'SET_REPORT_LANGUAGE': + return { ...state, reportLanguageOverride: action.payload.lang }; + case 'SET_ATTACH_DOCUMENTS': + return { ...state, attachDocuments: action.payload.value }; + case 'SET_INCLUDE_COVER_LETTER': + return { ...state, includeCoverLetter: action.payload.value }; + + case 'SET_OVERRIDE': + return { + ...state, + overrides: { ...state.overrides, [action.payload.key]: action.payload.value }, + }; + case 'RESET_OVERRIDE': { + const next = { ...state.overrides }; + delete next[action.payload.key]; + return { ...state, overrides: next }; + } + + case 'AI_GENERATION_STARTED': + return { ...state, aiRequestId: action.payload.requestId, aiError: '' }; + case 'AI_GENERATION_COMPLETE': + if (action.payload.requestId !== state.aiRequestId) return state; + return { ...state, aiContent: action.payload.result, overrides: {}, aiRequestId: null }; + case 'AI_GENERATION_ERROR': + if (action.payload.requestId !== state.aiRequestId) return state; + return { ...state, aiError: action.payload.error, aiRequestId: null }; + case 'AI_GENERATION_BLOCKED': + return { ...state, aiError: action.payload.error }; + + case 'DISCARD_EDITS': + // M-I: spread freshContentTier() for AC4 enforcement, then override aiError conditionally. + return { + ...state, + ...freshContentTier(), + aiError: state.aiRequestId !== null ? '' : state.aiError, + }; + + case 'GO_TO_STEP': + return { + ...state, + currentStep: action.payload.step, + maxReachedStep: Math.max(state.maxReachedStep, action.payload.step), + }; + + case 'PDF_GENERATED': + return { ...state, skippedDocuments: action.payload.skippedDocuments }; + + default: + return (action satisfies never, state); + } +} + +export function isGeneratingAi(state: WizardState): boolean { + return state.aiRequestId !== null; +} + +export function hasManualEdits(state: WizardState): boolean { + return Object.keys(state.overrides).length > 0; +} + +export function isDirty(state: WizardState): boolean { + return hasManualEdits(state) || state.aiContent !== null || isGeneratingAi(state); +} + +export function isGeneratingOnly(state: WizardState): boolean { + return isGeneratingAi(state) && !hasManualEdits(state) && state.aiContent === null; +}