import { resolveConnectedSpans, resolveProsthesis, resolveVoiceIntent, type ResolveContext, } from './extraction.resolver'; import type { ToothIntent, VoiceIntent } from './voice.types'; const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ kind: 'explicit', fdi, spoken, }); const CTX: ResolveContext = { todayIso: '2025-10-11', weekStartJs: 6, // Saturday — the fa week treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']), 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('resolveProsthesis', () => { const allowed = CTX.prosthesisTypeCodes; it('expands the default across every tooth', () => { const result = resolveProsthesis( { defaultType: 'monolithic_zirconia', overrides: [] }, ['14', '15'], allowed, ); expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia', '15': 'monolithic_zirconia', }); expect(result.prosthesis?.complete).toBe(true); }); it('applies per-tooth overrides on top of the default', () => { const result = resolveProsthesis( { defaultType: 'monolithic_zirconia', overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }], }, ['14', '26'], allowed, ); expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia', '26': 'pfm_crown', }); expect(result.prosthesis?.complete).toBe(true); }); it('marks the map incomplete when a tooth ends up untyped', () => { // Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch. const result = resolveProsthesis( { defaultType: null, overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }], }, ['14', '15'], allowed, ); expect(result.prosthesis?.complete).toBe(false); expect(result.prosthesis?.missingTeeth).toEqual(['15']); }); it('rejects a catalog code the clinic does not have', () => { const result = resolveProsthesis( { defaultType: 'gold_foil', overrides: [] }, ['14'], allowed, ); // Nothing usable was said, so there is no prosthesis to show — not an empty one. expect(result.prosthesis).toBeNull(); expect(result.unresolved).toEqual([ { spoken: 'gold_foil', reason: 'unknown_catalog_code' }, ]); }); it('ignores an override for a tooth that is not selected', () => { const result = resolveProsthesis( { defaultType: 'monolithic_zirconia', overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }], }, ['14'], allowed, ); expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' }); expect(result.unresolved).toEqual([ { spoken: 'سی و هفت', reason: 'tooth_not_selected' }, ]); }); it('reports no prosthesis at all when the object carries nothing usable', () => { // An empty-but-present map would paint a plain restoration with a fabricated // "incomplete, cannot ship" warning. for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) { expect( resolveProsthesis(empty, ['14', '15'], allowed).prosthesis, ).toBeNull(); } }); it('reports no prosthesis when there are no teeth to type', () => { const result = resolveProsthesis( { defaultType: 'monolithic_zirconia', overrides: [] }, [], allowed, ); expect(result.prosthesis).toBeNull(); }); it('distinguishes a tooth it could not understand from one that is not selected', () => { // Different corrective actions: add the tooth, versus repeat yourself. const result = resolveProsthesis( { defaultType: 'monolithic_zirconia', overrides: [ { tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' }, type: 'pfm_crown', }, ], }, ['14'], allowed, ); expect(result.unresolved).toEqual([ { spoken: 'نود و نه', reason: 'malformed' }, ]); }); it('returns null when no prosthesis was spoken', () => { expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull(); }); }); describe('resolveVoiceIntent', () => { const base: VoiceIntent = { treatmentType: 'restoration', teeth: [tooth('14'), tooth('15')], connectedSpans: [], comment: ' حساسیت به سرما ', prosthesis: null, 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.prosthesis).toBeNull(); 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('applies prosthesis over the span-expanded tooth set', () => { const result = resolveVoiceIntent( { ...base, treatmentType: 'prosthesis', teeth: [tooth('14')], connectedSpans: [{ from: tooth('14'), to: tooth('16') }], prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] }, }, CTX, ); // 15 was never spoken but is part of the bridge, so it must carry a type too. expect(result.teeth).toEqual(['14', '15', '16']); expect(result.prosthesis?.complete).toBe(true); expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([ '14', '15', '16', ]); }); 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', () => { 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 }, }, CTX, ); const reasons = result.unresolved.map((u) => u.reason).sort(); expect(reasons).toEqual([ 'invalid_date', 'not_permanent_tooth', 'span_not_same_arch', 'unknown_catalog_code', ]); }); it('treats an empty comment as absent', () => { expect( resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment, ).toBeNull(); }); });