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>
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|