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>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
/* 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([]);
|
|
});
|
|
});
|