import { describe, expect, it } from 'vitest'; import { nextOccurrence, nextOccurrenceFor, previewCron, previewTrigger, reconcileOccurrence, } from './schedule-time.js'; describe('schedule time', () => { it('calculates future occurrences in Beijing time across the UTC day boundary', () => { expect(previewCron('0 9 * * *', 2, new Date('2026-07-30T00:30:00.000Z'))).toEqual([ '2026-07-30T01:00:00.000Z', '2026-07-31T01:00:00.000Z', ]); expect(nextOccurrence('30 0 * * *', new Date('2026-07-30T15:59:00.000Z'))).toBe( '2026-07-30T16:30:00.000Z', ); }); it('rejects seconds fields and invalid cron grammar', () => { expect(() => previewCron('* * * * * *', 1)).toThrow(/five-field/i); expect(() => previewCron('99 99 * * *', 1)).toThrow(/cron/i); }); it('reconciles a missed due time according to the per-task policy', () => { const common = { cronExpression: '*/10 * * * *', nextDueAt: '2026-07-30T01:00:00.000Z', }; expect( reconcileOccurrence( { ...common, misfirePolicy: 'skip' }, new Date('2026-07-30T01:25:00.000Z'), ), ).toEqual({ action: 'skip', dueAt: '2026-07-30T01:20:00.000Z', nextDueAt: '2026-07-30T01:30:00.000Z', }); expect( reconcileOccurrence( { ...common, misfirePolicy: 'catch-up-once' }, new Date('2026-07-30T01:25:00.000Z'), ), ).toEqual({ action: 'run', dueAt: '2026-07-30T01:20:00.000Z', nextDueAt: '2026-07-30T01:30:00.000Z', }); }); it('expands Hub fixed schedules over the Beijing weekday and time grid', () => { const weekdays = { kind: 'fixed' as const, weekdays: [4, 5], times: ['09:00', '21:30'] }; // 2026-07-30 is a Thursday; 01:25Z is 09:25 in Beijing, so the same-day 21:30 slot is next. expect(previewTrigger(weekdays, 2, new Date('2026-07-30T01:25:00.000Z'))).toEqual([ '2026-07-30T13:30:00.000Z', '2026-07-31T01:00:00.000Z', ]); expect( nextOccurrenceFor( { kind: 'fixed', weekdays: [6], times: ['00:30'] }, new Date('2026-07-30T01:25:00.000Z'), ), ).toBe('2026-07-31T16:30:00.000Z'); }); it('keeps interval schedules anchored on the previous occurrence', () => { const interval = { kind: 'interval' as const, value: 90, unit: 'mins' as const }; expect(nextOccurrenceFor(interval, new Date('2026-07-30T01:00:00.000Z'))).toBe( '2026-07-30T02:30:00.000Z', ); expect( nextOccurrenceFor( interval, new Date('2026-07-30T05:00:00.000Z'), new Date('2026-07-30T01:00:00.000Z'), ), ).toBe('2026-07-30T05:30:00.000Z'); expect(previewTrigger(interval, 3, new Date('2026-07-30T01:00:00.000Z'))).toEqual([ '2026-07-30T02:30:00.000Z', '2026-07-30T04:00:00.000Z', '2026-07-30T05:30:00.000Z', ]); }); it('reconciles fixed and interval misfires against the stored due time', () => { expect( reconcileOccurrence( { trigger: { kind: 'interval', value: 90, unit: 'mins' }, nextDueAt: '2026-07-30T04:00:00.000Z', misfirePolicy: 'catch-up-once', }, new Date('2026-07-30T05:00:00.000Z'), ), ).toEqual({ action: 'run', dueAt: '2026-07-30T04:00:00.000Z', nextDueAt: '2026-07-30T05:30:00.000Z', }); expect( reconcileOccurrence( { trigger: { kind: 'fixed', weekdays: [4, 5], times: ['09:00', '21:30'] }, nextDueAt: '2026-07-30T01:00:00.000Z', misfirePolicy: 'skip', }, new Date('2026-07-30T01:25:00.000Z'), ), ).toEqual({ action: 'skip', dueAt: '2026-07-30T01:00:00.000Z', nextDueAt: '2026-07-30T13:30:00.000Z', }); }); });