@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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -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 +7 -0
  36. package/dist/cjs/index.js +18 -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 +23 -0
  43. package/dist/cjs/scheduler.js +302 -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/cjs/validate_rule.d.ts +4 -0
  49. package/dist/cjs/validate_rule.js +267 -0
  50. package/dist/dispatcher.d.ts +39 -0
  51. package/dist/dispatcher.d.ts.map +1 -0
  52. package/dist/dispatcher.js +111 -0
  53. package/dist/dispatcher.js.map +1 -0
  54. package/dist/dispatcher_store.d.ts +10 -0
  55. package/dist/dispatcher_store.d.ts.map +1 -0
  56. package/dist/dispatcher_store.js +13 -0
  57. package/dist/dispatcher_store.js.map +1 -0
  58. package/dist/generators/anchored.d.ts +4 -0
  59. package/dist/generators/anchored.d.ts.map +1 -0
  60. package/dist/generators/anchored.js +42 -0
  61. package/dist/generators/anchored.js.map +1 -0
  62. package/dist/generators/days_of_month.d.ts +4 -0
  63. package/dist/generators/days_of_month.d.ts.map +1 -0
  64. package/dist/generators/days_of_month.js +53 -0
  65. package/dist/generators/days_of_month.js.map +1 -0
  66. package/dist/generators/days_of_week.d.ts +6 -0
  67. package/dist/generators/days_of_week.d.ts.map +1 -0
  68. package/dist/generators/days_of_week.js +43 -0
  69. package/dist/generators/days_of_week.js.map +1 -0
  70. package/dist/generators/days_of_year.d.ts +4 -0
  71. package/dist/generators/days_of_year.d.ts.map +1 -0
  72. package/dist/generators/days_of_year.js +50 -0
  73. package/dist/generators/days_of_year.js.map +1 -0
  74. package/dist/generators/instant.d.ts +4 -0
  75. package/dist/generators/instant.d.ts.map +1 -0
  76. package/dist/generators/instant.js +15 -0
  77. package/dist/generators/instant.js.map +1 -0
  78. package/dist/generators/local_date_time.d.ts +4 -0
  79. package/dist/generators/local_date_time.d.ts.map +1 -0
  80. package/dist/generators/local_date_time.js +20 -0
  81. package/dist/generators/local_date_time.js.map +1 -0
  82. package/dist/generators/months_of_year.d.ts +4 -0
  83. package/dist/generators/months_of_year.d.ts.map +1 -0
  84. package/dist/generators/months_of_year.js +64 -0
  85. package/dist/generators/months_of_year.js.map +1 -0
  86. package/dist/generators/types.d.ts +10 -0
  87. package/dist/generators/types.d.ts.map +1 -0
  88. package/dist/generators/types.js +14 -0
  89. package/dist/generators/types.js.map +1 -0
  90. package/dist/generators/weeks_of_month.d.ts +4 -0
  91. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  92. package/dist/generators/weeks_of_month.js +61 -0
  93. package/dist/generators/weeks_of_month.js.map +1 -0
  94. package/dist/generators/yearly.d.ts +4 -0
  95. package/dist/generators/yearly.d.ts.map +1 -0
  96. package/dist/generators/yearly.js +37 -0
  97. package/dist/generators/yearly.js.map +1 -0
  98. package/dist/in_memory_port.d.ts +19 -0
  99. package/dist/in_memory_port.d.ts.map +1 -0
  100. package/dist/in_memory_port.js +45 -0
  101. package/dist/in_memory_port.js.map +1 -0
  102. package/dist/index.d.ts +8 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +7 -0
  105. package/dist/occurrence_key.d.ts +6 -0
  106. package/dist/occurrence_key.d.ts.map +1 -0
  107. package/dist/occurrence_key.js +25 -0
  108. package/dist/occurrence_key.js.map +1 -0
  109. package/dist/port.d.ts +16 -0
  110. package/dist/port.d.ts.map +1 -0
  111. package/dist/scheduler.d.ts +24 -0
  112. package/dist/scheduler.d.ts.map +1 -0
  113. package/dist/scheduler.js +241 -0
  114. package/dist/scheduler.js.map +1 -0
  115. package/dist/types.d.ts +150 -0
  116. package/dist/types.d.ts.map +1 -0
  117. package/dist/types.js +29 -0
  118. package/dist/types.js.map +1 -0
  119. package/dist/tz.d.ts +23 -0
  120. package/dist/tz.d.ts.map +1 -0
  121. package/dist/tz.js +131 -0
  122. package/dist/tz.js.map +1 -0
  123. package/dist/validate_rule.d.ts +5 -0
  124. package/dist/validate_rule.d.ts.map +1 -0
  125. package/dist/validate_rule.js +280 -0
  126. package/dist/validate_rule.js.map +1 -0
  127. package/package.json +76 -4
  128. package/src/__tests__/alterations.test.ts +177 -0
  129. package/src/__tests__/anchored.test.ts +107 -0
  130. package/src/__tests__/days_of_month.test.ts +162 -0
  131. package/src/__tests__/days_of_week.test.ts +134 -0
  132. package/src/__tests__/days_of_year.test.ts +158 -0
  133. package/src/__tests__/dispatcher.test.ts +317 -0
  134. package/src/__tests__/instant.test.ts +50 -0
  135. package/src/__tests__/local_date_time.test.ts +139 -0
  136. package/src/__tests__/months_of_year.test.ts +229 -0
  137. package/src/__tests__/scheduler.test.ts +888 -0
  138. package/src/__tests__/tz.test.ts +201 -0
  139. package/src/__tests__/validate_rule.test.ts +190 -0
  140. package/src/__tests__/weeks_of_month.test.ts +195 -0
  141. package/src/__tests__/yearly.test.ts +108 -0
  142. package/src/alterations.ts +108 -0
  143. package/src/dispatcher.ts +184 -0
  144. package/src/dispatcher_store.ts +26 -0
  145. package/src/generators/anchored.ts +69 -0
  146. package/src/generators/days_of_month.ts +100 -0
  147. package/src/generators/days_of_week.ts +101 -0
  148. package/src/generators/days_of_year.ts +88 -0
  149. package/src/generators/instant.ts +18 -0
  150. package/src/generators/local_date_time.ts +38 -0
  151. package/src/generators/months_of_year.ts +143 -0
  152. package/src/generators/types.ts +26 -0
  153. package/src/generators/weeks_of_month.ts +133 -0
  154. package/src/generators/yearly.ts +73 -0
  155. package/src/in_memory_port.ts +70 -0
  156. package/src/index.ts +25 -0
  157. package/src/occurrence_key.ts +42 -0
  158. package/src/port.ts +31 -0
  159. package/src/scheduler.ts +353 -0
  160. package/src/types.ts +369 -0
  161. package/src/tz.ts +246 -0
  162. package/src/validate_rule.ts +311 -0
  163. package/tsconfig.json +25 -0
