Files
Hankan/test/lib/hangul.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

135 lines
5.0 KiB
TypeScript

/* 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);
});
});