lib/ ships unchanged, so its types live in types/lib/ and are wired up by a
tsconfig path mapping. Adding types costs nothing; reimplementing the logic
would cost the two things that make this port possible.
The tests exist so a later refactor cannot silently drift them:
hangul the nine Composer cases named in the export README —
먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace
conjugation all seven irregular classes, and every form in
IRREGULAR_FORMS reachable through haeche()
srs the SM-2 transitions, ease and interval clamps
blocks parse → answerText round-trip for all four task types
101 tests.
One is a pinned defect rather than a guarantee. blocks.parse() never closes
a ::gloss block on its "=" line, so a multi-sentence gloss — which the tutor
prompt explicitly invites — collapses into one run-on line keeping only the
last translation. lib/ ships unchanged, so the test records the real
behaviour and the app works around it at the call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
/* 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,
|
|
} 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);
|
|
});
|
|
});
|