Files
gacha-event-tracker/test/recurrence.test.ts
T
Lucas WintherandClaude Opus 5 6dad359b2a Ask the cadence first, then only the dates it needs
The repeat controls sat after the dates and applied to whatever was there,
which meant a weekly chore still had to answer an end date it has no honest
answer to. Asking first inverts that: the cadence decides which dates are
even questions.

A preset carries no window. Weekly means the week is the window, so there is
no end to type and no ignorance to admit — five fields instead of eight, and
it stores exactly what the model already renders as back-to-back
occurrences. A dated recurring event is therefore a custom, which is where
the forever/delay control now lives; a one-off shows no repeat machinery at
all.

cadenceOf derives which of the five a saved event opens in, so a rule made
before this control existed opens in whichever answer describes it. Nothing
about the schema moved.

Switching cadence hides the end date rather than clearing it: hiding a field
and quietly discarding what is in it is how a form loses somebody's work
when they change their mind back.

The duplicate note was found by rendering the form and looking at it. Both
of the older notes explain the end-date field, so a preset — which has no
such field — was showing two sentences that said nearly the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00

593 lines
26 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts";
import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts";
// Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and
// UTC+2 in summer, so a step across 29 March 2026 crosses a real transition;
// on a UTC runner these tests would pass without ever exercising the case.
process.env.TZ = "Europe/Copenhagen";
/** Local wall-clock components, which is what a reader typed and reads back. */
function wall(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function at(local: string): number {
return new Date(local).getTime();
}
describe("addUnits", () => {
test("days and weeks step the calendar, not 24-hour blocks", () => {
expect(wall(addUnits(at("2026-08-20T09:00:00"), "days", 1))).toBe("2026-08-21 09:00");
expect(wall(addUnits(at("2026-08-20T09:00:00"), "weeks", 2))).toBe("2026-09-03 09:00");
});
test("a DST transition does not shift the wall-clock time", () => {
// 29 March 2026 is the spring-forward. A reader whose weekly reset is at
// 09:00 means 09:00 on both sides of it; stepping in fixed milliseconds
// would land 08:00 or 10:00 and quietly move their whole series.
expect(wall(addUnits(at("2026-03-28T09:00:00"), "days", 1))).toBe("2026-03-29 09:00");
expect(wall(addUnits(at("2026-03-25T09:00:00"), "weeks", 1))).toBe("2026-04-01 09:00");
// And the autumn fall-back, in the other direction.
expect(wall(addUnits(at("2026-10-24T09:00:00"), "days", 1))).toBe("2026-10-25 09:00");
});
test("months clamp to the last valid day rather than rolling over", () => {
// Date.parse and setMonth both roll 31 February forward into March. A date
// that silently moves is the one thing this codebase does not ship —
// readerInstant guards the same hazard on the way in.
expect(wall(addUnits(at("2026-01-31T09:00:00"), "months", 1))).toBe("2026-02-28 09:00");
expect(wall(addUnits(at("2028-01-31T09:00:00"), "months", 1))).toBe("2028-02-29 09:00");
expect(wall(addUnits(at("2026-03-31T09:00:00"), "months", 1))).toBe("2026-04-30 09:00");
});
test("clamping does not accumulate — the anchor day is restored", () => {
// Stepping one month at a time from 31 January must reach 31 March, not 28
// March: each step is measured from the anchor, so a February clamp is not
// allowed to shorten every later occurrence.
const anchor = at("2026-01-31T09:00:00");
expect(wall(addUnits(anchor, "months", 2))).toBe("2026-03-31 09:00");
});
test("a zero step is the identity", () => {
const anchor = at("2026-08-20T09:00:00");
expect(addUnits(anchor, "months", 0)).toBe(anchor);
});
});
describe("comesRoundEarly", () => {
// One predicate, exported, because two callers ask this question — the
// CustomEvent refine and the form that has to explain the refusal. Two
// copies would drift, and the form would start refusing saves the schema
// accepts or waving through ones it rejects.
test("a window closing before the next opening is fine", () => {
const start = at("2026-09-01T09:00:00");
const end = at("2026-09-08T09:00:00");
expect(comesRoundEarly(start, end, { unit: "weeks", interval: 2, until: null })).toBe(false);
});
test("closing exactly as the next opens is fine — they do not overlap", () => {
const start = at("2026-09-01T09:00:00");
const end = at("2026-09-08T09:00:00");
expect(comesRoundEarly(start, end, { unit: "weeks", interval: 1, until: null })).toBe(false);
});
test("a window still open when the next one starts is not", () => {
const start = at("2026-09-01T09:00:00");
const end = at("2026-09-15T09:00:00");
expect(comesRoundEarly(start, end, { unit: "weeks", interval: 1, until: null })).toBe(true);
});
test("no rule, or no stated end, has nothing to overlap", () => {
const start = at("2026-09-01T09:00:00");
expect(comesRoundEarly(start, at("2026-10-01T09:00:00"), null)).toBe(false);
expect(comesRoundEarly(start, null, { unit: "weeks", interval: 1, until: null })).toBe(false);
});
});
describe("Repeat", () => {
test("accepts a well-formed rule", () => {
const parsed = Repeat.parse({ unit: "weeks", interval: 2, until: null });
expect(parsed.interval).toBe(2);
});
test("rejects an interval outside 1..365", () => {
expect(Repeat.safeParse({ unit: "days", interval: 0, until: null }).success).toBe(false);
expect(Repeat.safeParse({ unit: "days", interval: 366, until: null }).success).toBe(false);
expect(Repeat.safeParse({ unit: "days", interval: 1.5, until: null }).success).toBe(false);
});
test("rejects an unknown unit", () => {
expect(Repeat.safeParse({ unit: "fortnights", interval: 1, until: null }).success).toBe(false);
});
});
describe("occurrence ids", () => {
const RULE = "myevent:k3f9qa2m01";
test("suffixes the rule id with the occurrence's own local start day", () => {
const id = occurrenceId(RULE, new Date("2026-09-01T09:00:00").getTime());
expect(id).toBe("myevent:k3f9qa2m01#2026-09-01");
});
test("the day is the reader's local day, not UTC's", () => {
// 00:30 local on 2 September is 22:30Z on the 1st, so the two readings
// disagree about which day this is. That disagreement is the whole point:
// the reader typed a local date and is shown a local date back, and a UTC
// reading would file this occurrence under the previous day for every
// reader east of UTC. Asserted with an instant where the two differ,
// because an instant where they agree proves nothing.
const id = occurrenceId(RULE, new Date("2026-09-02T00:30:00").getTime());
expect(id).toBe("myevent:k3f9qa2m01#2026-09-02");
});
test("the rule id is recoverable, and a plain id is its own rule", () => {
expect(ruleIdOf("myevent:k3f9qa2m01#2026-09-01")).toBe(RULE);
expect(ruleIdOf(RULE)).toBe(RULE);
// A feed id is colon-separated and carries no separator, so it survives.
expect(ruleIdOf("genshin:some-event:2026-09-01")).toBe("genshin:some-event:2026-09-01");
});
test("an occurrence is recognisable as one", () => {
expect(isOccurrenceId("myevent:k3f9qa2m01#2026-09-01")).toBe(true);
expect(isOccurrenceId(RULE)).toBe(false);
});
test("an occurrence id still reads as the reader's own", () => {
// isCustomEventId is a startsWith check on the first segment, so lane
// logic, RESERVED_ID_SEGMENTS and "never attributed to a source" all hold.
expect(isCustomEventId("myevent:k3f9qa2m01#2026-09-01")).toBe(true);
});
test("CustomEventId REJECTS an occurrence id", () => {
// The guardrail, asserted rather than assumed. '#' is outside [a-z0-9], so
// an occurrence cannot be written back into the customEvents store and
// cannot survive an import if one ever appears in a file. validRecords
// drops what fails this schema, which is exactly the behaviour we want.
expect(CustomEventId.safeParse(RULE).success).toBe(true);
expect(CustomEventId.safeParse("myevent:k3f9qa2m01#2026-09-01").success).toBe(false);
});
});
describe("movesOccurrences", () => {
const rule = (startsAt: string, interval: number) => ({
startsAt,
repeat: { unit: "weeks" as const, interval, until: null },
});
test("a changed anchor or interval re-keys every occurrence", () => {
const a = rule("2026-09-01T07:00:00.000Z", 2);
expect(movesOccurrences(a, rule("2026-09-02T07:00:00.000Z", 2))).toBe(true);
expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 3))).toBe(true);
});
test("changing only `until` does not", () => {
// It truncates the series; it does not move what is already in it, so no
// mark is stranded and the reader should not be warned that one is.
const a = rule("2026-09-01T07:00:00.000Z", 2);
const b = {
startsAt: "2026-09-01T07:00:00.000Z",
repeat: { unit: "weeks" as const, interval: 2, until: "2027-01-01T00:00:00.000Z" },
};
expect(movesOccurrences(a, b)).toBe(false);
});
test("adding or dropping a rule entirely counts as a move", () => {
const plain = { startsAt: "2026-09-01T07:00:00.000Z", repeat: null };
expect(movesOccurrences(plain, rule("2026-09-01T07:00:00.000Z", 2))).toBe(true);
expect(movesOccurrences(rule("2026-09-01T07:00:00.000Z", 2), plain)).toBe(true);
});
test("an untouched schedule moves nothing", () => {
const a = rule("2026-09-01T07:00:00.000Z", 2);
expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 2))).toBe(false);
});
});
describe("occurrencesOf", () => {
function rule(over: Partial<RepeatingEvent> = {}): RepeatingEvent {
return {
id: "myevent:k3f9qa2m01",
startsAt: new Date("2026-09-01T09:00:00").toISOString(),
startPrecision: "exact",
endsAt: new Date("2026-09-08T09:00:00").toISOString(),
endPrecision: "exact",
repeat: { unit: "weeks", interval: 2, until: null },
...over,
};
}
const day = (local: string) => new Date(local).getTime();
test("a non-repeating event yields nothing", () => {
// Callers keep the existing single-event path; this function is only ever
// about rules, which keeps the blast radius off events that already exist.
expect(occurrencesOf(rule({ repeat: null }), day("2026-01-01T00:00:00"), day("2027-01-01T00:00:00"))).toEqual([]);
});
test("slides the stated window forward by the interval", () => {
const got = occurrencesOf(rule(), day("2026-09-01T00:00:00"), day("2026-10-01T00:00:00"));
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-09-01",
"myevent:k3f9qa2m01#2026-09-15",
"myevent:k3f9qa2m01#2026-09-29",
]);
// The duration is held constant, not recomputed.
expect(new Date(got[1]!.endsAt).getTime() - new Date(got[1]!.startsAt).getTime())
.toBe(7 * 24 * 60 * 60 * 1000);
});
test("with no stated end, each occurrence runs until the next opens", () => {
// The point of the whole design: a rule supplies the boundary the rotation
// was missing, so `endsAt: null` here is not the unbounded case
// docs/SOURCES.md refuses. Occurrences are contiguous, with no gap.
const got = occurrencesOf(
rule({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } }),
day("2026-09-01T00:00:00"),
day("2026-09-23T00:00:00"),
);
expect(got).toHaveLength(4);
expect(got[0]!.endsAt).toBe(got[1]!.startsAt);
expect(got[1]!.endsAt).toBe(got[2]!.startsAt);
// Derived from the reader's own anchor, so it inherits that precision
// rather than claiming to be exact when their start was only a day.
expect(got[0]!.endPrecision).toBe("exact");
});
test("a derived end inherits the anchor's start precision", () => {
const got = occurrencesOf(
rule({ startPrecision: "day", endsAt: null, endPrecision: "unknown" }),
day("2026-09-01T00:00:00"),
day("2026-09-20T00:00:00"),
);
expect(got[0]!.endPrecision).toBe("day");
});
test("until stops the series, and the last window still closes on schedule", () => {
const got = occurrencesOf(
rule({
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "weeks", interval: 1, until: new Date("2026-09-16T00:00:00").toISOString() },
}),
day("2026-09-01T00:00:00"),
day("2026-12-01T00:00:00"),
);
// Opens 1, 8, 15 Sep. The 22nd is past `until`, so it never opens — but the
// 15th's window still runs its full week rather than being truncated.
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-09-01",
"myevent:k3f9qa2m01#2026-09-08",
"myevent:k3f9qa2m01#2026-09-15",
]);
expect(got[2]!.endsAt).toBe(new Date("2026-09-22T09:00:00").toISOString());
});
test("an occurrence overlapping the window at either edge is included", () => {
// A bar half off the left of the board is still on the board.
const got = occurrencesOf(rule(), day("2026-09-03T00:00:00"), day("2026-09-04T00:00:00"));
expect(got.map((o) => o.id)).toEqual(["myevent:k3f9qa2m01#2026-09-01"]);
});
test("monthly rules clamp and do not accumulate", () => {
const got = occurrencesOf(
rule({
startsAt: new Date("2026-01-31T09:00:00").toISOString(),
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "months", interval: 1, until: null },
}),
day("2026-01-01T00:00:00"),
day("2026-04-15T00:00:00"),
);
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-01-31",
"myevent:k3f9qa2m01#2026-02-28",
"myevent:k3f9qa2m01#2026-03-31",
]);
});
test("the cap bounds what one call can allocate", () => {
const got = occurrencesOf(
rule({ endsAt: null, endPrecision: "unknown", repeat: { unit: "days", interval: 1, until: null } }),
day("2026-01-01T00:00:00"),
day("2030-01-01T00:00:00"),
10,
);
expect(got).toHaveLength(10);
});
test("an ancient anchor still reaches a window years later", () => {
const got = occurrencesOf(
rule({
startsAt: new Date("2020-09-01T09:00:00").toISOString(),
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "days", interval: 1, until: null },
}),
day("2026-09-01T00:00:00"),
day("2026-09-04T00:00:00"),
);
// The anchor's 09:00 wall clock does not line up with the window's
// midnight boundary, so 31 August's occurrence — which, having no stated
// end, runs until 1 September 09:00 opens the next one — overlaps the
// window's first nine hours. Same edge rule as the test above, just
// reached from six years back instead of a few days.
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-08-31",
"myevent:k3f9qa2m01#2026-09-01",
"myevent:k3f9qa2m01#2026-09-02",
"myevent:k3f9qa2m01#2026-09-03",
]);
});
});
describe("occurrenceForId", () => {
function rule(over: Partial<RepeatingEvent> = {}): RepeatingEvent {
return {
id: "myevent:k3f9qa2m01",
startsAt: new Date("2026-09-01T09:00:00").toISOString(),
startPrecision: "exact",
endsAt: new Date("2026-09-08T09:00:00").toISOString(),
endPrecision: "exact",
repeat: { unit: "weeks", interval: 2, until: null },
...over,
};
}
test("an id off a real occurrence round-trips to that occurrence", () => {
const got = occurrenceForId(rule(), "myevent:k3f9qa2m01#2026-09-15");
expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-15");
expect(got?.startsAt).toBe(new Date("2026-09-15T09:00:00").toISOString());
});
test("this is exactly what click-to-open needs beyond the list's first two",
() => {
// LIST_OCCURRENCES only ever carries the first two; the third is exactly
// the case a timeline bar can name but `allRows.find` cannot resolve.
const got = occurrenceForId(rule(), "myevent:k3f9qa2m01#2026-09-29");
expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-29");
});
test("a bogus suffix returns null", () => {
expect(occurrenceForId(rule(), "myevent:k3f9qa2m01#not-a-date")).toBeNull();
expect(occurrenceForId(rule(), "myevent:k3f9qa2m01#2026-02-30")).toBeNull();
expect(occurrenceForId(rule(), "myevent:k3f9qa2m01")).toBeNull();
});
test("a suffix that names no occurrence of this rule returns null", () => {
// 2026-09-08 falls between two fortnightly openings (1st and 15th) and
// matches none of them.
expect(occurrenceForId(rule(), "myevent:k3f9qa2m01#2026-09-08")).toBeNull();
// A day inside a running occurrence's window but not that occurrence's
// own start day — ids are keyed by start day only.
expect(occurrenceForId(rule(), "myevent:k3f9qa2m01#2026-09-04")).toBeNull();
});
test("a non-repeating rule has no occurrences to resolve", () => {
expect(occurrenceForId(rule({ repeat: null }), "myevent:k3f9qa2m01#2026-09-01")).toBeNull();
});
});
describe("strandedOccurrences", () => {
function rule(over: Partial<RepeatingEvent> = {}): RepeatingEvent {
return {
id: "myevent:k3f9qa2m01",
startsAt: new Date("2026-09-01T09:00:00").toISOString(),
startPrecision: "exact",
endsAt: new Date("2026-09-08T09:00:00").toISOString(),
endPrecision: "exact",
repeat: { unit: "weeks", interval: 2, until: null },
...over,
};
}
test("a plain event checks its own bare id, not the empty occurrence list", () => {
// This is the transition that matters most: a reader marks a plain event
// done, then edits it to add a repeat. `record.repeat === null` used to
// short-circuit straight to 0 here, hiding the warning on exactly the
// save that re-keys every row out from under the mark.
const plain = rule({ repeat: null });
expect(strandedOccurrences(plain, Date.now(), (id) => id === "myevent:k3f9qa2m01")).toBe(1);
expect(strandedOccurrences(plain, Date.now(), () => false)).toBe(0);
});
test("a repeating rule checks its next occurrences, not its own bare id", () => {
const now = new Date("2026-09-03T12:00:00").getTime();
const marked = new Set(["myevent:k3f9qa2m01#2026-09-15"]);
expect(strandedOccurrences(rule(), now, (id) => marked.has(id))).toBe(1);
// The bare id is never at risk once a rule repeats — nothing is stored
// under it.
expect(strandedOccurrences(rule(), now, (id) => id === "myevent:k3f9qa2m01")).toBe(0);
});
test("counts every marked id among the next `count` occurrences", () => {
const now = new Date("2026-09-03T12:00:00").getTime();
const marked = new Set([
"myevent:k3f9qa2m01#2026-09-15",
"myevent:k3f9qa2m01#2026-09-29",
]);
expect(strandedOccurrences(rule(), now, (id) => marked.has(id), 3)).toBe(2);
});
});
describe("nextOccurrences", () => {
function rule(over: Partial<RepeatingEvent> = {}): RepeatingEvent {
return {
id: "myevent:k3f9qa2m01",
startsAt: new Date("2026-09-01T09:00:00").toISOString(),
startPrecision: "exact",
endsAt: new Date("2026-09-08T09:00:00").toISOString(),
endPrecision: "exact",
repeat: { unit: "weeks", interval: 2, until: null },
...over,
};
}
test("returns the running occurrence and the one after it", () => {
const now = new Date("2026-09-03T12:00:00").getTime();
const got = nextOccurrences(rule(), now, 2);
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-09-01",
"myevent:k3f9qa2m01#2026-09-15",
]);
});
test("between cycles it returns the next to open, plus the one after", () => {
// A rule with a gap has nothing running on 10 September. "Opens Saturday"
// is the honest answer; showing nothing would read as the rule being over.
const now = new Date("2026-09-10T12:00:00").getTime();
const got = nextOccurrences(rule(), now, 2);
expect(got.map((o) => o.id)).toEqual([
"myevent:k3f9qa2m01#2026-09-15",
"myevent:k3f9qa2m01#2026-09-29",
]);
});
test("a series past its until yields nothing", () => {
const got = nextOccurrences(
rule({ repeat: { unit: "weeks", interval: 2, until: new Date("2026-09-02T00:00:00").toISOString() } }),
new Date("2027-01-01T00:00:00").getTime(),
2,
);
expect(got).toEqual([]);
});
});
describe("repeatSpanning", () => {
// The three-state repeat control ("never / forever / with a delay") never
// asks the reader for a cadence when it can measure one. Forever asks for
// the next opening to land exactly where this occurrence closes; a delay
// pushes that target later. Both reduce to the same question, which is what
// this function answers: which {unit, interval} steps from the anchor to
// that target exactly.
test("a window that is a calendar month reads as every month", () => {
// Not "every 31 days" — a fixed day count drifts out of step by February,
// and the reader who typed 1 Jul to 1 Aug meant the month.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-08-01T09:00:00")))
.toEqual({ unit: "months", interval: 1, until: null });
});
test("whole weeks read as weeks, not days", () => {
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-08-12T09:00:00")))
.toEqual({ unit: "weeks", interval: 6, until: null });
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-15T09:00:00")))
.toEqual({ unit: "weeks", interval: 2, until: null });
});
test("anything else falls back to days", () => {
// The walkthrough case: 1 Jul to 26 Jul is 25 days, which is neither a
// month nor whole weeks, so it stays in the unit the reader can verify.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-26T09:00:00")))
.toEqual({ unit: "days", interval: 25, until: null });
});
test("a delayed target is the same question asked later", () => {
// A 7-day window with a 21-day delay: the next opening is 28 days after
// the anchor, which is four whole weeks.
const anchor = at("2026-07-01T09:00:00");
const closes = at("2026-07-08T09:00:00");
const delayed = addUnits(closes, "days", 21);
expect(repeatSpanning(anchor, delayed))
.toEqual({ unit: "weeks", interval: 4, until: null });
});
test("a span crossing a DST transition is still exact", () => {
// Spring forward is 29 March 2026. Measured in milliseconds this span is
// an hour short of four weeks and would fall through to no answer at all.
expect(repeatSpanning(at("2026-03-15T09:00:00"), at("2026-04-12T09:00:00")))
.toEqual({ unit: "weeks", interval: 4, until: null });
});
test("a target at or before the anchor has no answer", () => {
const anchor = at("2026-07-01T09:00:00");
expect(repeatSpanning(anchor, anchor)).toBe(null);
expect(repeatSpanning(anchor, at("2026-06-30T09:00:00"))).toBe(null);
});
test("a span that is not whole days has no answer", () => {
// Two exact times a few hours apart. Rounding to a day would move the
// reader's boundary, so the form asks them instead of guessing.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-08T14:30:00"))).toBe(null);
});
test("a span past the schema's ceiling has no answer", () => {
// `interval` is capped at 365, so a span no unit can express within that
// returns null rather than a rule the schema would reject.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2030-07-01T09:00:00"))).toBe(null);
});
});
describe("repeatModeOf", () => {
// Which of the three states the form should open in. Deliberately derived
// from the rule rather than stored beside it: "forever" and "a delay of
// zero" are the same rule, so remembering which button was pressed would be
// remembering something that makes no difference to the reader's schedule.
const start = at("2026-07-01T09:00:00");
const close = at("2026-07-08T09:00:00");
test("no rule is never", () => {
expect(repeatModeOf(start, close, null)).toBe("never");
});
test("a window that reopens exactly as it closes is forever", () => {
expect(repeatModeOf(start, close, { unit: "weeks", interval: 1, until: null }))
.toBe("forever");
});
test("a gap between closing and reopening is a delay", () => {
expect(repeatModeOf(start, close, { unit: "weeks", interval: 4, until: null }))
.toBe("delay");
});
test("an unstated end is forever, because there is no gap to describe", () => {
// "Weekly missions, resets Monday": the reader gave a cadence and no end,
// so each occurrence runs until the next opens. Contiguous by definition,
// and a delay would have nothing to be measured from.
expect(repeatModeOf(start, null, { unit: "weeks", interval: 1, until: null }))
.toBe("forever");
});
});
describe("cadenceOf", () => {
// Which of the form's five answers describes a saved event. Derived rather
// than stored, like `repeatModeOf`, so a rule made before the control
// existed — or one that arrived by import — opens in whichever answer
// actually fits it rather than in whichever happens to be the default.
const weekly = { unit: "weeks", interval: 1, until: null } as const;
test("no rule at all is a one-off", () => {
expect(cadenceOf(null, null)).toBe("one-off");
expect(cadenceOf("2026-09-08T00:00:00.000Z", null)).toBe("one-off");
});
test("a single unit with no end is the matching preset", () => {
expect(cadenceOf(null, { unit: "days", interval: 1, until: null })).toBe("daily");
expect(cadenceOf(null, weekly)).toBe("weekly");
expect(cadenceOf(null, { unit: "months", interval: 1, until: null })).toBe("monthly");
});
test("a longer cycle is a custom", () => {
expect(cadenceOf(null, { unit: "days", interval: 26, until: null })).toBe("custom");
expect(cadenceOf(null, { unit: "weeks", interval: 2, until: null })).toBe("custom");
});
test("a stated end is a custom, whatever the cycle", () => {
// The presets carry no window — picking one means the period *is* the
// window. An event that states its own end is saying something the preset
// cannot, so it belongs where that is sayable.
expect(cadenceOf("2026-09-08T00:00:00.000Z", weekly)).toBe("custom");
});
test("a series that stops is a custom", () => {
// There is no control for `until` in a preset, so opening one there would
// offer to save a rule quietly stripped of the date it stops on.
expect(
cadenceOf(null, { unit: "weeks", interval: 1, until: "2027-01-01T00:00:00.000Z" }),
).toBe("custom");
});
});