Files
dyolink/backend/src/modules/voice/due-date.resolver.spec.ts

293 lines
9.0 KiB
TypeScript
Raw Normal View History

import { resolveDueDate } from './due-date.resolver';
import type { DueIntent } from './voice.types';
// 2025-10-11 is a Saturday — the first day of the Iranian week.
const SATURDAY = '2025-10-11';
const THURSDAY = '2025-10-16';
describe('resolveDueDate', () => {
describe('weekday intents', () => {
it('resolves "this <weekday>" to the coming occurrence in the same week', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
SATURDAY,
);
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days in a Saturday-start week
});
it('resolves "next <weekday>" to the following week', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
SATURDAY,
);
expect(result.dueDate).toBe('2025-10-23');
});
fix(backend): correct "next weekday" and harden resolvers against model output Four defects found by review of the preceding commits. "next <weekday>" was occurrence-anchored ("this" plus seven) rather than week- anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of +7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30. A lab case a week late. "next" now counts from the start of the following Saturday-start week, which also lets "this" and "next" correctly coincide — said on a Thursday, "the coming Saturday" and "Saturday next week" are the same day. "this" stays occurrence-anchored so it can never resolve into the past. The other three all come from the same root cause: exported functions that are reachable from untrusted model output must degrade, not throw or drop. - a non-object `due` (the model emitting a bare string) was treated as "no deadline spoken" and silently discarded; only null/undefined mean absent now, anything else is flagged so the clinician sees something was heard and lost - isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion table, contradicting the module's own "degrade to null" contract; they now return false / 0, which also makes isValidJalaliDate's day check naturally false - civilDateInZone passed a client-supplied zone straight to Intl, which raises RangeError before any fallback; it now validates and backstops to UTC, so a bad zone costs at most a day rather than a 500 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
it('anchors "next" to the week, not to "this" plus seven', () => {
// Said on Thursday 2025-10-16: next week runs Sat 10-18 .. Fri 10-24, so its
// Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would
// overshoot to 10-30 — a lab case a week late.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
THURSDAY,
).dueDate,
).toBe('2025-10-23');
});
it('lets "this" and "next" coincide when they name the same day', () => {
// On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
THURSDAY,
).dueDate,
).toBe('2025-10-18');
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which: 'next' },
THURSDAY,
).dueDate,
).toBe('2025-10-18');
});
it('never resolves a weekday into the past', () => {
// Sunday already passed in the week containing Thursday 10-16.
for (const which of ['this', 'next'] as const) {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'sunday', which },
THURSDAY,
);
expect(result.dueDate).not.toBeNull();
expect(result.dueDate! > THURSDAY).toBe(true);
}
});
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
// A deadline of today is almost never what was meant.
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
THURSDAY,
);
expect(result.dueDate).toBe('2025-10-23');
});
it('resolves the Saturday that starts the next week', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
SATURDAY,
);
expect(result.dueDate).toBe('2025-10-18');
});
it('rejects an unknown weekday or qualifier', () => {
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'caturday',
which: 'this',
} as unknown as DueIntent,
SATURDAY,
).dueDate,
).toBeNull();
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: 'soon',
} as unknown as DueIntent,
SATURDAY,
).dueDate,
).toBeNull();
});
});
describe('offset intents', () => {
it('adds days, weeks and months', () => {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY)
.dueDate,
).toBe('2025-10-12');
expect(
resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY)
.dueDate,
).toBe('2025-10-18');
expect(
resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY)
.dueDate,
).toBe('2025-11-11');
});
it('clamps to the end of a shorter month', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
'2025-01-31',
).dueDate,
).toBe('2025-02-28');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
'2024-01-31',
).dueDate,
).toBe('2024-02-29');
});
it('crosses a year boundary', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 3 },
'2025-11-15',
).dueDate,
).toBe('2026-02-15');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 30 },
'2025-12-20',
).dueDate,
).toBe('2026-01-19');
});
it('rejects negative, fractional and absurd amounts', () => {
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount }, SATURDAY)
.dueDate,
).toBeNull();
}
});
});
describe('jalali intents', () => {
it('converts by arithmetic, not inference', () => {
expect(
resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY)
.dueDate,
).toBe('2025-10-17');
});
it('handles the leap-year Esfand 30', () => {
expect(
resolveDueDate(
{ kind: 'jalali', jy: 1403, jm: 12, jd: 30 },
'2025-03-01',
).dueDate,
).toBe('2025-03-20');
});
it('rejects Esfand 30 in a non-leap year', () => {
const result = resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
SATURDAY,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
});
});
describe('gregorian intents', () => {
it('accepts a real date and rejects an impossible one', () => {
expect(
resolveDueDate({ kind: 'gregorian', y: 2025, m: 10, d: 17 }, SATURDAY)
.dueDate,
).toBe('2025-10-17');
expect(
resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY)
.dueDate,
).toBeNull();
expect(
resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY)
.dueDate,
).toBeNull();
});
});
describe('guard rails', () => {
it('treats a past date as unresolved', () => {
const result = resolveDueDate(
{ kind: 'gregorian', y: 2020, m: 1, d: 1 },
SATURDAY,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
});
it('treats a date decades away as unresolved', () => {
expect(
resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY)
.dueDate,
).toBeNull();
});
it('accepts today itself via a zero-day offset', () => {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY)
.dueDate,
).toBe(SATURDAY);
});
it('reports no due date at all when the model said nothing, without flagging it', () => {
expect(resolveDueDate(null, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
expect(resolveDueDate(undefined, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
});
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
it('treats an object with no kind as no deadline, not a lost one', () => {
// A blank "heard but lost" row in front of a clinician who never mentioned a
// deadline is worse than saying nothing.
expect(resolveDueDate({} as never, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
});
it('flags an unrecognised kind and names it', () => {
const result = resolveDueDate({ kind: 'lunar_month' } as never, SATURDAY);
expect(result.dueDate).toBeNull();
expect(result.unresolved).toEqual({
spoken: 'lunar_month',
reason: 'invalid_date',
});
});
fix(backend): correct "next weekday" and harden resolvers against model output Four defects found by review of the preceding commits. "next <weekday>" was occurrence-anchored ("this" plus seven) rather than week- anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of +7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30. A lab case a week late. "next" now counts from the start of the following Saturday-start week, which also lets "this" and "next" correctly coincide — said on a Thursday, "the coming Saturday" and "Saturday next week" are the same day. "this" stays occurrence-anchored so it can never resolve into the past. The other three all come from the same root cause: exported functions that are reachable from untrusted model output must degrade, not throw or drop. - a non-object `due` (the model emitting a bare string) was treated as "no deadline spoken" and silently discarded; only null/undefined mean absent now, anything else is flagged so the clinician sees something was heard and lost - isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion table, contradicting the module's own "degrade to null" contract; they now return false / 0, which also makes isValidJalaliDate's day check naturally false - civilDateInZone passed a client-supplied zone straight to Intl, which raises RangeError before any fallback; it now validates and backstops to UTC, so a bad zone costs at most a day rather than a 500 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
it('flags a non-object deadline instead of silently dropping it', () => {
// A bare string is a deadline we failed to parse, not an absent one — the clinician
// must see that something was heard and lost.
for (const bad of ['next thursday', 42, true]) {
const result = resolveDueDate(bad as never, SATURDAY);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
}
expect(
resolveDueDate('next thursday' as never, SATURDAY).unresolved?.spoken,
).toBe('next thursday');
});
it('degrades rather than throwing on a malformed today or intent', () => {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date')
.dueDate,
).toBeNull();
expect(
resolveDueDate({ kind: 'nope' } as unknown as DueIntent, SATURDAY)
.dueDate,
).toBeNull();
});
it('echoes what was heard so the review sheet can show it', () => {
const result = resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
SATURDAY,
);
expect(result.unresolved?.spoken).toBe('1404/12/30');
});
});
});