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 " 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 " to the following week', () => { const result = resolveDueDate( { kind: 'weekday', weekday: 'thursday', which: 'next' }, SATURDAY, ); expect(result.dueDate).toBe('2025-10-23'); }); 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, }); }); 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'); }); }); });