2026-08-25 11:19:19 -04:00
|
|
|
/**
|
2026-08-31 10:41:36 -04:00
|
|
|
* 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.
|
2026-08-25 11:19:19 -04:00
|
|
|
*/
|
|
|
|
|
import { describe, expect, test } from "bun:test";
|
|
|
|
|
|
2026-08-31 10:41:36 -04:00
|
|
|
import {
|
|
|
|
|
INTERVAL,
|
2026-09-21 12:14:25 -04:00
|
|
|
RESERVED,
|
|
|
|
|
SCHEDULE,
|
2026-08-31 10:41:36 -04:00
|
|
|
STAGES,
|
|
|
|
|
WINDOWS,
|
|
|
|
|
addDays,
|
|
|
|
|
campaignDay,
|
|
|
|
|
campaignWeek,
|
|
|
|
|
etDate,
|
|
|
|
|
etHour,
|
|
|
|
|
isRestDay,
|
|
|
|
|
isoWeek,
|
|
|
|
|
rng,
|
|
|
|
|
sample,
|
2026-08-31 18:56:40 -04:00
|
|
|
TEMPERATURES,
|
|
|
|
|
temperatureOf,
|
2026-09-21 12:14:25 -04:00
|
|
|
topicDate,
|
2026-08-31 10:41:36 -04:00
|
|
|
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));
|
2026-08-25 11:19:19 -04:00
|
|
|
|
|
|
|
|
describe("DST-proof cron guard", () => {
|
|
|
|
|
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
|
|
|
|
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
|
|
|
|
|
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
|
|
|
|
|
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
|
|
|
|
|
});
|
|
|
|
|
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
|
|
|
|
|
// DST ends 2026-11-01; 2026-11-05 is EST.
|
|
|
|
|
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
|
|
|
|
|
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
|
|
|
|
|
});
|
|
|
|
|
test("saturday review fires only at ET midnight — both offsets", () => {
|
|
|
|
|
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
|
|
|
|
|
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
|
|
|
|
|
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
|
|
|
|
|
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
|
|
|
|
|
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
|
|
|
|
|
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
|
|
|
|
|
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
|
|
|
|
|
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
|
|
|
|
|
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("date math (noon-UTC anchored)", () => {
|
|
|
|
|
test("addDays crosses the DST-end boundary without drift", () => {
|
|
|
|
|
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
|
|
|
|
|
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
|
|
|
|
|
});
|
|
|
|
|
test("campaign math", () => {
|
|
|
|
|
expect(campaignDay("2026-08-17")).toBe(1);
|
|
|
|
|
expect(campaignDay("2026-08-24")).toBe(8);
|
|
|
|
|
expect(campaignWeek("2026-08-24")).toBe(2);
|
|
|
|
|
expect(campaignWeek("2026-08-30")).toBe(2);
|
|
|
|
|
expect(campaignWeek("2026-08-31")).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
test("iso week is stable across a week", () => {
|
|
|
|
|
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
|
|
|
|
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
|
|
|
|
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-31 10:41:36 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-21 12:14:25 -04:00
|
|
|
describe("the plan", () => {
|
|
|
|
|
test("never books the rest day, and gates only on Saturday", () => {
|
|
|
|
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
|
|
|
|
expect(isRestDay(date)).toBe(false);
|
|
|
|
|
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
|
|
|
|
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
2026-09-30 17:33:32 -04:00
|
|
|
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
|
|
|
|
|
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
|
2026-09-21 12:14:25 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a topic is taught once; a doubled day answers for both", () => {
|
|
|
|
|
const seen = new Set<number>();
|
|
|
|
|
for (const day of Object.values(SCHEDULE)) {
|
|
|
|
|
for (const topic of day.topics ?? []) {
|
|
|
|
|
expect(seen.has(topic)).toBe(false);
|
|
|
|
|
seen.add(topic);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(topicDate(17)).toBe("2026-09-21");
|
|
|
|
|
expect(topicDate(18)).toBe("2026-09-21");
|
|
|
|
|
expect(topicDate(999)).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 17:33:32 -04:00
|
|
|
test("a review is booked once, and no problem sits in two reviews", () => {
|
|
|
|
|
const issues = new Set<number>();
|
|
|
|
|
const problems = new Set<number>();
|
|
|
|
|
for (const day of Object.values(SCHEDULE)) {
|
|
|
|
|
if (!day.review) continue;
|
|
|
|
|
expect(issues.has(day.review.issue)).toBe(false);
|
|
|
|
|
issues.add(day.review.issue);
|
|
|
|
|
for (const lc of day.review.problems) {
|
|
|
|
|
expect(problems.has(lc)).toBe(false);
|
|
|
|
|
problems.add(lc);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-21 12:14:25 -04:00
|
|
|
test("every gate and set problem is reserved from the samplers", () => {
|
|
|
|
|
for (const day of Object.values(SCHEDULE)) {
|
|
|
|
|
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
|
|
|
|
expect(RESERVED.has(lc)).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(RESERVED.size).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-31 10:41:36 -04:00
|
|
|
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());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-31 18:56:40 -04:00
|
|
|
describe("concept temperature", () => {
|
|
|
|
|
test("the bands are the ladder's own windows", () => {
|
|
|
|
|
// Boundaries are inclusive, and the bands tile the whole age axis with no
|
|
|
|
|
// gap: whatever the windows become, every age still lands in exactly one.
|
|
|
|
|
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
|
|
|
|
|
expect(temperatureOf(0)).toBe("hot");
|
|
|
|
|
expect(temperatureOf(WINDOWS[0])).toBe("hot");
|
|
|
|
|
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
|
|
|
|
|
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
|
|
|
|
|
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
|
|
|
|
|
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
|
|
|
|
|
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("never attempted is cold, not unknown", () => {
|
|
|
|
|
expect(temperatureOf(null)).toBe("cold");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("every age of the campaign has exactly one band", () => {
|
|
|
|
|
for (let age = 0; age <= CALENDAR.length; age++) {
|
|
|
|
|
expect(TEMPERATURES).toContain(temperatureOf(age));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-25 11:19:19 -04:00
|
|
|
describe("deterministic sampling", () => {
|
|
|
|
|
test("same seed, same picks", () => {
|
|
|
|
|
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
|
|
|
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
|
|
|
|
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
|
|
|
|
});
|
|
|
|
|
});
|