@@ -0,0 +1,162 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateDaysOfMonth } from "../generators/days_of_month.js";
3
+ import { type DaysOfMonthRule, Month } from "../types.js";
4
+
5
+ const HOUR = 3_600_000;
6
+
7
+ describe("generateDaysOfMonth", () => {
8
+ test("15th of Jan and Mar, every other year", () => {
9
+ const rule: DaysOfMonthRule = {
10
+ kind: "daysOfMonth",
11
+ months: [Month.January, Month.March],
12
+ daysOfMonth: [15],
13
+ yearInterval: 2,
14
+ timeOfDay: 9 * HOUR,
15
+ timeSpan: { type: "duration", ms: HOUR },
16
+ seriesStart: Date.UTC(2024, 0, 1),
17
+ seriesEnd: null,
18
+ };
19
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2029, 0, 1));
20
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
21
+ "2024-01-15T09:00:00.000Z",
22
+ "2024-03-15T09:00:00.000Z",
23
+ "2026-01-15T09:00:00.000Z",
24
+ "2026-03-15T09:00:00.000Z",
25
+ "2028-01-15T09:00:00.000Z",
26
+ "2028-03-15T09:00:00.000Z",
27
+ ]);
28
+ });
29
+
30
+ test("payroll: 1st and 15th of every month", () => {
31
+ const rule: DaysOfMonthRule = {
32
+ kind: "daysOfMonth",
33
+ months: [
34
+ Month.January,
35
+ Month.February,
36
+ Month.March,
37
+ Month.April,
38
+ Month.May,
39
+ Month.June,
40
+ Month.July,
41
+ Month.August,
42
+ Month.September,
43
+ Month.October,
44
+ Month.November,
45
+ Month.December,
46
+ ],
47
+ daysOfMonth: [1, 15],
48
+ yearInterval: 1,
49
+ timeOfDay: 6 * HOUR,
50
+ timeSpan: { type: "duration", ms: HOUR },
51
+ seriesStart: Date.UTC(2026, 0, 1),
52
+ seriesEnd: null,
53
+ };
54
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
55
+ expect(out).toHaveLength(24); // 12 months × 2 days
56
+ expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-01-01T06:00:00.000Z");
57
+ expect(new Date(out[1].startTimestamp).toISOString()).toBe("2026-01-15T06:00:00.000Z");
58
+ expect(new Date(out[23].startTimestamp).toISOString()).toBe("2026-12-15T06:00:00.000Z");
59
+ });
60
+
61
+ test("quarterly tax dates", () => {
62
+ const rule: DaysOfMonthRule = {
63
+ kind: "daysOfMonth",
64
+ months: [Month.January, Month.April, Month.July, Month.September],
65
+ daysOfMonth: [15],
66
+ yearInterval: 1,
67
+ timeOfDay: 9 * HOUR,
68
+ timeSpan: { type: "duration", ms: HOUR },
69
+ seriesStart: Date.UTC(2026, 0, 1),
70
+ seriesEnd: null,
71
+ };
72
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
73
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
74
+ "2026-01-15T09:00:00.000Z",
75
+ "2026-04-15T09:00:00.000Z",
76
+ "2026-07-15T09:00:00.000Z",
77
+ "2026-09-15T09:00:00.000Z",
78
+ ]);
79
+ });
80
+
81
+ test("day 31 clamps to last day of shorter months", () => {
82
+ const rule: DaysOfMonthRule = {
83
+ kind: "daysOfMonth",
84
+ months: [Month.January, Month.April],
85
+ daysOfMonth: [31],
86
+ yearInterval: 1,
87
+ timeOfDay: 0,
88
+ timeSpan: { type: "duration", ms: HOUR },
89
+ seriesStart: Date.UTC(2026, 0, 1),
90
+ seriesEnd: null,
91
+ };
92
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
93
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
94
+ "2026-01-31T00:00:00.000Z", // Jan has 31
95
+ "2026-04-30T00:00:00.000Z", // Apr clamps to 30
96
+ ]);
97
+ });
98
+
99
+ test("day 30 + day 31 in April: dedup after clamp", () => {
100
+ // Both clamp to Apr 30 — should only emit once.
101
+ const rule: DaysOfMonthRule = {
102
+ kind: "daysOfMonth",
103
+ months: [Month.April],
104
+ daysOfMonth: [30, 31],
105
+ yearInterval: 1,
106
+ timeOfDay: 0,
107
+ timeSpan: { type: "duration", ms: HOUR },
108
+ seriesStart: Date.UTC(2026, 0, 1),
109
+ seriesEnd: null,
110
+ };
111
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
112
+ expect(out).toHaveLength(1);
113
+ expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-04-30T00:00:00.000Z");
114
+ });
115
+
116
+ test("count caps emissions across months × days", () => {
117
+ const rule: DaysOfMonthRule = {
118
+ kind: "daysOfMonth",
119
+ months: [Month.January, Month.July],
120
+ daysOfMonth: [1, 15],
121
+ yearInterval: 1,
122
+ timeOfDay: 0,
123
+ timeSpan: { type: "duration", ms: HOUR },
124
+ seriesStart: Date.UTC(2026, 0, 1),
125
+ seriesEnd: null,
126
+ count: 3,
127
+ };
128
+ const out = generateDaysOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
129
+ expect(out).toHaveLength(3);
130
+ });
131
+
132
+ test("throws on empty months / daysOfMonth", () => {
133
+ const base = {
134
+ kind: "daysOfMonth" as const,
135
+ yearInterval: 1,
136
+ timeOfDay: 0,
137
+ timeSpan: { type: "duration", ms: HOUR } as const,
138
+ seriesStart: 0,
139
+ seriesEnd: null,
140
+ };
141
+ expect(() =>
142
+ generateDaysOfMonth({ ...base, months: [], daysOfMonth: [1] }, "UTC", 0, 1000)
143
+ ).toThrow(/months/);
144
+ expect(() =>
145
+ generateDaysOfMonth({ ...base, months: [Month.January], daysOfMonth: [] }, "UTC", 0, 1000)
146
+ ).toThrow(/daysOfMonth/);
147
+ });
148
+
149
+ test("throws on day out of range", () => {
150
+ const rule: DaysOfMonthRule = {
151
+ kind: "daysOfMonth",
152
+ months: [Month.January],
153
+ daysOfMonth: [32],
154
+ yearInterval: 1,
155
+ timeOfDay: 0,
156
+ timeSpan: { type: "duration", ms: HOUR },
157
+ seriesStart: 0,
158
+ seriesEnd: null,
159
+ };
160
+ expect(() => generateDaysOfMonth(rule, "UTC", 0, 1000)).toThrow(/daysOfMonth/);
161
+ });
162
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateDaysOfWeek } from "../generators/days_of_week.js";
3
+ import { Day, type DaysOfWeekRule } from "../types.js";
4
+ import { utcToWallParts } from "../tz.js";
5
+
6
+ const HOUR = 3_600_000;
7
+
8
+ describe("generateDaysOfWeek", () => {
9
+ test("Mon/Wed/Fri at 9am UTC over a week", () => {
10
+ const rule: DaysOfWeekRule = {
11
+ kind: "daysOfWeek",
12
+ days: [Day.Monday, Day.Wednesday, Day.Friday],
13
+ weekInterval: 1,
14
+ timeOfDay: 9 * HOUR,
15
+ timeSpan: { type: "duration", ms: HOUR },
16
+ seriesStart: Date.UTC(2026, 4, 4), // Mon May 4 2026
17
+ seriesEnd: null,
18
+ };
19
+ const out = generateDaysOfWeek(rule, "UTC", Date.UTC(2026, 4, 4), Date.UTC(2026, 4, 11));
20
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
21
+ "2026-05-04T09:00:00.000Z", // Mon
22
+ "2026-05-06T09:00:00.000Z", // Wed
23
+ "2026-05-08T09:00:00.000Z", // Fri
24
+ ]);
25
+ });
26
+
27
+ test("every other week: skips weeks correctly", () => {
28
+ const rule: DaysOfWeekRule = {
29
+ kind: "daysOfWeek",
30
+ days: [Day.Tuesday],
31
+ weekInterval: 2,
32
+ timeOfDay: 0,
33
+ timeSpan: { type: "duration", ms: HOUR },
34
+ seriesStart: Date.UTC(2026, 4, 5), // Tue May 5 2026
35
+ seriesEnd: null,
36
+ };
37
+ const out = generateDaysOfWeek(rule, "UTC", Date.UTC(2026, 4, 1), Date.UTC(2026, 5, 1));
38
+ // Every other Tuesday starting May 5: 5, 19. (Jun 2 is outside window.)
39
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
40
+ "2026-05-05T00:00:00.000Z",
41
+ "2026-05-19T00:00:00.000Z",
42
+ ]);
43
+ });
44
+
45
+ test("count caps total emissions across weeks", () => {
46
+ const rule: DaysOfWeekRule = {
47
+ kind: "daysOfWeek",
48
+ days: [Day.Monday, Day.Wednesday, Day.Friday],
49
+ weekInterval: 1,
50
+ timeOfDay: 9 * HOUR,
51
+ timeSpan: { type: "duration", ms: HOUR },
52
+ seriesStart: Date.UTC(2026, 4, 4),
53
+ seriesEnd: null,
54
+ count: 5,
55
+ };
56
+ const out = generateDaysOfWeek(rule, "UTC", Date.UTC(2026, 4, 4), Date.UTC(2026, 5, 30));
57
+ expect(out).toHaveLength(5);
58
+ // Mon, Wed, Fri (week 1) + Mon, Wed (week 2) = 5
59
+ expect(new Date(out[4].startTimestamp).toISOString()).toBe("2026-05-13T09:00:00.000Z");
60
+ });
61
+
62
+ test("9am Monday in Denver stays at 9am wall across DST forward", () => {
63
+ // Spring forward Sun 2025-03-09. The Mondays around it: Mar 2, Mar 9 (no
64
+ // wait, Mar 9 is Sunday). Mar 2 (Mon, MST) and Mar 9 (Sun) — the next
65
+ // Monday is Mar 9? No: Mar 9 is Sun, Mar 10 is Mon. Use those.
66
+ const rule: DaysOfWeekRule = {
67
+ kind: "daysOfWeek",
68
+ days: [Day.Monday],
69
+ weekInterval: 1,
70
+ timeOfDay: 9 * HOUR,
71
+ timeSpan: { type: "duration", ms: HOUR },
72
+ seriesStart: Date.UTC(2025, 2, 1),
73
+ seriesEnd: null,
74
+ };
75
+ const out = generateDaysOfWeek(
76
+ rule,
77
+ "America/Denver",
78
+ Date.UTC(2025, 2, 1),
79
+ Date.UTC(2025, 2, 17)
80
+ );
81
+ // Mar 3 (MST), Mar 10 (MDT), and Mar 17 — but window ends at Mar 17 00:00
82
+ // UTC. At 9am Denver on Mar 17 = 15:00 UTC, which is past 17 00:00 UTC.
83
+ // So we expect: Mar 3, Mar 10.
84
+ expect(out.length).toBeGreaterThanOrEqual(2);
85
+ const isos = out.map((o) => new Date(o.startTimestamp).toISOString());
86
+ expect(isos[0]).toBe("2025-03-03T16:00:00.000Z"); // 9am MST = 16 UTC
87
+ expect(isos[1]).toBe("2025-03-10T15:00:00.000Z"); // 9am MDT = 15 UTC
88
+
89
+ // And the wall-time hour in Denver is 9 for both:
90
+ expect(utcToWallParts(out[0].startTimestamp, "America/Denver").hour).toBe(9);
91
+ expect(utcToWallParts(out[1].startTimestamp, "America/Denver").hour).toBe(9);
92
+ });
93
+
94
+ test("excludes occurrences before seriesStart", () => {
95
+ const rule: DaysOfWeekRule = {
96
+ kind: "daysOfWeek",
97
+ days: [Day.Sunday, Day.Monday, Day.Tuesday],
98
+ weekInterval: 1,
99
+ timeOfDay: 0,
100
+ timeSpan: { type: "duration", ms: HOUR },
101
+ seriesStart: Date.UTC(2026, 4, 5), // Tue May 5
102
+ seriesEnd: null,
103
+ };
104
+ // Window starts before seriesStart. Should emit Tue May 5 only (Sun May 3
105
+ // and Mon May 4 are before seriesStart).
106
+ const out = generateDaysOfWeek(rule, "UTC", Date.UTC(2026, 4, 1), Date.UTC(2026, 4, 6));
107
+ expect(out).toHaveLength(1);
108
+ expect(new Date(out[0].startTimestamp).toISOString()).toBe("2026-05-05T00:00:00.000Z");
109
+ });
110
+
111
+ test("throws on weekInterval = 0", () => {
112
+ const rule: DaysOfWeekRule = {
113
+ kind: "daysOfWeek",
114
+ days: [Day.Monday],
115
+ weekInterval: 0,
116
+ timeOfDay: 0,
117
+ seriesStart: 0,
118
+ seriesEnd: null,
119
+ };
120
+ expect(() => generateDaysOfWeek(rule, "UTC", 0, 1000)).toThrow(/weekInterval/);
121
+ });
122
+
123
+ test("throws on empty days", () => {
124
+ const rule: DaysOfWeekRule = {
125
+ kind: "daysOfWeek",
126
+ days: [],
127
+ weekInterval: 1,
128
+ timeOfDay: 0,
129
+ seriesStart: 0,
130
+ seriesEnd: null,
131
+ };
132
+ expect(() => generateDaysOfWeek(rule, "UTC", 0, 1000)).toThrow(/non-empty/);
133
+ });
134
+ });
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateDaysOfYear } from "../generators/days_of_year.js";
3
+ import type { DaysOfYearRule } from "../types.js";
4
+
5
+ const HOUR = 3_600_000;
6
+
7
+ describe("generateDaysOfYear", () => {
8
+ test("50th and 70th day of every year", () => {
9
+ const rule: DaysOfYearRule = {
10
+ kind: "daysOfYear",
11
+ daysOfYear: [50, 70],
12
+ yearInterval: 1,
13
+ timeOfDay: 0,
14
+ timeSpan: { type: "duration", ms: HOUR },
15
+ seriesStart: Date.UTC(2026, 0, 1),
16
+ seriesEnd: null,
17
+ };
18
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
19
+ // 2026 (non-leap): day 50 = Feb 19, day 70 = Mar 11
20
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
21
+ "2026-02-19T00:00:00.000Z",
22
+ "2026-03-11T00:00:00.000Z",
23
+ ]);
24
+ });
25
+
26
+ test("day 60 differs between leap and non-leap years", () => {
27
+ const rule: DaysOfYearRule = {
28
+ kind: "daysOfYear",
29
+ daysOfYear: [60],
30
+ yearInterval: 1,
31
+ timeOfDay: 0,
32
+ timeSpan: { type: "duration", ms: HOUR },
33
+ seriesStart: Date.UTC(2024, 0, 1),
34
+ seriesEnd: null,
35
+ };
36
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
37
+ // 2024 (leap): day 60 = Feb 29. 2025/2026 (non-leap): day 60 = Mar 1.
38
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
39
+ "2024-02-29T00:00:00.000Z",
40
+ "2025-03-01T00:00:00.000Z",
41
+ "2026-03-01T00:00:00.000Z",
42
+ ]);
43
+ });
44
+
45
+ test("day 366 only emits in leap years (skipped in non-leap)", () => {
46
+ const rule: DaysOfYearRule = {
47
+ kind: "daysOfYear",
48
+ daysOfYear: [366],
49
+ yearInterval: 1,
50
+ timeOfDay: 0,
51
+ timeSpan: { type: "duration", ms: HOUR },
52
+ seriesStart: Date.UTC(2024, 0, 1),
53
+ seriesEnd: null,
54
+ };
55
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2029, 0, 1));
56
+ // Only 2024 and 2028 are leap years in this range.
57
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
58
+ "2024-12-31T00:00:00.000Z",
59
+ "2028-12-31T00:00:00.000Z",
60
+ ]);
61
+ });
62
+
63
+ test("day 1 (Jan 1) every year", () => {
64
+ const rule: DaysOfYearRule = {
65
+ kind: "daysOfYear",
66
+ daysOfYear: [1],
67
+ yearInterval: 1,
68
+ timeOfDay: 0,
69
+ timeSpan: { type: "duration", ms: HOUR },
70
+ seriesStart: Date.UTC(2024, 0, 1),
71
+ seriesEnd: null,
72
+ };
73
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
74
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
75
+ "2024-01-01T00:00:00.000Z",
76
+ "2025-01-01T00:00:00.000Z",
77
+ "2026-01-01T00:00:00.000Z",
78
+ ]);
79
+ });
80
+
81
+ test("day 365 (last day of non-leap year)", () => {
82
+ const rule: DaysOfYearRule = {
83
+ kind: "daysOfYear",
84
+ daysOfYear: [365],
85
+ yearInterval: 1,
86
+ timeOfDay: 23 * HOUR + 59 * 60_000,
87
+ timeSpan: { type: "duration", ms: HOUR },
88
+ seriesStart: Date.UTC(2024, 0, 1),
89
+ seriesEnd: null,
90
+ };
91
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
92
+ // 2024 leap: day 365 = Dec 30. 2025/2026 non-leap: day 365 = Dec 31.
93
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
94
+ "2024-12-30T23:59:00.000Z",
95
+ "2025-12-31T23:59:00.000Z",
96
+ "2026-12-31T23:59:00.000Z",
97
+ ]);
98
+ });
99
+
100
+ test("yearInterval = 4 with day 366 emits each leap year", () => {
101
+ const rule: DaysOfYearRule = {
102
+ kind: "daysOfYear",
103
+ daysOfYear: [366],
104
+ yearInterval: 4,
105
+ timeOfDay: 0,
106
+ timeSpan: { type: "duration", ms: HOUR },
107
+ seriesStart: Date.UTC(2024, 0, 1),
108
+ seriesEnd: null,
109
+ };
110
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2033, 0, 1));
111
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
112
+ "2024-12-31T00:00:00.000Z",
113
+ "2028-12-31T00:00:00.000Z",
114
+ "2032-12-31T00:00:00.000Z",
115
+ ]);
116
+ });
117
+
118
+ test("count caps emissions", () => {
119
+ const rule: DaysOfYearRule = {
120
+ kind: "daysOfYear",
121
+ daysOfYear: [50, 70, 100],
122
+ yearInterval: 1,
123
+ timeOfDay: 0,
124
+ timeSpan: { type: "duration", ms: HOUR },
125
+ seriesStart: Date.UTC(2026, 0, 1),
126
+ seriesEnd: null,
127
+ count: 4,
128
+ };
129
+ const out = generateDaysOfYear(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2029, 0, 1));
130
+ expect(out).toHaveLength(4);
131
+ });
132
+
133
+ test("throws on empty daysOfYear", () => {
134
+ const rule: DaysOfYearRule = {
135
+ kind: "daysOfYear",
136
+ daysOfYear: [],
137
+ yearInterval: 1,
138
+ timeOfDay: 0,
139
+ timeSpan: { type: "duration", ms: HOUR },
140
+ seriesStart: 0,
141
+ seriesEnd: null,
142
+ };
143
+ expect(() => generateDaysOfYear(rule, "UTC", 0, 1000)).toThrow(/daysOfYear/);
144
+ });
145
+
146
+ test("throws on day out of range (1..366)", () => {
147
+ const rule: DaysOfYearRule = {
148
+ kind: "daysOfYear",
149
+ daysOfYear: [367],
150
+ yearInterval: 1,
151
+ timeOfDay: 0,
152
+ timeSpan: { type: "duration", ms: HOUR },
153
+ seriesStart: 0,
154
+ seriesEnd: null,
155
+ };
156
+ expect(() => generateDaysOfYear(rule, "UTC", 0, 1000)).toThrow(/daysOfYear/);
157
+ });
158
+ });