Skip to content

Commit 1116d4d

Browse files
Rylan-cgiclaude
andcommitted
fix(schedule3-subpage): address PR #344 review — one parser, gated commit, keyed contract
All three findings confirmed against the code; the reviewer's analysis of the parser split was exactly right (toNum yields 1000/16/1234 for 1e3/0x10/12,34 where committedNum yields null). 1. The row cells and the footer now share one parse. `numeric()` moves from lax `toNum` to strict `committedNum`, so a derived Crown cannot disagree with the Subtotal Crown above it. Also closes the sidebar: `toNum` had no finiteness guard, so Infinity rendered as ∞. 2. The blur commit now holds invalid and unusable entries, as every other surface does. One correction to the review here: gating on `errs` would not have worked. `rowErrors` is populated only by `persist` (useEditableCostRows.ts:204) — i.e. on a Save attempt — so it is still empty during the entry the gate has to catch, and the field is not marked invalid until Save either. The gate runs `config.validate` on the blurred value instead. 3. The `deriveSummary` doc comment now states the keyed contract and points at the note in schedule3OtherAcceptableCosts/derived.ts that explains why. Three tests added, each mutation-verified against the specific fix it covers. Finding 1 needed the in-flight-add path to be observable at all: once the commit gate is in place, a lax-only value can never reach `committed`, so the only surface where the two parsers still meet is a locally added row, which falls back to its live values while its PUT is in flight — and `1e3` clears validation, so it can be added. Also dropped a fourth test I had written for "one field's blur commits its neighbours' half-typed entries": that is unreachable, because moving focus to another field blurs the first one. The `prev[row.key]` merge is kept as a correctness tidy, not a bug fix, and is commented as such. Suite 1294/1296 (two timeout flakes, 221/221 in isolation), zero unhandled errors, build clean, no new type errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0f20f60 commit 1116d4d

2 files changed

Lines changed: 118 additions & 5 deletions

File tree

frontend/src/components/schedule3OtherAcceptableCosts/__tests__/OtherAcceptableCosts.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,54 @@ describe('Other Acceptable Costs sub-page (Story 4.4) — edit-in-place + batch
101101
expect(footerCells()).toEqual(['600', '500', '100'])
102102
})
103103

104+
test('a lax-only entry moves NEITHER the row Crown nor the footer (#344 review)', async () => {
105+
// `1e3` is the case where the page's two parsers disagreed: `validateOtherAcceptable` accepts it
106+
// (`Number('1e3')` is 1000, in range, so no error is shown), the row's derived Crown used lax
107+
// `toNum` and read 1000, and the footer mirror used strict `committedNum` and read null. The page
108+
// then contradicted itself — Crown 700 in the row, Crown 100 in the footer, over a Total $ field
109+
// displaying `1e3`. Two fixes meet here: the derived columns now parse with `committedNum` like the
110+
// footer, and a strictly-unparseable entry no longer commits at all. `0x10` and `12,34` are the
111+
// same class.
112+
server.use(http.get(URL, () => HttpResponse.json(doc)))
113+
render(<OtherAcceptableCostsPage />)
114+
const user = userEvent.setup()
115+
await screen.findByDisplayValue('Consulting')
116+
117+
const total = within(rowOf('Consulting')).getByLabelText('Edit total')
118+
await user.clear(total)
119+
await user.type(total, '1e3')
120+
await user.tab()
121+
122+
// Both derived surfaces HOLD the last committed figures, and agree with each other.
123+
expect(within(rowOf('1e3')).getByText('500')).toBeInTheDocument() // 800 − 300, as served
124+
expect(footerCells()).toEqual(['1,400', '500', '900'])
125+
})
126+
127+
test('an out-of-range entry holds the footer while the field sits invalid (#344 review)', async () => {
128+
// The rule `useCommittedValues` documents and every other surface followed: an invalid entry holds
129+
// its previous committed value, because legacy's failed round-trip left the last valid figures on
130+
// screen. This sub-page committed unconditionally, so 999,999,999 (over the ±99,999,999 cost range)
131+
// drove the footer to a figure the server can never produce while the field sat red beside it.
132+
server.use(http.get(URL, () => HttpResponse.json(doc)))
133+
render(<OtherAcceptableCostsPage />)
134+
const user = userEvent.setup()
135+
await screen.findByDisplayValue('Consulting')
136+
137+
const total = within(rowOf('Consulting')).getByLabelText('Edit total')
138+
await user.clear(total)
139+
await user.type(total, '999999999')
140+
await user.tab()
141+
142+
// The entered text stays on screen for correction...
143+
expect(within(rowOf('999,999,999')).getByLabelText('Edit total')).toHaveValue('999,999,999')
144+
// ...but the footer holds the last figures the server could actually produce.
145+
//
146+
// NOTE this page does not mark the field invalid until a Save is attempted (`rowErrors` is filled
147+
// by `persist` alone), so there is no red state to assert here yet. That is why the commit gate
148+
// runs `config.validate` itself instead of reading `rowErrors`.
149+
expect(footerCells()).toEqual(['1,400', '500', '900'])
150+
})
151+
104152
test('a locally added row counts toward the footer while its PUT is in flight (#291)', async () => {
105153
// PROVEN by the code review: the footer fell back to `{}` for a row appended before its PUT
106154
// landed, so it silently omitted a row visibly sitting in the grid. It now falls back to the
@@ -126,6 +174,34 @@ describe('Other Acceptable Costs sub-page (Story 4.4) — edit-in-place + batch
126174
})
127175
})
128176

