@j13b/scheduler 0.0.0-stage → 0.1.0

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/dist/alterations.d.ts +6 -0
  4. package/dist/alterations.d.ts.map +1 -0
  5. package/dist/alterations.js +61 -0
  6. package/dist/alterations.js.map +1 -0
  7. package/dist/cjs/alterations.d.ts +5 -0
  8. package/dist/cjs/alterations.js +96 -0
  9. package/dist/cjs/dispatcher.d.ts +38 -0
  10. package/dist/cjs/dispatcher.js +165 -0
  11. package/dist/cjs/dispatcher_store.d.ts +9 -0
  12. package/dist/cjs/dispatcher_store.js +15 -0
  13. package/dist/cjs/generators/anchored.d.ts +3 -0
  14. package/dist/cjs/generators/anchored.js +57 -0
  15. package/dist/cjs/generators/days_of_month.d.ts +3 -0
  16. package/dist/cjs/generators/days_of_month.js +76 -0
  17. package/dist/cjs/generators/days_of_week.d.ts +5 -0
  18. package/dist/cjs/generators/days_of_week.js +65 -0
  19. package/dist/cjs/generators/days_of_year.d.ts +3 -0
  20. package/dist/cjs/generators/days_of_year.js +70 -0
  21. package/dist/cjs/generators/instant.d.ts +3 -0
  22. package/dist/cjs/generators/instant.js +15 -0
  23. package/dist/cjs/generators/local_date_time.d.ts +3 -0
  24. package/dist/cjs/generators/local_date_time.js +31 -0
  25. package/dist/cjs/generators/months_of_year.d.ts +3 -0
  26. package/dist/cjs/generators/months_of_year.js +109 -0
  27. package/dist/cjs/generators/types.d.ts +9 -0
  28. package/dist/cjs/generators/types.js +19 -0
  29. package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
  30. package/dist/cjs/generators/weeks_of_month.js +99 -0
  31. package/dist/cjs/generators/yearly.d.ts +3 -0
  32. package/dist/cjs/generators/yearly.js +52 -0
  33. package/dist/cjs/in_memory_port.d.ts +18 -0
  34. package/dist/cjs/in_memory_port.js +59 -0
  35. package/dist/cjs/index.d.ts +6 -0
  36. package/dist/cjs/index.js +14 -0
  37. package/dist/cjs/occurrence_key.d.ts +5 -0
  38. package/dist/cjs/occurrence_key.js +21 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/port.d.ts +15 -0
  41. package/dist/cjs/port.js +2 -0
  42. package/dist/cjs/scheduler.d.ts +24 -0
  43. package/dist/cjs/scheduler.js +460 -0
  44. package/dist/cjs/types.d.ts +149 -0
  45. package/dist/cjs/types.js +58 -0
  46. package/dist/cjs/tz.d.ts +22 -0
  47. package/dist/cjs/tz.js +197 -0
  48. package/dist/dispatcher.d.ts +39 -0
  49. package/dist/dispatcher.d.ts.map +1 -0
  50. package/dist/dispatcher.js +111 -0
  51. package/dist/dispatcher.js.map +1 -0
  52. package/dist/dispatcher_store.d.ts +10 -0
  53. package/dist/dispatcher_store.d.ts.map +1 -0
  54. package/dist/dispatcher_store.js +13 -0
  55. package/dist/dispatcher_store.js.map +1 -0
  56. package/dist/generators/anchored.d.ts +4 -0
  57. package/dist/generators/anchored.d.ts.map +1 -0
  58. package/dist/generators/anchored.js +42 -0
  59. package/dist/generators/anchored.js.map +1 -0
  60. package/dist/generators/days_of_month.d.ts +4 -0
  61. package/dist/generators/days_of_month.d.ts.map +1 -0
  62. package/dist/generators/days_of_month.js +53 -0
  63. package/dist/generators/days_of_month.js.map +1 -0
  64. package/dist/generators/days_of_week.d.ts +6 -0
  65. package/dist/generators/days_of_week.d.ts.map +1 -0
  66. package/dist/generators/days_of_week.js +43 -0
  67. package/dist/generators/days_of_week.js.map +1 -0
  68. package/dist/generators/days_of_year.d.ts +4 -0
  69. package/dist/generators/days_of_year.d.ts.map +1 -0
  70. package/dist/generators/days_of_year.js +50 -0
  71. package/dist/generators/days_of_year.js.map +1 -0
  72. package/dist/generators/instant.d.ts +4 -0
  73. package/dist/generators/instant.d.ts.map +1 -0
  74. package/dist/generators/instant.js +15 -0
  75. package/dist/generators/instant.js.map +1 -0
  76. package/dist/generators/local_date_time.d.ts +4 -0
  77. package/dist/generators/local_date_time.d.ts.map +1 -0
  78. package/dist/generators/local_date_time.js +20 -0
  79. package/dist/generators/local_date_time.js.map +1 -0
  80. package/dist/generators/months_of_year.d.ts +4 -0
  81. package/dist/generators/months_of_year.d.ts.map +1 -0
  82. package/dist/generators/months_of_year.js +64 -0
  83. package/dist/generators/months_of_year.js.map +1 -0
  84. package/dist/generators/types.d.ts +10 -0
  85. package/dist/generators/types.d.ts.map +1 -0
  86. package/dist/generators/types.js +14 -0
  87. package/dist/generators/types.js.map +1 -0
  88. package/dist/generators/weeks_of_month.d.ts +4 -0
  89. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  90. package/dist/generators/weeks_of_month.js +61 -0
  91. package/dist/generators/weeks_of_month.js.map +1 -0
  92. package/dist/generators/yearly.d.ts +4 -0
  93. package/dist/generators/yearly.d.ts.map +1 -0
  94. package/dist/generators/yearly.js +37 -0
  95. package/dist/generators/yearly.js.map +1 -0
  96. package/dist/in_memory_port.d.ts +19 -0
  97. package/dist/in_memory_port.d.ts.map +1 -0
  98. package/dist/in_memory_port.js +45 -0
  99. package/dist/in_memory_port.js.map +1 -0
  100. package/dist/index.d.ts +7 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +6 -0
  103. package/dist/occurrence_key.d.ts +6 -0
  104. package/dist/occurrence_key.d.ts.map +1 -0
  105. package/dist/occurrence_key.js +25 -0
  106. package/dist/occurrence_key.js.map +1 -0
  107. package/dist/port.d.ts +16 -0
  108. package/dist/port.d.ts.map +1 -0
  109. package/dist/scheduler.d.ts +25 -0
  110. package/dist/scheduler.d.ts.map +1 -0
  111. package/dist/scheduler.js +332 -0
  112. package/dist/scheduler.js.map +1 -0
  113. package/dist/types.d.ts +150 -0
  114. package/dist/types.d.ts.map +1 -0
  115. package/dist/types.js +29 -0
  116. package/dist/types.js.map +1 -0
  117. package/dist/tz.d.ts +23 -0
  118. package/dist/tz.d.ts.map +1 -0
  119. package/dist/tz.js +131 -0
  120. package/dist/tz.js.map +1 -0
  121. package/package.json +70 -4
  122. package/src/__tests__/alterations.test.ts +177 -0
  123. package/src/__tests__/anchored.test.ts +107 -0
  124. package/src/__tests__/days_of_month.test.ts +162 -0
  125. package/src/__tests__/days_of_week.test.ts +134 -0
  126. package/src/__tests__/days_of_year.test.ts +158 -0
  127. package/src/__tests__/dispatcher.test.ts +317 -0
  128. package/src/__tests__/instant.test.ts +50 -0
  129. package/src/__tests__/local_date_time.test.ts +139 -0
  130. package/src/__tests__/months_of_year.test.ts +229 -0
  131. package/src/__tests__/scheduler.test.ts +888 -0
  132. package/src/__tests__/tz.test.ts +201 -0
  133. package/src/__tests__/weeks_of_month.test.ts +195 -0
  134. package/src/__tests__/yearly.test.ts +108 -0
  135. package/src/alterations.ts +108 -0
  136. package/src/dispatcher.ts +184 -0
  137. package/src/dispatcher_store.ts +26 -0
  138. package/src/generators/anchored.ts +69 -0
  139. package/src/generators/days_of_month.ts +100 -0
  140. package/src/generators/days_of_week.ts +101 -0
  141. package/src/generators/days_of_year.ts +88 -0
  142. package/src/generators/instant.ts +18 -0
  143. package/src/generators/local_date_time.ts +38 -0
  144. package/src/generators/months_of_year.ts +143 -0
  145. package/src/generators/types.ts +26 -0
  146. package/src/generators/weeks_of_month.ts +133 -0
  147. package/src/generators/yearly.ts +73 -0
  148. package/src/in_memory_port.ts +70 -0
  149. package/src/index.ts +24 -0
  150. package/src/occurrence_key.ts +42 -0
  151. package/src/port.ts +31 -0
  152. package/src/scheduler.ts +499 -0
  153. package/src/types.ts +369 -0
  154. package/src/tz.ts +246 -0
  155. package/tsconfig.json +25 -0
