1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3
4import {
5 isMissedFixedTimeRun,
6 isSameSchedule,
7 nextScheduledRunAt,
8 parseTimeOfDay,
9} from "./Schedule.ts";
10
11describe("scheduled task schedule calculation", () => {
12 it("parses 24-hour times", () => {
13 expect(parseTimeOfDay("09:30")).toEqual({ hour: 9, minute: 30 });
14 expect(parseTimeOfDay("23:59")).toEqual({ hour: 23, minute: 59 });
15 expect(parseTimeOfDay("25:00")).toBeNull();
16 });
17
18 it("calculates interval schedules from the supplied instant", () => {
19 const next = nextScheduledRunAt(
20 { type: "interval", everyMs: 5 * 60_000 },
21 DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"),
22 );
23 expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:05:00.000Z");
24 });
25
26 it("clamps legacy sub-minute intervals to the one-minute execution floor", () => {
27 const next = nextScheduledRunAt(
28 { type: "interval", everyMs: 1_000 },
29 DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"),
30 );
31 expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:01:00.000Z");
32 });
33
34 it("skips to the next matching fixed-time weekday", () => {
35 const next = nextScheduledRunAt(
36 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5] },
37 DateTime.makeZonedUnsafe(
38 {
39 year: 2026,
40 month: 7,
41 day: 3,
42 hour: 10,
43 minute: 0,
44 second: 0,
45 millisecond: 0,
46 },
47 { timeZone: "America/Los_Angeles", adjustForTimeZone: true },
48 ),
49 );
50 const parts = next ? DateTime.toParts(next) : null;
51 expect(parts?.weekDay).toBe(1);
52 expect(parts?.hour).toBe(9);
53 expect(parts?.minute).toBe(0);
54 });
55
56 it("skips fixed-time runs missed by more than the grace window", () => {
57 const fixedTime = { type: "fixed_time", timeOfDay: "09:00" } as const;
58 const dueAt = DateTime.makeUnsafe("2026-07-01T09:00:00.000Z");
59 const withinGrace = DateTime.makeUnsafe("2026-07-01T09:05:00.000Z");
60 const pastGrace = DateTime.makeUnsafe("2026-07-01T15:00:00.000Z");
61 // A run only slightly late (poll jitter, short sleep) still fires.
62 expect(isMissedFixedTimeRun(fixedTime, dueAt, withinGrace)).toBe(false);
63 // A run hours past its slot is skipped and rescheduled instead.
64 expect(isMissedFixedTimeRun(fixedTime, dueAt, pastGrace)).toBe(true);
65 // Interval schedules always catch up with a single run, never skip.
66 expect(isMissedFixedTimeRun({ type: "interval", everyMs: 60_000 }, dueAt, pastGrace)).toBe(
67 false,
68 );
69 });
70
71 it("compares schedules structurally", () => {
72 expect(
73 isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }),
74 ).toBe(true);
75 expect(
76 isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 30_000 }),
77 ).toBe(false);
78 expect(
79 isSameSchedule(
80 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2] },
81 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2] },
82 ),
83 ).toBe(true);
84 // Weekday masks are sets: order and duplicates do not change firing.
85 expect(
86 isSameSchedule(
87 { type: "fixed_time", timeOfDay: "09:00", weekdays: [5, 1] },
88 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 5, 5] },
89 ),
90 ).toBe(true);
91 // Omitted, empty, and all-seven masks all mean daily.
92 expect(
93 isSameSchedule(
94 { type: "fixed_time", timeOfDay: "09:00" },
95 { type: "fixed_time", timeOfDay: "09:00", weekdays: [0, 1, 2, 3, 4, 5, 6] },
96 ),
97 ).toBe(true);
98 expect(
99 isSameSchedule(
100 { type: "fixed_time", timeOfDay: "09:00", weekdays: [] },
101 { type: "fixed_time", timeOfDay: "09:00" },
102 ),
103 ).toBe(true);
104 expect(
105 isSameSchedule(
106 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2] },
107 { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 3] },
108 ),
109 ).toBe(false);
110 expect(
111 isSameSchedule(
112 { type: "fixed_time", timeOfDay: "09:00" },
113 { type: "fixed_time", timeOfDay: "09:30" },
114 ),
115 ).toBe(false);
116 expect(
117 isSameSchedule(
118 { type: "interval", everyMs: 60_000 },
119 { type: "fixed_time", timeOfDay: "09:00" },
120 ),
121 ).toBe(false);
122 });
123
124 it.each([
125 ["9:00", "09:00"],
126 ["09:00", "9:00"],
127 ["0:30", "00:30"],
128 ])("treats %s and %s as the same fixed-time schedule", (before, after) => {
129 expect(
130 isSameSchedule(
131 { type: "fixed_time", timeOfDay: before },
132 { type: "fixed_time", timeOfDay: after },
133 ),
134 ).toBe(true);
135 });
136});