feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,22 @@
|
||||
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,
|
||||
resolveProsthesis,
|
||||
resolveProsthesisAssignment,
|
||||
resolveVoiceIntent,
|
||||
type ProsthesisLeaf,
|
||||
type ResolveContext,
|
||||
} from './extraction.resolver';
|
||||
import type { ToothIntent, VoiceIntent } from './voice.types';
|
||||
import type {
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
UnresolvedItem,
|
||||
VoiceIntent,
|
||||
} from './voice.types';
|
||||
|
||||
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
kind: 'explicit',
|
||||
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
spoken,
|
||||
});
|
||||
|
||||
const positional = (
|
||||
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
|
||||
): 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']),
|
||||
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
||||
prosthesisLeaves: PROSTHESIS_LEAVES,
|
||||
prosthesisCategoryCodes: new Set([
|
||||
'crown',
|
||||
'implant',
|
||||
'removable',
|
||||
'appliance',
|
||||
]),
|
||||
prosthesisSubcategoryCodes: new Set(['night_guard']),
|
||||
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
||||
};
|
||||
|
||||
@@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProsthesis', () => {
|
||||
const allowed = CTX.prosthesisTypeCodes;
|
||||
|
||||
it('expands the default across every tooth', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
['14', '15'],
|
||||
allowed,
|
||||
describe('resolveProsthesisAssignment', () => {
|
||||
function resolve(assignment: ProsthesisAssignment, index = 0) {
|
||||
const unresolved: UnresolvedItem[] = [];
|
||||
const resolved = resolveProsthesisAssignment(
|
||||
assignment,
|
||||
index,
|
||||
CTX,
|
||||
unresolved,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({
|
||||
'14': 'monolithic_zirconia',
|
||||
'15': 'monolithic_zirconia',
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
|
||||
it('resolves explicit tooth targets to FDI codes', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('12'), tooth('13')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش پیافام برای ۱۲ و ۱۳',
|
||||
});
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
expect(resolved.targets.sort()).toEqual(['12', '13']);
|
||||
expect(resolved.types).toEqual(['pfm_crown']);
|
||||
});
|
||||
|
||||
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',
|
||||
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(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' },
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(resolved.types.sort()).toEqual([
|
||||
'monolithic_zirconia',
|
||||
'zirconia_abutment',
|
||||
]);
|
||||
});
|
||||
|
||||
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('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('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('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(Boolean),
|
||||
);
|
||||
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 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(
|
||||
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([
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [
|
||||
{
|
||||
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
|
||||
type: 'pfm_crown',
|
||||
},
|
||||
],
|
||||
spoken: 'gold_foil',
|
||||
reason: 'unknown_catalog_code',
|
||||
assignmentIndex: 0,
|
||||
},
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'نود و نه', reason: 'malformed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when no prosthesis was spoken', () => {
|
||||
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
|
||||
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('defers the region check for a mixed-region category (removable)', () => {
|
||||
// complete_denture is 'arch', partial_denture is 'crown' — no region to check until a
|
||||
// leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still
|
||||
// the bare category.
|
||||
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' }),
|
||||
);
|
||||
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).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => {
|
||||
teeth: [tooth('14'), tooth('15')],
|
||||
connectedSpans: [],
|
||||
comment: ' حساسیت به سرما ',
|
||||
prosthesis: null,
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
due: null,
|
||||
@@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.treatmentType).toBe('restoration');
|
||||
expect(result.teeth).toEqual(['14', '15']);
|
||||
expect(result.comment).toBe('حساسیت به سرما');
|
||||
expect(result.prosthesis).toBeNull();
|
||||
expect(result.prosthesisAssignments).toEqual([]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.labMatchExact).toBe(false);
|
||||
});
|
||||
|
||||
it('applies prosthesis over the span-expanded tooth set', () => {
|
||||
it('forces treatmentType to prosthesis when an assignment resolves', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
treatmentType: 'prosthesis',
|
||||
teeth: [tooth('14')],
|
||||
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
|
||||
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
treatmentType: 'restoration',
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش برای ۱۲',
|
||||
},
|
||||
],
|
||||
},
|
||||
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',
|
||||
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' } },
|
||||
@@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.dueDate).toBe('2025-10-16');
|
||||
});
|
||||
|
||||
it('collects unresolved items from every stage', () => {
|
||||
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
@@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user