From e271858a33eff874affc60a9454be3750517453c Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Mon, 7 Sep 2026 12:27:11 +0800 Subject: [PATCH] fix(voice): repair the two defects the correctness critic found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by the /orchestrate correctness critic against 15ddb9a, and both verified by hand before fixing. 1. A picked tooth chip was dropped on Apply. pickCandidate ticked the teeth row from `available`, which is memoised from `effective` — the value the same handler is in the middle of changing. On "ترمیم برای دندون دو" the backend returns no teeth, so `available.teeth` was false at pick time and `prev.teeth` was false too; `false || false` stuck permanently. A variant of the original live-test failure. Each branch now ticks the row it feeds and returns, so nothing reads the stale memo. 2. Decision 41's type-row lock was missing. The treatmentType row was a plain toggle, and applyVoiceResult derived `labDependent` from `result.treatmentType` rather than the `detail.treatmentType` it writes — so unticking the type row saved prosthesis lab rows on whatever type the appointment purpose had seeded. Row gains a `locked` state, the type row locks while the prosthesis row is ticked, Apply sends the forced tick, and `labDependent` now comes from the detail being written. Gates: backend 16 suites / 209 tests, frontend 37 Vitest tests, tsc --noEmit clean, next build clean, ESLint 0 new warnings (VoiceReviewSheet.tsx 0 issues; the 10 in TreatmentWorkspace.tsx are pre-existing and unchanged in count). Not covered by a test: both fixes live in component state logic, which the chosen Vitest scope — pure helpers, no React, no DOM — cannot reach. §12's manual checks cover them. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + .../ui/treatment/TreatmentWorkspace.tsx | 10 ++-- .../ui/treatment/VoiceReviewSheet.tsx | 53 ++++++++++++++----- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 66fb8d5..1c05114 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -930,6 +930,7 @@ "voiceNotUnderstood": "Not understood", "voicePickTooth": "Which tooth?", "voicePickJaw": "Which jaw?", + "voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.", "voiceTeethAndProsthesis": "Teeth and prosthesis", "voiceNoProsthesisHeard": "no prosthesis heard", "voiceStackRefused": "not added — cannot be combined", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 9dd9436..23f800d 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -931,6 +931,7 @@ "voiceNotUnderstood": "شناسایی نشد", "voicePickTooth": "کدام دندان؟", "voicePickJaw": "کدام فک؟", + "voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین می‌کند — قابل برداشتن نیست.", "voiceTeethAndProsthesis": "دندان‌ها و پروتز", "voiceNoProsthesisHeard": "پروتزی شنیده نشد", "voiceStackRefused": "افزوده نشد — قابل ترکیب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 08f94ed..749a281 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -930,6 +930,7 @@ "voiceNotUnderstood": "Niet begrepen", "voicePickTooth": "Welk element?", "voicePickJaw": "Welke kaak?", + "voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.", "voiceTeethAndProsthesis": "Elementen en prothese", "voiceNoProsthesisHeard": "geen prothese gehoord", "voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd", diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 6287483..dd659c9 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -62,10 +62,7 @@ import { unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; -import { - isLabDependentResult, - prosthesisTargetLines, -} from '@/components/treatment/voiceReviewRows'; +import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { @@ -2211,7 +2208,10 @@ export function TreatmentWorkspace({ // "teeth" independently of "prosthesis" could save an empty detail, since // `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis` // alone drives both below; `selection.teeth` only ever applies to the plain row. - const labDependent = isLabDependentResult(result, labDependentCodes); + // Derived from the detail actually being written, never from `result.treatmentType`. + // Reading the result meant that unticking the type row still took the prosthesis branch, + // saving lab rows on whatever type the appointment purpose had seeded (decision 41). + const labDependent = labDependentCodes.has(detail.treatmentType); if (!labDependent && selection.teeth) { detail.teeth = [...result.teeth]; detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index 71bc0ad..072263f 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -110,6 +110,12 @@ export function VoiceReviewSheet({ () => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog), [prosthesisLines, joblessTargets, prosthesisCatalog], ); + // Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row + // pins the type row. Locking it is what makes "restoration detail carrying lab rows" + // unreachable rather than merely discouraged. + const typeForcedByProsthesis = + available.prosthesis && selection.prosthesis && effective.treatmentType != null; + const nothingToApply = !hasAnythingToApply(effective, labDependentCodes); const selectedCount = countSelected(selection, available); @@ -134,6 +140,12 @@ export function VoiceReviewSheet({ return chosenTeeth.includes(code as FdiToothId); }; + /** + * A picked candidate has no meaning unless its own row is ticked, so each branch ticks the + * row it feeds. Never derive that from `available`: it is memoised from `effective`, which + * this handler is in the middle of changing, so it still reports the row as unavailable and + * the pick would apply nothing. + */ const pickCandidate = (item: VoiceUnresolvedItem, code: string) => { const index = item.assignmentIndex; if (item.reason === 'arch_not_spoken' && index != null) { @@ -145,12 +157,18 @@ export function VoiceReviewSheet({ [index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch], }; }); - } else if (item.reason === 'prosthesis_type_ambiguous' && index != null) { + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; + } + if (item.reason === 'prosthesis_type_ambiguous' && index != null) { setChosenLeaves((prev) => { const cur = prev[index] ?? []; return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] }; }); - } else if (index != null) { + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; + } + if (index != null) { setChosenAssignmentTeeth((prev) => { const cur = prev[index] ?? []; const tooth = code as FdiToothId; @@ -159,16 +177,12 @@ export function VoiceReviewSheet({ [index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth], }; }); - } else { - const tooth = code as FdiToothId; - setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth])); + setSelection((prev) => ({ ...prev, prosthesis: true })); + return; } - // A picked candidate has no meaning unless its row is ticked. - setSelection((prev) => ({ - ...prev, - teeth: prev.teeth || available.teeth, - prosthesis: true, - })); + const tooth = code as FdiToothId; + setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth])); + setSelection((prev) => ({ ...prev, teeth: true })); }; const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => { @@ -209,8 +223,10 @@ export function VoiceReviewSheet({ {available.treatmentType ? ( {labelFor(effective.treatmentType, treatmentCatalog)} @@ -378,7 +394,13 @@ export function VoiceReviewSheet({ type="button" variant="primary" disabled={selectedCount === 0} - onClick={() => onApply(selection, effective)} + onClick={() => + onApply( + // The locked type row is ticked in the UI, so it must be ticked in what applies. + typeForcedByProsthesis ? { ...selection, treatmentType: true } : selection, + effective, + ) + } fullWidth className="sm:w-auto" > @@ -395,17 +417,20 @@ function Row({ checked, onChange, warning, + locked, children, }: { label: string; checked: boolean; onChange: (checked: boolean) => void; warning?: string; + /** Ticked and not untickable — the value is implied by another row (decision 41). */ + locked?: boolean; children: React.ReactNode; }) { return (
- +
{children}
{warning ? (