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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
131
frontend/src/components/treatment/toothSelectionGroups.spec.ts
Normal file
131
frontend/src/components/treatment/toothSelectionGroups.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
|
||||
Reference in New Issue
Block a user