2026-08-20 17:17:15 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 17:17:15 +03:30
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 17:17:15 +03:30
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|