fix(treatment): never keep a bridge across a tooth that left it

A bridge is a contiguous span, but every operation that filtered teeth out of
a group kept the `connected` kind as long as two teeth remained. Reduce a
12-13-14 span to 12 and 14 and you get a "bridge" with no pontic — and worse,
both teeth keep one selectionGroupId, so the lab receives them as a single
unit and task generation builds work that cannot be made.

linkedEdgesFromGroups already guarded the drawn marks with areArchNeighbors,
so the chart looked right while the data was wrong.

Adds splitDisconnectedRuns, which breaks what remains into contiguous runs: a
run of two or more stays connected, a run of one becomes a single. The first
run keeps the original groupId so lab rows pointing at it stay valid, and
pruneToothProsthesisForGroups re-maps the rest.

Applied at all three sites that filter a group's teeth, which each carried
their own copy of the length check:
- groupsFromFlatTeeth, when teeth are no longer selected
- applyShiftRange, when a shift-range takes teeth from an existing group
- removeTeethFromGroups, the path pruneDetailTeethToJobs uses

toggleToothInGroups already split into runs by hand for the single-removal
case; this is the same rule, shared.

Tests: a new toothSelectionGroups.spec.ts (13) plus two for
removeTeethFromGroups. The middle-tooth cases fail without the fix; the
end-tooth and contiguous cases pass either way and exist to prove it does not
over-reach. CLAUDE.md updated for the third Vitest file.

Not fixed: a pre-existing unused `leftIdx` warning in unlinkAdjacentTeeth,
unrelated to this change.

Gates: 52 Vitest tests, tsc --noEmit clean, next build clean, ESLint
unchanged at 1 pre-existing warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 17:26:23 +08:00
parent dc23c3dd1c
commit 46c8a25139
5 changed files with 198 additions and 16 deletions

View File

@@ -39,7 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`) |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`) |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
@@ -103,7 +103,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts` and `voiceReviewRows.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts` and `toothSelectionGroups.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
## Deployment