fix(voice): repair the two defects the correctness critic found
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) <noreply@anthropic.com>
This commit is contained in:
@@ -930,6 +930,7 @@
|
|||||||
"voiceNotUnderstood": "Not understood",
|
"voiceNotUnderstood": "Not understood",
|
||||||
"voicePickTooth": "Which tooth?",
|
"voicePickTooth": "Which tooth?",
|
||||||
"voicePickJaw": "Which jaw?",
|
"voicePickJaw": "Which jaw?",
|
||||||
|
"voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.",
|
||||||
"voiceTeethAndProsthesis": "Teeth and prosthesis",
|
"voiceTeethAndProsthesis": "Teeth and prosthesis",
|
||||||
"voiceNoProsthesisHeard": "no prosthesis heard",
|
"voiceNoProsthesisHeard": "no prosthesis heard",
|
||||||
"voiceStackRefused": "not added — cannot be combined",
|
"voiceStackRefused": "not added — cannot be combined",
|
||||||
|
|||||||
@@ -931,6 +931,7 @@
|
|||||||
"voiceNotUnderstood": "شناسایی نشد",
|
"voiceNotUnderstood": "شناسایی نشد",
|
||||||
"voicePickTooth": "کدام دندان؟",
|
"voicePickTooth": "کدام دندان؟",
|
||||||
"voicePickJaw": "کدام فک؟",
|
"voicePickJaw": "کدام فک؟",
|
||||||
|
"voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین میکند — قابل برداشتن نیست.",
|
||||||
"voiceTeethAndProsthesis": "دندانها و پروتز",
|
"voiceTeethAndProsthesis": "دندانها و پروتز",
|
||||||
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
|
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
|
||||||
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
|
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
|
||||||
|
|||||||
@@ -930,6 +930,7 @@
|
|||||||
"voiceNotUnderstood": "Niet begrepen",
|
"voiceNotUnderstood": "Niet begrepen",
|
||||||
"voicePickTooth": "Welk element?",
|
"voicePickTooth": "Welk element?",
|
||||||
"voicePickJaw": "Welke kaak?",
|
"voicePickJaw": "Welke kaak?",
|
||||||
|
"voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.",
|
||||||
"voiceTeethAndProsthesis": "Elementen en prothese",
|
"voiceTeethAndProsthesis": "Elementen en prothese",
|
||||||
"voiceNoProsthesisHeard": "geen prothese gehoord",
|
"voiceNoProsthesisHeard": "geen prothese gehoord",
|
||||||
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
|
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ import {
|
|||||||
unlinkAdjacentTeeth,
|
unlinkAdjacentTeeth,
|
||||||
} from '@/components/treatment/toothSelectionGroups';
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
|
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
|
||||||
import {
|
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
|
||||||
isLabDependentResult,
|
|
||||||
prosthesisTargetLines,
|
|
||||||
} from '@/components/treatment/voiceReviewRows';
|
|
||||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||||
import {
|
import {
|
||||||
@@ -2211,7 +2208,10 @@ export function TreatmentWorkspace({
|
|||||||
// "teeth" independently of "prosthesis" could save an empty detail, since
|
// "teeth" independently of "prosthesis" could save an empty detail, since
|
||||||
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
|
// `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.
|
// 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) {
|
if (!labDependent && selection.teeth) {
|
||||||
detail.teeth = [...result.teeth];
|
detail.teeth = [...result.teeth];
|
||||||
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
|
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ export function VoiceReviewSheet({
|
|||||||
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
|
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
|
||||||
[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 nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
|
||||||
const selectedCount = countSelected(selection, available);
|
const selectedCount = countSelected(selection, available);
|
||||||
|
|
||||||
@@ -134,6 +140,12 @@ export function VoiceReviewSheet({
|
|||||||
return chosenTeeth.includes(code as FdiToothId);
|
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 pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
|
||||||
const index = item.assignmentIndex;
|
const index = item.assignmentIndex;
|
||||||
if (item.reason === 'arch_not_spoken' && index != null) {
|
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],
|
[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) => {
|
setChosenLeaves((prev) => {
|
||||||
const cur = prev[index] ?? [];
|
const cur = prev[index] ?? [];
|
||||||
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
|
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) => {
|
setChosenAssignmentTeeth((prev) => {
|
||||||
const cur = prev[index] ?? [];
|
const cur = prev[index] ?? [];
|
||||||
const tooth = code as FdiToothId;
|
const tooth = code as FdiToothId;
|
||||||
@@ -159,16 +177,12 @@ export function VoiceReviewSheet({
|
|||||||
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
|
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
setSelection((prev) => ({ ...prev, prosthesis: true }));
|
||||||
const tooth = code as FdiToothId;
|
return;
|
||||||
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
|
|
||||||
}
|
}
|
||||||
// A picked candidate has no meaning unless its row is ticked.
|
const tooth = code as FdiToothId;
|
||||||
setSelection((prev) => ({
|
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
|
||||||
...prev,
|
setSelection((prev) => ({ ...prev, teeth: true }));
|
||||||
teeth: prev.teeth || available.teeth,
|
|
||||||
prosthesis: true,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
|
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
|
||||||
@@ -209,8 +223,10 @@ export function VoiceReviewSheet({
|
|||||||
{available.treatmentType ? (
|
{available.treatmentType ? (
|
||||||
<Row
|
<Row
|
||||||
label={t('treatmentType')}
|
label={t('treatmentType')}
|
||||||
checked={selection.treatmentType}
|
checked={selection.treatmentType || typeForcedByProsthesis}
|
||||||
onChange={toggle('treatmentType')}
|
onChange={toggle('treatmentType')}
|
||||||
|
locked={typeForcedByProsthesis}
|
||||||
|
warning={typeForcedByProsthesis ? t('voiceTypeForcedByProsthesis') : undefined}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{labelFor(effective.treatmentType, treatmentCatalog)}
|
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||||
@@ -378,7 +394,13 @@ export function VoiceReviewSheet({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={selectedCount === 0}
|
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
|
fullWidth
|
||||||
className="sm:w-auto"
|
className="sm:w-auto"
|
||||||
>
|
>
|
||||||
@@ -395,17 +417,20 @@ function Row({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
warning,
|
warning,
|
||||||
|
locked,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
|
/** Ticked and not untickable — the value is implied by another row (decision 41). */
|
||||||
|
locked?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
|
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
|
||||||
<Checkbox checked={checked} onChange={onChange} label={label} />
|
<Checkbox checked={checked} onChange={onChange} label={label} disabled={locked} />
|
||||||
<div className="mt-1 ps-7 min-w-0">{children}</div>
|
<div className="mt-1 ps-7 min-w-0">{children}</div>
|
||||||
{warning ? (
|
{warning ? (
|
||||||
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
|
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user