Files
dyolink/backend/src/common/fdi.spec.ts
Amin Mousavi b4aff39797 feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.

Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.

Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.

Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.

Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
  warning on a plain restoration
- an override naming a tooth outside the selection now reports
  tooth_not_selected rather than malformed; the clinician was understood, the
  tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
  "heard but lost" row; an unrecognised kind is still flagged, and named

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30

145 lines
4.5 KiB
TypeScript

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']);
});
});
});