feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and treatment.utils.ts already held a private copy of the tooth set. Lift it into common/fdi.ts rather than create a second source of truth; treatment.utils now imports it, behaviour unchanged (existing suites still pass). toFdi() is the single place the patient-right convention lives: quadrant 1 is the patient's upper right, so upper+patient_right -> 1x, upper+patient_left -> 2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards mirrors every quadrant and yields a valid-looking code for the wrong tooth, which no schema check can catch — so all four quadrants are pinned by tests, along with out-of-range positions never being clamped and deciduous teeth being rejected outright (the chart is permanent dentition only). Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21 and 41-31 count as neighbours exactly as the chart treats them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
backend/src/common/fdi.spec.ts
Normal file
125
backend/src/common/fdi.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
areArchNeighbors,
|
||||
FDI_TOOTH_IDS,
|
||||
isFdiTooth,
|
||||
sameArch,
|
||||
teethBetweenInclusive,
|
||||
toFdi,
|
||||
} from './fdi';
|
||||
|
||||
describe('FDI geometry', () => {
|
||||
describe('toFdi — the patient-right convention', () => {
|
||||
// A mirrored quadrant produces a *valid* code for the wrong tooth, so no schema
|
||||
// check can catch it. These four cases are the guard.
|
||||
it('maps upper + patient right to quadrant 1', () => {
|
||||
expect(toFdi('upper', 'patient_right', 6)).toBe('16');
|
||||
expect(toFdi('upper', 'patient_right', 1)).toBe('11');
|
||||
});
|
||||
|
||||
it('maps upper + patient left to quadrant 2', () => {
|
||||
expect(toFdi('upper', 'patient_left', 6)).toBe('26');
|
||||
expect(toFdi('upper', 'patient_left', 8)).toBe('28');
|
||||
});
|
||||
|
||||
it('maps lower + patient left to quadrant 3', () => {
|
||||
expect(toFdi('lower', 'patient_left', 6)).toBe('36');
|
||||
});
|
||||
|
||||
it('maps lower + patient right to quadrant 4', () => {
|
||||
expect(toFdi('lower', 'patient_right', 6)).toBe('46');
|
||||
expect(toFdi('lower', 'patient_right', 8)).toBe('48');
|
||||
});
|
||||
|
||||
it('never clamps an out-of-range position', () => {
|
||||
expect(toFdi('upper', 'patient_right', 9)).toBeNull();
|
||||
expect(toFdi('upper', 'patient_right', 0)).toBeNull();
|
||||
expect(toFdi('upper', 'patient_right', -1)).toBeNull();
|
||||
expect(toFdi('upper', 'patient_right', 1.5)).toBeNull();
|
||||
expect(toFdi('upper', 'patient_right', Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFdiTooth', () => {
|
||||
it('accepts all 32 permanent teeth', () => {
|
||||
expect(FDI_TOOTH_IDS.size).toBe(32);
|
||||
for (const tooth of FDI_TOOTH_IDS) expect(isFdiTooth(tooth)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects deciduous teeth — the chart has no primary dentition', () => {
|
||||
for (const tooth of ['51', '55', '61', '71', '85']) {
|
||||
expect(isFdiTooth(tooth)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
for (const value of [
|
||||
'',
|
||||
'1',
|
||||
'19',
|
||||
'10',
|
||||
'29',
|
||||
'99',
|
||||
14,
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
]) {
|
||||
expect(isFdiTooth(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjacency', () => {
|
||||
it('treats neighbours within a quadrant as adjacent', () => {
|
||||
expect(areArchNeighbors('14', '15')).toBe(true);
|
||||
expect(areArchNeighbors('15', '14')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats the midline pairs as adjacent', () => {
|
||||
expect(areArchNeighbors('11', '21')).toBe(true);
|
||||
expect(areArchNeighbors('41', '31')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-neighbours and cross-arch pairs', () => {
|
||||
expect(areArchNeighbors('14', '16')).toBe(false);
|
||||
expect(areArchNeighbors('18', '28')).toBe(false);
|
||||
expect(areArchNeighbors('14', '44')).toBe(false);
|
||||
expect(areArchNeighbors('14', '14')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameArch', () => {
|
||||
it('groups by arch, not by quadrant', () => {
|
||||
expect(sameArch('18', '28')).toBe(true);
|
||||
expect(sameArch('48', '38')).toBe(true);
|
||||
expect(sameArch('18', '48')).toBe(false);
|
||||
expect(sameArch('14', '99')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teethBetweenInclusive', () => {
|
||||
it('returns the span in arch order regardless of argument order', () => {
|
||||
expect(teethBetweenInclusive('14', '16')).toEqual(['16', '15', '14']);
|
||||
expect(teethBetweenInclusive('16', '14')).toEqual(['16', '15', '14']);
|
||||
});
|
||||
|
||||
it('spans the midline', () => {
|
||||
expect(teethBetweenInclusive('12', '22')).toEqual([
|
||||
'12',
|
||||
'11',
|
||||
'21',
|
||||
'22',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns a single tooth for identical endpoints', () => {
|
||||
expect(teethBetweenInclusive('14', '14')).toEqual(['14']);
|
||||
});
|
||||
|
||||
it('returns null across arches or for unknown teeth', () => {
|
||||
expect(teethBetweenInclusive('14', '44')).toBeNull();
|
||||
expect(teethBetweenInclusive('14', '99')).toBeNull();
|
||||
expect(teethBetweenInclusive('99', '14')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
backend/src/common/fdi.ts
Normal file
120
backend/src/common/fdi.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* FDI tooth geometry — permanent dentition only.
|
||||
*
|
||||
* Mirrors `frontend/src/components/treatment/fdiToothMeta.ts` and the adjacency rules in
|
||||
* `toothSelectionGroups.ts`. Adjacency is defined by position in the arch order, so the
|
||||
* midline pairs (11–21, 41–31) are neighbours, exactly as the chart treats them.
|
||||
*/
|
||||
|
||||
export type Arch = 'upper' | 'lower';
|
||||
|
||||
/** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */
|
||||
export type PatientSide = 'patient_right' | 'patient_left';
|
||||
|
||||
/**
|
||||
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28).
|
||||
* That is the drawn left-to-right layout, which is the mirror of the patient's own sides.
|
||||
* Do not read a tooth position off this array by index — use `toFdi()`, which owns the
|
||||
* side convention.
|
||||
*/
|
||||
export const FDI_UPPER_ARCH_ORDER = [
|
||||
'18',
|
||||
'17',
|
||||
'16',
|
||||
'15',
|
||||
'14',
|
||||
'13',
|
||||
'12',
|
||||
'11',
|
||||
'21',
|
||||
'22',
|
||||
'23',
|
||||
'24',
|
||||
'25',
|
||||
'26',
|
||||
'27',
|
||||
'28',
|
||||
] as const;
|
||||
|
||||
/** Lower arch, same chart ordering: patient's RIGHT (48) → midline → patient's LEFT (38). */
|
||||
export const FDI_LOWER_ARCH_ORDER = [
|
||||
'48',
|
||||
'47',
|
||||
'46',
|
||||
'45',
|
||||
'44',
|
||||
'43',
|
||||
'42',
|
||||
'41',
|
||||
'31',
|
||||
'32',
|
||||
'33',
|
||||
'34',
|
||||
'35',
|
||||
'36',
|
||||
'37',
|
||||
'38',
|
||||
] as const;
|
||||
|
||||
export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
|
||||
...FDI_UPPER_ARCH_ORDER,
|
||||
...FDI_LOWER_ARCH_ORDER,
|
||||
]);
|
||||
|
||||
export function isFdiTooth(value: unknown): value is string {
|
||||
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
|
||||
}
|
||||
|
||||
function archOrder(tooth: string): readonly string[] | null {
|
||||
if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth))
|
||||
return FDI_UPPER_ARCH_ORDER;
|
||||
if ((FDI_LOWER_ARCH_ORDER as readonly string[]).includes(tooth))
|
||||
return FDI_LOWER_ARCH_ORDER;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sameArch(a: string, b: string): boolean {
|
||||
const archA = archOrder(a);
|
||||
const archB = archOrder(b);
|
||||
return Boolean(archA && archB && archA === archB);
|
||||
}
|
||||
|
||||
export function areArchNeighbors(a: string, b: string): boolean {
|
||||
const arch = archOrder(a);
|
||||
if (!arch || !sameArch(a, b)) return false;
|
||||
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
|
||||
}
|
||||
|
||||
/** Inclusive span between two teeth of the same arch, in arch order. Null if not comparable. */
|
||||
export function teethBetweenInclusive(a: string, b: string): string[] | null {
|
||||
const arch = archOrder(a);
|
||||
if (!arch || !sameArch(a, b)) return null;
|
||||
const i = arch.indexOf(a);
|
||||
const j = arch.indexOf(b);
|
||||
if (i < 0 || j < 0) return null;
|
||||
const [from, to] = i <= j ? [i, j] : [j, i];
|
||||
return [...arch.slice(from, to + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
|
||||
*
|
||||
* This function is the single place the patient-right convention lives. Getting it
|
||||
* backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth,
|
||||
* which no schema check can catch — hence the exhaustive test coverage.
|
||||
*/
|
||||
export function toFdi(
|
||||
arch: Arch,
|
||||
side: PatientSide,
|
||||
position: number,
|
||||
): string | null {
|
||||
if (!Number.isInteger(position) || position < 1 || position > 8) return null;
|
||||
let quadrant: number;
|
||||
if (arch === 'upper') {
|
||||
quadrant = side === 'patient_right' ? 1 : 2;
|
||||
} else {
|
||||
quadrant = side === 'patient_left' ? 3 : 4;
|
||||
}
|
||||
const code = `${quadrant}${position}`;
|
||||
return FDI_TOOTH_IDS.has(code) ? code : null;
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
const FDI_TOOTH_IDS = new Set([
|
||||
'11', '12', '13', '14', '15', '16', '17', '18',
|
||||
'21', '22', '23', '24', '25', '26', '27', '28',
|
||||
'31', '32', '33', '34', '35', '36', '37', '38',
|
||||
'41', '42', '43', '44', '45', '46', '47', '48',
|
||||
]);
|
||||
import { FDI_TOOTH_IDS } from '../../common/fdi';
|
||||
|
||||
export function normalizeTeeth(teeth: unknown): string[] {
|
||||
if (!Array.isArray(teeth)) {
|
||||
@@ -35,7 +30,10 @@ export function normalizeToothSelectionGroups(
|
||||
for (const row of value) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const rec = row as Record<string, unknown>;
|
||||
const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : '';
|
||||
const groupId =
|
||||
typeof rec.groupId === 'string' && rec.groupId.trim()
|
||||
? rec.groupId.trim()
|
||||
: '';
|
||||
if (!groupId) continue;
|
||||
const kind = rec.kind === 'connected' ? 'connected' : 'single';
|
||||
const teeth = normalizeTeeth(rec.teeth);
|
||||
@@ -64,7 +62,8 @@ export function generateTreatmentTitle(
|
||||
}
|
||||
|
||||
const parts = cases.map((c) => {
|
||||
const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
|
||||
const label =
|
||||
c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
|
||||
if (c.teeth.length > 0) {
|
||||
return `${label} ${c.teeth.join(', ')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user