diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:29:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:29:54 +0900 |
| commit | b8bc5c71076d0318291e120e056e8103344493ff (patch) | |
| tree | 8012bc4108f44c1557b175220f672d597d0edb0c /packages/api/tests | |
| parent | 97c1b40ead19cdfe54b9a7aeb2c0fdcc1c9653b1 (diff) | |
| download | dispatch-b8bc5c71076d0318291e120e056e8103344493ff.tar.gz dispatch-b8bc5c71076d0318291e120e056e8103344493ff.zip | |
feat(api): extract pure wake-scheduler helpers (nextDailyAfter, recoverScheduleEntry)
Side-effect-free module so missed-wake recovery and rescheduling can be
unit-tested without booting Hono or touching SQLite.
- nextDailyAfter: advances by 24h increments until strictly > now (handles
multi-day gaps in a single step instead of looping a day at a time).
- recoverScheduleEntry: classifies a past next_wake_at into 'fire now,
then advance' vs 'silently advance' based on MISSED_WAKE_GRACE_MS (2h).
- CLAUDE_RESET_OFFSET_HOURS / resetHourFor: single source of truth for the
'+5h reset' display, previously hardcoded in three places.
Includes 12 unit tests covering grace boundaries, multi-day skip, custom
grace windows, and midnight wraparound.
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/wake-scheduler.test.ts | 98 |
1 files changed, 98 insertions, 0 deletions
diff --git a/packages/api/tests/wake-scheduler.test.ts b/packages/api/tests/wake-scheduler.test.ts new file mode 100644 index 0000000..0e5731c --- /dev/null +++ b/packages/api/tests/wake-scheduler.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + CLAUDE_RESET_OFFSET_HOURS, + DAILY_INTERVAL_MS, + MISSED_WAKE_GRACE_MS, + nextDailyAfter, + recoverScheduleEntry, + resetHourFor, +} from "../src/wake-scheduler.js"; + +const HOUR = 60 * 60 * 1000; + +describe("nextDailyAfter", () => { + it("returns the input when it is already strictly in the future", () => { + const now = 1_700_000_000_000; + const future = now + 60_000; + expect(nextDailyAfter(future, now)).toBe(future); + }); + + it("advances by exactly 24h when the input is 1ms in the past", () => { + const now = 1_700_000_000_000; + const previous = now - 1; + expect(nextDailyAfter(previous, now)).toBe(previous + DAILY_INTERVAL_MS); + }); + + it("skips multiple missed days in a single step when far in the past", () => { + const now = 1_700_000_000_000; + const previous = now - 3 * DAILY_INTERVAL_MS - HOUR; // 3d 1h ago + const next = nextDailyAfter(previous, now); + expect(next).toBeGreaterThan(now); + // Should be the next 24h-multiple boundary, not just +24h. + expect((next - previous) % DAILY_INTERVAL_MS).toBe(0); + expect(next - previous).toBe(4 * DAILY_INTERVAL_MS); + }); + + it("returns previous + 1 day exactly when previous == now", () => { + const now = 1_700_000_000_000; + expect(nextDailyAfter(now, now)).toBe(now + DAILY_INTERVAL_MS); + }); +}); + +describe("recoverScheduleEntry", () => { + const now = 1_700_000_000_000; + + it("leaves a future entry unchanged and does not fire", () => { + const stored = now + 5 * HOUR; + expect(recoverScheduleEntry(stored, now)).toEqual({ + nextWakeAt: stored, + shouldFireNow: false, + }); + }); + + it("fires now for an entry missed by less than the grace window", () => { + const stored = now - HOUR; // 1h ago, within 2h grace + const recovered = recoverScheduleEntry(stored, now); + expect(recovered.shouldFireNow).toBe(true); + expect(recovered.nextWakeAt).toBeGreaterThan(now); + }); + + it("fires now for an entry missed by exactly the grace window", () => { + const stored = now - MISSED_WAKE_GRACE_MS; + expect(recoverScheduleEntry(stored, now).shouldFireNow).toBe(true); + }); + + it("does NOT fire for an entry missed by more than the grace window", () => { + const stored = now - MISSED_WAKE_GRACE_MS - 1; + const recovered = recoverScheduleEntry(stored, now); + expect(recovered.shouldFireNow).toBe(false); + expect(recovered.nextWakeAt).toBeGreaterThan(now); + }); + + it("always returns a future nextWakeAt for past entries (regardless of grace)", () => { + for (const ageDays of [0.1, 0.5, 1, 2, 7, 30]) { + const stored = now - ageDays * DAILY_INTERVAL_MS; + const { nextWakeAt } = recoverScheduleEntry(stored, now); + expect(nextWakeAt, `age=${ageDays}d`).toBeGreaterThan(now); + } + }); + + it("respects a custom grace window", () => { + const stored = now - 10 * 60_000; // 10 min ago + expect(recoverScheduleEntry(stored, now, 5 * 60_000).shouldFireNow).toBe(false); + expect(recoverScheduleEntry(stored, now, 15 * 60_000).shouldFireNow).toBe(true); + }); +}); + +describe("resetHourFor / CLAUDE_RESET_OFFSET_HOURS", () => { + it("adds the offset modulo 24", () => { + expect(resetHourFor(0)).toBe(CLAUDE_RESET_OFFSET_HOURS); + expect(resetHourFor(20)).toBe((20 + CLAUDE_RESET_OFFSET_HOURS) % 24); + expect(resetHourFor(23)).toBe((23 + CLAUDE_RESET_OFFSET_HOURS) % 24); + }); + + it("wraps cleanly across midnight", () => { + // Wake at 22:15, +5h = 03:00 + expect(resetHourFor(22)).toBe(3); + }); +}); |
