import { areArchNeighbors, sortInArchOrder, 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(); }); }); describe('sortInArchOrder', () => { it('sorts along the arch rather than lexically', () => { expect(sortInArchOrder(['14', '16', '15'])).toEqual(['16', '15', '14']); }); it('places 11 beside 21 across the midline', () => { expect(sortInArchOrder(['21', '11', '12'])).toEqual(['12', '11', '21']); }); it('is a no-op for an empty or single-tooth list', () => { expect(sortInArchOrder([])).toEqual([]); expect(sortInArchOrder(['14'])).toEqual(['14']); }); it('does not drop teeth it cannot place', () => { expect(sortInArchOrder(['99', '14']).sort()).toEqual(['14', '99']); }); }); });