177+
test('an in-flight added row derives its Crown with the FOOTER\'s parser (#344 review)', async () => {
178+
// The one path where the row/footer parser split is still observable. A row appended before its
179+
// PUT lands is absent from `committed`, so `committedFor` falls back to its LIVE values — and
180+
// `1e3` clears `validateOtherAcceptable` (`Number('1e3')` is 1000, in range), so it can be added.
181+
// With the row cells on lax `toNum` and the footer on strict `committedNum`, the row read Crown
182+
// 1,000 while the footer excluded it from Harvest entirely: 1,400 under a visible row of 1e3.
183+
server.use(
184+
http.get(URL, () => HttpResponse.json(doc)),
185+
http.put(URL, async () => {
186+
await delay(50)
187+
return HttpResponse.json(doc)
188+
}),
189+
)
190+
render(<OtherAcceptableCostsPage />)
191+
const user = userEvent.setup()
192+
await screen.findByDisplayValue('Consulting')
193+
194+
await user.type(screen.getByLabelText('Description'), 'Exponential')
195+
await user.type(screen.getByLabelText('Total $'), '1e3')
196+
await user.click(screen.getByRole('button', { name: /^add$/i }))
197+
198+
const added = await screen.findByDisplayValue('1e3')
199+
const row = added.closest('tr') as HTMLElement
200+
// One parser, so the row agrees with the footer: neither counts the unparseable entry.
201+
expect(within(row).queryByText('1,000')).not.toBeInTheDocument()
202+
expect(footerCells()).toEqual(['1,400', '500', '900'])
203+
})
204+
129205
test('lists groups as editable inputs with live-derived crown + subtotal + add form', async () => {
130206
server.use(http.get(URL, () => HttpResponse.json(doc)))
131207
render(<OtherAcceptableCostsPage />)

frontend/src/components/schedule3SubPage/index.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@carbon/react'
1616
import { TrashCan } from '@carbon/icons-react'
1717
import { fmtNumber, groupInput, numStrGroup, toNum } from '@/utils/number'
18+
import { committedNum, isUnusableStrictEntry } from '@/utils/derivedMath'
1819
import EditableSubPageLayout from '@/components/core/EditableSubPageLayout'
1920
import SubPanel from '@/components/core/SubPanel'
2021
import { useEditableCostRows, type EditRow } from '@/hooks/useEditableCostRows'
@@ -100,7 +101,11 @@ export interface Schedule3SubPageConfig<
100101
summaryItems: Schedule3SubPageSummaryItem<TDoc>[]
101102
/**
102103
* Optional display-only mirror for the Totals footer (defect #291): given the blur-committed row
103-
* values, return one figure per `summaryItems` entry, in the same order. Present only on the pages
104+
* values, return a figure per `summaryItems` entry KEYED BY that entry's `key` — not a positional
105+
* list. The keyed contract is deliberate (see `schedule3OtherAcceptableCosts/derived.ts`, where
106+
* `OtherAcceptableSubtotal` is "keyed so it cannot be mis-paired with the summary labels
107+
* positionally"): a triple looked up by name cannot silently pair with the wrong label when a
108+
* column is added or reordered. Present only on the pages
104109
* whose legacy footer refreshed during entry — Other Acceptable Costs, whose Total $ / PO&P $
105110
* handlers rendered `footerValues` (schedule3SubtotalOtherCosts.xhtml:74,83). The Included
106111
* Unacceptable page omits it: its only handler was `render="cost"`, with no derived target, so
@@ -162,8 +167,20 @@ function Schedule3SubPage<TRow extends Schedule3SubPageRow, TDoc extends Schedul
162167
}, [editor.data])
163168

164169
// Numeric view of a row's entered values, for the live-derived read-only columns (e.g. Crown $).
170+
//
171+
// `committedNum`, NOT `toNum` (PR #344 review): this must be the SAME parse the footer mirror uses
172+
// (`deriveOtherAcceptableSubtotal` → `committedNum` → `parseDecimalInput`), or the two disagree on
173+
// every form the lax parser accepts and the strict one rejects. `1e3` was the clearest case —
174+
// `validateOtherAcceptable` passes it (`Number('1e3')` is 1000, in range), the row's Crown $ showed
175+
// `1,000 − pop`, and the footer excluded the row entirely because `committedNum('1e3')` is null. Same
176+
// for `0x10` → 16 and a mis-grouped `12,34` → 1234. That is the same self-contradiction the comment
177+
// on `rowCells` says was ruled out — row Crowns of 700 and 400 under a Subtotal Crown of 900 —
178+
// reached by a parser mismatch instead of a timing one.
179+
//
180+
// It also closes a smaller gap the review noted in passing: `toNum` has no finiteness guard, so
181+
// `Infinity` rendered `∞` in Crown $. `committedNum` rejects it, and the wire never carried it.
165182
const numeric = (values: SubPageValues): Record<string, number | null> =>
166-
Object.fromEntries(config.fields.map((f) => [f.key, toNum(values[f.key])]))
183+
Object.fromEntries(config.fields.map((f) => [f.key, committedNum(values[f.key] ?? '')]))
167184

168185
// Client-side column sort, matching the legacy Schedule 3 sub-page dataTables: Description, every
169186
// editable field, AND each derived read-only column (e.g. Crown $) are sortable. See useRowSort for
@@ -219,14 +236,34 @@ function Schedule3SubPage<TRow extends Schedule3SubPageRow, TDoc extends Schedul
219236
size="sm"
220237
value={row.values[field.key] ?? ''}
221238
onChange={(e) => setRowValue(row.key, field.key, e.target.value)}
222-
// Re-group on blur only — regrouping mid-keystroke would fight the caret.
239+
// Re-group on blur only — regrouping mid-keystroke would fight the caret. The
240+
// re-group is cosmetic and unconditional (`groupInput` returns invalid text
241+
// unchanged, so a typo stays on screen); only the COMMIT is gated below.
223242
onBlur={() => {
224243
const grouped = groupInput(row.values[field.key] ?? '')
225244
setRowValue(row.key, field.key, grouped)
226-
// Commit the grouped string, so `committed` and the field hold the same text.
245+
// An invalid or unusable entry HOLDS its previous committed value (PR #344 review).
246+
// This is the rule `useCommittedValues` documents and every other surface already
247+
// followed — Schedule 4 reimplements the same guard. Without it an out-of-range
248+
// 999,999,999 in Total $ moved the footer to a figure the server can never produce.
249+
//
250+
// Validate HERE rather than reading `rowErrors`: that map is populated only by
251+
// `persist` (useEditableCostRows.ts:204), i.e. on a Save attempt, so it is still
252+
// empty during the entry this gate has to catch. `errs` below is right for the
253+
// field's `invalid` styling and wrong as a commit gate.
254+
const next = { ...row.values, [field.key]: grouped }
255+
const blurErrs = config.validate(row.description, next)
256+
if (blurErrs[field.key] !== undefined || isUnusableStrictEntry(grouped)) {
257+
return
258+
}
259+
// Merge onto the row's previous committed values rather than the live `row.values`,
260+
// so a blur advances only the field that blurred. Not a reachable bug today —
261+
// moving focus to another field blurs this one first — but it keeps the snapshot's
262+
// meaning exact rather than relying on focus ordering.
227263
setCommitted((prev) => ({
228264
...prev,
229-
[row.key]: { ...row.values, [field.key]: grouped },
265+
// Commit the grouped string, so `committed` and the field hold the same text.
266+
[row.key]: { ...(prev[row.key] ?? row.values), [field.key]: grouped },
230267
}))
231268
}}
232269
invalid={Boolean(errs[field.key])}

0 commit comments

Comments
 (0)