import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi'; import { PROSTHESIS_TYPES, PROSTHESIS_CATEGORY_LABELS, PROSTHESIS_SUBCATEGORY_LABELS, } from '../../../prisma/catalog-seed-data'; import { resolveConnectedSpans, resolveProsthesisAssignment, resolveVoiceIntent, type ProsthesisLeaf, type ResolveContext, } from './extraction.resolver'; import type { ProsthesisAssignment, ToothIntent, UnresolvedItem, VoiceIntent, } from './voice.types'; const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ kind: 'explicit', fdi, spoken, }); const positional = ( overrides: Partial> = {}, ): ToothIntent => ({ kind: 'positional', arch: undefined as never, side: undefined as never, position: Number.NaN, spoken: '', ...overrides, }); const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [ { code: 'pfm_crown', category: 'crown', subcategory: '', chartRegion: 'crown', }, { code: 'monolithic_zirconia', category: 'crown', subcategory: '', chartRegion: 'crown', }, { code: 'zirconia_abutment', category: 'implant', subcategory: '', chartRegion: 'root', }, { code: 'screw_retained', category: 'implant', subcategory: '', chartRegion: 'crown', }, { code: 'night_guard_soft', category: 'appliance', subcategory: 'night_guard', chartRegion: 'arch', }, { code: 'complete_denture', category: 'removable', subcategory: '', chartRegion: 'arch', }, { code: 'partial_denture', category: 'removable', subcategory: '', chartRegion: 'crown', }, ]; const CTX: ResolveContext = { todayIso: '2025-10-11', weekStartJs: 6, // Saturday — the fa week treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), prosthesisLeaves: PROSTHESIS_LEAVES, prosthesisCategoryCodes: new Set([ 'crown', 'implant', 'removable', 'appliance', ]), prosthesisSubcategoryCodes: new Set(['night_guard']), linkedLabIds: new Set(['lab-sina', 'lab-mehr']), }; describe('resolveConnectedSpans', () => { it('selects the teeth between the endpoints, which were never named', () => { // "a bridge from 14 to 16" must select 15 too. const result = resolveConnectedSpans( [{ from: tooth('14'), to: tooth('16') }], [], ); expect(result.teeth).toEqual(['14', '15', '16']); expect(result.groups).toEqual([ { groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] }, ]); }); it('orders group teeth along the arch, not lexically', () => { const result = resolveConnectedSpans( [{ from: tooth('16'), to: tooth('14') }], [], ); expect(result.groups[0].teeth).toEqual(['16', '15', '14']); }); it('spans the midline', () => { const result = resolveConnectedSpans( [{ from: tooth('12'), to: tooth('22') }], [], ); expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']); }); it('merges overlapping spans into one bridge', () => { const result = resolveConnectedSpans( [ { from: tooth('14'), to: tooth('16') }, { from: tooth('15'), to: tooth('17') }, ], [], ); const connected = result.groups.filter((g) => g.kind === 'connected'); expect(connected).toHaveLength(1); expect(connected[0].teeth).toEqual(['17', '16', '15', '14']); }); it('gives loose teeth their own single groups', () => { const result = resolveConnectedSpans( [{ from: tooth('14'), to: tooth('15') }], ['26'], ); expect(result.groups).toEqual([ { groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] }, { groupId: 'voice-s-26', kind: 'single', teeth: ['26'] }, ]); }); it('never produces a one-tooth connected group', () => { const result = resolveConnectedSpans( [{ from: tooth('14'), to: tooth('14') }], [], ); expect(result.groups).toEqual([ { groupId: 'voice-s-14', kind: 'single', teeth: ['14'] }, ]); expect(result.unresolved).toEqual([]); }); it('reports a cross-arch span rather than guessing', () => { const result = resolveConnectedSpans( [{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }], [], ); expect(result.groups).toEqual([]); expect(result.unresolved).toEqual([ { spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' }, ]); }); it('reports a span with an unresolvable endpoint', () => { const result = resolveConnectedSpans( [{ from: tooth('14'), to: tooth('99') }], [], ); expect(result.unresolved[0].reason).toBe('malformed'); }); it('survives a non-array', () => { expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([ '14', ]); }); }); describe('resolveProsthesisAssignment', () => { function resolve(assignment: ProsthesisAssignment, index = 0) { const unresolved: UnresolvedItem[] = []; const resolved = resolveProsthesisAssignment( assignment, index, CTX, unresolved, ); return { resolved, unresolved }; } it('resolves explicit tooth targets to FDI codes', () => { const { resolved } = resolve({ targets: [tooth('12'), tooth('13')], types: ['pfm_crown'], spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳', }); expect(resolved.targets.sort()).toEqual(['12', '13']); expect(resolved.types).toEqual(['pfm_crown']); }); it('resolves a stack — more than one type on the same target', () => { const { resolved } = resolve({ targets: [tooth('12')], types: ['zirconia_abutment', 'monolithic_zirconia'], spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲', }); expect(resolved.targets).toEqual(['12']); expect(resolved.types.sort()).toEqual([ 'monolithic_zirconia', 'zirconia_abutment', ]); }); it('resolves an arch target with no position to the jaw sentinel', () => { const { resolved } = resolve({ targets: [positional({ arch: 'upper' })], types: ['night_guard_soft'], spoken: 'نایت گارد فک بالا', }); expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]); }); it('resolves "both jaws" to both sentinels', () => { const { resolved } = resolve({ targets: [positional({ arch: 'both' })], types: ['night_guard_soft'], spoken: 'نایت گارد هر دو فک', }); expect(resolved.targets.sort()).toEqual( [ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(), ); }); it('classifies a leaf, a category and a subcategory code independently', () => { const leaf = resolve({ targets: [tooth('12')], types: ['pfm_crown'], spoken: '', }); expect(leaf.resolved.types).toEqual(['pfm_crown']); expect(leaf.unresolved).toEqual([]); const category = resolve({ targets: [tooth('12')], types: ['crown'], spoken: 'روکش', }); expect(category.resolved.types).toEqual([]); expect(category.unresolved).toEqual([ { spoken: 'روکش', reason: 'prosthesis_type_ambiguous', candidates: ['monolithic_zirconia', 'pfm_crown'], assignmentIndex: 0, }, ]); const subcategory = resolve({ targets: [positional({ arch: 'upper' })], types: ['night_guard'], spoken: 'نایت گارد', }); expect(subcategory.resolved.types).toEqual([]); expect(subcategory.unresolved[0]).toMatchObject({ reason: 'prosthesis_type_ambiguous', candidates: ['night_guard_soft'], }); }); it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => { const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code)); const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category)); const subcategoryCodes = new Set( PROSTHESIS_TYPES.map((t) => t.subcategory).filter( (code): code is string => Boolean(code), ), ); for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false); for (const code of subcategoryCodes) { expect(leafCodes.has(code)).toBe(false); expect(categoryCodes.has(code)).toBe(false); } }); it('reports a code not in the supplied catalog', () => { const { resolved, unresolved } = resolve({ targets: [tooth('12')], types: ['gold_foil'], spoken: '', }); expect(resolved.types).toEqual([]); expect(unresolved).toEqual([ { spoken: 'gold_foil', reason: 'unknown_catalog_code', assignmentIndex: 0, }, ]); }); it('rejects an arch code aimed at a tooth', () => { const { resolved, unresolved } = resolve({ targets: [tooth('12')], types: ['night_guard_soft'], spoken: 'دندون ۱۲ نایت گارد', }); expect(resolved.targets).toEqual([]); expect(unresolved).toEqual([ { spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 }, ]); }); it('rejects a tooth code aimed at a jaw', () => { const { resolved, unresolved } = resolve({ targets: [positional({ arch: 'upper', spoken: 'فک بالا' })], types: ['pfm_crown'], spoken: 'فک بالا', }); expect(resolved.targets).toEqual([]); expect(unresolved).toEqual([ { spoken: 'فک بالا', reason: 'code_not_valid_for_target', assignmentIndex: 0, }, ]); }); it('narrows a mixed-region category to the leaves the target can carry', () => { // complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a // leaf was picked left nothing to complete it, and wrote a complete denture onto one // tooth. The target kind narrows the candidates instead, so an impossible leaf is never // offered. const toothTarget = resolve({ targets: [tooth('12')], types: ['removable'], spoken: 'دنچر برای ۱۲', }); expect(toothTarget.resolved.targets).toEqual(['12']); expect(toothTarget.unresolved).toContainEqual( expect.objectContaining({ reason: 'prosthesis_type_ambiguous', candidates: ['partial_denture'], }), ); expect(toothTarget.unresolved).not.toContainEqual( expect.objectContaining({ reason: 'code_not_valid_for_target' }), ); const jawTarget = resolve({ targets: [positional({ arch: 'upper' })], types: ['removable'], spoken: 'دنچر فک بالا', }); expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]); expect(jawTarget.unresolved).toContainEqual( expect.objectContaining({ reason: 'prosthesis_type_ambiguous', candidates: ['complete_denture'], }), ); expect(jawTarget.unresolved).not.toContainEqual( expect.objectContaining({ reason: 'code_not_valid_for_target' }), ); }); it('rejects a category with no leaf the target can carry', () => { // `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch // category and must not defer. Aimed at a jaw it is a contradiction. const { resolved, unresolved } = resolve({ targets: [positional({ arch: 'upper', spoken: 'فک بالا' })], types: ['implant'], spoken: 'ایمپلنت بالا', }); expect(resolved.targets).toEqual([]); expect(resolved.types).toEqual([]); expect(unresolved).toContainEqual( expect.objectContaining({ reason: 'code_not_valid_for_target' }), ); expect(unresolved).not.toContainEqual( expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }), ); }); it('rejects a stack where only one code suits the target', () => { // One legal crown must not admit an arch-only appliance onto the same tooth. Validating // with `some` over the stack's regions did exactly that. const { resolved, unresolved } = resolve({ targets: [tooth('12')], types: ['pfm_crown', 'night_guard_soft'], spoken: 'دندون ۱۲ روکش و نایت گارد', }); expect(resolved.targets).toEqual([]); expect(unresolved).toContainEqual( expect.objectContaining({ spoken: '12', reason: 'code_not_valid_for_target', }), ); }); it('accepts a stack whose every code suits the target', () => { const { resolved, unresolved } = resolve({ targets: [tooth('12')], types: ['zirconia_abutment', 'monolithic_zirconia'], spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا', }); expect(resolved.targets).toEqual(['12']); expect(resolved.types).toEqual([ 'zirconia_abutment', 'monolithic_zirconia', ]); expect(unresolved).toEqual([]); }); it('a target with no types resolves with an empty types[], and does not fail the assignment', () => { const { resolved } = resolve({ targets: [tooth('13'), tooth('14')], types: [], spoken: '۱۳ و ۱۴', }); // Nothing to validate a region against yet, so a bare target still resolves as valid // geometry — the frontend renders it struck through ("no prosthesis heard") because // `types` is empty and no `prosthesis_type_ambiguous` item references this assignment. expect(resolved.targets.sort()).toEqual(['13', '14']); expect(resolved.types).toEqual([]); }); it('one target failing validity does not exclude the rest of the assignment', () => { const { resolved, unresolved } = resolve({ targets: [tooth('12'), positional({ arch: 'upper' })], types: ['pfm_crown'], spoken: '', }); expect(resolved.targets).toEqual(['12']); expect(unresolved).toContainEqual( expect.objectContaining({ reason: 'code_not_valid_for_target' }), ); }); it('carries an assignment target that could not be resolved, with the arch candidates', () => { const { unresolved } = resolve({ targets: [positional({ spoken: 'نایت گارد' })], types: ['night_guard_soft'], spoken: 'نایت گارد', }); expect(unresolved).toContainEqual({ spoken: 'نایت گارد', reason: 'arch_not_spoken', candidates: ['upper', 'lower'], assignmentIndex: 0, }); }); it('carries the assignment index on a missing-quadrant target', () => { const { unresolved } = resolve({ targets: [positional({ position: 2, spoken: 'دندون دو' })], types: ['pfm_crown'], spoken: 'دندون دو روکش', }); expect(unresolved).toContainEqual( expect.objectContaining({ reason: 'tooth_missing_quadrant', assignmentIndex: 0, }), ); }); }); describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => { const locales = ['fa', 'en', 'nl'] as const; it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))( 'category %s', (_code, labels) => { for (const locale of locales) { expect(labels[locale]?.trim()).toBeTruthy(); } }, ); it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))( 'subcategory %s', (_code, labels) => { for (const locale of locales) { expect(labels[locale]?.trim()).toBeTruthy(); } }, ); }); describe('resolveVoiceIntent', () => { const base: VoiceIntent = { treatmentType: 'restoration', teeth: [tooth('14'), tooth('15')], connectedSpans: [], comment: ' حساسیت به سرما ', prosthesis: [], labId: null, labMatchExact: false, due: null, }; it('composes a plain restoration', () => { const result = resolveVoiceIntent(base, CTX); expect(result.treatmentType).toBe('restoration'); expect(result.teeth).toEqual(['14', '15']); expect(result.comment).toBe('حساسیت به سرما'); expect(result.prosthesisAssignments).toEqual([]); expect(result.unresolved).toEqual([]); }); it('rejects a treatment type outside the catalog', () => { const result = resolveVoiceIntent( { ...base, treatmentType: 'teeth_whitening' }, CTX, ); expect(result.treatmentType).toBeNull(); expect(result.unresolved).toContainEqual({ spoken: 'teeth_whitening', reason: 'unknown_catalog_code', }); }); it('drops a lab id the clinic is not linked to', () => { // Shipping to a lab the clinic never named is worse than shipping nowhere. const result = resolveVoiceIntent( { ...base, labId: 'lab-elsewhere', labMatchExact: true }, CTX, ); expect(result.labId).toBeNull(); expect(result.labMatchExact).toBe(false); }); it('keeps a linked lab and its exactness flag', () => { const result = resolveVoiceIntent( { ...base, labId: 'lab-sina', labMatchExact: true }, CTX, ); expect(result.labId).toBe('lab-sina'); expect(result.labMatchExact).toBe(true); }); it('reports a hallucinated lab rather than dropping it silently', () => { // A near-miss lab id must not look identical to "no lab was spoken". const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX); // The id is not what the clinician said — quoting it back shows them a raw UUID. expect(result.unresolved).toContainEqual({ spoken: '', reason: 'unknown_catalog_code', }); }); it('never reports an inexact match as exact when the lab was dropped', () => { const result = resolveVoiceIntent( { ...base, labId: null, labMatchExact: true }, CTX, ); expect(result.labMatchExact).toBe(false); }); it('forces treatmentType to prosthesis when an assignment resolves', () => { const result = resolveVoiceIntent( { ...base, treatmentType: 'restoration', prosthesis: [ { targets: [tooth('12')], types: ['pfm_crown'], spoken: 'روکش برای ۱۲', }, ], }, CTX, ); expect(result.treatmentType).toBe('prosthesis'); expect(result.prosthesisAssignments).toEqual([ { targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' }, ]); }); it('does not force prosthesis when every assignment resolved no target', () => { const result = resolveVoiceIntent( { ...base, prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }], }, CTX, ); expect(result.treatmentType).toBe('restoration'); }); it('resolves a due date through the same context', () => { const result = resolveVoiceIntent( { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, CTX, ); expect(result.dueDate).toBe('2025-10-16'); }); it('collects unresolved items from every stage, and only assignment items carry an index', () => { const result = resolveVoiceIntent( { ...base, treatmentType: 'nope', teeth: [tooth('51', 'شیری')], connectedSpans: [{ from: tooth('14'), to: tooth('44') }], due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, prosthesis: [ { targets: [tooth('99', 'نود و نه')], types: ['pfm_crown'], spoken: '', }, ], }, CTX, ); const reasons = result.unresolved.map((u) => u.reason).sort(); expect(reasons).toEqual([ 'invalid_date', 'malformed', 'not_permanent_tooth', 'span_not_same_arch', 'unknown_catalog_code', ]); const outsideAssignment = result.unresolved.filter( (u) => u.reason === 'not_permanent_tooth', ); expect(outsideAssignment[0].assignmentIndex).toBeUndefined(); const insideAssignment = result.unresolved.filter( (u) => u.reason === 'malformed', ); expect(insideAssignment[0].assignmentIndex).toBe(0); }); it('treats an empty comment as absent', () => { expect( resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment, ).toBeNull(); }); });