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