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