@@ -0,0 +1,317 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { Dispatcher } from "../dispatcher.js";
3
+ import { InMemoryDispatcherStore } from "../dispatcher_store.js";
4
+ import { InMemorySchedulerPort } from "../in_memory_port.js";
5
+ import { Scheduler } from "../scheduler.js";
6
+ import type { ScheduledEvent } from "../types.js";
7
+
8
+ interface CronPayload {
9
+ type: "cron";
10
+ command: string;
11
+ }
12
+
13
+ const HOUR = 3_600_000;
14
+ const T0_DATE = new Date(2026, 4, 11, 0, 0, 0);
15
+
16
+ // Each test captures a single T0 (the test's "logical now") and derives
17
+ // everything from it — cursor seeds, event startOns, expectation timestamps.
18
+ // This removes any implicit dependency on Date.now() being frozen by fake
19
+ // timers between calls. If you ever remove vi.useFakeTimers() or someone
20
+ // changes the harness, the tests still mean the same thing.
21
+ function makeFakes() {
22
+ const port = new InMemorySchedulerPort<CronPayload>();
23
+ const scheduler = new Scheduler(port);
24
+ const fired: ScheduledEvent<CronPayload>[] = [];
25
+ const errors: { err: unknown; event?: ScheduledEvent<CronPayload> }[] = [];
26
+ const store = new InMemoryDispatcherStore();
27
+ const T0 = T0_DATE.getTime();
28
+ return { port, scheduler, fired, errors, store, T0 };
29
+ }
30
+
31
+ beforeEach(() => {
32
+ vi.useFakeTimers();
33
+ vi.setSystemTime(T0_DATE);
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.useRealTimers();
38
+ });
39
+
40
+ describe("Dispatcher — catch-up on start", () => {
41
+ test("fires all events between cursor and now on first tick", async () => {
42
+ const { scheduler, fired, store, T0 } = makeFakes();
43
+ // Pre-seed the cursor at T0.
44
+ await store.saveCursor(T0);
45
+
46
+ // Add three events at known offsets from T0.
47
+ await scheduler.add({
48
+ id: "e1",
49
+ timezone: "UTC",
50
+ rule: { kind: "instant", startOn: T0 + 1 * HOUR },
51
+ payload: { type: "cron", command: "1" },
52
+ });
53
+ await scheduler.add({
54
+ id: "e2",
55
+ timezone: "UTC",
56
+ rule: { kind: "instant", startOn: T0 + 2 * HOUR },
57
+ payload: { type: "cron", command: "2" },
58
+ });
59
+ await scheduler.add({
60
+ id: "e3",
61
+ timezone: "UTC",
62
+ rule: { kind: "instant", startOn: T0 + 5 * HOUR },
63
+ payload: { type: "cron", command: "3" },
64
+ });
65
+
66
+ // Simulate downtime: jump the wall clock to T0 + 3h, past the first two.
67
+ vi.setSystemTime(new Date(T0 + 3 * HOUR));
68
+
69
+ const dispatcher = new Dispatcher({
70
+ scheduler,
71
+ fire: async (event) => {
72
+ fired.push(event);
73
+ },
74
+ store,
75
+ });
76
+ await dispatcher.start();
77
+ dispatcher.stop();
78
+
79
+ expect(fired.map((e) => e.payload.command)).toEqual(["1", "2"]);
80
+ });
81
+
82
+ test("first run with no cursor seeds it to now (no backfill)", async () => {
83
+ const { scheduler, fired, store, T0 } = makeFakes();
84
+ // Event 1 hour before T0 — would fire if cursor were earlier than T0.
85
+ await scheduler.add({
86
+ id: "past",
87
+ timezone: "UTC",
88
+ rule: { kind: "instant", startOn: T0 - HOUR },
89
+ payload: { type: "cron", command: "past" },
90
+ });
91
+
92
+ const dispatcher = new Dispatcher({
93
+ scheduler,
94
+ fire: async (e) => {
95
+ fired.push(e);
96
+ },
97
+ store,
98
+ });
99
+ await dispatcher.start();
100
+ dispatcher.stop();
101
+
102
+ // Cursor was null → seeded to T0 → past event not in [T0, T0+1) range.
103
+ expect(fired).toHaveLength(0);
104
+ // Cursor advances to `now + 1` (windowEnd convention) so events at
105
+ // exactly `now` would be picked up but not re-emitted on the next tick.
106
+ expect(await store.loadCursor()).toBe(T0 + 1);
107
+ });
108
+ });
109
+
110
+ describe("Dispatcher — live firing", () => {
111
+ test("fires an event when its time arrives", async () => {
112
+ const { scheduler, fired, store, T0 } = makeFakes();
113
+ await store.saveCursor(T0);
114
+
115
+ await scheduler.add({
116
+ id: "soon",
117
+ timezone: "UTC",
118
+ rule: { kind: "instant", startOn: T0 + HOUR },
119
+ payload: { type: "cron", command: "ping" },
120
+ });
121
+
122
+ const dispatcher = new Dispatcher({
123
+ scheduler,
124
+ fire: async (e) => {
125
+ fired.push(e);
126
+ },
127
+ store,
128
+ });
129
+ await dispatcher.start();
130
+
131
+ // Advance to just past the event.
132
+ await vi.advanceTimersByTimeAsync(HOUR + 100);
133
+ dispatcher.stop();
134
+
135
+ expect(fired.map((e) => e.payload.command)).toEqual(["ping"]);
136
+ });
137
+ });
138
+
139
+ describe("Dispatcher — wake on add (the race)", () => {
140
+ test("adding an event during sleep wakes the dispatcher and fires it on time", async () => {
141
+ const { scheduler, fired, store, T0 } = makeFakes();
142
+ await store.saveCursor(T0);
143
+
144
+ // Initial event 5 hours out from T0.
145
+ await scheduler.add({
146
+ id: "later",
147
+ timezone: "UTC",
148
+ rule: { kind: "instant", startOn: T0 + 5 * HOUR },
149
+ payload: { type: "cron", command: "later" },
150
+ });
151
+
152
+ const dispatcher = new Dispatcher({
153
+ scheduler,
154
+ fire: async (e) => {
155
+ fired.push(e);
156
+ },
157
+ store,
158
+ });
159
+ await dispatcher.start();
160
+
161
+ // 30 minutes pass — dispatcher is sleeping until T0 + 5h.
162
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
163
+ expect(fired).toHaveLength(0);
164
+
165
+ // A new event gets added at T0 + 1h — before "later". Scheduler.changed
166
+ // notifies; dispatcher wakes from its sleep and re-queries nextDue.
167
+ await scheduler.add({
168
+ id: "sooner",
169
+ timezone: "UTC",
170
+ rule: { kind: "instant", startOn: T0 + 1 * HOUR },
171
+ payload: { type: "cron", command: "sooner" },
172
+ });
173
+ // Let microtasks settle (the wake is sync but the loop is async).
174
+ await vi.advanceTimersByTimeAsync(0);
175
+
176
+ // Advance past "sooner"'s firing time (T0 + 1h, currently at T0 + 30m).
177
+ await vi.advanceTimersByTimeAsync(40 * 60 * 1000); // total +1h10m
178
+ expect(fired.map((e) => e.payload.command)).toContain("sooner");
179
+ expect(fired).toHaveLength(1); // only sooner so far
180
+
181
+ // Continue past "later" (T0 + 5h).
182
+ await vi.advanceTimersByTimeAsync(5 * HOUR);
183
+ dispatcher.stop();
184
+
185
+ expect(fired.map((e) => e.payload.command)).toEqual(["sooner", "later"]);
186
+ });
187
+ });
188
+
189
+ describe("Dispatcher — failure handling", () => {
190
+ test("fire throwing: logged via onError, cursor still advances", async () => {
191
+ const { scheduler, fired, errors, store, T0 } = makeFakes();
192
+ await store.saveCursor(T0);
193
+
194
+ await scheduler.add({
195
+ id: "bad",
196
+ timezone: "UTC",
197
+ rule: { kind: "instant", startOn: T0 + HOUR },
198
+ payload: { type: "cron", command: "boom" },
199
+ });
200
+ await scheduler.add({
201
+ id: "good",
202
+ timezone: "UTC",
203
+ rule: { kind: "instant", startOn: T0 + 2 * HOUR },
204
+ payload: { type: "cron", command: "ok" },
205
+ });
206
+
207
+ const dispatcher = new Dispatcher({
208
+ scheduler,
209
+ fire: async (e) => {
210
+ if (e.payload.command === "boom") throw new Error("kaboom");
211
+ fired.push(e);
212
+ },
213
+ store,
214
+ onError: (err, event) => {
215
+ errors.push({ err, event });
216
+ },
217
+ });
218
+ await dispatcher.start();
219
+
220
+ await vi.advanceTimersByTimeAsync(3 * HOUR);
221
+ dispatcher.stop();
222
+
223
+ expect(errors).toHaveLength(1);
224
+ expect((errors[0].err as Error).message).toBe("kaboom");
225
+ expect(errors[0].event?.payload.command).toBe("boom");
226
+ // The "good" event still fires after the failure.
227
+ expect(fired.map((e) => e.payload.command)).toEqual(["ok"]);
228
+ });
229
+ });
230
+
231
+ describe("Dispatcher — stop()", () => {
232
+ test("stop prevents further firing", async () => {
233
+ const { scheduler, fired, store, T0 } = makeFakes();
234
+ await store.saveCursor(T0);
235
+
236
+ await scheduler.add({
237
+ id: "a",
238
+ timezone: "UTC",
239
+ rule: { kind: "instant", startOn: T0 + HOUR },
240
+ payload: { type: "cron", command: "a" },
241
+ });
242
+ await scheduler.add({
243
+ id: "b",
244
+ timezone: "UTC",
245
+ rule: { kind: "instant", startOn: T0 + 2 * HOUR },
246
+ payload: { type: "cron", command: "b" },
247
+ });
248
+
249
+ const dispatcher = new Dispatcher({
250
+ scheduler,
251
+ fire: async (e) => {
252
+ fired.push(e);
253
+ },
254
+ store,
255
+ });
256
+ await dispatcher.start();
257
+
258
+ await vi.advanceTimersByTimeAsync(HOUR + 100);
259
+ dispatcher.stop();
260
+ await vi.advanceTimersByTimeAsync(10 * HOUR);
261
+
262
+ expect(fired.map((e) => e.payload.command)).toEqual(["a"]);
263
+ });
264
+
265
+ test("stop is idempotent", async () => {
266
+ const { scheduler, store, T0 } = makeFakes();
267
+ await store.saveCursor(T0);
268
+ const dispatcher = new Dispatcher({
269
+ scheduler,
270
+ fire: async () => {},
271
+ store,
272
+ });
273
+ await dispatcher.start();
274
+ dispatcher.stop();
275
+ expect(() => dispatcher.stop()).not.toThrow();
276
+ });
277
+ });
278
+
279
+ describe("Dispatcher — cursor persistence", () => {
280
+ test("cursor survives stop/start, preventing re-fire", async () => {
281
+ const { scheduler, fired, store, T0 } = makeFakes();
282
+ await store.saveCursor(T0);
283
+
284
+ await scheduler.add({
285
+ id: "e1",
286
+ timezone: "UTC",
287
+ rule: { kind: "instant", startOn: T0 + HOUR },
288
+ payload: { type: "cron", command: "1" },
289
+ });
290
+
291
+ let dispatcher = new Dispatcher({
292
+ scheduler,
293
+ fire: async (e) => {
294
+ fired.push(e);
295
+ },
296
+ store,
297
+ });
298
+ await dispatcher.start();
299
+ await vi.advanceTimersByTimeAsync(HOUR + 100);
300
+ dispatcher.stop();
301
+ expect(fired).toHaveLength(1);
302
+
303
+ // Restart — cursor is now past the event, so it shouldn't re-fire.
304
+ dispatcher = new Dispatcher({
305
+ scheduler,
306
+ fire: async (e) => {
307
+ fired.push(e);
308
+ },
309
+ store,
310
+ });
311
+ await dispatcher.start();
312
+ await vi.advanceTimersByTimeAsync(5 * HOUR);
313
+ dispatcher.stop();
314
+
315
+ expect(fired).toHaveLength(1);
316
+ });
317
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateInstant } from "../generators/instant.js";
3
+ import type { InstantRule } from "../types.js";
4
+
5
+ describe("generateInstant", () => {
6
+ const rule: InstantRule = {
7
+ kind: "instant",
8
+ startOn: Date.UTC(2026, 4, 11, 14, 0, 0),
9
+ timeSpan: { type: "duration", ms: 60 * 60_000 },
10
+ };
11
+
12
+ test("emits if event intersects window", () => {
13
+ const out = generateInstant(rule, undefined, Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 12));
14
+ expect(out).toHaveLength(1);
15
+ expect(out[0]).toEqual({
16
+ startTimestamp: rule.startOn,
17
+ endTimestamp: rule.startOn + 60 * 60_000,
18
+ });
19
+ });
20
+
21
+ test("emits if window is entirely inside event duration", () => {
22
+ const out = generateInstant(
23
+ rule,
24
+ undefined,
25
+ rule.startOn + 10 * 60_000,
26
+ rule.startOn + 20 * 60_000
27
+ );
28
+ expect(out).toHaveLength(1);
29
+ });
30
+
31
+ test("does not emit when window is fully before", () => {
32
+ const out = generateInstant(rule, undefined, Date.UTC(2026, 4, 1), Date.UTC(2026, 4, 10));
33
+ expect(out).toHaveLength(0);
34
+ });
35
+
36
+ test("does not emit when window is fully after", () => {
37
+ const out = generateInstant(rule, undefined, Date.UTC(2026, 4, 20), Date.UTC(2026, 4, 25));
38
+ expect(out).toHaveLength(0);
39
+ });
40
+
41
+ test("does not emit when window is exactly the end (half-open)", () => {
42
+ const out = generateInstant(
43
+ rule,
44
+ undefined,
45
+ rule.startOn + 60 * 60_000,
46
+ rule.startOn + 60 * 60_000 + 1000
47
+ );
48
+ expect(out).toHaveLength(0);
49
+ });
50
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateLocalDateTime } from "../generators/local_date_time.js";
3
+ import type { LocalDateTimeRule } from "../types.js";
4
+
5
+ const HOUR = 3_600_000;
6
+
7
+ describe("generateLocalDateTime", () => {
8
+ test("resolves wall-clock fields through the template tz", () => {
9
+ // May 11 2026 09:00 in Denver is MDT (UTC-6) → 15:00 UTC.
10
+ const rule: LocalDateTimeRule = {
11
+ kind: "localDateTime",
12
+ year: 2026,
13
+ month: 4, // May
14
+ day: 11,
15
+ timeOfDay: 9 * HOUR,
16
+ timeSpan: { type: "duration", ms: HOUR },
17
+ };
18
+ const out = generateLocalDateTime(
19
+ rule,
20
+ "America/Denver",
21
+ Date.UTC(2026, 4, 1),
22
+ Date.UTC(2026, 4, 30)
23
+ );
24
+ expect(out).toHaveLength(1);
25
+ expect(out[0].startTimestamp).toBe(Date.UTC(2026, 4, 11, 15));
26
+ expect(out[0].endTimestamp).toBe(Date.UTC(2026, 4, 11, 16));
27
+ });
28
+
29
+ test("with no tz, wall-clock fields are interpreted as UTC", () => {
30
+ const rule: LocalDateTimeRule = {
31
+ kind: "localDateTime",
32
+ year: 2026,
33
+ month: 4,
34
+ day: 11,
35
+ timeOfDay: 9 * HOUR,
36
+ };
37
+ const out = generateLocalDateTime(rule, undefined, Date.UTC(2026, 4, 1), Date.UTC(2026, 4, 30));
38
+ expect(out).toHaveLength(1);
39
+ expect(out[0].startTimestamp).toBe(Date.UTC(2026, 4, 11, 9));
40
+ });
41
+
42
+ test("clamps day to month length (Feb 30 → Feb 28 in a non-leap year)", () => {
43
+ const rule: LocalDateTimeRule = {
44
+ kind: "localDateTime",
45
+ year: 2026, // non-leap
46
+ month: 1, // Feb
47
+ day: 30,
48
+ timeOfDay: 0,
49
+ };
50
+ const out = generateLocalDateTime(rule, undefined, Date.UTC(2026, 1, 1), Date.UTC(2026, 2, 1));
51
+ expect(out).toHaveLength(1);
52
+ expect(out[0].startTimestamp).toBe(Date.UTC(2026, 1, 28));
53
+ });
54
+
55
+ test("hourSpan flexes across spring-forward (8 wall-hours = 7 real hours)", () => {
56
+ // Mar 7 2026 23:00 Denver (MST, UTC-7) → 06:00 UTC Mar 8.
57
+ // +8 wall hours lands at 07:00 wall Mar 8, now MDT (UTC-6) → 13:00 UTC.
58
+ // Real elapsed: 7h, because the clock jumped 02:00 → 03:00 overnight.
59
+ const rule: LocalDateTimeRule = {
60
+ kind: "localDateTime",
61
+ year: 2026,
62
+ month: 2, // March
63
+ day: 7,
64
+ timeOfDay: 23 * HOUR,
65
+ timeSpan: { type: "hourSpan", hours: 8 },
66
+ };
67
+ const out = generateLocalDateTime(
68
+ rule,
69
+ "America/Denver",
70
+ Date.UTC(2026, 2, 1),
71
+ Date.UTC(2026, 2, 31)
72
+ );
73
+ expect(out).toHaveLength(1);
74
+ expect(out[0].startTimestamp).toBe(Date.UTC(2026, 2, 8, 6));
75
+ expect(out[0].endTimestamp).toBe(Date.UTC(2026, 2, 8, 13));
76
+ expect(out[0].endTimestamp - out[0].startTimestamp).toBe(7 * HOUR);
77
+ });
78
+
79
+ describe("spring-forward gap on the start (02:30 Mar 8 2026 Denver — nonexistent)", () => {
80
+ // The clock skips 02:00 → 03:00, so 02:30 doesn't exist that day.
81
+ const rule: LocalDateTimeRule = {
82
+ kind: "localDateTime",
83
+ year: 2026,
84
+ month: 2, // March
85
+ day: 8,
86
+ timeOfDay: 2 * HOUR + 30 * 60_000,
87
+ };
88
+ const gen = (dis?: Parameters<typeof generateLocalDateTime>[4]) =>
89
+ generateLocalDateTime(
90
+ rule,
91
+ "America/Denver",
92
+ Date.UTC(2026, 2, 1),
93
+ Date.UTC(2026, 2, 31),
94
+ dis
95
+ );
96
+
97
+ test("default (compatible) moves forward to the later instant", () => {
98
+ const out = gen();
99
+ expect(out).toHaveLength(1);
100
+ expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-03-08T09:30:00.000Z");
101
+ });
102
+
103
+ test("earlier resolves to the earlier instant", () => {
104
+ const out = gen("earlier");
105
+ expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-03-08T08:30:00.000Z");
106
+ });
107
+
108
+ test("skip drops the nonexistent occurrence", () => {
109
+ expect(gen("skip")).toHaveLength(0);
110
+ });
111
+
112
+ test("reject throws", () => {
113
+ expect(() => gen("reject")).toThrow(/gap/);
114
+ });
115
+ });
116
+
117
+ test("does not emit when the occurrence is outside the window", () => {
118
+ const rule: LocalDateTimeRule = {
119
+ kind: "localDateTime",
120
+ year: 2026,
121
+ month: 4,
122
+ day: 11,
123
+ timeOfDay: 9 * HOUR,
124
+ };
125
+ const out = generateLocalDateTime(rule, undefined, Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 30));
126
+ expect(out).toHaveLength(0);
127
+ });
128
+
129
+ test("throws on out-of-range month", () => {
130
+ const rule = {
131
+ kind: "localDateTime",
132
+ year: 2026,
133
+ month: 12,
134
+ day: 1,
135
+ timeOfDay: 0,
136
+ } as unknown as LocalDateTimeRule;
137
+ expect(() => generateLocalDateTime(rule, undefined, 0, Date.UTC(2030, 0, 1))).toThrow();
138
+ });
139
+ });