fix(voice): validate a code's region against its target properly

Second correctness pass on e271858 found three ways an arch-only appliance
could be written as a per-tooth job, plus a gap in the previous repair.

1. The deferred region check was never completed. A mixed-region category
   resolved with no region recorded, and nothing re-validated the leaf the
   clinician then picked — "پروتز متحرک برای دندون ۱۲" wrote a complete
   denture onto tooth 12, which the manual chart cannot produce and which
   task generation would expand as denture steps. Targets now resolve BEFORE
   types, so the target kind narrows a category's candidates and an
   impossible leaf is never offered. The deferral disappears with it.

2. `some` over the stack's regions let one legal code admit every other.
   `types: ['pfm_crown','night_guard_soft']` on tooth 12 passed, because
   `crown` suited the tooth, and wrote a night guard onto that tooth. Now
   every named code must suit the target.

3. `regions.size === 1` also deferred `implant`, whose leaves span root and
   crown — both tooth regions, so not a tooth/arch category at all. An
   implant aimed at a jaw resolved as a UA target. Narrowing by target kind
   rejects it instead.

4. The e271858 type-row lock ticked the row and the payload but not the
   count, so the sheet read "Apply 1 field" while two landed. One
   `effectiveSelection` now drives the count and the payload.

Also narrows a `filter(Boolean)` in the disjointness test that left two
implicit-any errors under the full tsconfig (nest build excludes specs, so
the repo gate never saw them). Pre-existing at 15ddb9a.

Adds four resolver tests: candidate narrowing per target kind, a category
with no usable leaf, a stack where only one code suits, and a legal stack.
All three defects passed the previous 209 tests.

Gates: backend 212 tests, nest build, ESLint clean on the voice module;
frontend 37 Vitest tests, tsc --noEmit, ESLint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:42:06 +08:00
parent e271858a33
commit f1a4594a0a
3 changed files with 170 additions and 66 deletions

View File

@@ -116,8 +116,15 @@ export function VoiceReviewSheet({
const typeForcedByProsthesis =
available.prosthesis && selection.prosthesis && effective.treatmentType != null;
// What Apply actually sends. The locked type row is ticked on screen, so it has to be
// ticked in the count and in the payload alike — counting the raw `selection` showed
// "Apply 1" while two fields landed.
const effectiveSelection: VoiceApplySelection = typeForcedByProsthesis
? { ...selection, treatmentType: true }
: selection;
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(selection, available);
const selectedCount = countSelected(effectiveSelection, available);
const targetLabel = (target: string): string => {
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
@@ -394,13 +401,7 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
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,
)
}
onClick={() => onApply(effectiveSelection, effective)}
fullWidth
className="sm:w-auto"
>