/* The learner model — what the client enforces instead of trusting the tutor. Earned progress, recall evidence, the phase-review checklist, the practice set and the letter-level check, each against a real database with the shipped dictionary loaded. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { Db } from "@app/db/types.js"; import { editCard, editEvidence } from "@app/db/writes.js"; import { UNITS } from "@app/domain/gate.js"; import { CONF_STEP, advanceUnit, applyProgressReport, isExerciseAnswer, isReady, noteAnswer, readProgress, reviewCoverage, } from "@app/domain/progress.js"; import { applyResults, cardLemmaFor, currentRound, emptyEvidence, isLearned, readEvidence } from "@app/domain/evidence.js"; import { applyConfirmed, checklist, coverage } from "@app/domain/ledger.js"; import { confusable, practicePool, practiceSet, statLine } from "@app/domain/practice.js"; import { expectedFor, recallLetterBlock } from "@app/domain/letters.js"; import { editCurrentUnit } from "@app/db/writes.js"; import { SECURE_INTERVAL, newCard } from "@lib/srs.js"; import { dictionaryDb } from "../helpers/dict-db.js"; const TODAY = 20_000; let db: Db; beforeEach(async () => { db = await dictionaryDb(1); }); afterEach(async () => { vi.unstubAllGlobals(); await db.close(); }); const unit = (id: string) => UNITS.find((u) => u.id === id)!; const ok = (item: string) => ({ item, ok: true, mistakenFor: "" }); const wrong = (item: string, mistakenFor = "") => ({ item, ok: false, mistakenFor }); describe("earned progress", () => { it("ignores a report on a unit nothing has been answered in", async () => { const r = await applyProgressReport(db, await readProgress(db), 85, "looks solid"); expect(r).toEqual({ stored: 0, ignored: true, clamped: false }); }); it("caps a rise at +25 per message, and honours a fall in full", async () => { await noteAnswer(db, await readProgress(db), "My answers:\n나 → I"); expect((await applyProgressReport(db, await readProgress(db), 95, "")).stored).toBe(CONF_STEP); expect((await applyProgressReport(db, await readProgress(db), 90, "")).stored).toBe(2 * CONF_STEP); expect((await applyProgressReport(db, await readProgress(db), 10, "slipping")).stored).toBe(10); expect((await readProgress(db)).notes["1.1"]).toBe("slipping"); }); it("offers the next unit only at 85% with three answers behind it", async () => { await noteAnswer(db, await readProgress(db), "My answers:\n나 → I"); for (const n of [25, 50, 75, 90]) await applyProgressReport(db, await readProgress(db), n, ""); let p = await readProgress(db); expect(p.confidence["1.1"]).toBe(90); expect(isReady(p)).toBe(false); // one answer expect(await advanceUnit(db, p)).toBeNull(); await noteAnswer(db, p, "나무"); await noteAnswer(db, p, "I think it is 나무"); p = await readProgress(db); expect(isReady(p)).toBe(true); expect(await advanceUnit(db, p)).toBe("1.2"); }); it("does not count a request for an exercise as an answer", () => { expect(isExerciseAnswer("My written answers:\nthe sea → 바다")).toBe(true); expect(isExerciseAnswer("is 바다 the sea?")).toBe(true); expect(isExerciseAnswer("Let's start unit 1.1 자음과 모음 (Consonants & vowels).")).toBe(false); expect(isExerciseAnswer("Give me a matching exercise")).toBe(false); }); it("keeps a 다지기 review closed while its checklist is open, whatever the score", async () => { await editCurrentUnit(db, "1.10"); const p0 = await readProgress(db); for (let i = 0; i < 3; i++) await noteAnswer(db, p0, `answer ${i}`); for (const n of [25, 50, 75, 90]) await applyProgressReport(db, await readProgress(db), n, ""); const p = await readProgress(db); const cover = await reviewCoverage(db, p); expect(cover?.total).toBe(132); expect(isReady(p, cover)).toBe(false); expect(await advanceUnit(db, p)).toBeNull(); }); }); describe("recall evidence", () => { it("counts a round per marked message, and one outcome per word in it", async () => { await applyResults(db, [ok("나무"), wrong("나무")], [], TODAY); expect(await currentRound(db)).toBe(1); expect((await readEvidence(db, ["나무"])).get("나무")).toMatchObject({ ok: 1, wrong: 0, rounds: 1 }); }); it("never credits a correct answer given after a lookup", async () => { await applyResults(db, [ok("바다")], ["바다"], TODAY); expect((await readEvidence(db, ["바다"])).get("바다")).toMatchObject({ ok: 0, lookups: 1, streak: 0 }); }); it("drops a marked item that names no studiable word", async () => { const out = await applyResults(db, [ok("없는낱말"), ok("나무")], [], TODAY); expect(out?.ignored).toEqual(["없는낱말"]); expect(out?.recorded).toEqual(["나무"]); }); it("keeps what he mistook a word for", async () => { await applyResults(db, [wrong("바다", "바지")], [], TODAY); expect(await db.get("SELECT mistook FROM confusion WHERE word = '바다'")).toEqual({ mistook: "바지" }); }); /* lib/srs.js alone would call this learned: its span runs from the first outcome of ANY kind. PORT.md measures from the first CORRECT answer. */ it("measures the five-round span from the first correct answer, not the first outcome", async () => { const other = async () => applyResults(db, [ok("소리")], [], TODAY + 100); await applyResults(db, [wrong("나무")], [], TODAY); // round 1 await other(); // 2 await other(); // 3 for (let r = 4; r <= 6; r++) await applyResults(db, [ok("나무")], [], TODAY + r); let e = (await readEvidence(db, ["나무"])).get("나무")!; expect(e).toMatchObject({ ok: 3, first_round: 1, first_ok_round: 4, last_ok_round: 6 }); expect(isLearned(e)).toBe(false); for (let r = 7; r <= 9; r++) await applyResults(db, [ok("나무")], [], TODAY + r); e = (await readEvidence(db, ["나무"])).get("나무")!; expect(isLearned(e)).toBe(true); }); it("lets the schedule take a good grade at most once a day", async () => { const id = (await cardLemmaFor(db, "나무"))!; await applyResults(db, [ok("나무")], [], TODAY); await applyResults(db, [ok("나무")], [], TODAY); await applyResults(db, [ok("나무")], [], TODAY); expect(await db.get("SELECT reps FROM card WHERE lemma_id = ?", [id])).toEqual({ reps: 1 }); }); }); describe("the phase-review checklist", () => { it("lists Phase 1 as its nine units and 123 words — 132 items", () => { const list = checklist(1); expect(list.rules).toHaveLength(9); expect(list.words).toHaveLength(123); }); it("ignores ::confirmed outside a 다지기 unit", async () => { const out = await applyConfirmed(db, unit("1.4"), ["받침"]); expect(out.ticked).toEqual([]); }); it("ticks a rule on the tutor's word, but a word only on evidence", async () => { const review = unit("1.10"); let out = await applyConfirmed(db, review, ["받침", "나무", "없는것"]); expect(out).toEqual({ ticked: ["받침"], reopened: [], refused: ["나무"] }); await editEvidence(db, { ...emptyEvidence("나무"), ok: 3, streak: 3, rounds: 3, first_round: 1, last_round: 7, last_seen: 7, first_ok_round: 1, last_ok_round: 7, }); out = await applyConfirmed(db, review, ["나무", "-받침"]); expect(out).toEqual({ ticked: ["나무"], reopened: ["받침"], refused: [] }); const c = await coverage(db, 1); expect(c.openRules).toContain("받침"); expect(c.openWords).not.toContain("나무"); expect(c.done).toBe(1); }); it("counts a word secure in review as already confirmed", async () => { const id = (await cardLemmaFor(db, "바다"))!; await editCard(db, id, { ...newCard(), state: 2, interval: SECURE_INTERVAL, due: TODAY + 30, reps: 6 }); expect((await coverage(db, 1)).openWords).not.toContain("바다"); }); }); describe("the practice set", () => { it("brings back a missed word once it has gone unseen, and mixes the word classes", async () => { await applyResults(db, [wrong("바다")], [], TODAY); // He is on 1.1, so the pool is 1.1's twenty words. const pool = practicePool(unit("1.1"), await readProgress(db)); expect(pool).toContain("바다"); // Just missed, it ranks below a word never tested: 40 for the miss + // 30 for no correct answer yet, against 90 for 20 rounds of "unseen". let set = await practiceSet(db, pool, 10, await currentRound(db)); expect(set[0]!.ko).not.toBe("바다"); // Eight rounds later — marking words from another unit — it leads. for (let r = 0; r < 8; r++) await applyResults(db, [ok("코")], [], TODAY + r); set = await practiceSet(db, pool, 10, await currentRound(db)); expect(set[0]!.ko).toBe("바다"); expect(set).toHaveLength(10); // Round-robin by class: the set is not one kind of word. expect(new Set(set.map((w) => w.pos)).size).toBeGreaterThan(1); }); it("describes each word by its evidence, and names the words one letter away", () => { const line = statLine({ ko: "발", en: "foot", evidence: null }, 5); expect(line).toMatch(/^발 \(foot\) \[never tested\]/); expect(confusable("발").length).toBeGreaterThan(0); expect(confusable("바쁘다")).toContain("나쁘다"); }); }); describe("the letter-level check", () => { const words = [ { ko: "짧다", gloss: "to be short", note: "" }, { ko: "바다", gloss: "sea", note: "" }, ]; it("finds the spelling a recall prompt wants in the message's ::words", () => { expect(expectedFor("to be short (dictionary form)", words)).toBe("짧다"); expect(expectedFor("the sea", words)).toBe("바다"); expect(expectedFor("a mountain", words)).toBe(""); }); it("tells a qualified meaning from the bare one", () => { const pronouns = [ { ko: "나", gloss: "I", note: "" }, { ko: "저", gloss: "I (humble)", note: "" }, ]; expect(expectedFor("I (humble)", pronouns)).toBe("저"); expect(expectedFor("I", pronouns)).toBe("나"); }); it("hands the tutor the jamo comparison for a wrong spelling, and nothing for a right one", () => { const task = { type: "recall" as const, items: [{ q: "to be short", hint: "" }, { q: "the sea", hint: "" }] }; const block = recallLetterBlock(task, ["빫다", "바다"], words); expect(block).toContain("LETTER-LEVEL CHECK"); expect(block).toContain("first consonant: wrote ㅃ, should be ㅉ"); expect(block).not.toContain("the sea"); expect(recallLetterBlock(task, ["짧다", "바다"], words)).toBe(""); }); });