Sfoglia il codice sorgente

test: 添加 Cron 调度单元测试 (测试计划 07)

覆盖 parseCronExpression、computeNextCronRun、cronToHuman,
包含有效/无效表达式、字段范围验证、下次运行计算、人类可读描述,
共 38 个测试用例。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude-code-best 3 settimane fa
parent
commit
f81a767f83
1 ha cambiato i file con 253 aggiunte e 0 eliminazioni
  1. 253 0
      src/utils/__tests__/cron.test.ts

+ 253 - 0
src/utils/__tests__/cron.test.ts

@@ -0,0 +1,253 @@
+import { describe, expect, test } from "bun:test";
+import { parseCronExpression, computeNextCronRun, cronToHuman } from "../cron";
+
+describe("parseCronExpression", () => {
+  describe("valid expressions", () => {
+    test("parses wildcard fields", () => {
+      const result = parseCronExpression("* * * * *");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toHaveLength(60);
+      expect(result!.hour).toHaveLength(24);
+      expect(result!.dayOfMonth).toHaveLength(31);
+      expect(result!.month).toHaveLength(12);
+      expect(result!.dayOfWeek).toHaveLength(7);
+    });
+
+    test("parses specific values", () => {
+      const result = parseCronExpression("30 14 1 6 3");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([30]);
+      expect(result!.hour).toEqual([14]);
+      expect(result!.dayOfMonth).toEqual([1]);
+      expect(result!.month).toEqual([6]);
+      expect(result!.dayOfWeek).toEqual([3]);
+    });
+
+    test("parses step syntax", () => {
+      const result = parseCronExpression("*/5 * * * *");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
+    });
+
+    test("parses range syntax", () => {
+      const result = parseCronExpression("1-5 * * * *");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([1, 2, 3, 4, 5]);
+    });
+
+    test("parses range with step", () => {
+      const result = parseCronExpression("1-10/3 * * * *");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([1, 4, 7, 10]);
+    });
+
+    test("parses comma-separated list", () => {
+      const result = parseCronExpression("1,15,30 * * * *");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([1, 15, 30]);
+    });
+
+    test("parses day-of-week 7 as Sunday alias", () => {
+      const result = parseCronExpression("0 0 * * 7");
+      expect(result).not.toBeNull();
+      expect(result!.dayOfWeek).toEqual([0]);
+    });
+
+    test("parses range with day-of-week 7", () => {
+      const result = parseCronExpression("0 0 * * 5-7");
+      expect(result).not.toBeNull();
+      expect(result!.dayOfWeek).toEqual([0, 5, 6]);
+    });
+
+    test("parses complex combined expression", () => {
+      const result = parseCronExpression("0,30 9-17 * * 1-5");
+      expect(result).not.toBeNull();
+      expect(result!.minute).toEqual([0, 30]);
+      expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
+      expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5]);
+    });
+  });
+
+  describe("invalid expressions", () => {
+    test("returns null for wrong field count", () => {
+      expect(parseCronExpression("* * *")).toBeNull();
+    });
+
+    test("returns null for out-of-range values", () => {
+      expect(parseCronExpression("60 * * * *")).toBeNull();
+    });
+
+    test("returns null for invalid step", () => {
+      expect(parseCronExpression("*/0 * * * *")).toBeNull();
+    });
+
+    test("returns null for reversed range", () => {
+      expect(parseCronExpression("10-5 * * * *")).toBeNull();
+    });
+
+    test("returns null for empty string", () => {
+      expect(parseCronExpression("")).toBeNull();
+    });
+
+    test("returns null for non-numeric tokens", () => {
+      expect(parseCronExpression("abc * * * *")).toBeNull();
+    });
+  });
+
+  describe("field range validation", () => {
+    test("minute: 0-59", () => {
+      expect(parseCronExpression("0 * * * *")).not.toBeNull();
+      expect(parseCronExpression("59 * * * *")).not.toBeNull();
+      expect(parseCronExpression("60 * * * *")).toBeNull();
+    });
+
+    test("hour: 0-23", () => {
+      expect(parseCronExpression("* 0 * * *")).not.toBeNull();
+      expect(parseCronExpression("* 23 * * *")).not.toBeNull();
+      expect(parseCronExpression("* 24 * * *")).toBeNull();
+    });
+
+    test("dayOfMonth: 1-31", () => {
+      expect(parseCronExpression("* * 1 * *")).not.toBeNull();
+      expect(parseCronExpression("* * 31 * *")).not.toBeNull();
+      expect(parseCronExpression("* * 0 * *")).toBeNull();
+      expect(parseCronExpression("* * 32 * *")).toBeNull();
+    });
+
+    test("month: 1-12", () => {
+      expect(parseCronExpression("* * * 1 *")).not.toBeNull();
+      expect(parseCronExpression("* * * 12 *")).not.toBeNull();
+      expect(parseCronExpression("* * * 0 *")).toBeNull();
+      expect(parseCronExpression("* * * 13 *")).toBeNull();
+    });
+
+    test("dayOfWeek: 0-6 (plus 7 alias)", () => {
+      expect(parseCronExpression("* * * * 0")).not.toBeNull();
+      expect(parseCronExpression("* * * * 6")).not.toBeNull();
+      expect(parseCronExpression("* * * * 7")).not.toBeNull(); // alias for 0
+      expect(parseCronExpression("* * * * 8")).toBeNull();
+    });
+  });
+});
+
+describe("computeNextCronRun", () => {
+  test("finds next minute", () => {
+    const fields = parseCronExpression("31 14 * * *")!;
+    const from = new Date(2026, 0, 15, 14, 30, 45); // 14:30:45
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getHours()).toBe(14);
+    expect(next!.getMinutes()).toBe(31);
+  });
+
+  test("finds next hour", () => {
+    const fields = parseCronExpression("0 15 * * *")!;
+    const from = new Date(2026, 0, 15, 14, 30);
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getHours()).toBe(15);
+    expect(next!.getMinutes()).toBe(0);
+  });
+
+  test("rolls to next day", () => {
+    const fields = parseCronExpression("0 10 * * *")!;
+    const from = new Date(2026, 0, 15, 14, 30);
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getDate()).toBe(16);
+    expect(next!.getHours()).toBe(10);
+  });
+
+  test("is strictly after from date", () => {
+    const fields = parseCronExpression("30 14 * * *")!;
+    const from = new Date(2026, 0, 15, 14, 30, 0); // exactly on cron time
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getTime()).toBeGreaterThan(from.getTime());
+  });
+
+  test("every 5 minutes from arbitrary time", () => {
+    const fields = parseCronExpression("*/5 * * * *")!;
+    const from = new Date(2026, 0, 15, 14, 32);
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getMinutes()).toBe(35);
+  });
+
+  test("every minute", () => {
+    const fields = parseCronExpression("* * * * *")!;
+    const from = new Date(2026, 0, 15, 14, 32, 45);
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getMinutes()).toBe(33);
+  });
+
+  test("handles step across midnight", () => {
+    const fields = parseCronExpression("0 0 * * *")!;
+    const from = new Date(2026, 0, 15, 23, 59);
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    expect(next!.getHours()).toBe(0);
+    expect(next!.getDate()).toBe(16);
+  });
+
+  test("OR semantics when both dom and dow constrained", () => {
+    // dom=15, dow=3(Wed) - matches 15th OR Wednesday
+    const fields = parseCronExpression("0 0 15 * 3")!;
+    const from = new Date(2026, 0, 12, 0, 0); // Monday Jan 12
+    const next = computeNextCronRun(fields, from);
+    expect(next).not.toBeNull();
+    // Should match the first of either: next Wednesday(Jan 14) or 15th(Jan 15)
+    const dayOfWeek = next!.getDay();
+    const dayOfMonth = next!.getDate();
+    expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true);
+  });
+});
+
+describe("cronToHuman", () => {
+  test("every N minutes", () => {
+    expect(cronToHuman("*/5 * * * *")).toBe("Every 5 minutes");
+  });
+
+  test("every minute", () => {
+    expect(cronToHuman("*/1 * * * *")).toBe("Every minute");
+  });
+
+  test("every hour at :00", () => {
+    expect(cronToHuman("0 * * * *")).toBe("Every hour");
+  });
+
+  test("every hour at :30", () => {
+    expect(cronToHuman("30 * * * *")).toBe("Every hour at :30");
+  });
+
+  test("every N hours", () => {
+    expect(cronToHuman("0 */2 * * *")).toBe("Every 2 hours");
+  });
+
+  test("daily at specific time", () => {
+    const result = cronToHuman("30 9 * * *");
+    expect(result).toContain("Every day at");
+    expect(result).toContain("9:30");
+  });
+
+  test("specific day of week", () => {
+    const result = cronToHuman("0 9 * * 3");
+    expect(result).toContain("Wednesday");
+    expect(result).toContain("9:00");
+  });
+
+  test("weekdays", () => {
+    const result = cronToHuman("0 9 * * 1-5");
+    expect(result).toContain("Weekdays");
+    expect(result).toContain("9:00");
+  });
+
+  test("returns raw cron for complex patterns", () => {
+    expect(cronToHuman("0,30 9-17 * * 1-5")).toBe("0,30 9-17 * * 1-5");
+  });
+
+  test("returns raw cron for wrong field count", () => {
+    expect(cronToHuman("* * *")).toBe("* * *");
+  });
+});