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>
This commit is contained in:
214
test/lib/blocks.test.ts
Normal file
214
test/lib/blocks.test.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
144
test/lib/conjugation.test.ts
Normal file
144
test/lib/conjugation.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
test/lib/hangul.test.ts
Normal file
134
test/lib/hangul.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
test/lib/srs.test.ts
Normal file
170
test/lib/srs.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
types/lib/blocks.d.ts
vendored
Normal file
80
types/lib/blocks.d.ts
vendored
Normal file
@@ -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<GlossRole, string>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
33
types/lib/conjugation.d.ts
vendored
Normal file
33
types/lib/conjugation.d.ts
vendored
Normal file
@@ -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<string, string>;
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
80
types/lib/gate.d.ts
vendored
Normal file
80
types/lib/gate.d.ts
vendored
Normal file
@@ -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<string, boolean>;
|
||||||
|
confidence?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
38
types/lib/hangul.d.ts
vendored
Normal file
38
types/lib/hangul.d.ts
vendored
Normal file
@@ -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<string, string>;
|
||||||
|
};
|
||||||
38
types/lib/srs.d.ts
vendored
Normal file
38
types/lib/srs.d.ts
vendored
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user