Files
Hankan/test/lib/srs.test.ts
MechaCat02 e72b77d6c2 chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:49:52 +02:00

249 lines
7.5 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,
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);
});
});