Files
Hankan/test/lib/conjugation.test.ts
MechaCat02 966043ab3f 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>
2026-09-08 19:12:19 +02:00

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([]);
});
});