返回 DeepSeek-Reasonix
heartbeat-next-run.test.ts
根目录 / desktop / frontend / src / __tests__ / heartbeat-next-run.test.ts
1 // Run: tsx src/__tests__/heartbeat-next-run.test.ts
2
3 import { changeHeartbeatFrequency, cronToInterval, heartbeatBuildCycleInterval, heartbeatNextRunAt, intervalToCron, mergeEngineRunState, nextCycleRunAt, prepareTasksByNextRun } from "../custom/features/heartbeat/HeartbeatPanel";
4
5 let passed = 0;
6 let failed = 0;
7
8 function eq(a: unknown, b: unknown, label: string) {
9 if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
10 const ak = JSON.stringify(a, Object.keys(a as object).sort());
11 const bk = JSON.stringify(b, Object.keys(b as object).sort());
12 if (ak === bk) {
13 process.stdout.write(` PASS ${label}\n`);
14 passed += 1;
15 } else {
16 process.stdout.write(` FAIL ${label}: expected ${bk}, got ${ak}\n`);
17 failed += 1;
18 }
19 return;
20 }
21 if (a === b) {
22 process.stdout.write(` PASS ${label}\n`);
23 passed += 1;
24 } else {
25 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
26 failed += 1;
27 }
28 }
29
30 function localMs(year: number, month: number, day: number, hour: number, minute: number): number {
31 return new Date(year, month - 1, day, hour, minute, 0, 0).getTime();
32 }
33
34 console.log("\nheartbeat next run");
35
36 eq(
37 heartbeatNextRunAt(
38 { interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30) },
39 localMs(2026, 6, 18, 17, 20),
40 ),
41 localMs(2026, 6, 18, 17, 0),
42 "plain interval stays due after elapsed",
43 );
44
45 eq(
46 heartbeatNextRunAt(
47 { interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
48 localMs(2026, 6, 18, 17, 20),
49 ),
50 localMs(2026, 6, 19, 9, 0),
51 "time window defers elapsed interval to next opening",
52 );
53
54 eq(
55 heartbeatNextRunAt(
56 { interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
57 localMs(2026, 6, 18, 16, 10),
58 ),
59 localMs(2026, 6, 18, 16, 30),
60 "time window keeps next run inside the open window",
61 );
62
63 eq(
64 heartbeatNextRunAt(
65 { interval: "30m", lastRunAt: localMs(2026, 6, 18, 21, 50), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
66 localMs(2026, 6, 18, 22, 10),
67 ),
68 localMs(2026, 6, 18, 22, 20),
69 "cross-midnight window keeps due time in the open window",
70 );
71
72 eq(
73 heartbeatNextRunAt(
74 { interval: "30m", lastRunAt: localMs(2026, 6, 18, 11, 30), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
75 localMs(2026, 6, 18, 12, 10),
76 ),
77 localMs(2026, 6, 18, 22, 0),
78 "cross-midnight window waits for today's opening from midday",
79 );
80
81 eq(
82 heartbeatNextRunAt(
83 { interval: "24h|daily@20:00", lastRunAt: localMs(2026, 6, 18, 20, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
84 localMs(2026, 6, 19, 19, 0),
85 ),
86 localMs(2026, 6, 19, 20, 0),
87 "cycle next run ignores stale interval time windows",
88 );
89
90 eq(
91 heartbeatBuildCycleInterval("daily", [], "09:00"),
92 "24h|weekly:mon@09:00",
93 "empty daily day selection does not save as every day",
94 );
95
96 eq(
97 heartbeatBuildCycleInterval("weekly", [], "09:00"),
98 "168h|weekly:mon@09:00",
99 "weekly default uses one weekday",
100 );
101
102 eq(
103 heartbeatNextRunAt(
104 { interval: "0 9 * * 1-5", lastRunAt: 0 },
105 localMs(2026, 6, 15, 10, 0),
106 ),
107 localMs(2026, 6, 16, 9, 0),
108 "cron weekday schedule computes next business-day run",
109 );
110
111 eq(
112 heartbeatNextRunAt(
113 { interval: "*/15 * * * *", lastRunAt: 0 },
114 localMs(2026, 6, 18, 10, 7),
115 ),
116 localMs(2026, 6, 18, 10, 15),
117 "cron every-15-minutes computes next slot",
118 );
119
120 eq(
121 heartbeatNextRunAt(
122 { interval: "0 9 * * 1-5", lastRunAt: localMs(2026, 6, 15, 9, 0) },
123 localMs(2026, 6, 15, 9, 0),
124 ),
125 localMs(2026, 6, 16, 9, 0),
126 "cron next run ignores lastRunAt and uses wall clock",
127 );
128
129 console.log("\nengine-run merge (SivanCola review: trigger must not clobber draft)");
130
131 // 立即运行返回后:磁盘旧快照只贡献 runHistory/topicId/lastRunAt,等待期间的
132 // 草稿编辑(title/prompt/interval)必须保留
133 eq(
134 mergeEngineRunState(
135 { id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", lastRunAt: 100 },
136 { id: "t1", title: "旧标题", prompt: "旧 prompt", interval: "1h", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
137 ),
138 { id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
139 "trigger merge keeps draft title while adopting engine run fields",
140 );
141 // 没有 runHistory 的旧磁盘快照不得清掉草稿中已有 runHistory
142 eq(
143 mergeEngineRunState(
144 { id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 100 },
145 { id: "t1", title: "旧标题", prompt: "旧", interval: "1h", enabled: true, lastRunAt: 110 },
146 ),
147 { id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 110 },
148 "trigger merge adopts fresh lastRunAt without dropping draft runHistory",
149 );
150
151 // ── 周期转 cron / 周期 next-run 语义(SivanCola review 回归) ──
152
153 console.log("cycle → cron conversion guards");
154
155 // biweekly 无法无损转 cron → null
156 eq(intervalToCron("336h|biweekly:mon@09:00"), null, "biweekly refuses cron conversion");
157
158 // 秒级任务无法转 cron → null
159 eq(intervalToCron("30s"), null, "seconds refuse cron conversion");
160
161 // 跨午夜窗口 22:00–06:00 无法表达 → null
162 eq(intervalToCron("1h", "22:00", "06:00"), null, "cross-midnight window refuses conversion");
163
164 // 时间窗口结束时刻 exclusive:09:00–17:00 → 小时 9-16(不含 17 点)
165 eq(intervalToCron("1h", "09:00", "17:00"), "0 9-16 * * *", "window end hour is exclusive");
166
167 // SivanCola review (2026-08-14): 2h + 窗口不能转成 0 9-16(那会把每2小时
168 // 变成每小时,频率放大)→ 拒绝转换
169 eq(intervalToCron("2h", "09:00", "17:00"), null, "2h window refuses conversion (frequency would grow)");
170
171 // 非整点窗口 09:30-17:30 会被截断成整点 → 拒绝转换
172 eq(intervalToCron("1h", "09:30", "17:30"), null, "non-top-of-hour window refuses conversion");
173 eq(intervalToCron("30m", "09:30", "17:30"), null, "non-top-of-hour window refuses m-interval conversion");
174
175 // 周期任务带残留窗口:周期任务有自己的时刻(@12:00),引擎忽略窗口,
176 // 转 cron 必须保持原时刻,不能折叠成窗口小时范围(否则每天变成每小时)
177 eq(intervalToCron("24h|daily@12:00", "09:00", "17:00"), "0 12 * * *", "daily with stale window keeps its own clock");
178
179 // daily 周期正常转换(无窗口)
180 eq(intervalToCron("24h|daily@09:00"), "0 9 * * *", "daily converts to cron");
181
182 // weekly 周期正常转换
183 eq(intervalToCron("168h|weekly:mon,wed@09:00"), "0 9 * * 1,3", "weekly converts to cron");
184
185 // monthly 周期正常转换
186 eq(intervalToCron("720h|monthly:15@09:00"), "0 9 15 * *", "monthly converts to cron");
187
188 console.log("cycle next-run (schedule semantics, not cron)");
189
190 // daily: 下个 22:00
191 eq(
192 nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 10, 0)),
193 localMs(2026, 8, 11, 22, 0),
194 "daily next run is today's 22:00",
195 );
196
197 // daily: 已过 22:00 → 明天 22:00
198 eq(
199 nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 23, 0)),
200 localMs(2026, 8, 12, 22, 0),
201 "daily next run rolls to tomorrow after 22:00",
202 );
203
204 // weekly: 下个周五 16:00(2026-08-14 是周五)
205 eq(
206 nextCycleRunAt("168h|weekly:fri@16:00", localMs(2026, 8, 11, 10, 0)),
207 localMs(2026, 8, 14, 16, 0),
208 "weekly next run is next Friday 16:00",
209 );
210
211 // monthly: 下个 15 号 09:00
212 eq(
213 nextCycleRunAt("720h|monthly:15@09:00", localMs(2026, 8, 11, 10, 0)),
214 localMs(2026, 8, 15, 9, 0),
215 "monthly next run is 15th 09:00",
216 );
217
218 // 月末 clamp:monthly:31 在 2026-04-01 之后的下一次是 2026-04-30(4 月只有
219 // 30 天,后端 monthlyCandidate 将 day=31 clamp 到当月末)——前端镜像后端
220 // 语义,不能跳到 5 月 31 号。
221 eq(
222 nextCycleRunAt("720h|monthly:31@09:00", localMs(2026, 4, 1, 10, 0)),
223 localMs(2026, 4, 30, 9, 0),
224 "monthly day-31 clamps to April 30 (mirrors backend monthlyCandidate)",
225 );
226 // 闰年 2 月:day=29 在非闰年 2027 年 clamp 到 2 月 28 日
227 eq(
228 nextCycleRunAt("720h|monthly:29@09:00", localMs(2027, 2, 1, 10, 0)),
229 localMs(2027, 2, 28, 9, 0),
230 "monthly day-29 clamps to February 28 in a non-leap year",
231 );
232 // 闰年 2028 年 2 月 29 日保持 29 日
233 eq(
234 nextCycleRunAt("720h|monthly:29@09:00", localMs(2028, 2, 1, 10, 0)),
235 localMs(2028, 2, 29, 9, 0),
236 "monthly day-29 keeps February 29 in a leap year",
237 );
238
239 // yearly: 下个 1-1 09:00(2027-01-01)
240 eq(
241 nextCycleRunAt("8760h|yearly:1-1@09:00", localMs(2026, 8, 11, 10, 0)),
242 localMs(2027, 1, 1, 9, 0),
243 "yearly next run is next Jan 1st 09:00",
244 );
245
246 // 离线期间已到期:daily 任务上次运行在 2026-08-16 09:00,schedule 基于
247 // lastRunAt 求下一次 = 2026-08-17 09:00,而当前已到 8/18 10:00 → next 落在
248 // 过去。taskNextRun 据此显示 dueSoon(当前应执行),而不是跳过到明天。
249 eq(
250 heartbeatNextRunAt(
251 { interval: "24h|daily@09:00", lastRunAt: localMs(2026, 8, 16, 9, 0) },
252 localMs(2026, 8, 18, 10, 0),
253 ),
254 localMs(2026, 8, 17, 9, 0),
255 "daily next run falls in the past when the task is overdue offline",
256 );
257
258 // ── review fixes 回归 ──
259
260 console.log("cron dow=7 Sunday alias");
261
262 eq(
263 heartbeatNextRunAt(
264 { interval: "0 9 * * 7", lastRunAt: 0 },
265 localMs(2026, 8, 10, 10, 0), // Monday
266 ),
267 localMs(2026, 8, 16, 9, 0), // next Sunday 09:00
268 "dow=7 (Sunday alias) computes the next Sunday run",
269 );
270
271 eq(
272 heartbeatNextRunAt(
273 { interval: "0 9 * * 0,7", lastRunAt: 0 },
274 localMs(2026, 8, 10, 10, 0), // Monday
275 ),
276 localMs(2026, 8, 16, 9, 0),
277 "dow=0,7 both spellings compute the next Sunday run",
278 );
279
280 console.log("biweekly parity mirrors the Go engine (Monday week anchor)");
281
282 // 2026-08-06 is a Thursday; engine parity is anchored on the Monday of the
283 // creation week (2026-08-03). Candidate Monday 2026-08-10 is week 1 away →
284 // skipped; next fire is Monday 2026-08-17 (week 2).
285 eq(
286 nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 6, 9, 0)),
287 localMs(2026, 8, 17, 9, 0),
288 "biweekly with Thursday anchor skips the first Monday (Monday-anchored parity)",
289 );
290
291 // Anchor on a Monday itself: creation week = 2026-08-10; next fire is 2026-08-24.
292 eq(
293 nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 10, 9, 0)),
294 localMs(2026, 8, 24, 9, 0),
295 "biweekly with Monday anchor fires every other Monday",
296 );
297
298 console.log("cronToInterval refuses lossy conversions");
299
300 eq(cronToInterval("0 9 * * 1"), null, "weekly cron refuses interval conversion");
301 eq(cronToInterval("0 9 * * *"), null, "fixed-time cron refuses interval conversion");
302 eq(cronToInterval("0 0 1 * *"), null, "dom-restricted cron refuses interval conversion");
303 eq(cronToInterval("*/15 * * * *"), "15m", "every-N-minutes cron converts");
304 eq(cronToInterval("0 */2 * * *"), "2h", "every-N-hours cron converts");
305 eq(cronToInterval("0 * * * *"), null, "top-of-every-hour is not a simple interval");
306
307 console.log("frontend isCronExpr field bounds (dom/month 1-based, step>0, lo<=hi)");
308
309 eq(
310 heartbeatNextRunAt(
311 { interval: "0 0 0 * *", lastRunAt: 0 }, // dom=0 — "midnight every day" typo
312 localMs(2026, 8, 10, 10, 0),
313 ),
314 null,
315 "dom=0 is rejected: no next run is computed for an invalid expression",
316 );
317
318 // SivanCola review: 永不执行的表达式必须被拒绝
319 eq(
320 heartbeatNextRunAt(
321 { interval: "*/0 * * * *", lastRunAt: 0 }, // step 0: value % 0 is never 0
322 localMs(2026, 8, 10, 10, 0),
323 ),
324 null,
325 "zero step */0 is rejected (never fires)",
326 );
327 eq(
328 heartbeatNextRunAt(
329 { interval: "0 0 5-1 * *", lastRunAt: 0 }, // descending range never matches
330 localMs(2026, 8, 10, 10, 0),
331 ),
332 null,
333 "descending range 5-1 is rejected (never matches)",
334 );
335 eq(
336 heartbeatNextRunAt(
337 { interval: "0 0 1 * 8", lastRunAt: 0 }, // dow 8 out of range
338 localMs(2026, 8, 10, 10, 0),
339 ),
340 null,
341 "dow=8 is rejected (out of range)",
342 );
343
344 eq(
345 heartbeatNextRunAt(
346 { interval: "0 0 1 0 *", lastRunAt: 0 }, // month=0
347 localMs(2026, 8, 10, 10, 0),
348 ),
349 null,
350 "month=0 is rejected",
351 );
352
353 eq(
354 heartbeatNextRunAt(
355 { interval: "0 0 1 * *", lastRunAt: 0 },
356 localMs(2026, 8, 10, 10, 0),
357 ),
358 localMs(2026, 9, 1, 0, 0),
359 "valid dom expression still computes a next run",
360 );
361
362 console.log("cron steps use field minima and retain long-horizon matches");
363
364 eq(
365 heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2025, 12, 31, 12, 0)),
366 localMs(2026, 1, 1, 0, 0),
367 "wildcard month step starts from January",
368 );
369 eq(
370 heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2026, 1, 31, 0, 0)),
371 localMs(2026, 3, 1, 0, 0),
372 "wildcard month step skips February",
373 );
374 eq(
375 heartbeatNextRunAt({ interval: "1/2 * * * *", lastRunAt: 0 }, localMs(2026, 1, 1, 0, 1)),
376 localMs(2026, 1, 1, 0, 3),
377 "single-value step continues from its explicit start",
378 );
379 eq(
380 heartbeatNextRunAt({ interval: "0 0 29 2 *", lastRunAt: 0 }, localMs(2028, 3, 1, 0, 0)),
381 localMs(2032, 2, 29, 0, 0),
382 "leap-day cron searches beyond one year",
383 );
384
385 console.log("task list computes each next run once");
386
387 const listNow = localMs(2026, 8, 10, 10, 0);
388 const listTasks = [
389 { id: "late", title: "late", prompt: "", interval: "0 0 29 2 *", enabled: true, lastRunAt: 1 },
390 { id: "new", title: "new", prompt: "", interval: "0 0 31 2 *", enabled: true },
391 { id: "paused", title: "paused", prompt: "", interval: "0 0 31 2 *", enabled: false, lastRunAt: 1 },
392 ];
393 let nextRunCalls = 0;
394 const preparedTasks = prepareTasksByNextRun(listTasks, listNow, (task) => {
395 nextRunCalls += 1;
396 return task.id === "late" ? listNow + 60000 : null;
397 });
398 eq(nextRunCalls, listTasks.length, "list preparation resolves each task exactly once");
399 eq(preparedTasks.map(({ task }) => task.id).join(","), "new,late,paused", "list preparation preserves next-run ordering");
400 eq(preparedTasks[1].nextRunAt, listNow + 60000, "list rows reuse the precomputed next-run value");
401
402 console.log("frequency conversion keeps the selected editor on lossy paths");
403
404 eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "0 9 * * 1" }, "interval"), null, "weekly cron cannot switch to interval");
405 eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "30s" }, "cron"), null, "seconds cannot switch to cron");
406 eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "336h|biweekly:mon@09:00" }, "cron"), null, "biweekly cannot switch to cron");
407 eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "1h", timeWindowStart: "22:00", timeWindowEnd: "06:00" }, "cron"), null, "cross-midnight interval cannot switch to cron");
408
409 console.log("biweekly parity uses civil dates across DST");
410
411 const originalTZ = process.env.TZ;
412 process.env.TZ = "America/New_York";
413 eq(
414 nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 3, 9, 10, 0), localMs(2026, 3, 2, 9, 0)),
415 localMs(2026, 3, 16, 9, 0),
416 "spring-forward keeps the even-week Monday",
417 );
418 eq(
419 nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 11, 2, 10, 0), localMs(2026, 10, 26, 9, 0)),
420 localMs(2026, 11, 9, 9, 0),
421 "fall-back keeps the even-week Monday",
422 );
423 if (originalTZ === undefined) delete process.env.TZ;
424 else process.env.TZ = originalTZ;
425
426 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
427 if (failed > 0) process.exit(1);
428
428 lines TYPESCRIPT