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('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('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'); }); }); });