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>
135 lines
5.0 KiB
TypeScript
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);
|
|
});
|
|
});
|