210 lines
5.9 KiB
TypeScript
210 lines
5.9 KiB
TypeScript
|
|
import {
|
||
|
|
toVoiceIntent,
|
||
|
|
VOICE_INTENT_JSON_SCHEMA,
|
||
|
|
type WireVoiceIntent,
|
||
|
|
} from './extraction.wire';
|
||
|
|
|
||
|
|
const emptyDue = {
|
||
|
|
kind: 'none' as const,
|
||
|
|
weekday: null,
|
||
|
|
which: null,
|
||
|
|
unit: null,
|
||
|
|
amount: null,
|
||
|
|
jy: null,
|
||
|
|
jm: null,
|
||
|
|
jd: null,
|
||
|
|
y: null,
|
||
|
|
m: null,
|
||
|
|
d: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
|
||
|
|
treatmentType: null,
|
||
|
|
teeth: [],
|
||
|
|
connectedSpans: [],
|
||
|
|
comment: null,
|
||
|
|
prosthesisDefaultType: null,
|
||
|
|
prosthesisOverrides: [],
|
||
|
|
labId: null,
|
||
|
|
labMatchExact: false,
|
||
|
|
due: emptyDue,
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
const positionalTooth = {
|
||
|
|
spoken: 'شش بالا راست',
|
||
|
|
fdi: null,
|
||
|
|
arch: 'upper' as const,
|
||
|
|
side: 'patient_right' as const,
|
||
|
|
position: 6,
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('VOICE_INTENT_JSON_SCHEMA', () => {
|
||
|
|
it('is strict — every property required, no extras', () => {
|
||
|
|
expect(VOICE_INTENT_JSON_SCHEMA.additionalProperties).toBe(false);
|
||
|
|
expect([...VOICE_INTENT_JSON_SCHEMA.required].sort()).toEqual(
|
||
|
|
Object.keys(VOICE_INTENT_JSON_SCHEMA.properties).sort(),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('requires every field of the due object, since strict mode allows no optionals', () => {
|
||
|
|
const due = VOICE_INTENT_JSON_SCHEMA.properties.due;
|
||
|
|
expect([...due.required].sort()).toEqual(
|
||
|
|
Object.keys(due.properties).sort(),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('toVoiceIntent', () => {
|
||
|
|
it('narrows a positional tooth', () => {
|
||
|
|
const result = toVoiceIntent(wire({ teeth: [positionalTooth] }));
|
||
|
|
expect(result.teeth[0]).toEqual({
|
||
|
|
kind: 'positional',
|
||
|
|
arch: 'upper',
|
||
|
|
side: 'patient_right',
|
||
|
|
position: 6,
|
||
|
|
spoken: 'شش بالا راست',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('narrows an explicit FDI tooth, which wins over positional fields', () => {
|
||
|
|
const result = toVoiceIntent(
|
||
|
|
wire({ teeth: [{ ...positionalTooth, fdi: '14', spoken: 'یک چهار' }] }),
|
||
|
|
);
|
||
|
|
expect(result.teeth[0]).toEqual({
|
||
|
|
kind: 'explicit',
|
||
|
|
fdi: '14',
|
||
|
|
spoken: 'یک چهار',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps due.kind "none" to no deadline', () => {
|
||
|
|
expect(toVoiceIntent(wire()).due).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('narrows each due kind', () => {
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(
|
||
|
|
wire({
|
||
|
|
due: {
|
||
|
|
...emptyDue,
|
||
|
|
kind: 'weekday',
|
||
|
|
weekday: 'thursday',
|
||
|
|
which: 'next',
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
).due,
|
||
|
|
).toEqual({ kind: 'weekday', weekday: 'thursday', which: 'next' });
|
||
|
|
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(
|
||
|
|
wire({ due: { ...emptyDue, kind: 'offset', unit: 'week', amount: 2 } }),
|
||
|
|
).due,
|
||
|
|
).toEqual({ kind: 'offset', unit: 'week', amount: 2 });
|
||
|
|
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(
|
||
|
|
wire({ due: { ...emptyDue, kind: 'jalali', jy: 1404, jm: 7, jd: 25 } }),
|
||
|
|
).due,
|
||
|
|
).toEqual({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 });
|
||
|
|
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(
|
||
|
|
wire({
|
||
|
|
due: { ...emptyDue, kind: 'gregorian', y: 2025, m: 10, d: 17 },
|
||
|
|
}),
|
||
|
|
).due,
|
||
|
|
).toEqual({ kind: 'gregorian', y: 2025, m: 10, d: 17 });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('ignores a non-FDI-shaped fdi and keeps the positional fields', () => {
|
||
|
|
// A model emitting fdi:"6" alongside correct arch/side/position must still yield 16,
|
||
|
|
// not lose the tooth to the explicit branch.
|
||
|
|
const result = toVoiceIntent(
|
||
|
|
wire({ teeth: [{ ...positionalTooth, fdi: '6' }] }),
|
||
|
|
);
|
||
|
|
expect(result.teeth[0]).toEqual({
|
||
|
|
kind: 'positional',
|
||
|
|
arch: 'upper',
|
||
|
|
side: 'patient_right',
|
||
|
|
position: 6,
|
||
|
|
spoken: 'شش بالا راست',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects impossible FDI shapes from the explicit branch', () => {
|
||
|
|
for (const fdi of ['99', '0', '140', '9', 'ab']) {
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(wire({ teeth: [{ ...positionalTooth, fdi }] })).teeth[0]
|
||
|
|
.kind,
|
||
|
|
).toBe('positional');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('passes an unrecognised due kind through so it can be flagged', () => {
|
||
|
|
// Collapsing it to null would make a misunderstood deadline indistinguishable from
|
||
|
|
// no deadline at all, and the resolver's flagging path unreachable.
|
||
|
|
const result = toVoiceIntent(
|
||
|
|
wire({ due: { ...emptyDue, kind: 'lunar_month' as never } }),
|
||
|
|
);
|
||
|
|
expect(result.due).toEqual({ kind: 'lunar_month' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports no prosthesis when neither a default nor an override was given', () => {
|
||
|
|
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('builds a prosthesis intent from a default alone', () => {
|
||
|
|
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
|
||
|
|
expect(result.prosthesis).toEqual({
|
||
|
|
defaultType: 'pfm_crown',
|
||
|
|
overrides: [],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('builds a prosthesis intent from overrides alone', () => {
|
||
|
|
const result = toVoiceIntent(
|
||
|
|
wire({
|
||
|
|
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(result.prosthesis?.defaultType).toBeNull();
|
||
|
|
expect(result.prosthesis?.overrides).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('narrows connected spans', () => {
|
||
|
|
const result = toVoiceIntent(
|
||
|
|
wire({
|
||
|
|
connectedSpans: [
|
||
|
|
{
|
||
|
|
from: { ...positionalTooth, fdi: '14' },
|
||
|
|
to: { ...positionalTooth, fdi: '16' },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(result.connectedSpans[0].from).toEqual({
|
||
|
|
kind: 'explicit',
|
||
|
|
fdi: '14',
|
||
|
|
spoken: 'شش بالا راست',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is total — a missing or malformed payload yields a resolvable shape, not a throw', () => {
|
||
|
|
// Whatever survives here is reported as unresolved downstream rather than crashing.
|
||
|
|
for (const bad of [undefined, null, {}, { teeth: 'nope', due: 5 }]) {
|
||
|
|
expect(() => toVoiceIntent(bad as never)).not.toThrow();
|
||
|
|
const result = toVoiceIntent(bad as never);
|
||
|
|
expect(result.teeth).toEqual([]);
|
||
|
|
expect(result.due).toBeNull();
|
||
|
|
expect(result.labMatchExact).toBe(false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('coerces a non-boolean labMatchExact to false', () => {
|
||
|
|
expect(
|
||
|
|
toVoiceIntent(wire({ labMatchExact: 'yes' as never })).labMatchExact,
|
||
|
|
).toBe(false);
|
||
|
|
});
|
||
|
|
});
|