diff --git a/CLAUDE.md b/CLAUDE.md index a86a059..f7dd166 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/frontend/src/components/treatment/prosthesisTree.spec.ts b/frontend/src/components/treatment/prosthesisTree.spec.ts index 0de7e01..9c505c7 100644 --- a/frontend/src/components/treatment/prosthesisTree.spec.ts +++ b/frontend/src/components/treatment/prosthesisTree.spec.ts @@ -8,8 +8,11 @@ import { catalogByCode, effectiveChartRegion, PARTIAL_DENTURE_CODE, + removeTeethFromGroups, toothRegionColors, } from './prosthesisTree'; +import type { FdiToothId } from '@/types/treatment'; +import type { ToothSelectionGroup } from './toothSelectionGroups'; const CATALOG: ProsthesisCatalogEntry[] = [ { @@ -174,3 +177,27 @@ describe('toothRegionColors', () => { expect(Object.keys(root)).toHaveLength(0); }); }); + +describe('removeTeethFromGroups', () => { + const bridge: ToothSelectionGroup = { + groupId: 'g1', + kind: 'connected', + teeth: ['14', '13', '12'] as FdiToothId[], + }; + + it('does not leave a pontic-less bridge when a middle tooth goes', () => { + // pruneDetailTeethToJobs routes through here, so this is the voice path: a job removed + // from 13 must not leave 12 and 14 wired together as one unit. + const out = removeTeethFromGroups([bridge], ['13'] as FdiToothId[]); + expect(out.every((g) => g.kind === 'single')).toBe(true); + expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14'])); + expect(new Set(out.map((g) => g.groupId)).size).toBe(2); + }); + + it('keeps the remaining span when an end tooth goes', () => { + const out = removeTeethFromGroups([bridge], ['12'] as FdiToothId[]); + expect(out).toHaveLength(1); + expect(out[0].kind).toBe('connected'); + expect(out[0].teeth).toEqual(['14', '13']); + }); +}); diff --git a/frontend/src/components/treatment/prosthesisTree.ts b/frontend/src/components/treatment/prosthesisTree.ts index 8494352..a6a6d22 100644 --- a/frontend/src/components/treatment/prosthesisTree.ts +++ b/frontend/src/components/treatment/prosthesisTree.ts @@ -6,6 +6,7 @@ import { deriveTeethFromGroups, groupsFromFlatTeeth, newGroupId, + splitDisconnectedRuns, } from '@/components/treatment/toothSelectionGroups'; const PROSTHESIS_CATEGORY_ORDER = [ @@ -337,11 +338,9 @@ export function removeTeethFromGroups( for (const group of groups) { const kept = group.teeth.filter((t) => !drop.has(t)); if (kept.length === 0) continue; - if (kept.length === 1) { - next.push({ groupId: group.groupId, kind: 'single', teeth: kept }); - } else { - next.push({ ...group, teeth: kept, kind: group.kind === 'connected' ? 'connected' : 'single' }); - } + // Removing a middle tooth can leave a connected group non-contiguous, which is no longer + // one bridge. splitDisconnectedRuns keeps the runs that are still spans. + next.push(...splitDisconnectedRuns({ ...group, teeth: kept })); } return next.length > 0 ? next : groupsFromFlatTeeth([]); } diff --git a/frontend/src/components/treatment/toothSelectionGroups.spec.ts b/frontend/src/components/treatment/toothSelectionGroups.spec.ts new file mode 100644 index 0000000..d724507 --- /dev/null +++ b/frontend/src/components/treatment/toothSelectionGroups.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import type { FdiToothId } from '@/types/treatment'; +import type { ToothSelectionGroup } from './toothSelectionGroups'; +import { + areArchNeighbors, + connectedTeethSet, + groupsFromFlatTeeth, + linkedEdgesFromGroups, + splitDisconnectedRuns, +} from './toothSelectionGroups'; + +const bridge = (teeth: string[], groupId = 'g1'): ToothSelectionGroup => ({ + groupId, + kind: 'connected', + teeth: teeth as FdiToothId[], +}); + +describe('splitDisconnectedRuns', () => { + it('leaves a contiguous bridge alone, keeping its id', () => { + // 14-13-12 are consecutive in FDI_UPPER_LEFT_TO_RIGHT. + const out = splitDisconnectedRuns(bridge(['12', '13', '14'])); + expect(out).toEqual([ + { groupId: 'g1', kind: 'connected', teeth: ['14', '13', '12'] }, + ]); + }); + + it('splits a bridge that lost its middle tooth into two singles', () => { + // The reported case: a 12-13-14 span reduced to 12 and 14 is not a bridge, it is two + // separate crowns. 13 is the pontic, and it is gone. + const out = splitDisconnectedRuns(bridge(['12', '14'])); + expect(out.map((g) => [g.kind, g.teeth])).toEqual([ + ['single', ['14']], + ['single', ['12']], + ]); + }); + + it('keeps each side that is still a span', () => { + // 16-15-14 | gap at 13 | 12-11 + const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15', '16'])); + expect(out.map((g) => [g.kind, g.teeth])).toEqual([ + ['connected', ['16', '15', '14']], + ['connected', ['12', '11']], + ]); + }); + + it('gives the first run the original id and the rest fresh ids', () => { + const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15'], 'keep-me')); + expect(out[0].groupId).toBe('keep-me'); + expect(out[1].groupId).not.toBe('keep-me'); + expect(out[1].groupId).toBeTruthy(); + }); + + it('never emits a one-tooth connected group', () => { + const out = splitDisconnectedRuns(bridge(['12'])); + expect(out).toEqual([{ groupId: 'g1', kind: 'single', teeth: ['12'] }]); + }); + + it('splits a bridge spanning two arches, which is never one span', () => { + // areArchNeighbors is false across arches, so an upper tooth and a lower one cannot be + // consecutive whatever their numbers. + const out = splitDisconnectedRuns(bridge(['12', '42'])); + expect(out.every((g) => g.kind === 'single')).toBe(true); + expect(out).toHaveLength(2); + }); + + it('passes a single group through untouched', () => { + const single: ToothSelectionGroup = { + groupId: 'g2', + kind: 'single', + teeth: ['26'] as FdiToothId[], + }; + expect(splitDisconnectedRuns(single)).toEqual([single]); + }); +}); + +describe('groupsFromFlatTeeth', () => { + it('does not keep a bridge across a tooth that is no longer selected', () => { + // This is what produced a pontic-less bridge: the filter dropped 13 and the group kept + // its `connected` kind, so both crowns shared one selectionGroupId. + const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[], [ + bridge(['12', '13', '14']), + ]); + expect(out.some((g) => g.kind === 'connected')).toBe(false); + expect(connectedTeethSet(out).size).toBe(0); + expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14'])); + // Two crowns must not share a selection group, or the lab builds them as one unit. + expect(new Set(out.map((g) => g.groupId)).size).toBe(2); + }); + + it('keeps a bridge whose teeth all survive', () => { + const out = groupsFromFlatTeeth(['12', '13', '14'] as FdiToothId[], [ + bridge(['12', '13', '14']), + ]); + expect(out).toHaveLength(1); + expect(out[0].kind).toBe('connected'); + }); + + it('adds a tooth no existing group covers', () => { + const out = groupsFromFlatTeeth(['12', '13', '26'] as FdiToothId[], [ + bridge(['12', '13']), + ]); + expect(connectedTeethSet(out)).toEqual(new Set(['12', '13'])); + expect(out.find((g) => g.teeth.includes('26' as FdiToothId))?.kind).toBe('single'); + }); + + it('makes one single group per tooth when there is nothing to preserve', () => { + const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[]); + expect(out.map((g) => g.kind)).toEqual(['single', 'single']); + }); +}); + +describe('linkedEdgesFromGroups', () => { + it('draws no edge across a gap, so the split matches what was already drawn', () => { + // The marks were already correct before the split — only the group kind and its shared + // id were wrong. This pins that the two now agree. + expect(linkedEdgesFromGroups([bridge(['12', '14'])]).size).toBe(0); + expect(linkedEdgesFromGroups([bridge(['12', '13'])]).size).toBe(1); + }); +}); + +describe('areArchNeighbors', () => { + it('reads adjacency from arch order, not from the numbers', () => { + // 12 and 13 are neighbours; 12 and 14 are two apart with 13 between them. + expect(areArchNeighbors('12' as FdiToothId, '13' as FdiToothId)).toBe(true); + expect(areArchNeighbors('12' as FdiToothId, '14' as FdiToothId)).toBe(false); + // Across the midline, 11 and 21 are adjacent even though the numbers jump. + expect(areArchNeighbors('11' as FdiToothId, '21' as FdiToothId)).toBe(true); + // Across arches, never. + expect(areArchNeighbors('12' as FdiToothId, '42' as FdiToothId)).toBe(false); + }); +}); diff --git a/frontend/src/components/treatment/toothSelectionGroups.ts b/frontend/src/components/treatment/toothSelectionGroups.ts index 3b6fed4..2864b1e 100644 --- a/frontend/src/components/treatment/toothSelectionGroups.ts +++ b/frontend/src/components/treatment/toothSelectionGroups.ts @@ -58,11 +58,8 @@ export function groupsFromFlatTeeth( teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[], })) .filter((g) => g.teeth.length > 0) - .map((g) => ({ - ...g, - kind: normalizeGroupKind(g.kind, g.teeth), - teeth: sortInArchOrder(g.teeth), - })); + .map((g) => ({ ...g, teeth: sortInArchOrder(g.teeth) })) + .flatMap(splitDisconnectedRuns); const covered = new Set(next.flatMap((g) => g.teeth)); for (const tooth of teeth) { if (!covered.has(tooth)) { @@ -121,6 +118,37 @@ export function areArchNeighbors(a: FdiToothId, b: FdiToothId): boolean { return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1; } +/** + * A bridge is a contiguous span, so a connected group that has lost a middle tooth is no longer + * one bridge. `linkedEdgesFromGroups` already refuses to draw an edge between non-neighbours, but + * the group keeps its `connected` kind and its single `groupId` — and that id becomes the + * `selectionGroupId` on every lab row, so task generation would build one unit spanning the gap. + * A "bridge" from 12 to 14 with no pontic is work no lab can make. + * + * Split what is left into contiguous runs; a run of one tooth becomes a single. The first run + * keeps the original `groupId`, so lab rows already pointing at it stay valid, and + * `pruneToothProsthesisForGroups` re-maps the rest. + */ +export function splitDisconnectedRuns(group: ToothSelectionGroup): ToothSelectionGroup[] { + if (group.kind !== 'connected' || group.teeth.length < 2) { + return [{ ...group, kind: normalizeGroupKind(group.kind, group.teeth) }]; + } + const ordered = sortInArchOrder(group.teeth); + const runs: FdiToothId[][] = [[ordered[0]]]; + for (let i = 1; i < ordered.length; i++) { + if (areArchNeighbors(ordered[i - 1], ordered[i])) { + runs[runs.length - 1].push(ordered[i]); + } else { + runs.push([ordered[i]]); + } + } + return runs.map((teeth, index) => ({ + groupId: index === 0 ? group.groupId : newGroupId(), + kind: teeth.length >= 2 ? ('connected' as const) : ('single' as const), + teeth, + })); +} + function archOrder(tooth: FdiToothId): FdiToothId[] | null { if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) { return FDI_UPPER_LEFT_TO_RIGHT; @@ -285,10 +313,7 @@ export function applyShiftRange( teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[], })) .filter((g) => g.teeth.length > 0) - .map((g) => ({ - ...g, - kind: normalizeGroupKind(g.kind, g.teeth), - })); + .flatMap(splitDisconnectedRuns); for (const tooth of unionTeeth) { next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });