From 966043ab3f8bba02fcf6d8cead7e565577260302 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 19:12:19 +0200 Subject: [PATCH] feat(lib): type declarations and golden tests for the exported modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/lib/blocks.test.ts | 214 +++++++++++++++++++++++++++++++++++ test/lib/conjugation.test.ts | 144 +++++++++++++++++++++++ test/lib/hangul.test.ts | 134 ++++++++++++++++++++++ test/lib/srs.test.ts | 170 ++++++++++++++++++++++++++++ types/lib/blocks.d.ts | 80 +++++++++++++ types/lib/conjugation.d.ts | 33 ++++++ types/lib/gate.d.ts | 80 +++++++++++++ types/lib/hangul.d.ts | 38 +++++++ types/lib/srs.d.ts | 38 +++++++ 9 files changed, 931 insertions(+) create mode 100644 test/lib/blocks.test.ts create mode 100644 test/lib/conjugation.test.ts create mode 100644 test/lib/hangul.test.ts create mode 100644 test/lib/srs.test.ts create mode 100644 types/lib/blocks.d.ts create mode 100644 types/lib/conjugation.d.ts create mode 100644 types/lib/gate.d.ts create mode 100644 types/lib/hangul.d.ts create mode 100644 types/lib/srs.d.ts diff --git a/test/lib/blocks.test.ts b/test/lib/blocks.test.ts new file mode 100644 index 0000000..428b39c --- /dev/null +++ b/test/lib/blocks.test.ts @@ -0,0 +1,214 @@ +/* Golden tests pinning lib/blocks.js — the protocol the tutor drives the UI + with. parse() turns a reply into renderable structure; answerText() turns a + completed task back into the message the student sends. */ + +import { describe, it, expect } from "vitest"; +import { parse, answerText, ROLES } from "@lib/blocks.js"; +import type { TaskState } from "@lib/blocks.js"; + +describe("parse — ::words", () => { + it("reads 한글 | meaning | note rows", () => { + const p = parse("Here you go.\n::words\n먹어 | eat | 반말, from 먹다\n밥 | rice\n::"); + expect(p.words).toEqual([ + { ko: "먹어", gloss: "eat", note: "반말, from 먹다" }, + { ko: "밥", gloss: "rice", note: "" }, + ]); + }); + + it("truncates the body at ::words — the block is contractually last", () => { + const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk"); + expect(p.body).toBe("Read this."); + }); +}); + +describe("parse — the four task types", () => { + it("translate", () => { + const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::"); + expect(p.task).toEqual({ type: "translate", items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }] }); + expect(p.body).toBe("Try these."); + }); + + it("match", () => { + const p = parse("::task match\n친구 | friend\n물 | water\n::"); + expect(p.task).toEqual({ + type: "match", + pairs: [ + { ko: "친구", gloss: "friend" }, + { ko: "물", gloss: "water" }, + ], + }); + }); + + it("build — first field is the English, the rest are chips in order", () => { + const p = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::"); + expect(p.task).toEqual({ + type: "build", + items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }], + }); + }); + + it("choice", () => { + const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::"); + expect(p.task).toEqual({ + type: "choice", + items: [{ q: "나 학교 ___ 가", options: ["에", "에서", "을"] }], + }); + }); + + it("drops malformed rows rather than emitting half a task", () => { + // A choice needs more than one option; a match needs both sides. + expect(parse("::task choice\nonly a question\n::").task).toEqual({ type: "choice", items: [] }); + expect(parse("::task match\n친구\n::").task).toEqual({ type: "match", pairs: [] }); + }); + + it("is null when there is no task block", () => { + expect(parse("Just talking.").task).toBeNull(); + }); +}); + +describe("parse — ::gloss", () => { + it("reads parts and the = line, defaulting the role to N", () => { + const p = parse( + "Look:\n::gloss\n저는 | T | I | 는\n학교에 | P | to school | 에\n갔어 | V | went | 었\n= I went to school.\n::", + ); + expect(p.gloss).toEqual([ + { + parts: [ + { ko: "저는", role: "T", gloss: "I", highlight: "는" }, + { ko: "학교에", role: "P", gloss: "to school", highlight: "에" }, + { ko: "갔어", role: "V", gloss: "went", highlight: "었" }, + ], + en: "I went to school.", + }, + ]); + }); + + /* KNOWN LIMITATION, pinned deliberately. + The system prompt tells the tutor it may put several sentences in one + ::gloss block, separated by their = lines. parse() sets `en` on the + current block when it meets "=", but never closes the block, so the + parts of every sentence pile into one run-on line and only the last + translation survives. + + lib/ ships unchanged, so this is not fixed here. The app splits a + ::gloss block on its = lines and calls parse() once per sentence — + see app/src/domain/gloss.ts. If lib/blocks.js is ever revised, the + one-line fix is `cur = null` after setting `en`, and this test and + that workaround both go away. */ + it("does NOT close a block on the = line (see gloss.ts for the workaround)", () => { + const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::"); + expect(p.gloss).toHaveLength(1); + expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가", "밥"]); + expect(p.gloss![0]!.en).toBe("Rice."); // "I go." is lost + }); + + it("defaults a missing role to N and an absent highlight to empty", () => { + const p = parse("::gloss\n밥 | | rice\n= Rice.\n::"); + expect(p.gloss![0]!.parts[0]).toEqual({ ko: "밥", role: "N", gloss: "rice", highlight: "" }); + }); +}); + +describe("parse — ::progress", () => { + it("reads the score and the note", () => { + const p = parse("Nice work.\n::progress 72 | particles are landing"); + expect(p.progress).toEqual({ score: 72, note: "particles are landing" }); + expect(p.body).toBe("Nice work."); + }); + + it("clamps to 0..100", () => { + expect(parse("::progress 999").progress!.score).toBe(100); + }); + + it("tolerates a missing note", () => { + expect(parse("::progress 40").progress).toEqual({ score: 40, note: "" }); + }); +}); + +describe("parse — a full reply", () => { + const reply = [ + "You have this. Two right, one to look at.", + "", + "::gloss", + "나 | S | I", + "밥 | O | rice", + "먹어 | V | eat", + "= I eat rice.", + "::", + "", + "::task translate", + "우리 밥 먹어", + "::", + "::words", + "밥 | rice", + "먹어 | eat | from 먹다", + "::", + "::progress 64 | word order is solid", + ].join("\n"); + + it("pulls every block out and leaves clean prose behind", () => { + const p = parse(reply); + expect(p.body).toBe("You have this. Two right, one to look at."); + expect(p.task!.type).toBe("translate"); + expect(p.words).toHaveLength(2); + expect(p.gloss).toHaveLength(1); + expect(p.progress!.score).toBe(64); + }); +}); + +describe("ROLES", () => { + it("covers every role a gloss part can carry", () => { + expect(Object.keys(ROLES).sort()).toEqual(["C", "M", "N", "O", "P", "Q", "S", "T", "V"]); + // N is the unmarked role and deliberately has no label, so it stays out + // of the legend. + expect(ROLES.N).toBe(""); + }); +}); + +describe("answerText — the message the student sends back", () => { + it("translate", () => { + const task = parse("::task translate\n우리 밥 먹어\n학교 작아\n::").task!; + const out = answerText(task, ["we eat rice", ""], ["학교"]); + expect(out).toBe( + "My answers:\n우리 밥 먹어 → we eat rice\n학교 작아 → (not sure)\n\n(I had to look up: 학교)", + ); + }); + + it("match", () => { + const task = parse("::task match\n친구 | friend\n물 | water\n::").task!; + const out = answerText(task, { pairs: [{ ko: "친구", gloss: "friend" }] }, []); + expect(out).toBe("My pairings:\n친구 = friend\n\n(No lookups.)"); + }); + + it("build", () => { + const task = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::").task!; + expect(answerText(task, [["우리", "밥", "먹어"]], [])).toBe( + "My sentences:\nWe eat rice. → 우리 밥 먹어\n\n(No lookups.)", + ); + }); + + it("choice — an unanswered item reads as not sure", () => { + const task = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::").task!; + expect(answerText(task, [0], [])).toContain("나 학교 ___ 가 → 에"); + expect(answerText(task, [null], [])).toContain("나 학교 ___ 가 → (not sure)"); + }); + + it("always reports the lookup state — that closed loop drives the next exercise", () => { + const task = parse("::task translate\n밥\n::").task!; + expect(answerText(task, ["rice"], [])).toContain("(No lookups.)"); + expect(answerText(task, ["rice"], ["밥", "먹어"])).toContain("(I had to look up: 밥, 먹어)"); + }); + + it("round-trips: every task type parses and answers without throwing", () => { + const blocks: [string, TaskState][] = [ + ["::task translate\n밥\n::", ["rice"]], + ["::task match\n밥 | rice\n::", { pairs: [] }], + ["::task build\nRice. | 밥\n::", [["밥"]]], + ["::task choice\n___ | 밥 | 물\n::", [1]], + ]; + for (const [src, state] of blocks) { + const t = parse(src).task!; + expect(t, src).not.toBeNull(); + expect(typeof answerText(t, state, []), src).toBe("string"); + } + }); +}); diff --git a/test/lib/conjugation.test.ts b/test/lib/conjugation.test.ts new file mode 100644 index 0000000..42e1573 --- /dev/null +++ b/test/lib/conjugation.test.ts @@ -0,0 +1,144 @@ +/* Golden tests pinning lib/conjugation.js. This module encodes the seven + Korean irregular classes and is the reason the app ships no runtime + morphological analyser — surfaceForms() generates the index at build time. + It ships unchanged; these tests exist so it stays that way. */ + +import { describe, it, expect } from "vitest"; +import { + haeche, + past, + polite, + irregularClass, + explain, + surfaceForms, + IRREGULAR_FORMS, +} from "@lib/conjugation.js"; + +describe("haeche — the 아/어 rule", () => { + const regular: [string, string][] = [ + ["먹다", "먹어"], // consonant-final, dark vowel + ["앉다", "앉아"], // consonant-final, bright vowel + ["좋다", "좋아"], + ["읽다", "읽어"], + ["가다", "가"], // ㅏ-final stem contracts + ["서다", "서"], // ㅓ-final stem contracts + ["오다", "와"], // ㅗ + 아 → ㅘ + ["주다", "줘"], // ㅜ + 어 → ㅝ + ["마시다", "마셔"], // ㅣ + 어 → ㅕ + ]; + it.each(regular)("%s → %s", (dict, want) => { + expect(haeche(dict)).toBe(want); + }); + + it("returns null for anything that is not a dictionary form", () => { + expect(haeche("학교")).toBeNull(); + expect(haeche("")).toBeNull(); + expect(haeche("다")).toBeNull(); + }); +}); + +describe("the seven irregular classes", () => { + const cases: [string, string, string][] = [ + // dict, 해체, class + ["크다", "커", "ㅡ"], // ㅡ drops + ["바쁘다", "바빠", "ㅡ"], // ㅡ drops, preceding vowel is bright + ["슬프다", "슬퍼", "ㅡ"], + ["덥다", "더워", "ㅂ"], // ㅂ → 우 + ["어렵다", "어려워", "ㅂ"], + ["듣다", "들어", "ㄷ"], // ㄷ → ㄹ + ["걷다", "걸어", "ㄷ"], + ["모르다", "몰라", "르"], // 르 doubles + ["빠르다", "빨라", "르"], + ["낫다", "나아", "ㅅ"], // ㅅ drops + ["짓다", "지어", "ㅅ"], + ["그렇다", "그래", "ㅎ"], // ㅎ drops, vowel shifts + ["어떻다", "어때", "ㅎ"], + ["하다", "해", "special"], + ["되다", "돼", "special"], + ["이다", "야", "special"], + ]; + + it.each(cases)("%s → %s (%s 불규칙)", (dict, form, cls) => { + expect(haeche(dict)).toBe(form); + expect(irregularClass(dict)).toBe(cls); + }); + + it("covers all seven classes plus the specials", () => { + const seen = new Set(cases.map(([, , c]) => c)); + expect(seen).toEqual(new Set(["ㅡ", "ㅂ", "ㄷ", "르", "ㅅ", "ㅎ", "special"])); + }); + + it("every listed irregular form is reachable through haeche()", () => { + for (const [dict, form] of Object.entries(IRREGULAR_FORMS)) { + expect(haeche(dict), dict).toBe(form); + } + }); + + it("calls ordinary stems regular", () => { + for (const d of ["먹다", "가다", "읽다", "좋다"]) expect(irregularClass(d), d).toBe("regular"); + }); +}); + +describe("past and polite", () => { + const cases: [string, string][] = [ + ["먹어", "먹었어"], // final present → append 었어 + ["가", "갔어"], // open syllable takes ㅆ + ["해", "했어"], + ["와", "왔어"], + ["몰라", "몰랐어"], + ]; + it.each(cases)("%s → %s", (present, want) => { + expect(past(present)).toBe(want); + }); + + it("polite just adds 요", () => { + expect(polite("먹어")).toBe("먹어요"); + expect(polite(null)).toBeNull(); + }); + + it("past of nothing is nothing", () => { + expect(past(null)).toBeNull(); + }); +}); + +describe("explain — which rule was missed", () => { + it("names the irregular class when there is one", () => { + expect(explain("듣다")).toBe("ㄷ 불규칙"); + expect(explain("크다")).toBe("ㅡ 불규칙"); + }); + it("names the 하다 contraction", () => { + expect(explain("공부하다")).toBe("하다 → 해"); + }); + it("otherwise explains the vowel choice", () => { + expect(explain("먹다")).toContain("어"); + expect(explain("앉다")).toContain("아"); + }); +}); + +describe("surfaceForms — the build-time index generator", () => { + it("emits 반말, polite and past for a regular verb", () => { + const out = surfaceForms("먹다", "to eat"); + expect(out.map((s) => s.form)).toEqual(["먹어", "먹어요", "먹었어"]); + // "to eat" is stripped to "eat"; the past form is marked as such. + expect(out[0]!.gloss).toBe("eat"); + expect(out[2]!.gloss).toBe("eat (past)"); + // Every form carries its lemma so the index can point back. + for (const s of out) expect(s.note).toContain("먹다"); + }); + + it("strips a leading 'to be' as well as 'to'", () => { + expect(surfaceForms("좋다", "to be good")[0]!.gloss).toBe("good"); + }); + + it("handles irregulars through the same path", () => { + expect(surfaceForms("듣다", "to listen").map((s) => s.form)).toEqual([ + "들어", + "들어요", + "들었어", + ]); + }); + + it("emits nothing for a non-verb", () => { + expect(surfaceForms("학교", "school")).toEqual([]); + }); +}); diff --git a/test/lib/hangul.test.ts b/test/lib/hangul.test.ts new file mode 100644 index 0000000..ebab0a3 --- /dev/null +++ b/test/lib/hangul.test.ts @@ -0,0 +1,134 @@ +/* Golden tests pinning lib/hangul.js. These exist so a later refactor cannot + silently drift the 두벌식 IME — the module itself ships unchanged. + The nine cases named in the export README are all here. */ + +import { describe, it, expect } from "vitest"; +import { Composer, compose, decompose, isJamo, CHO, JUNG, JONG, KEYBOARD } from "@lib/hangul.js"; + +/** Type a sequence of jamo into a fresh composer, returning the final value. */ +function type(jamo: string[]): string { + const c = new Composer(); + let v = ""; + for (const j of jamo) v = c.key(v, j); + return v; +} + +describe("decompose / compose", () => { + it("round-trips every syllable block", () => { + for (const ch of ["가", "한", "값", "괜", "찮", "의", "뭐", "읽"]) { + const d = decompose(ch); + expect(d, ch).not.toBeNull(); + const [i, m, f] = d!; + expect(compose(i, m, f), ch).toBe(ch); + } + }); + + it("returns null for anything that is not a syllable block", () => { + for (const ch of ["ㄱ", "ㅏ", "A", "1", " ", "。"]) expect(decompose(ch), ch).toBeNull(); + }); + + it("indexes agree with the jamo tables", () => { + const d = decompose("값")!; + expect(CHO[d[0]]).toBe("ㄱ"); + expect(JUNG[d[1]]).toBe("ㅏ"); + expect(JONG[d[2]]).toBe("ㅄ"); + }); + + it("classifies jamo by position", () => { + expect(isJamo.initial("ㄱ")).toBe(true); + expect(isJamo.medial("ㅏ")).toBe(true); + expect(isJamo.final("ㅄ")).toBe(true); + // ㅃ is a valid initial but never a final. + expect(isJamo.initial("ㅃ")).toBe(true); + expect(isJamo.final("ㅃ")).toBe(false); + }); +}); + +describe("Composer — the nine cases", () => { + it("먹어 — a final splits off when a vowel follows", () => { + expect(type(["ㅁ", "ㅓ", "ㄱ", "ㅇ", "ㅓ"])).toBe("먹어"); + }); + + it("왔어 — compound vowel ㅗ+ㅏ, then a tense final", () => { + expect(type(["ㅇ", "ㅗ", "ㅏ", "ㅆ", "ㅇ", "ㅓ"])).toBe("왔어"); + }); + + it("읽어 — a compound final gives up its second half", () => { + expect(type(["ㅇ", "ㅣ", "ㄹ", "ㄱ", "ㅇ", "ㅓ"])).toBe("읽어"); + }); + + it("괜찮아 — compound vowel ㅗ+ㅐ and compound final ㄴ+ㅎ", () => { + expect(type(["ㄱ", "ㅗ", "ㅐ", "ㄴ", "ㅊ", "ㅏ", "ㄴ", "ㅎ", "ㅇ", "ㅏ"])).toBe("괜찮아"); + }); + + it("값 — a compound final that stays put", () => { + expect(type(["ㄱ", "ㅏ", "ㅂ", "ㅅ"])).toBe("값"); + }); + + it("의사 — the ㅡ+ㅣ compound vowel", () => { + expect(type(["ㅇ", "ㅡ", "ㅣ", "ㅅ", "ㅏ"])).toBe("의사"); + }); + + it("뭐야 — ㅜ+ㅓ, and a bare vowel starting a new block", () => { + expect(type(["ㅁ", "ㅜ", "ㅓ", "ㅇ", "ㅑ"])).toBe("뭐야"); + }); + + it("backspace peels jamo-wise, not character-wise", () => { + const c = new Composer(); + let v = ""; + for (const j of ["ㄱ", "ㅏ", "ㅂ", "ㅅ"]) v = c.key(v, j); + expect(v).toBe("값"); + v = c.back(v); + expect(v).toBe("갑"); // the compound final loses its second half + v = c.back(v); + expect(v).toBe("가"); // then the final entirely + v = c.back(v); + expect(v).toBe("ㄱ"); // then the vowel + v = c.back(v); + expect(v).toBe(""); // then the initial + }); + + it("backspace pulls a committed block back into the buffer", () => { + const c = new Composer(); + let v = c.text(c.key(c.key("", "ㄱ"), "ㅏ"), " "); // "가 " — committed + expect(v).toBe("가 "); + v = c.back(v); // removes the space + expect(v).toBe("가"); + v = c.back(v); // decomposes 가 and drops its vowel + expect(v).toBe("ㄱ"); + }); +}); + +describe("Composer — committing", () => { + it("text() commits the buffer and appends literally", () => { + const c = new Composer(); + let v = ""; + for (const j of ["ㅁ", "ㅓ", "ㄱ", "ㅇ", "ㅓ"]) v = c.key(v, j); + v = c.text(v, "?"); + expect(v).toBe("먹어?"); + expect(c.empty).toBe(true); + }); + + it("a consonant that cannot be a final starts a new block", () => { + // ㅃ is not a legal final, so 아 commits and ㅃ opens the next block. + expect(type(["ㅇ", "ㅏ", "ㅃ", "ㅏ"])).toBe("아빠"); + }); +}); + +describe("KEYBOARD", () => { + it("is the standard 두벌식 layout with the tense pairs on shift", () => { + expect(KEYBOARD.rows.map((r) => r.length)).toEqual([10, 9, 7]); + const flat = KEYBOARD.rows.flat(); + expect(new Set(flat).size).toBe(flat.length); // no key appears twice + expect(KEYBOARD.shift).toMatchObject({ ㅂ: "ㅃ", ㅈ: "ㅉ", ㄷ: "ㄸ", ㄱ: "ㄲ", ㅅ: "ㅆ" }); + // Shift carries the five tense consonants and the two tense vowels + // (ㅐ→ㅒ, ㅔ→ㅖ), so a shifted form is typeable in its own position. + for (const [base, shifted] of Object.entries(KEYBOARD.shift)) { + if (isJamo.medial(base)) expect(isJamo.medial(shifted), shifted).toBe(true); + else expect(isJamo.initial(shifted), shifted).toBe(true); + } + // Every shiftable key is actually on the layout. + const flatKeys = new Set(flat); + for (const base of Object.keys(KEYBOARD.shift)) expect(flatKeys.has(base), base).toBe(true); + }); +}); diff --git a/test/lib/srs.test.ts b/test/lib/srs.test.ts new file mode 100644 index 0000000..d65a8de --- /dev/null +++ b/test/lib/srs.test.ts @@ -0,0 +1,170 @@ +/* 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); + }); +}); diff --git a/types/lib/blocks.d.ts b/types/lib/blocks.d.ts new file mode 100644 index 0000000..fb03bc7 --- /dev/null +++ b/types/lib/blocks.d.ts @@ -0,0 +1,80 @@ +/* Declarations for lib/blocks.js — the module itself ships unchanged. */ + +export interface WordEntry { + ko: string; + gloss: string; + note: string; +} + +export interface TranslateTask { + type: "translate"; + items: { q: string }[]; +} +export interface MatchTask { + type: "match"; + pairs: { ko: string; gloss: string }[]; +} +export interface BuildTask { + type: "build"; + /** chips are given in correct order; the UI shuffles them. */ + items: { en: string; chips: string[] }[]; +} +export interface ChoiceTask { + type: "choice"; + items: { q: string; options: string[] }[]; +} +export type Task = TranslateTask | MatchTask | BuildTask | ChoiceTask; +export type TaskType = Task["type"]; + +/** One uppercase letter; see ROLES. */ +export type GlossRole = "S" | "T" | "O" | "V" | "P" | "C" | "Q" | "M" | "N"; + +export interface GlossPart { + ko: string; + role: GlossRole; + gloss: string; + /** The meaningful piece INSIDE the word — particle, tense marker, ending. */ + highlight: string; +} +export interface GlossBlock { + parts: GlossPart[]; + en: string; +} + +export interface Progress { + score: number; + note: string; +} + +export interface ParsedMessage { + body: string; + words: WordEntry[] | null; + task: Task | null; + gloss: GlossBlock[] | null; + progress: Progress | null; +} + +export function parse(text: string): ParsedMessage; + +/** Roles a gloss part can carry, and what the UI should do with each. */ +export const ROLES: Record; + +/** State shapes accepted by answerText(), per task type. */ +export type TranslateState = string[]; +export type MatchState = { pairs: { ko: string; gloss: string }[] }; +export type BuildState = string[][]; +export type ChoiceState = (number | null)[]; +export type TaskState = TranslateState | MatchState | BuildState | ChoiceState; + +/** Turn a completed task back into the message the student sends. + The state shape follows the task type, so these are overloads rather + than one signature over a union. */ +export function answerText( + task: TranslateTask, + state: TranslateState, + lookups?: string[], +): string; +export function answerText(task: MatchTask, state: MatchState, lookups?: string[]): string; +export function answerText(task: BuildTask, state: BuildState, lookups?: string[]): string; +export function answerText(task: ChoiceTask, state: ChoiceState, lookups?: string[]): string; +export function answerText(task: Task, state: TaskState, lookups?: string[]): string; diff --git a/types/lib/conjugation.d.ts b/types/lib/conjugation.d.ts new file mode 100644 index 0000000..61844cc --- /dev/null +++ b/types/lib/conjugation.d.ts @@ -0,0 +1,33 @@ +/* Declarations for lib/conjugation.js — the module itself ships unchanged. */ + +/** Forms that do not fall out of the rules and are simply known. */ +export const IRREGULAR_FORMS: Record; + +/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */ +export function haeche(dict: string): string | null; + +/** 반말 present → 반말 past. 먹어 → 먹었어. */ +export function past(present: string | null): string | null; + +export function polite(present: string | null): string | null; + +export type IrregularClass = + | "ㅡ" | "ㅂ" | "ㄷ" | "르" | "ㅅ" | "ㅎ" | "special" | "regular"; + +/** Which class a dictionary form belongs to — drives the "why" in feedback. */ +export function irregularClass(dict: string): IrregularClass; + +/** Human explanation of the rule applied — shown when an answer is wrong. */ +export function explain(dict: string): string; + +export interface SurfaceForm { + form: string; + gloss: string; + note: string; +} + +/** + * Build-time: every surface form a learner will meet, mapped back to its lemma. + * This is what replaces a runtime morphological analyser. + */ +export function surfaceForms(dict: string, gloss: string): SurfaceForm[]; diff --git a/types/lib/gate.d.ts b/types/lib/gate.d.ts new file mode 100644 index 0000000..1fa7a2c --- /dev/null +++ b/types/lib/gate.d.ts @@ -0,0 +1,80 @@ +/* Declarations for lib/gate.js — the module itself ships unchanged. + + The gate is what stops material being taught out of order. buildGate() + computes it from curriculum + progress; renderGate() turns it into the + {{GATE}} section of prompt/tutor-system.md. */ + +export interface Unit { + id: string; + ko: string; + name: string; + goal: string; + vocabUnit: boolean; + teaches: string[]; + avoid?: string[]; + /** Strictly NEW vocabulary. Deliberate repeats live in revisits[]. */ + words: string[]; + revisits?: { word: string; from: string }[]; +} + +/** A Unit with its phase stamped on, as flatten() returns it. */ +export interface FlatUnit extends Unit { + phase: number; + phaseKo: string; + phaseName: string; +} + +export interface Phase { + phase: number; + ko: string; + name: string; + units: Unit[]; +} + +export interface Curriculum { + version: number; + note?: string; + phases: Phase[]; +} + +export interface ProgressState { + current: string; + done: Record; + confidence?: Record; +} + +/** + * Replaces the hand-listed word set with a dictionary query. Returning a + * frequency band here is what turns 371 hand-typed words into something + * that scales. + */ +export type VocabQuery = (unit: FlatUnit, done: FlatUnit[]) => string[]; + +export interface GateOptions { + vocabQuery?: VocabQuery; +} + +export interface Gate { + unit: FlatUnit; + phase: { n: number; ko: string; name: string }; + taught: string[]; + forbidden: { near: string[]; tailUnit: FlatUnit | null; count: number }; + vocabulary: string[]; + newWords: string[]; + /** Words met earlier that this unit should deliberately bring back. */ + revisits: string[]; + confidence: number | null; + next: FlatUnit | null; + finished: string[]; +} + +export function flatten(curriculum: Curriculum): FlatUnit[]; + +export function buildGate( + curriculum: Curriculum, + progress: ProgressState, + opts?: GateOptions, +): Gate; + +/** Render the gate into the system prompt section. Keep the headings. */ +export function renderGate(g: Gate): string; diff --git a/types/lib/hangul.d.ts b/types/lib/hangul.d.ts new file mode 100644 index 0000000..3666b75 --- /dev/null +++ b/types/lib/hangul.d.ts @@ -0,0 +1,38 @@ +/* Declarations for lib/hangul.js — the module itself ships unchanged. */ + +export const CHO: string; +export const JUNG: string; +export const JONG: string; + +/** [initialIndex, medialIndex, finalIndex], or null if not a syllable block. */ +export function decompose(ch: string): [number, number, number] | null; +export function compose(i: number, m: number, f?: number): string; + +export const isJamo: { + initial(c: string): boolean; + medial(c: string): boolean; + final(c: string): boolean; +}; + +/** + * A 두벌식 IME. Hold one per input: feed jamo with key(), literal text with + * text(), Backspace with back(). Each call returns the full new value. + */ +export class Composer { + cho: string | null; + jung: string | null; + jong: string | null; + reset(): void; + readonly empty: boolean; + /** The syllable currently being assembled, as text. */ + render(): string; + key(value: string, jamo: string): string; + back(value: string): string; + /** Commit the buffer and append literal text (space, punctuation). */ + text(value: string, t: string): string; +} + +export const KEYBOARD: { + rows: string[][]; + shift: Record; +}; diff --git a/types/lib/srs.d.ts b/types/lib/srs.d.ts new file mode 100644 index 0000000..b8f3919 --- /dev/null +++ b/types/lib/srs.d.ts @@ -0,0 +1,38 @@ +/* Declarations for lib/srs.js — the module itself ships unchanged. */ + +export const AGAIN: 0; +export const HARD: 1; +export const GOOD: 2; +export const EASY: 3; +export type Grade = 0 | 1 | 2 | 3; + +export const NEW: 0; +export const LEARNING: 1; +export const REVIEW: 2; +export type CardState = 0 | 1 | 2; + +/** Days at which a card counts as known. */ +export const SECURE_INTERVAL: number; + +export interface Card { + state: CardState; + interval: number; + ease: number; + /** Day number, not a timestamp — see dayNumber(). */ + due: number; + reps: number; + lapses: number; +} + +export function newCard(): Card; +export function grade(card: Card, g: Grade, today: number): Card; +export function markKnown(today: number): Card; + +export type CardStatus = "new" | "learning" | "review" | "secure"; +export function statusOf(card: Card | null | undefined): CardStatus; + +/** Label for the interval a grade would produce — shown on the buttons. */ +export function preview(card: Card | null | undefined, g: Grade, today: number): string; + +/** Local day number, DST-safe. */ +export function dayNumber(d?: Date): number;