import { describe, expect, it } from "vitest"; import { rematerialize } from "./rematerialize.js"; import { nextOccurrences } from "./occurrences_extra.js"; import type { StoredOccurrence } from "./rematerialize.js"; /** Build stored rows as a worker would have persisted them. */ function storedFrom(spec: { cron: string; timezone: string }, from: string, count: number): StoredOccurrence[] { return nextOccurrences(spec, from, count).map((o) => ({ nominalLocal: o.nominalLocal, fireAtUtc: o.fireAtUtc, claimed: false, })); } describe("rematerialize — invalidation and regeneration", () => { it("deletes unclaimed future occurrences and regenerates from the change instant", () => { const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5); // One past occurrence was claimed. stored[0] = { ...stored[0]!, claimed: true }; const changeInstant = "2024-03-02T00:00:00Z"; const result = rematerialize(oldSpec, { cron: "0 10 * * *", timezone: "America/New_York" }, stored, { changeInstant, lastFiredNominalLocal: stored[0]!.nominalLocal, count: 3, }); // Unclaimed future rows are invalidated. expect(result.toDeleteFireAtUtc).toEqual( stored.slice(1).map((r) => r.fireAtUtc), ); // New occurrences start strictly after the change instant and the last claim. expect(result.toInsert.length).toBe(3); expect(result.toInsert[0]!.fireAtUtc).toBeGreaterThan(Date.parse(changeInstant)); expect(result.toInsert[0]!.nominalLocal).toBe("2024-03-02T10:00"); // The new tzdata version is reported for recording. expect(result.tzdataVersion).toMatch(/.+/); }); it("never refires a nominal slot already fired under the old zone (NY -> LA same day)", () => { // Daily 09:00. Under New York the 2024-03-01 09:00 slot fired at 14:00Z. const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored: StoredOccurrence[] = [ { nominalLocal: "2024-03-01T09:00", fireAtUtc: Date.parse("2024-03-01T14:00:00Z"), claimed: true }, ...storedFrom(oldSpec, "2024-03-02T00:00:00Z", 3), ]; const result = rematerialize(oldSpec, { cron: "0 9 * * *", timezone: "America/Los_Angeles" }, stored, { changeInstant: "2024-03-01T15:00:00Z", // later the same day, after the NY firing lastFiredNominalLocal: "2024-03-01T09:00", count: 3, }); // 09:00 in Los Angeles on 03-01 is 17:00Z — but that nominal slot was // already fired under New York, so it must NOT appear. expect(result.toInsert.map((o) => o.nominalLocal)).not.toContain("2024-03-01T09:00"); expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([ "2024-03-02T09:00", "2024-03-03T09:00", "2024-03-04T09:00", ].slice(0, 3)); expect(result.toInsert[0]!.fireAtUtc).toBe(Date.parse("2024-03-02T17:00:00Z")); }); it("claimed rows are never deleted", () => invariants()); function invariants(): boolean { const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5); stored[0] = { ...stored[0]!, claimed: true }; const result = rematerialize(oldSpec, oldSpec, stored, { changeInstant: "2024-03-02T00:00:00Z", lastFiredNominalLocal: stored[0]!.nominalLocal, count: 3, }); expect(result.toDeleteFireAtUtc).not.toContain(stored[0]!.fireAtUtc); return true; } it("inserts never collide with SURVIVING rows (deletes are applied first)", () => { const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5); stored[0] = { ...stored[0]!, claimed: true }; const result = rematerialize(oldSpec, oldSpec, stored, { changeInstant: "2024-03-01T00:00:00Z", lastFiredNominalLocal: stored[0]!.nominalLocal, count: 10, }); const deleting = new Set(result.toDeleteFireAtUtc); const surviving = stored.filter((r) => !deleting.has(r.fireAtUtc)).map((r) => r.fireAtUtc); expect(surviving).toEqual([stored[0]!.fireAtUtc]); // only the claimed row survives for (const o of result.toInsert) { expect(surviving.includes(o.fireAtUtc)).toBe(false); } }); it("unchanged spec regenerates the same instants it deleted (idempotent)", () => { const spec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 5); stored[0] = { ...stored[0]!, claimed: true }; const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-01T00:00:00Z", lastFiredNominalLocal: stored[0]!.nominalLocal, count: stored.length - 1, // regenerate exactly the deleted horizon }); const deletedSorted = [...result.toDeleteFireAtUtc].sort((a, b) => a - b); expect(result.toInsert.map((o) => o.fireAtUtc)).toEqual(deletedSorted); }); it("toInsert fire instants are unique among themselves and cross the gap once", () => { const spec = { cron: "*/15 * * * *", timezone: "America/New_York" }; const stored = storedFrom(spec, "2024-03-08T00:00:00Z", 20); // 150 quarter-hours from 03-09 00:15Z reaches past the 03-10 07:00Z edge. const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-09T00:00:00Z", count: 150 }); const instants = result.toInsert.map((o) => o.fireAtUtc); expect(new Set(instants).size).toBe(instants.length); const edge = instants.filter((i) => i === Date.parse("2024-03-10T07:00:00Z")); expect(edge.length).toBe(1); // The nominal slots 02:00..02:45 collapse onto that one instant, and the // surviving occurrence carries the latest collapsed nominal slot. const atEdge = result.toInsert.find((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z")); expect(atEdge?.nominalLocal).toBe("2024-03-10T03:00"); }); it("tzdata-only change (same zone) still regenerates correctly", () => { const spec = { cron: "0 9 * * *", timezone: "America/New_York" }; const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 3); const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-01T00:00:00Z", tzdataVersion: "2025a", count: 2, }); expect(result.tzdataVersion).toBe("2025a"); expect(result.toInsert.length).toBe(2); }); it("reports the current tzdata version when not overridden", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; const result = rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00Z" }); expect(typeof result.tzdataVersion).toBe("string"); expect(result.tzdataVersion.length).toBeGreaterThan(0); }); it("a schedule with no history regenerates from the change instant alone", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; const result = rematerialize(spec, { ...spec, timezone: "Europe/Berlin" }, [], { changeInstant: "2024-03-05T00:00:00Z", count: 2, }); expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([ "2024-03-05T09:00", "2024-03-06T09:00", ]); expect(result.toDeleteFireAtUtc).toEqual([]); }); it("unclaimed PAST occurrences are not deleted (only future ones)", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; const stored: StoredOccurrence[] = [ { nominalLocal: "2024-02-01T09:00", fireAtUtc: Date.parse("2024-02-01T09:00:00Z"), claimed: false }, { nominalLocal: "2024-04-01T09:00", fireAtUtc: Date.parse("2024-04-01T09:00:00Z"), claimed: false }, ]; const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-01T00:00:00Z" }); expect(result.toDeleteFireAtUtc).toEqual([Date.parse("2024-04-01T09:00:00Z")]); }); it("survives a whole-day skip: Apia daily 09:00 across the 2011 change", () => { const spec = { cron: "0 9 * * *", timezone: "Pacific/Apia" }; const stored = storedFrom(spec, "2011-12-28T00:00:00Z", 3); const result = rematerialize(spec, spec, stored, { changeInstant: "2011-12-29T12:00:00Z", lastFiredNominalLocal: "2011-12-28T09:00", count: 2, }); expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([ "2011-12-29T09:00", "2011-12-30T09:00", // skipped day, shifted forward ]); }); }); describe("rematerialize — input validation", () => { it("rejects an invalid new zone", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; expect(() => rematerialize(spec, { cron: "0 9 * * *", timezone: "local" }, [], { changeInstant: "2024-01-01T00:00:00Z" }), ).toThrow(); }); it("rejects an invalid new cron", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; expect(() => rematerialize(spec, { cron: "99 * * * *", timezone: "UTC" }, [], { changeInstant: "2024-01-01T00:00:00Z" }), ).toThrow(); }); it("rejects an offset-less change instant", () => { const spec = { cron: "0 9 * * *", timezone: "UTC" }; expect(() => rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00" })).toThrow(); }); });