Files
dyolink/backend/src/modules/voice/due-date.resolver.spec.ts
Amin Mousavi 3d64414dd3 fix(backend): stop showing the clinician null, NaN and the wrong failure
Two ways a voice failure described itself wrongly.

describe() built the quoted-back text from fields that are all nullable on
the wire, and toVoiceIntent casts rather than checks — so a half-classified
deadline rendered as “null null” — not a usable date, and an offset with no
amount as “+NaN day”. Blank is already handled by the sheet; it now falls
back to that.

The DTO's constraints resolved to unrelated codes: maxLength fell through
to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was
missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported
container said the language was invalid. Both now name their own code —
the validation factory already returns a message verbatim when it is itself
a known ErrorCode, so this needs no change to the shared mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30

386 lines
11 KiB
TypeScript

import { resolveDueDate, weekStartForLocale } from './due-date.resolver';
import type { DueIntent } from './voice.types';
// 2025-10-11 is a Saturday — the first day of the Iranian week.
const FA_WEEK = 6; // Saturday
const EU_WEEK = 1; // Monday
const SATURDAY = '2025-10-11';
const THURSDAY = '2025-10-16';
describe('resolveDueDate', () => {
describe('weekday intents', () => {
it('resolves "this <weekday>" to the coming occurrence', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days
});
it('resolves "next <weekday>" to that weekday in the following week', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBe('2025-10-23');
});
it('anchors "next" to the week, not to "this" plus seven', () => {
// Said on Thursday 2025-10-16, the Iranian 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,
FA_WEEK,
).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.
for (const which of ['this', 'next'] as const) {
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which },
THURSDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-18');
}
});
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
// A deadline of today is almost never what was meant.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
THURSDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-23');
});
it('never resolves a weekday into the past', () => {
for (const which of ['this', 'next'] as const) {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'sunday', which },
THURSDAY,
FA_WEEK,
);
expect(result.dueDate).not.toBeNull();
expect(result.dueDate! > THURSDAY).toBe(true);
}
});
it('anchors "next" to a Monday week for en and nl', () => {
// The same sentence means a different day depending on where the week starts.
// Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16.
// The Saturday-start week is 10-18..10-24, Thursday = 10-23.
const intent = {
kind: 'weekday',
weekday: 'thursday',
which: 'next',
} as const;
expect(resolveDueDate(intent, SATURDAY, EU_WEEK).dueDate).toBe(
'2025-10-16',
);
expect(resolveDueDate(intent, SATURDAY, FA_WEEK).dueDate).toBe(
'2025-10-23',
);
});
it('maps each locale to its week start', () => {
expect(weekStartForLocale('fa')).toBe(FA_WEEK);
expect(weekStartForLocale('en')).toBe(EU_WEEK);
expect(weekStartForLocale('nl')).toBe(EU_WEEK);
expect(weekStartForLocale('unknown')).toBe(EU_WEEK);
});
it('treats a missing qualifier as "this" rather than failing', () => {
// A bare weekday carries no qualifier; failing would discard a real spoken deadline.
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: null,
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
).dueDate,
).toBe(THURSDAY);
});
it('rejects an unknown weekday', () => {
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'caturday',
which: 'this',
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
});
});
describe('offset intents', () => {
it('adds days, weeks and months', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-12');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'week', amount: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-18');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
SATURDAY,
FA_WEEK,
).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,
FA_WEEK,
).dueDate,
).toBeNull();
}
});
});
describe('jalali intents', () => {
it('converts by arithmetic, not inference', () => {
expect(
resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 7, jd: 25 },
SATURDAY,
FA_WEEK,
).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,
FA_WEEK,
).dueDate,
).toBe('2025-10-17');
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2025, m: 2, d: 30 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2025, m: 13, d: 1 },
SATURDAY,
FA_WEEK,
).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,
FA_WEEK,
).dueDate,
).toBeNull();
});
it('accepts today itself via a zero-day offset', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 0 },
SATURDAY,
FA_WEEK,
).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,
});
});
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',
});
});
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');
});
});
});
describe('what an unresolvable deadline quotes back', () => {
// The wire shape allows nulls in every field and toVoiceIntent casts rather than
// checks, so these reach the resolver intact. The sheet renders `spoken` verbatim.
it('never puts "null" or "NaN" in front of the clinician', () => {
const bad = [
{ kind: 'weekday', weekday: null, which: null },
{ kind: 'offset', unit: null, amount: null },
{ kind: 'offset', unit: 'day', amount: Number.NaN },
{ kind: 'jalali', jy: null, jm: 7, jd: 25 },
{ kind: 'gregorian', y: 2026, m: null, d: null },
];
for (const intent of bad) {
const result = resolveDueDate(
intent as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/);
}
});
it('still quotes a deadline it did understand the words of', () => {
const result = resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: null,
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
// A weekday with no "this/next" resolves, so nothing is quoted back at all.
expect(result.dueDate).not.toBeNull();
});
});