diff --git a/apps/api/src/srs.test.ts b/apps/api/src/srs.test.ts index 3c53d10..9198e64 100644 --- a/apps/api/src/srs.test.ts +++ b/apps/api/src/srs.test.ts @@ -1,11 +1,30 @@ /** - * DST-guard and date-math checks with fixed instants — the acceptance proof - * that the double crons fire exactly once at 8 AM ET (digest) and midnight - * Saturday ET (review issue) on BOTH UTC offsets. + * Acceptance proof for the two rules that cannot be eyeballed in production: + * the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday + * ET (review issue) on BOTH UTC offsets, and NOTHING is ever scheduled on the + * Sunday rest day — which is the whole reason the ladder's windows are 3 and 7. */ import { describe, expect, test } from "bun:test"; -import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts"; +import { + INTERVAL, + STAGES, + WINDOWS, + addDays, + campaignDay, + campaignWeek, + etDate, + etHour, + isRestDay, + isoWeek, + rng, + sample, + weekdayOf, + workingDay, +} from "./srs.ts"; + +/** Every ET date of the campaign, plus the deferred-Hard tail. */ +const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i)); describe("DST-proof cron guard", () => { test("digest fires only at ET hour 8 — EDT (UTC-4)", () => { @@ -50,6 +69,69 @@ describe("date math (noon-UTC anchored)", () => { }); }); +describe("the rest day", () => { + test("Sunday is the only rest day", () => { + expect(isRestDay("2026-08-30")).toBe(true); // Sunday + expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work + expect(isRestDay("2026-08-31")).toBe(false); + }); + + test("workingDay never returns a Sunday, from any day and any offset", () => { + for (const date of CALENDAR) { + for (let offset = 0; offset < 10; offset++) { + expect(isRestDay(workingDay(date, offset))).toBe(false); + } + } + }); + + test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => { + expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday + expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday + expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday + }); + + test("offsets count working days, so a spill skips the rest day", () => { + // Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday. + expect(workingDay("2026-09-05", 1)).toBe("2026-09-07"); + expect(workingDay("2026-09-05", 2)).toBe("2026-09-08"); + // Two deferred Hards per working day from a Monday. + expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday + expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped + }); + + test("every ladder interval lands on a working day, from every learning day", () => { + for (const date of CALENDAR) { + if (isRestDay(date)) continue; + for (const days of WINDOWS) { + const review = workingDay(addDays(date, days)); + expect(isRestDay(review)).toBe(false); + // Never earlier than the interval: a review may slip, never shorten. + expect(review >= addDays(date, days)).toBe(true); + } + } + }); + + test("only Thursday's +3 needs the slide", () => { + const slid = CALENDAR.filter( + (date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3), + ); + expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only + for (const date of CALENDAR) { + if (isRestDay(date)) continue; + expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday + } + }); +}); + +describe("the ladder", () => { + test("two rungs, named after the windows", () => { + expect(STAGES).toEqual(["new", "+3", "+7", "retired"]); + expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]); + // Every non-terminal stage has an interval, and only those. + expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort()); + }); +}); + describe("deterministic sampling", () => { test("same seed, same picks", () => { const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];