@j13b/scheduler 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/alterations.d.ts +6 -0
- package/dist/alterations.d.ts.map +1 -0
- package/dist/alterations.js +61 -0
- package/dist/alterations.js.map +1 -0
- package/dist/cjs/alterations.d.ts +5 -0
- package/dist/cjs/alterations.js +96 -0
- package/dist/cjs/dispatcher.d.ts +38 -0
- package/dist/cjs/dispatcher.js +165 -0
- package/dist/cjs/dispatcher_store.d.ts +9 -0
- package/dist/cjs/dispatcher_store.js +15 -0
- package/dist/cjs/generators/anchored.d.ts +3 -0
- package/dist/cjs/generators/anchored.js +57 -0
- package/dist/cjs/generators/days_of_month.d.ts +3 -0
- package/dist/cjs/generators/days_of_month.js +76 -0
- package/dist/cjs/generators/days_of_week.d.ts +5 -0
- package/dist/cjs/generators/days_of_week.js +65 -0
- package/dist/cjs/generators/days_of_year.d.ts +3 -0
- package/dist/cjs/generators/days_of_year.js +70 -0
- package/dist/cjs/generators/instant.d.ts +3 -0
- package/dist/cjs/generators/instant.js +15 -0
- package/dist/cjs/generators/local_date_time.d.ts +3 -0
- package/dist/cjs/generators/local_date_time.js +31 -0
- package/dist/cjs/generators/months_of_year.d.ts +3 -0
- package/dist/cjs/generators/months_of_year.js +109 -0
- package/dist/cjs/generators/types.d.ts +9 -0
- package/dist/cjs/generators/types.js +19 -0
- package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
- package/dist/cjs/generators/weeks_of_month.js +99 -0
- package/dist/cjs/generators/yearly.d.ts +3 -0
- package/dist/cjs/generators/yearly.js +52 -0
- package/dist/cjs/in_memory_port.d.ts +18 -0
- package/dist/cjs/in_memory_port.js +59 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/occurrence_key.d.ts +5 -0
- package/dist/cjs/occurrence_key.js +21 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/port.d.ts +15 -0
- package/dist/cjs/port.js +2 -0
- package/dist/cjs/scheduler.d.ts +23 -0
- package/dist/cjs/scheduler.js +302 -0
- package/dist/cjs/types.d.ts +149 -0
- package/dist/cjs/types.js +58 -0
- package/dist/cjs/tz.d.ts +22 -0
- package/dist/cjs/tz.js +197 -0
- package/dist/cjs/validate_rule.d.ts +4 -0
- package/dist/cjs/validate_rule.js +267 -0
- package/dist/dispatcher.d.ts +39 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +111 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/dispatcher_store.d.ts +10 -0
- package/dist/dispatcher_store.d.ts.map +1 -0
- package/dist/dispatcher_store.js +13 -0
- package/dist/dispatcher_store.js.map +1 -0
- package/dist/generators/anchored.d.ts +4 -0
- package/dist/generators/anchored.d.ts.map +1 -0
- package/dist/generators/anchored.js +42 -0
- package/dist/generators/anchored.js.map +1 -0
- package/dist/generators/days_of_month.d.ts +4 -0
- package/dist/generators/days_of_month.d.ts.map +1 -0
- package/dist/generators/days_of_month.js +53 -0
- package/dist/generators/days_of_month.js.map +1 -0
- package/dist/generators/days_of_week.d.ts +6 -0
- package/dist/generators/days_of_week.d.ts.map +1 -0
- package/dist/generators/days_of_week.js +43 -0
- package/dist/generators/days_of_week.js.map +1 -0
- package/dist/generators/days_of_year.d.ts +4 -0
- package/dist/generators/days_of_year.d.ts.map +1 -0
- package/dist/generators/days_of_year.js +50 -0
- package/dist/generators/days_of_year.js.map +1 -0
- package/dist/generators/instant.d.ts +4 -0
- package/dist/generators/instant.d.ts.map +1 -0
- package/dist/generators/instant.js +15 -0
- package/dist/generators/instant.js.map +1 -0
- package/dist/generators/local_date_time.d.ts +4 -0
- package/dist/generators/local_date_time.d.ts.map +1 -0
- package/dist/generators/local_date_time.js +20 -0
- package/dist/generators/local_date_time.js.map +1 -0
- package/dist/generators/months_of_year.d.ts +4 -0
- package/dist/generators/months_of_year.d.ts.map +1 -0
- package/dist/generators/months_of_year.js +64 -0
- package/dist/generators/months_of_year.js.map +1 -0
- package/dist/generators/types.d.ts +10 -0
- package/dist/generators/types.d.ts.map +1 -0
- package/dist/generators/types.js +14 -0
- package/dist/generators/types.js.map +1 -0
- package/dist/generators/weeks_of_month.d.ts +4 -0
- package/dist/generators/weeks_of_month.d.ts.map +1 -0
- package/dist/generators/weeks_of_month.js +61 -0
- package/dist/generators/weeks_of_month.js.map +1 -0
- package/dist/generators/yearly.d.ts +4 -0
- package/dist/generators/yearly.d.ts.map +1 -0
- package/dist/generators/yearly.js +37 -0
- package/dist/generators/yearly.js.map +1 -0
- package/dist/in_memory_port.d.ts +19 -0
- package/dist/in_memory_port.d.ts.map +1 -0
- package/dist/in_memory_port.js +45 -0
- package/dist/in_memory_port.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/occurrence_key.d.ts +6 -0
- package/dist/occurrence_key.d.ts.map +1 -0
- package/dist/occurrence_key.js +25 -0
- package/dist/occurrence_key.js.map +1 -0
- package/dist/port.d.ts +16 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/scheduler.d.ts +24 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +241 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/tz.d.ts +23 -0
- package/dist/tz.d.ts.map +1 -0
- package/dist/tz.js +131 -0
- package/dist/tz.js.map +1 -0
- package/dist/validate_rule.d.ts +5 -0
- package/dist/validate_rule.d.ts.map +1 -0
- package/dist/validate_rule.js +280 -0
- package/dist/validate_rule.js.map +1 -0
- package/package.json +76 -4
- package/src/__tests__/alterations.test.ts +177 -0
- package/src/__tests__/anchored.test.ts +107 -0
- package/src/__tests__/days_of_month.test.ts +162 -0
- package/src/__tests__/days_of_week.test.ts +134 -0
- package/src/__tests__/days_of_year.test.ts +158 -0
- package/src/__tests__/dispatcher.test.ts +317 -0
- package/src/__tests__/instant.test.ts +50 -0
- package/src/__tests__/local_date_time.test.ts +139 -0
- package/src/__tests__/months_of_year.test.ts +229 -0
- package/src/__tests__/scheduler.test.ts +888 -0
- package/src/__tests__/tz.test.ts +201 -0
- package/src/__tests__/validate_rule.test.ts +190 -0
- package/src/__tests__/weeks_of_month.test.ts +195 -0
- package/src/__tests__/yearly.test.ts +108 -0
- package/src/alterations.ts +108 -0
- package/src/dispatcher.ts +184 -0
- package/src/dispatcher_store.ts +26 -0
- package/src/generators/anchored.ts +69 -0
- package/src/generators/days_of_month.ts +100 -0
- package/src/generators/days_of_week.ts +101 -0
- package/src/generators/days_of_year.ts +88 -0
- package/src/generators/instant.ts +18 -0
- package/src/generators/local_date_time.ts +38 -0
- package/src/generators/months_of_year.ts +143 -0
- package/src/generators/types.ts +26 -0
- package/src/generators/weeks_of_month.ts +133 -0
- package/src/generators/yearly.ts +73 -0
- package/src/in_memory_port.ts +70 -0
- package/src/index.ts +25 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +353 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/src/validate_rule.ts +311 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { Day } from "../types.js";
|
|
3
|
+
import {
|
|
4
|
+
addDaysInTz,
|
|
5
|
+
addMonthsInTz,
|
|
6
|
+
dayOfWeekInTz,
|
|
7
|
+
monthLength,
|
|
8
|
+
utcToWallParts,
|
|
9
|
+
wallTimeToUtc,
|
|
10
|
+
} from "../tz.js";
|
|
11
|
+
|
|
12
|
+
// Reference moments:
|
|
13
|
+
// 2025-03-09 09:00 UTC = 2025-03-09 02:00 MST = 03:00 MDT (the gap)
|
|
14
|
+
// 2025-11-02 08:00 UTC = 2025-11-02 02:00 MDT = 01:00 MST (the overlap)
|
|
15
|
+
// America/Denver: -07:00 in MDT, -06:00 in MST. Wait — opposite. MST is
|
|
16
|
+
// standard (-07), MDT is daylight (-06). Spring forward = MST→MDT (lose
|
|
17
|
+
// an hour). Fall back = MDT→MST (gain an hour).
|
|
18
|
+
|
|
19
|
+
describe("tz — UTC pass-through", () => {
|
|
20
|
+
test("utcToWallParts treats UTC as itself", () => {
|
|
21
|
+
const w = utcToWallParts(Date.UTC(2026, 4, 11, 14, 30, 45, 250), "UTC");
|
|
22
|
+
expect(w).toEqual({
|
|
23
|
+
year: 2026,
|
|
24
|
+
month: 4,
|
|
25
|
+
day: 11,
|
|
26
|
+
hour: 14,
|
|
27
|
+
minute: 30,
|
|
28
|
+
second: 45,
|
|
29
|
+
ms: 250,
|
|
30
|
+
dayOfWeek: Day.Monday,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("wallTimeToUtc in UTC = Date.UTC", () => {
|
|
35
|
+
const expected = Date.UTC(2026, 4, 11, 14, 30, 45, 250);
|
|
36
|
+
const msOfDay = 14 * 3_600_000 + 30 * 60_000 + 45 * 1000 + 250;
|
|
37
|
+
expect(wallTimeToUtc(2026, 4, 11, msOfDay, "UTC")).toBe(expected);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("tz — IANA round-trips", () => {
|
|
42
|
+
test("New York: 9am wall = 14:00 UTC in winter (EST)", () => {
|
|
43
|
+
const utc = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "America/New_York");
|
|
44
|
+
expect(new Date(utc).toISOString()).toBe("2026-01-15T14:00:00.000Z");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("New York: 9am wall = 13:00 UTC in summer (EDT)", () => {
|
|
48
|
+
const utc = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "America/New_York");
|
|
49
|
+
expect(new Date(utc).toISOString()).toBe("2026-07-15T13:00:00.000Z");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Tokyo: 9am wall = 00:00 UTC year-round (no DST)", () => {
|
|
53
|
+
const winter = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "Asia/Tokyo");
|
|
54
|
+
const summer = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "Asia/Tokyo");
|
|
55
|
+
expect(new Date(winter).toISOString()).toBe("2026-01-15T00:00:00.000Z");
|
|
56
|
+
expect(new Date(summer).toISOString()).toBe("2026-07-15T00:00:00.000Z");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Round-trip: utcToWallParts → wallTimeToUtc preserves the instant", () => {
|
|
60
|
+
const original = Date.UTC(2025, 5, 15, 18, 42, 17);
|
|
61
|
+
const w = utcToWallParts(original, "Europe/London");
|
|
62
|
+
const msOfDay = w.hour * 3_600_000 + w.minute * 60_000 + w.second * 1000;
|
|
63
|
+
expect(wallTimeToUtc(w.year, w.month, w.day, msOfDay, "Europe/London")).toBe(original);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("tz — DST boundaries", () => {
|
|
68
|
+
// Spring forward: 2025-03-09, America/Denver. Clock jumps 02:00 MST → 03:00 MDT.
|
|
69
|
+
// 02:30 wall is "non-existent". We resolve to the EARLIER instant — i.e.
|
|
70
|
+
// treat the wall fields with the pre-transition (MST, -07) offset. So
|
|
71
|
+
// 02:30 → 09:30 UTC (since pre-transition Denver is UTC-7).
|
|
72
|
+
// Wait: actually pre-transition offset is MST (-7), so 02:30 MST → 09:30 UTC.
|
|
73
|
+
// The IMPLEMENTATION returns 08:30 UTC, which corresponds to 02:30 in the
|
|
74
|
+
// pre-jump timeline interpreted as if it were ALREADY MDT (-6). Hmm.
|
|
75
|
+
//
|
|
76
|
+
// What's actually happening: the algorithm reads the offset at the
|
|
77
|
+
// post-correction guess (which lands AFTER the jump), so it uses MDT (-6).
|
|
78
|
+
// 02:30 wall - (-6h) = 08:30 UTC. So we resolve to the EARLIER UTC instant
|
|
79
|
+
// by using the POST-transition (smaller magnitude) offset. This is the
|
|
80
|
+
// Luxon / date-fns-tz default for spring-forward.
|
|
81
|
+
// Default disambiguation is now "compatible": a spring-forward gap moves
|
|
82
|
+
// FORWARD to the later instant. 02:30 (nonexistent) → 09:30 UTC (i.e. the
|
|
83
|
+
// clock jumps to 03:30 MDT). "earlier" recovers the old 08:30 UTC behavior.
|
|
84
|
+
test("spring forward gap: default (compatible) resolves to the later instant", () => {
|
|
85
|
+
const utc = wallTimeToUtc(2025, 2, 9, 2 * 3_600_000 + 30 * 60_000, "America/Denver");
|
|
86
|
+
expect(new Date(utc).toISOString()).toBe("2025-03-09T09:30:00.000Z");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("spring forward gap: disambiguation policies pick gap direction", () => {
|
|
90
|
+
const wall = 2 * 3_600_000 + 30 * 60_000;
|
|
91
|
+
const earlier = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "earlier");
|
|
92
|
+
const later = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "later");
|
|
93
|
+
const compatible = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "compatible");
|
|
94
|
+
expect(new Date(earlier).toISOString()).toBe("2025-03-09T08:30:00.000Z");
|
|
95
|
+
expect(new Date(later).toISOString()).toBe("2025-03-09T09:30:00.000Z");
|
|
96
|
+
expect(compatible).toBe(later);
|
|
97
|
+
expect(() => wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "reject")).toThrow(/gap/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Fall back: 2025-11-02, America/Denver. Clock falls 02:00 MDT → 01:00 MST.
|
|
101
|
+
// 01:30 wall is ambiguous. We resolve to the EARLIER instant: 07:30 UTC
|
|
102
|
+
// (the first occurrence, while still on MDT), not 08:30 UTC.
|
|
103
|
+
test("fall back ambiguous wall time resolves to earlier instant (compatible default)", () => {
|
|
104
|
+
const utc = wallTimeToUtc(2025, 10, 2, 1 * 3_600_000 + 30 * 60_000, "America/Denver");
|
|
105
|
+
expect(new Date(utc).toISOString()).toBe("2025-11-02T07:30:00.000Z");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("fall back overlap: disambiguation policies pick which occurrence", () => {
|
|
109
|
+
const wall = 1 * 3_600_000 + 30 * 60_000;
|
|
110
|
+
const earlier = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "earlier");
|
|
111
|
+
const later = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "later");
|
|
112
|
+
expect(new Date(earlier).toISOString()).toBe("2025-11-02T07:30:00.000Z");
|
|
113
|
+
expect(new Date(later).toISOString()).toBe("2025-11-02T08:30:00.000Z");
|
|
114
|
+
// "compatible" picks the earlier occurrence for an overlap.
|
|
115
|
+
expect(wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "compatible")).toBe(earlier);
|
|
116
|
+
expect(() => wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "reject")).toThrow(/overlap/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Same wall-clock time, two different UTCs across spring forward.
|
|
120
|
+
test("9am Denver wall is 16:00 UTC before, 15:00 UTC after spring forward", () => {
|
|
121
|
+
const before = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver"); // Sat
|
|
122
|
+
const after = wallTimeToUtc(2025, 2, 10, 9 * 3_600_000, "America/Denver"); // Mon
|
|
123
|
+
expect(new Date(before).toISOString()).toBe("2025-03-08T16:00:00.000Z");
|
|
124
|
+
expect(new Date(after).toISOString()).toBe("2025-03-10T15:00:00.000Z");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("tz — addDaysInTz", () => {
|
|
129
|
+
test("adding 1 day across spring forward keeps wall time stable", () => {
|
|
130
|
+
// 2025-03-08 09:00 Denver
|
|
131
|
+
const start = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver");
|
|
132
|
+
const next = addDaysInTz(start, 1, "America/Denver");
|
|
133
|
+
const w = utcToWallParts(next, "America/Denver");
|
|
134
|
+
expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
|
|
135
|
+
year: 2025,
|
|
136
|
+
month: 2,
|
|
137
|
+
day: 9,
|
|
138
|
+
hour: 9,
|
|
139
|
+
});
|
|
140
|
+
// The actual elapsed UTC ms is 23 hours, not 24.
|
|
141
|
+
expect(next - start).toBe(23 * 3_600_000);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("adding 1 day across fall back keeps wall time stable", () => {
|
|
145
|
+
const start = wallTimeToUtc(2025, 10, 1, 9 * 3_600_000, "America/Denver");
|
|
146
|
+
const next = addDaysInTz(start, 1, "America/Denver");
|
|
147
|
+
const w = utcToWallParts(next, "America/Denver");
|
|
148
|
+
expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
|
|
149
|
+
year: 2025,
|
|
150
|
+
month: 10,
|
|
151
|
+
day: 2,
|
|
152
|
+
hour: 9,
|
|
153
|
+
});
|
|
154
|
+
// 25-hour day.
|
|
155
|
+
expect(next - start).toBe(25 * 3_600_000);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("adding negative days works", () => {
|
|
159
|
+
const start = wallTimeToUtc(2026, 4, 11, 12 * 3_600_000, "UTC");
|
|
160
|
+
const before = addDaysInTz(start, -10, "UTC");
|
|
161
|
+
expect(new Date(before).toISOString()).toBe("2026-05-01T12:00:00.000Z");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("tz — addMonthsInTz", () => {
|
|
166
|
+
test("adds months in wall sense", () => {
|
|
167
|
+
const start = wallTimeToUtc(2026, 0, 31, 12 * 3_600_000, "UTC"); // Jan 31
|
|
168
|
+
const next = addMonthsInTz(start, 1, "UTC"); // Feb 31 → Mar 3 (JS rolls over)
|
|
169
|
+
const w = utcToWallParts(next, "UTC");
|
|
170
|
+
expect({ year: w.year, month: w.month, day: w.day }).toEqual({
|
|
171
|
+
year: 2026,
|
|
172
|
+
month: 2, // March (JS rolls Feb 31 → Mar 3)
|
|
173
|
+
day: 3,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("tz — dayOfWeekInTz", () => {
|
|
179
|
+
test("crosses date boundary correctly in non-UTC tz", () => {
|
|
180
|
+
// 2026-05-11 23:00 UTC = 2026-05-11 16:00 PDT (Monday in LA, Monday in UTC)
|
|
181
|
+
const utc = Date.UTC(2026, 4, 11, 23, 0, 0);
|
|
182
|
+
expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Monday);
|
|
183
|
+
expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("date boundary case: 04:00 UTC Tuesday is still Monday in LA", () => {
|
|
187
|
+
// 2026-05-12 04:00 UTC = 2026-05-11 21:00 PDT
|
|
188
|
+
const utc = Date.UTC(2026, 4, 12, 4, 0, 0);
|
|
189
|
+
expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Tuesday);
|
|
190
|
+
expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("tz — monthLength", () => {
|
|
195
|
+
test("returns civil month length", () => {
|
|
196
|
+
expect(monthLength(2024, 1)).toBe(29); // Feb 2024 (leap)
|
|
197
|
+
expect(monthLength(2025, 1)).toBe(28); // Feb 2025
|
|
198
|
+
expect(monthLength(2026, 0)).toBe(31); // Jan
|
|
199
|
+
expect(monthLength(2026, 3)).toBe(30); // Apr
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { InMemorySchedulerPort } from "../in_memory_port.js";
|
|
3
|
+
import { Scheduler } from "../scheduler.js";
|
|
4
|
+
import type { RecurrenceRule } from "../types.js";
|
|
5
|
+
import { RULE_KINDS, validateRule, validateTimeSpan } from "../validate_rule.js";
|
|
6
|
+
|
|
7
|
+
const START = Date.UTC(2026, 0, 1);
|
|
8
|
+
const SEVEN_AM = 7 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
// A rule as it arrives from JSON: anything at all.
|
|
11
|
+
const loose = (rule: unknown): RecurrenceRule => rule as RecurrenceRule;
|
|
12
|
+
|
|
13
|
+
const VALID: RecurrenceRule[] = [
|
|
14
|
+
{ kind: "instant", startOn: START },
|
|
15
|
+
{ kind: "localDateTime", year: 2026, month: 0, day: 1, timeOfDay: SEVEN_AM },
|
|
16
|
+
{ kind: "anchored", anchorDate: START, intervalMs: 1000, seriesStart: START, seriesEnd: null },
|
|
17
|
+
{
|
|
18
|
+
kind: "daysOfWeek",
|
|
19
|
+
days: [1],
|
|
20
|
+
weekInterval: 1,
|
|
21
|
+
timeOfDay: 0,
|
|
22
|
+
seriesStart: START,
|
|
23
|
+
seriesEnd: null,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
kind: "weeksOfMonth",
|
|
27
|
+
days: [0],
|
|
28
|
+
weeks: [1, 3],
|
|
29
|
+
monthInterval: 1,
|
|
30
|
+
timeOfDay: SEVEN_AM,
|
|
31
|
+
seriesStart: START,
|
|
32
|
+
seriesEnd: null,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kind: "monthsOfYear",
|
|
36
|
+
months: [10],
|
|
37
|
+
weeks: [4],
|
|
38
|
+
days: [4],
|
|
39
|
+
yearInterval: 1,
|
|
40
|
+
timeOfDay: 0,
|
|
41
|
+
seriesStart: START,
|
|
42
|
+
seriesEnd: null,
|
|
43
|
+
count: 3,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
kind: "yearly",
|
|
47
|
+
month: 6,
|
|
48
|
+
day: 4,
|
|
49
|
+
yearInterval: 1,
|
|
50
|
+
timeOfDay: 0,
|
|
51
|
+
seriesStart: START,
|
|
52
|
+
seriesEnd: null,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: "daysOfMonth",
|
|
56
|
+
months: [0, 1],
|
|
57
|
+
daysOfMonth: [1, 15],
|
|
58
|
+
yearInterval: 1,
|
|
59
|
+
timeOfDay: 0,
|
|
60
|
+
seriesStart: START,
|
|
61
|
+
seriesEnd: START + 1,
|
|
62
|
+
timeSpan: { type: "duration", ms: 60_000 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: "daysOfYear",
|
|
66
|
+
daysOfYear: [1, 366],
|
|
67
|
+
yearInterval: 1,
|
|
68
|
+
timeOfDay: 0,
|
|
69
|
+
seriesStart: START,
|
|
70
|
+
seriesEnd: null,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
describe("validateRule accepts", () => {
|
|
75
|
+
test.each(VALID.map((r) => [r.kind, r] as const))("a whole %s rule", (_kind, rule) => {
|
|
76
|
+
expect(() => validateRule(rule)).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("every kind is covered here", () => {
|
|
80
|
+
expect(VALID.map((r) => r.kind).sort()).toEqual([...RULE_KINDS].sort());
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The rules an LLM sent for "the 1st and 3rd Sunday at 7am". Before, each one
|
|
85
|
+
// crashed inside the scheduler ("Cannot read properties of undefined", "Invalid
|
|
86
|
+
// time value"); now each names what is wrong.
|
|
87
|
+
describe("validateRule names the field that is wrong", () => {
|
|
88
|
+
test("a field the kind does not have", () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], weekdays: [0] }))
|
|
91
|
+
).toThrow(
|
|
92
|
+
'WeeksOfMonthRule has no field "weekdays" (its fields are: days, weeks, monthInterval'
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a required field that is missing, with the form it expects", () => {
|
|
97
|
+
expect(() => validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], days: [0] }))).toThrow(
|
|
98
|
+
"WeeksOfMonthRule.monthInterval is required: a whole number >= 1 (1 = every month)"
|
|
99
|
+
);
|
|
100
|
+
expect(() =>
|
|
101
|
+
validateRule(loose({ kind: "weeksOfMonth", weeks: [1, 3], days: [0], monthInterval: 1 }))
|
|
102
|
+
).toThrow("WeeksOfMonthRule.timeOfDay is required: ms after local midnight");
|
|
103
|
+
expect(() =>
|
|
104
|
+
validateRule(
|
|
105
|
+
loose({
|
|
106
|
+
kind: "weeksOfMonth",
|
|
107
|
+
weeks: [1, 3],
|
|
108
|
+
days: [0],
|
|
109
|
+
monthInterval: 1,
|
|
110
|
+
timeOfDay: SEVEN_AM,
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
).toThrow("WeeksOfMonthRule.seriesStart is required: a UTC timestamp in ms");
|
|
114
|
+
expect(() =>
|
|
115
|
+
validateRule(
|
|
116
|
+
loose({
|
|
117
|
+
kind: "weeksOfMonth",
|
|
118
|
+
weeks: [1, 3],
|
|
119
|
+
days: [0],
|
|
120
|
+
monthInterval: 1,
|
|
121
|
+
timeOfDay: SEVEN_AM,
|
|
122
|
+
seriesStart: START,
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
).toThrow("WeeksOfMonthRule.seriesEnd is required: a UTC timestamp in ms or null (no end)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a wrong type or value", () => {
|
|
129
|
+
const base = VALID[4] as unknown as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
expect(() => validateRule(loose({ ...base, days: [] }))).toThrow(
|
|
132
|
+
"WeeksOfMonthRule.days must be a non-empty array"
|
|
133
|
+
);
|
|
134
|
+
expect(() => validateRule(loose({ ...base, days: 0 }))).toThrow(
|
|
135
|
+
"WeeksOfMonthRule.days must be a non-empty array"
|
|
136
|
+
);
|
|
137
|
+
expect(() => validateRule(loose({ ...base, days: [7] }))).toThrow("7 is out of range");
|
|
138
|
+
expect(() => validateRule(loose({ ...base, weeks: [0] }))).toThrow("0 is out of range");
|
|
139
|
+
expect(() => validateRule(loose({ ...base, timeOfDay: "07:00" }))).toThrow(
|
|
140
|
+
"WeeksOfMonthRule.timeOfDay must be ms after local midnight"
|
|
141
|
+
);
|
|
142
|
+
expect(() => validateRule(loose({ ...base, seriesStart: "2026-01-01" }))).toThrow(
|
|
143
|
+
"seriesStart must be a UTC timestamp"
|
|
144
|
+
);
|
|
145
|
+
expect(() => validateRule(loose({ ...base, count: -1 }))).toThrow(
|
|
146
|
+
"count must be a whole number >= 0 or null"
|
|
147
|
+
);
|
|
148
|
+
expect(() => validateRule(loose({ ...base, seriesEnd: START }))).toThrow(
|
|
149
|
+
"seriesEnd must be > seriesStart"
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unknown kind, or no rule at all", () => {
|
|
154
|
+
expect(() => validateRule(loose({ kind: "monthly" }))).toThrow(
|
|
155
|
+
'Unknown rule kind "monthly" (expected one of instant,'
|
|
156
|
+
);
|
|
157
|
+
expect(() => validateRule(loose(null))).toThrow('A rule must be an object with a "kind"');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("validateTimeSpan", () => {
|
|
162
|
+
test("accepts nothing, a duration, or an hour span", () => {
|
|
163
|
+
expect(() => validateTimeSpan(undefined)).not.toThrow();
|
|
164
|
+
expect(() => validateTimeSpan({ type: "duration", ms: 1 })).not.toThrow();
|
|
165
|
+
expect(() => validateTimeSpan({ type: "hourSpan", hours: 1.5 })).not.toThrow();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("rejects anything else with the forms it takes", () => {
|
|
169
|
+
expect(() => validateTimeSpan({ ms: 5 })).toThrow('timeSpan must be { "type": "duration"');
|
|
170
|
+
expect(() => validateTimeSpan({ type: "duration", ms: 0 })).toThrow("timeSpan.ms must be >= 1");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("the scheduler refuses a malformed rule before storing it", () => {
|
|
175
|
+
test("add throws the field message and stores nothing", async () => {
|
|
176
|
+
const port = new InMemorySchedulerPort<{ type: "event" }>();
|
|
177
|
+
const scheduler = new Scheduler(port);
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
scheduler.add({
|
|
181
|
+
id: "t1",
|
|
182
|
+
rule: loose({ kind: "weeksOfMonth", weeks: [1, 3], weekdays: [0] }),
|
|
183
|
+
timezone: "UTC",
|
|
184
|
+
payload: { type: "event" },
|
|
185
|
+
})
|
|
186
|
+
).rejects.toThrow('has no field "weekdays"');
|
|
187
|
+
|
|
188
|
+
expect(await port.getTemplate("t1")).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { generateWeeksOfMonth } from "../generators/weeks_of_month.js";
|
|
3
|
+
import { Day, type WeeksOfMonthRule } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
|
|
7
|
+
describe("generateWeeksOfMonth", () => {
|
|
8
|
+
test("2nd Tuesday of every month for a quarter", () => {
|
|
9
|
+
const rule: WeeksOfMonthRule = {
|
|
10
|
+
kind: "weeksOfMonth",
|
|
11
|
+
days: [Day.Tuesday],
|
|
12
|
+
weeks: [2],
|
|
13
|
+
monthInterval: 1,
|
|
14
|
+
timeOfDay: 14 * HOUR,
|
|
15
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
16
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
17
|
+
seriesEnd: null,
|
|
18
|
+
};
|
|
19
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
|
|
20
|
+
// 2nd Tuesdays: Jan 13, Feb 10, Mar 10
|
|
21
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
22
|
+
"2026-01-13T14:00:00.000Z",
|
|
23
|
+
"2026-02-10T14:00:00.000Z",
|
|
24
|
+
"2026-03-10T14:00:00.000Z",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("last Friday of every month", () => {
|
|
29
|
+
const rule: WeeksOfMonthRule = {
|
|
30
|
+
kind: "weeksOfMonth",
|
|
31
|
+
days: [Day.Friday],
|
|
32
|
+
weeks: [-1],
|
|
33
|
+
monthInterval: 1,
|
|
34
|
+
timeOfDay: 17 * HOUR,
|
|
35
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
36
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
37
|
+
seriesEnd: null,
|
|
38
|
+
};
|
|
39
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
|
|
40
|
+
// Last Fridays: Jan 30, Feb 27, Mar 27
|
|
41
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
42
|
+
"2026-01-30T17:00:00.000Z",
|
|
43
|
+
"2026-02-27T17:00:00.000Z",
|
|
44
|
+
"2026-03-27T17:00:00.000Z",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("5th Friday: emits only in months that have one", () => {
|
|
49
|
+
const rule: WeeksOfMonthRule = {
|
|
50
|
+
kind: "weeksOfMonth",
|
|
51
|
+
days: [Day.Friday],
|
|
52
|
+
weeks: [5],
|
|
53
|
+
monthInterval: 1,
|
|
54
|
+
timeOfDay: 0,
|
|
55
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
56
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
57
|
+
seriesEnd: null,
|
|
58
|
+
};
|
|
59
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
|
|
60
|
+
// 2026: months with a 5th Friday are Jan (30), May (29), Jul (31), Oct (30).
|
|
61
|
+
// Wait — let me recompute: 5th Friday means the month has 5 Fridays.
|
|
62
|
+
// Jan 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
|
|
63
|
+
// May 2026 Fridays: 1,8,15,22,29 → 5 Fridays, 5th = 29
|
|
64
|
+
// Jul 2026 Fridays: 3,10,17,24,31 → 5 Fridays, 5th = 31
|
|
65
|
+
// Oct 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
|
|
66
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
67
|
+
"2026-01-30T00:00:00.000Z",
|
|
68
|
+
"2026-05-29T00:00:00.000Z",
|
|
69
|
+
"2026-07-31T00:00:00.000Z",
|
|
70
|
+
"2026-10-30T00:00:00.000Z",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("every other month: skips months correctly", () => {
|
|
75
|
+
const rule: WeeksOfMonthRule = {
|
|
76
|
+
kind: "weeksOfMonth",
|
|
77
|
+
days: [Day.Wednesday],
|
|
78
|
+
weeks: [1],
|
|
79
|
+
monthInterval: 2,
|
|
80
|
+
timeOfDay: 12 * HOUR,
|
|
81
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
82
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
83
|
+
seriesEnd: null,
|
|
84
|
+
};
|
|
85
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 6, 1));
|
|
86
|
+
// 1st Wed of Jan/Mar/May 2026 = Jan 7, Mar 4, May 6
|
|
87
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
88
|
+
"2026-01-07T12:00:00.000Z",
|
|
89
|
+
"2026-03-04T12:00:00.000Z",
|
|
90
|
+
"2026-05-06T12:00:00.000Z",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("throws on week = 0", () => {
|
|
95
|
+
const rule: WeeksOfMonthRule = {
|
|
96
|
+
kind: "weeksOfMonth",
|
|
97
|
+
days: [Day.Monday],
|
|
98
|
+
weeks: [0],
|
|
99
|
+
monthInterval: 1,
|
|
100
|
+
timeOfDay: 0,
|
|
101
|
+
seriesStart: 0,
|
|
102
|
+
seriesEnd: null,
|
|
103
|
+
};
|
|
104
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/week/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("throws on monthInterval = 0", () => {
|
|
108
|
+
const rule: WeeksOfMonthRule = {
|
|
109
|
+
kind: "weeksOfMonth",
|
|
110
|
+
days: [Day.Monday],
|
|
111
|
+
weeks: [1],
|
|
112
|
+
monthInterval: 0,
|
|
113
|
+
timeOfDay: 0,
|
|
114
|
+
seriesStart: 0,
|
|
115
|
+
seriesEnd: null,
|
|
116
|
+
};
|
|
117
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/monthInterval/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("first AND last week: Mon/Wed/Fri on weeks [1, -1] of every month", () => {
|
|
121
|
+
// The user's original use case.
|
|
122
|
+
const rule: WeeksOfMonthRule = {
|
|
123
|
+
kind: "weeksOfMonth",
|
|
124
|
+
days: [Day.Monday, Day.Wednesday, Day.Friday],
|
|
125
|
+
weeks: [1, -1],
|
|
126
|
+
monthInterval: 1,
|
|
127
|
+
timeOfDay: 9 * HOUR,
|
|
128
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
129
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
130
|
+
seriesEnd: null,
|
|
131
|
+
};
|
|
132
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
|
|
133
|
+
// Jan 2026 starts Thursday. Days of week:
|
|
134
|
+
// Thu Jan 1, Fri Jan 2 (1st Fri), Sat Jan 3, Sun Jan 4,
|
|
135
|
+
// Mon Jan 5 (1st Mon), Tue Jan 6, Wed Jan 7 (1st Wed), ...
|
|
136
|
+
// Last week of Jan 2026:
|
|
137
|
+
// Mon Jan 26 (last Mon), Wed Jan 28 (last Wed), Fri Jan 30 (last Fri).
|
|
138
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
139
|
+
"2026-01-02T09:00:00.000Z", // 1st Fri
|
|
140
|
+
"2026-01-05T09:00:00.000Z", // 1st Mon
|
|
141
|
+
"2026-01-07T09:00:00.000Z", // 1st Wed
|
|
142
|
+
"2026-01-26T09:00:00.000Z", // last Mon
|
|
143
|
+
"2026-01-28T09:00:00.000Z", // last Wed
|
|
144
|
+
"2026-01-30T09:00:00.000Z", // last Fri
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("multiple positive weeks: 1st, 3rd, 5th Tuesday", () => {
|
|
149
|
+
const rule: WeeksOfMonthRule = {
|
|
150
|
+
kind: "weeksOfMonth",
|
|
151
|
+
days: [Day.Tuesday],
|
|
152
|
+
weeks: [1, 3, 5],
|
|
153
|
+
monthInterval: 1,
|
|
154
|
+
timeOfDay: 0,
|
|
155
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
156
|
+
seriesStart: Date.UTC(2026, 5, 1),
|
|
157
|
+
seriesEnd: null,
|
|
158
|
+
};
|
|
159
|
+
// June 2026 Tuesdays: 2, 9, 16, 23, 30 → 1st=2, 3rd=16, 5th=30
|
|
160
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 5, 1), Date.UTC(2026, 6, 1));
|
|
161
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
162
|
+
"2026-06-02T00:00:00.000Z",
|
|
163
|
+
"2026-06-16T00:00:00.000Z",
|
|
164
|
+
"2026-06-30T00:00:00.000Z",
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("duplicate weeks are deduped (no double-emit)", () => {
|
|
169
|
+
const rule: WeeksOfMonthRule = {
|
|
170
|
+
kind: "weeksOfMonth",
|
|
171
|
+
days: [Day.Monday],
|
|
172
|
+
weeks: [1, 1, 1],
|
|
173
|
+
monthInterval: 1,
|
|
174
|
+
timeOfDay: 0,
|
|
175
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
176
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
177
|
+
seriesEnd: null,
|
|
178
|
+
};
|
|
179
|
+
const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
|
|
180
|
+
expect(out).toHaveLength(1); // 1st Monday of Jan 2026 = Jan 5
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("throws on empty weeks", () => {
|
|
184
|
+
const rule: WeeksOfMonthRule = {
|
|
185
|
+
kind: "weeksOfMonth",
|
|
186
|
+
days: [Day.Monday],
|
|
187
|
+
weeks: [],
|
|
188
|
+
monthInterval: 1,
|
|
189
|
+
timeOfDay: 0,
|
|
190
|
+
seriesStart: 0,
|
|
191
|
+
seriesEnd: null,
|
|
192
|
+
};
|
|
193
|
+
expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/weeks/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { generateYearly } from "../generators/yearly.js";
|
|
3
|
+
import { Month, type YearlyRule } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
|
|
7
|
+
describe("generateYearly", () => {
|
|
8
|
+
test("every June 11", () => {
|
|
9
|
+
const rule: YearlyRule = {
|
|
10
|
+
kind: "yearly",
|
|
11
|
+
month: Month.June,
|
|
12
|
+
day: 11,
|
|
13
|
+
yearInterval: 1,
|
|
14
|
+
timeOfDay: 9 * HOUR,
|
|
15
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
16
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
17
|
+
seriesEnd: null,
|
|
18
|
+
};
|
|
19
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
|
|
20
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
21
|
+
"2024-06-11T09:00:00.000Z",
|
|
22
|
+
"2025-06-11T09:00:00.000Z",
|
|
23
|
+
"2026-06-11T09:00:00.000Z",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("every other Jan 1", () => {
|
|
28
|
+
const rule: YearlyRule = {
|
|
29
|
+
kind: "yearly",
|
|
30
|
+
month: Month.January,
|
|
31
|
+
day: 1,
|
|
32
|
+
yearInterval: 2,
|
|
33
|
+
timeOfDay: 0,
|
|
34
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
35
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
36
|
+
seriesEnd: null,
|
|
37
|
+
};
|
|
38
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
|
|
39
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
40
|
+
"2024-01-01T00:00:00.000Z",
|
|
41
|
+
"2026-01-01T00:00:00.000Z",
|
|
42
|
+
"2028-01-01T00:00:00.000Z",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("Feb 29 falls back to Feb 28 in non-leap years", () => {
|
|
47
|
+
const rule: YearlyRule = {
|
|
48
|
+
kind: "yearly",
|
|
49
|
+
month: Month.February,
|
|
50
|
+
day: 29,
|
|
51
|
+
yearInterval: 1,
|
|
52
|
+
timeOfDay: 0,
|
|
53
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
54
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
55
|
+
seriesEnd: null,
|
|
56
|
+
};
|
|
57
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
|
|
58
|
+
expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
|
|
59
|
+
"2024-02-29T00:00:00.000Z", // leap
|
|
60
|
+
"2025-02-28T00:00:00.000Z", // fallback
|
|
61
|
+
"2026-02-28T00:00:00.000Z", // fallback
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("count caps emissions", () => {
|
|
66
|
+
const rule: YearlyRule = {
|
|
67
|
+
kind: "yearly",
|
|
68
|
+
month: Month.July,
|
|
69
|
+
day: 4,
|
|
70
|
+
yearInterval: 1,
|
|
71
|
+
timeOfDay: 0,
|
|
72
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
73
|
+
seriesStart: Date.UTC(2024, 0, 1),
|
|
74
|
+
seriesEnd: null,
|
|
75
|
+
count: 2,
|
|
76
|
+
};
|
|
77
|
+
const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
|
|
78
|
+
expect(out).toHaveLength(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws on month out of range", () => {
|
|
82
|
+
const rule: YearlyRule = {
|
|
83
|
+
kind: "yearly",
|
|
84
|
+
month: 12 as Month,
|
|
85
|
+
day: 1,
|
|
86
|
+
yearInterval: 1,
|
|
87
|
+
timeOfDay: 0,
|
|
88
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
89
|
+
seriesStart: 0,
|
|
90
|
+
seriesEnd: null,
|
|
91
|
+
};
|
|
92
|
+
expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/month/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("throws on day < 1 or > 31", () => {
|
|
96
|
+
const rule: YearlyRule = {
|
|
97
|
+
kind: "yearly",
|
|
98
|
+
month: Month.January,
|
|
99
|
+
day: 32,
|
|
100
|
+
yearInterval: 1,
|
|
101
|
+
timeOfDay: 0,
|
|
102
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
103
|
+
seriesStart: 0,
|
|
104
|
+
seriesEnd: null,
|
|
105
|
+
};
|
|
106
|
+
expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/day/);
|
|
107
|
+
});
|
|
108
|
+
});
|