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:
2026-08-20 17:05:54 +03:30
parent 4764401766
commit dfd376d97a
3 changed files with 252 additions and 8 deletions

View 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();
});
});
});