refactor(voice): extract the apply decision so it can be tested

applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.

buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.

21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.

One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.

Not covered, still manual: the labCaseDraftsRef timing needs a real render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 19:55:39 +08:00
parent 78fd3eb092
commit 2c3e28ac79
7 changed files with 557 additions and 112 deletions

View File

@@ -550,6 +550,41 @@ Files: `backend/src/modules/voice/extraction.prompt.ts`,
Verified: `npx eslint src/modules/voice/**/*.ts` clean, `npm test -- extraction` 74/74.
No unit test — both are prompt text, which the extraction specs deliberately do not assert on.
## 2026-09-11 — the apply decision is now testable (dyolink)
Pulled the decision out of `applyVoiceResult` into
`frontend/src/components/treatment/voiceApply.ts` — `buildVoiceApplyPlan(result, selection, ctx)`
returns `{ detail, labCaseDraft }` and writes nothing. The callback shrank from ~115 lines to the
commit it always should have been: two `setState`s, two ref writes, and the save order.
`newDetail` / `newLabCaseDraft` moved from `TreatmentWorkspace.tsx` to `treatmentDetailRules.ts`,
so the pure module can build a draft without importing a component.
21 new tests in `voiceApply.spec.ts`. Proved they bite by reverting each bug in place:
| Reintroduced bug | Failing tests |
|---|---|
| `labDependent` read from `result.treatmentType` (decision 41) | 1 |
| plain `teeth` merged onto a prosthesis detail (decision 40) | 2 |
One group of tests asserts an invariant that belongs to the *backend*: every `toothProsthesis`
row naming a real tooth must appear in `detail.teeth`. That is
`TREATMENT_TOOTH_NOT_ON_DETAIL` (`treatments.service.ts:806`) — the error the clinic saw — now
checked on the client side of the wire, across the four shapes that produced it: two separated
teeth, one tooth, a two-code stack, and a jaw plus a tooth.
Still not covered, and still manual (§12): the `labCaseDraftsRef` timing, which needs a real
render, and the persist ordering.
Gate: `tsc --noEmit` clean, `vitest run` 73/73 (was 52), `next build` compiled, eslint back to its
pre-change 10 warnings (the 11th was an import I had orphaned).
Files: `frontend/src/components/treatment/voiceApply.ts` (new),
`frontend/src/components/treatment/voiceApply.spec.ts` (new),
`frontend/src/components/treatment/treatmentDetailRules.ts`,
`frontend/src/components/ui/treatment/TreatmentWorkspace.tsx`,
`docs/specs/voice-treatment-entry/spec.md`, `CLAUDE.md`.
## Remaining before this is shippable
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is

View File

@@ -744,6 +744,29 @@ chart writes through — and `applyVoiceResult` applies through it too. A job th
refuse, such as an implant plus a post & core on one tooth, is shown struck through and named.
Not silently dropped, and not silently applied. The rules stay in one file (§6).
### What to apply is a pure function, applying it is not
`applyVoiceResult` used to decide and commit in one callback inside a 3200-line client
component. Nothing could reach it: it is not exported, and rendering its component means mocking
next-intl, the i18n router and six axios modules. Three of the five defects found in live use
sat in exactly that callback, and the 209 tests that were green at the time all covered helpers
around it.
The decision now lives in `frontend/src/components/treatment/voiceApply.ts` as
`buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft }`. It is pure apart from
the two client-id generators, so a test states a dictation and a set of ticks and reads back the
exact rows that will be written. The component keeps what only a component can do — `setDetails`,
the ref writes the in-flight save reads, and the order that lets lab rows carry a real
`treatmentDetailId`.
`voiceApply.spec.ts` pins the three rules that were broken, and one more that is not ours:
every `toothProsthesis` row naming a real tooth must be in `detail.teeth`, which is
`TREATMENT_TOOTH_NOT_ON_DETAIL` at `treatments.service.ts:806` asserted on the client side of the
wire. That invariant spans two processes, so neither side could state it alone before.
What this does **not** cover: the ref-versus-state timing, which needs a real render, and the
save order. Those stay manual (§12).
### Three chip kinds, one pattern
An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is