Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8d778f1
fix(schedule-2): recalculate derived figures on blur during entry (#291)
Rylan-cgi Aug 21, 2026
d5cc58a
fix(schedule-4): recalculate the location panel's $/m3 on blur (#291)
Rylan-cgi Aug 21, 2026
ea27d1a
fix(schedule-1): recalculate derived figures on blur during entry (#291)
Rylan-cgi Aug 21, 2026
e505361
fix(schedule-3): recalculate derived figures on blur during entry (#291)
Rylan-cgi Aug 21, 2026
4196eec
fix(schedule-1..4): code-review fixes — exact-decimal rounding, real …
Rylan-cgi Aug 21, 2026
4acedd2
fix(schedule-6,7a): recalculate derived figures on blur during entry …
Rylan-cgi Aug 21, 2026
c54560e
fix(schedule-5): recalculate camp rates and totals on blur during ent…
Rylan-cgi Aug 21, 2026
bd4cbee
test(schedule-6): cover the mirrored $ / m3, including the footer bou…
Rylan-cgi Aug 21, 2026
6180be2
test(schedule-7a): cover the four mirrored bridge totals (#291)
Rylan-cgi Aug 21, 2026
348a7f3
fix(schedule-5-subpage): recalculate the row rate and footer on blur …
Rylan-cgi Aug 21, 2026
1c39eac
fix(schedule-3-subpage): recalculate the Other Acceptable footer on b…
Rylan-cgi Aug 21, 2026
21d779b
fix(review): whole-dollar + strict parse + commit gates; Sch 7A echo …
Rylan-cgi Aug 21, 2026
9a58597
fix(review): Schedule 5 + sub-page — diverged rates, delete desync, r…
Rylan-cgi Aug 21, 2026
7cf91cd
fix(review): Schedule 3 sub-page — one blur event, in-flight rows, ke…
Rylan-cgi Aug 21, 2026
cb5292c
test(review): real transcriptions, honest headers, sumN coverage, Sch…
Rylan-cgi Aug 21, 2026
5fd9c17
Merge remote-tracking branch 'origin/main' into fix/bugfix-291-auto-r…
Rylan-cgi Aug 21, 2026
e666ac8
fix(schedule6): import groupInput — the blur re-mask shipped inert (#…
Rylan-cgi Aug 21, 2026
0f20f60
refactor: clear three SonarQube smells, and pin what one of them exposed
Rylan-cgi Aug 21, 2026
1116d4d
fix(schedule3-subpage): address PR #344 review — one parser, gated co…
Rylan-cgi Aug 21, 2026
6d8cc2c
Merge remote-tracking branch 'origin/main' into fix/bugfix-291-auto-r…
Rylan-cgi Aug 24, 2026
86e9fc7
style(schedule3-subpage): prettier — double quotes on a test name wit…
Rylan-cgi Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions frontend/e2e/features/sch2/uc-sch2-001-report-costs/defects.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,21 @@ at-rest state.
- **Ticket:** [bcgov/nr-ilcr#291](https://github.com/bcgov/nr-ilcr/issues/291) — *"[BUGFIX]: Automatic
Recalculation for Schedule 1, 2, 3 and 4."* Raised app-wide, not just for Schedule 2; the behaviour
difference is explained in a comment on that issue.
- **Status:** OPEN — **with the dev.** The Schedule 2 dev will fix it when he gets a chance; QA confirms
and closes this status line afterwards. **If recalculation-on-blur is restored, `happy-path.feature`
will fail** — it currently pins the existing behaviour, so update that scenario as part of the fix
rather than treating it as a regression (noted on the issue too).
- **Test:** covered as the app behaves — `happy-path.feature` `@p0 @S01` asserts the at-rest figures are
still shown *after* entry and *before* the Save, then the recomputed figures after it, so a change in
either direction fails. No red, because this is traceable to a ratified architecture decision rather
than a suspected fault.
- **Status:** **RESOLVED 2026-08-21** — recalculation-on-blur restored across Schedules 1-4 on
`fix/bugfix-291-auto-recalculation`. Spine AD-5 was amended: the "computed server-side, never
accepted from a client" rule governs authority and persistence, and does not forbid a display-only
mirror that keeps read-only cells tracking entry before Save. The mirror lives in one `derived.ts`
per schedule, is never sent on a write, and is superseded by the server echo on every Save.
`happy-path.feature` was updated as this entry instructed — its pre-save block now carries the
recalculated figures, deliberately identical to the post-save block, so the scenario asserts
mirror-vs-server agreement against a real backend rather than pinning the divergence. Trigger was
on **blur**, not per keystroke, matching the legacy `f:ajax event="change"` handlers.
Record: `_bmad-output/implementation-artifacts/defect-291-automatic-recalculation-schedules-1-4.md`.
- **Test:** `happy-path.feature` `@p0 @S01` now asserts the RECALCULATED figures after entry and before
the Save, and the same figures again after it — so it fails if the mirror and the server ever
disagree, and fails again if recalculation-on-blur is removed. Frontend unit and RTL coverage sits in
`components/schedule{1,2,3,4}/__tests__/derived.test.ts` and each page's `#291` tests, with
expectations transcribed from the backend service tests.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,20 @@ Feature: Schedule 2 — enter and save purchased log costs and log sales
| Less Log Sales volume | 4 |
| Less Log Sales cost | 1000 |
| comments | E2E happy path — Schedule 2 |
# DIVERGENCE FROM LEGACY, pinned deliberately (see defects.md DIV-1): legacy recomputed the derived
# figures on each field's own `f:ajax event="change"`, so the totals moved as you typed. Every derived
# value is now computed server-side and only refreshed by the save response (AD-5/AD-6), so BEFORE the
# save the table still shows the at-rest figures. Asserting that here means a future change in either
# direction is caught rather than silently absorbed.
# DIV-1 RESOLVED (#291, 2026-08-21): legacy recomputed the derived figures on each field's own
# `f:ajax event="change"`, so the totals moved as focus left a field. That behaviour is restored by a
# display-only client mirror (spine AD-5 amended), so BEFORE the save the table already shows the
# recalculated figures. The numbers below are deliberately IDENTICAL to the post-save block further
# down: pre-save they come from the mirror, post-save from the server echo, so this scenario is now a
# genuine mirror-vs-server agreement check — the AC5 "no jump on Save" guarantee, against a real
# backend. The carried Wood Overhead and Total Company Logging rows must NOT move (they belong to
# Schedules 3 and 1), which the unchanged rows in the post-save block still assert.
Then the Schedule 2 document shows:
| row | volume | cost | perUnit |
| Subtotal: | 10 | 0 | 0.00 |
| Net Purchased/Private Log Cost: | 10 | 0 | 0.00 |
| Total Average Logging Costs: | 20 | 10 | 0.50 |
| row | volume | cost | perUnit |
| Subtotal: | 10 | 50,000 | 5,000.00 |
| (less) Log Sales: | 4 | 1,000 | 250.00 |
| Net Purchased/Private Log Cost: | 6 | 49,000 | 8,166.67 |
| Total Average Logging Costs: | 16 | 49,010 | 3,063.13 |
When I save Schedule 2
Then I should see the message "Data saved successfully"
# BR-06: subtotal, net purchased and total average are all recomputed server-side from the entered
Expand Down
218 changes: 218 additions & 0 deletions frontend/src/components/schedule1/__tests__/Schedule1.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ const schedule1Doc = {
forestMgmtAdminCost: 600000,
lessSilvAdminCost: 150000,
otherCosts: { volume: 8000, costSubtotal: 24000, perUnit: 3.0, count: 2 },
// The server's own derived figures for THIS document, so the mirror can be compared against them
// rather than against hand arithmetic in this file (code review 2026-08-21). Computed from
// Schedule1Service's formulas: subtotal = 50000 line-12 + 600000 FMA + 24000 other = 674000;
// total silviculture = 20000 actual - 150000 Sch3 admin (no accrued) = -130000; grand total = 544000;
// its rate = 544000 / 54321 crown = 10.01; less-silv-admin rate = 150000 / 55 = 2727.27. The 143/144
// and 140 rates are null because those volumes are absent from the fixture.
subtotalCompanyLoggingCost: 674000,
subtotalCompanyLoggingPerUnit: null,
totalSilvicultureCost: -130000,
totalSilviculturePerUnit: null,
totalCompanyLoggingCost: 544000,
totalCompanyLoggingPerUnit: 10.01,
forestMgmtAdminPerUnit: null,
lessSilvAdminPerUnit: 2727.27,
warnings: [],
}

Expand Down Expand Up @@ -100,6 +114,210 @@ const problemBody = (status: number, detail: string) =>
})

describe('Schedule1 editable page', () => {
// ---- Defect #291: derived figures track data entry, on blur, before Save. -----------------------

/** A cost-table row's cells as text: [label, volume, cost, $/m³]. */
const rowCells = (label: string | RegExp) => {
const tr = screen.getByText(label).closest('tr')
if (!tr) throw new Error(`no row for "${String(label)}"`)
return within(tr)
.getAllByRole('cell')
.map((cell) => cell.textContent)
}
/** The read-only $/m³ cell of a cost-table row (index 3). */
const rate = (label: string | RegExp) => rowCells(label)[3]
/** The read-only cost cell (index 2). */
const costOf = (label: string | RegExp) => rowCells(label)[2]

const SUBTOTAL = 'Subtotal Company Logging Cost (no Silviculture)'
const GRAND_TOTAL = 'Total Company Logging Costs (Including total Silviculture Cost)'

test('typing alone moves nothing; blurring a logging cost recalculates the whole chain (#291)', async () => {
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
const user = userEvent.setup()

const cost = await screen.findByLabelText('Standing Tree to Loaded Truck cost')
// Seeded: 50000/1000 = 50.00. Subtotal = 50000 + 600000 FMA + 24000 other = 674000.
expect(rate('Standing Tree to Loaded Truck')).toBe('50.00')
expect(costOf(SUBTOTAL)).toBe('674,000')

await user.clear(cost)
await user.type(cost, '100000')
expect(rate('Standing Tree to Loaded Truck')).toBe('50.00') // not per keystroke
expect(costOf(SUBTOTAL)).toBe('674,000')

await user.tab()
expect(rate('Standing Tree to Loaded Truck')).toBe('100.00') // 100000/1000
expect(costOf(SUBTOTAL)).toBe('724,000') // 100000 + 600000 + 24000
// Grand total = subtotal + total silviculture (20000 − 150000 = −130000) = 594000,
// over the Sch 3 crown volume 54321 -> 10.93.
expect(costOf(GRAND_TOTAL)).toBe('594,000')
expect(rate(GRAND_TOTAL)).toBe('10.93')
})

test("a volume blur recalculates that row's $/m³ only (#291)", async () => {
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
const user = userEvent.setup()

const volume = await screen.findByLabelText('Standing Tree to Loaded Truck volume')
await user.clear(volume)
await user.type(volume, '2000')
await user.tab()

expect(rate('Standing Tree to Loaded Truck')).toBe('25.00') // 50000/2000
// A volume is not part of any cost total, so the subtotal is unchanged.
expect(costOf(SUBTOTAL)).toBe('674,000')
})

test("139's RATE is mirrored while its COST stays the Schedule 3 pull (#291)", async () => {
// Row 139's two halves come from different places, and the page treats them differently on
// purpose: the cost is pulled from Schedule 3 and nothing on this page feeds it, but the VOLUME is
// user-entered -- so `deriveSchedule1` computes a rate for 139 (derived.ts:98) even though it
// computes no cost for it. The mirror therefore supersedes 139's rate and must NOT touch its cost.
//
// Written after a SonarQube refactor of a three-deep ternary (2026-08-21) exposed that this
// opposite-precedence rule was pinned by no test: inverting it left all 268 green.
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
const user = userEvent.setup()

const label = 'Less Silviculture Admin Costs'
// Served state: 150,000 pulled from Schedule 3 over the fixture's volume of 55.
expect(await screen.findByLabelText(`${label} volume`)).toHaveValue('55')
expect(rate(label)).toBe('2,727.27') // 150,000 / 55

const volume = screen.getByLabelText(`${label} volume`)
await user.clear(volume)
await user.type(volume, '60000')
await user.tab()

// The rate moved off the served figure -- the mirror owns it.
expect(rate(label)).toBe('2.50') // 150,000 / 60,000
// ...while the cost is still the Schedule 3 pull, untouched by the mirror.
expect(costOf(label)).toBe('150,000')
})

test('the Other Costs $/m³ tracks the volume entered on this page (#291)', async () => {
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
const user = userEvent.setup()

const volume = await screen.findByLabelText('Subtotal Other Costs volume')
// Seeded: subtotal 24000 over volume 8000 = 3.00.
expect(rate(/^Subtotal Other Costs\(2\):$/)).toBe('3.00')

await user.clear(volume)
await user.type(volume, '6000')
await user.tab()
expect(rate(/^Subtotal Other Costs\(2\):$/)).toBe('4.00') // 24000/6000

// Clearing it blanks the rate rather than dividing by zero.
await user.clear(screen.getByLabelText('Subtotal Other Costs volume'))
await user.tab()
expect(rate(/^Subtotal Other Costs\(2\):$/)).toBe('—')
})

test('Total Silviculture keeps legacy null semantics as costs are entered (#291)', async () => {
// The pre-fill fixture has every volume set and every cost blank, with no Sch 3 admin pull, so
// Total Silviculture must read blank — not a negative admin cost.
server.use(http.get(URL, () => HttpResponse.json(prefillDoc)))
render(<Schedule1 />)
const user = userEvent.setup()

const label = 'Total Silviculture (As per Financial Statements)'
expect(await screen.findByText(label)).toBeInTheDocument()
expect(costOf(label)).toBe('—')

// Entering an Accrued cost alone is enough to produce a total (addition needs one operand).
const accrued = screen.getByLabelText('Accrued less Actual $ Spent cost')
await user.clear(accrued)
await user.type(accrued, '50000')
await user.tab()
expect(costOf(label)).toBe('50,000')
})

test('on load the mirror reproduces the served figures exactly (#291 AC5)', async () => {
// A direct mirror-vs-server comparison with no edit involved: the fixture now carries the figures
// Schedule1Service computes for it, so a mirror that rounds or propagates nulls differently fails
// here. Schedule 1 is the page with the two easiest-to-conflate rules, and before the code review
// its fixture carried no derived fields at all — so every assertion was self-referential.
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
await screen.findByText('Standing Tree to Loaded Truck')

expect(costOf(SUBTOTAL)).toBe('674,000')
expect(costOf(GRAND_TOTAL)).toBe('544,000')
expect(rate(GRAND_TOTAL)).toBe('10.01')
expect(rate('Standing Tree to Loaded Truck')).toBe('50.00')
expect(rate('Less Silviculture Admin Costs')).toBe('2,727.27') // 150000 / 55
expect(costOf('Total Silviculture (As per Financial Statements)')).toBe('-130,000')
expect(rate(/^Subtotal Other Costs\(2\):$/)).toBe('3.00')
})

test('the mirror equals the SERVER figures, before and after Save (#291 AC5)', async () => {
// Asserted against the echo's own derived fields, not against a snapshot of the pre-Save render:
// an editable page always renders the mirror, so comparing render-to-render compared the mirror
// with itself and passed even with a wrong echo (code review 2026-08-21).
server.use(
http.get(URL, () => HttpResponse.json(schedule1Doc)),
http.put(URL, () =>
HttpResponse.json({
...schedule1Doc,
revisionCount: 4,
lineItems: [{ costItemCode: 12, volume: 1000, cost: 100000, perUnit: 100.0 }],
subtotalCompanyLoggingCost: 724000,
totalSilvicultureCost: -130000,
totalCompanyLoggingCost: 594000,
totalCompanyLoggingPerUnit: 10.93,
message: { key: 'dataSavedSuccesfullyInfoMsg', text: 'Data saved successfully' },
}),
),
)
render(<Schedule1 />)
const user = userEvent.setup()

const cost = await screen.findByLabelText('Standing Tree to Loaded Truck cost')
await user.clear(cost)
await user.type(cost, '100000')
await user.tab()

// The mirror must already agree with what the server will send: 100000 + 600000 + 24000 = 724000,
// grand total 724000 - 130000 = 594000, rate 594000 / 54321 = 10.93.
expect(costOf(SUBTOTAL)).toBe('724,000')
expect(costOf(GRAND_TOTAL)).toBe('594,000')
expect(rate(GRAND_TOTAL)).toBe('10.93')

await user.click(screen.getAllByRole('button', { name: /^save$/i })[0])
expect(await screen.findByText('Data saved successfully')).toBeInTheDocument()

expect(costOf(SUBTOTAL)).toBe('724,000')
expect(costOf(GRAND_TOTAL)).toBe('594,000')
expect(rate(GRAND_TOTAL)).toBe('10.93')
})

test('view mode renders the document figures as-is — no client recomputation (#291 AC7)', async () => {
// A stored subtotal that deliberately disagrees with the line items: a recomputing view would show
// 674,000 instead of the server's own figure.
server.use(
http.get(URL, () =>
HttpResponse.json({
...schedule1Doc,
trackStatus: 'S',
editable: false,
subtotalCompanyLoggingCost: 999999,
subtotalCompanyLoggingPerUnit: 111.11,
}),
),
)
render(<Schedule1 />)

expect(await screen.findByText(SUBTOTAL)).toBeInTheDocument()
expect(costOf(SUBTOTAL)).toBe('999,999')
expect(rate(SUBTOTAL)).toBe('111.11')
})

test('editable:true renders an editable form; perUnit stays read-only (AC1)', async () => {
server.use(http.get(URL, () => HttpResponse.json(schedule1Doc)))
render(<Schedule1 />)
Expand Down
Loading
Loading