/* Golden tests pinning lib/srs.js — SM-2 lite, four grades, day-granularity. Note the vocabulary: statusOf() calls a mature card "secure". The app uses that word throughout; the original artifact said "known". */ import { describe, it, expect } from "vitest"; import type { Card } from "@lib/srs.js"; import { AGAIN, HARD, GOOD, EASY, NEW, LEARNING, REVIEW, SECURE_INTERVAL, newCard, grade, markKnown, statusOf, preview, dayNumber, LEARNED_OK, LEARNED_STREAK, LEARNED_SPAN, newEvidence, noteOutcome, isLearned, acceptConfirmation, } from "@lib/srs.js"; const TODAY = 20_000; describe("newCard", () => { it("starts new, unscheduled, at the default ease", () => { expect(newCard()).toEqual({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0, }); }); }); describe("grading a new card", () => { it("Again keeps it in learning, due today", () => { const c = grade(newCard(), AGAIN, TODAY); expect(c).toMatchObject({ state: LEARNING, interval: 0, due: TODAY, reps: 1 }); }); it("Hard behaves like Again at this stage", () => { expect(grade(newCard(), HARD, TODAY)).toMatchObject({ state: LEARNING, interval: 0 }); }); it("Good graduates it to one day", () => { expect(grade(newCard(), GOOD, TODAY)).toMatchObject({ state: REVIEW, interval: 1, due: TODAY + 1, }); }); it("Easy graduates it to four days", () => { expect(grade(newCard(), EASY, TODAY)).toMatchObject({ state: REVIEW, interval: 4, due: TODAY + 4, }); }); it("does not touch ease before the card is in review", () => { for (const g of [AGAIN, HARD, GOOD, EASY] as const) { expect(grade(newCard(), g, TODAY).ease).toBe(2.5); } }); }); describe("grading a review card", () => { const reviewing: Card = { state: REVIEW, interval: 10, ease: 2.5, due: TODAY, reps: 5, lapses: 0 }; it("Again lapses it back to learning and drops ease", () => { const c = grade(reviewing, AGAIN, TODAY); expect(c).toMatchObject({ state: LEARNING, interval: 0, due: TODAY, lapses: 1 }); expect(c.ease).toBeCloseTo(2.3); }); it("Hard nudges the interval up and ease down", () => { const c = grade(reviewing, HARD, TODAY); expect(c.interval).toBe(12); // 10 * 1.2 expect(c.ease).toBeCloseTo(2.35); }); it("Good multiplies by ease", () => { expect(grade(reviewing, GOOD, TODAY).interval).toBe(25); // 10 * 2.5 }); it("Easy multiplies by ease and a bonus, and raises ease", () => { const c = grade(reviewing, EASY, TODAY); expect(c.ease).toBeCloseTo(2.65); expect(c.interval).toBe(34); // round(10 * 2.65 * 1.3) }); it("clamps ease to [1.3, 3]", () => { const floored = grade({ ...reviewing, ease: 1.35 }, AGAIN, TODAY); expect(floored.ease).toBe(1.3); const capped = grade({ ...reviewing, ease: 2.95 }, EASY, TODAY); expect(capped.ease).toBe(3); }); it("clamps the interval to a year", () => { const c = grade({ ...reviewing, interval: 300 }, EASY, TODAY); expect(c.interval).toBe(365); expect(c.due).toBe(TODAY + 365); }); it("counts every answer as a rep", () => { expect(grade(reviewing, GOOD, TODAY).reps).toBe(6); }); }); describe("statusOf", () => { it("reports the four states, calling a mature card secure", () => { expect(statusOf(null)).toBe("new"); expect(statusOf(newCard())).toBe("new"); expect(statusOf({ ...newCard(), state: LEARNING })).toBe("learning"); expect(statusOf({ ...newCard(), state: REVIEW, interval: 10 })).toBe("review"); expect(statusOf({ ...newCard(), state: REVIEW, interval: SECURE_INTERVAL })).toBe("secure"); }); }); describe("markKnown — the 'I already know this' escape hatch", () => { it("jumps straight to a secure interval", () => { const c = markKnown(TODAY); expect(statusOf(c)).toBe("secure"); expect(c.due).toBe(TODAY + SECURE_INTERVAL); expect(c.reps).toBe(0); }); }); describe("preview — the labels on the grade buttons", () => { it("describes what each grade would do without mutating the card", () => { const c = newCard(); expect(preview(c, AGAIN, TODAY)).toBe("again now"); expect(preview(c, GOOD, TODAY)).toBe("1 day"); expect(preview(c, EASY, TODAY)).toBe("4 days"); expect(c).toEqual(newCard()); // untouched }); it("switches to months past 30 days", () => { const mature: Card = { state: REVIEW, interval: 40, ease: 2.5, due: TODAY, reps: 9, lapses: 0 }; expect(preview(mature, GOOD, TODAY)).toBe("3 months"); }); it("accepts a missing card", () => { expect(preview(null, GOOD, TODAY)).toBe("1 day"); }); }); describe("dayNumber", () => { it("is a whole number of days and is stable within a local day", () => { const morning = new Date(2026, 2, 15, 0, 30); const night = new Date(2026, 2, 15, 23, 30); expect(dayNumber(morning)).toBe(dayNumber(night)); expect(Number.isInteger(dayNumber(morning))).toBe(true); }); it("advances by exactly one across midnight, DST or not", () => { // Late March covers the European DST transition. const a = dayNumber(new Date(2026, 2, 28, 12)); const b = dayNumber(new Date(2026, 2, 29, 12)); const c = dayNumber(new Date(2026, 2, 30, 12)); expect(b - a).toBe(1); expect(c - b).toBe(1); }); }); /* Recall evidence decides whether a word is KNOWN; the schedule above only decides when to show it. The client refuses a ::confirmed it does not support, because the tutor certified words on a single correct answer. */ describe("recall evidence", () => { const run = (steps: [outcome: "ok" | "wrong", round: number, lookedUp?: boolean][]) => steps.reduce((e, [o, r, l]) => noteOutcome(e, o, r, l), newEvidence()); it("starts empty", () => { expect(newEvidence()).toEqual({ ok: 0, wrong: 0, lookups: 0, streak: 0, firstRound: 0, lastRound: 0, lastSeen: 0, rounds: 0, }); expect([LEARNED_OK, LEARNED_STREAK, LEARNED_SPAN]).toEqual([3, 2, 5]); }); it("is pure — the record passed in is not modified", () => { const e = newEvidence(); noteOutcome(e, "ok", 1); expect(e.ok).toBe(0); }); it("never counts a lookup as recall, and a lookup resets the streak", () => { const e = run([ ["ok", 1], ["ok", 2], ["ok", 3, true], ]); expect(e).toMatchObject({ ok: 2, lookups: 1, streak: 0, rounds: 3 }); }); it("learns three corrects in three rounds spanning five", () => { const e = run([ ["ok", 1], ["ok", 4], ["ok", 7], ]); expect(isLearned(e)).toBe(true); expect(acceptConfirmation(e)).toBe(true); }); it("refuses three corrects crammed into consecutive rounds", () => { expect(isLearned(run([["ok", 1], ["ok", 2], ["ok", 3]]))).toBe(false); }); /* PINNED AS IT IS — lib is looser than PORT.md in two ways, and the app enforces PORT.md's version at the call site (domain/turn.ts): · several corrects in ONE round each count; · the span runs from the first outcome of any kind, not the first correct, so a wrong answer in round 1 lengthens it. */ it("counts every correct within a round", () => { expect(run([["ok", 1], ["ok", 1]])).toMatchObject({ ok: 2, streak: 2, rounds: 1 }); }); it("measures the span from the first outcome, correct or not", () => { const e = run([ ["wrong", 1], ["ok", 4], ["ok", 5], ["ok", 6], ]); expect(e).toMatchObject({ firstRound: 1, lastRound: 6 }); expect(isLearned(e)).toBe(true); }); });