Files
Hankan/test/lib/srs.test.ts
MechaCat02 966043ab3f feat(lib): type declarations and golden tests for the exported modules
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>
2026-09-08 19:12:19 +02:00

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);
});
});