| 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 |