Files
gacha-event-tracker/test/recurrence.test.ts
T
Lucas WintherandClaude Opus 5 b0523a7cb9 Resolve any occurrence back to its rule, and count what a repeat adds strands
The lists only ever build allRows from a rule's first two occurrences
(LIST_OCCURRENCES), but the timeline draws every occurrence the board window
admits. Clicking a bar past the second set openId to an id allRows could not
resolve, so the detail sheet silently failed to open and per-occurrence
completion was unreachable for those bars. occurrenceForId parses the day an
occurrence id names and regenerates just that neighbourhood via occurrencesOf,
so App.tsx can resolve through the rule when the direct lookup misses.

Separately, strandedBy's `if (record.repeat === null) return 0` hid the
warning on exactly the transition where it matters most: a reader marks a
plain event done, then edits it to add a repeat, and the save re-keys every
row from `id` to `id#<date>` with no notice that the mark under the bare id
is about to become unreachable. strandedOccurrences checks the record's own
id when it has no repeat yet, and its next occurrences once it does.

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

458 lines
19 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { addUnits, comesRoundEarly, Repeat, 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([]);
});
});