/* 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, spellOut, letterDiff, letterCheck, } 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); }); }); /* A model cannot see the letters inside a syllable. Asked about 빫다 for 짧다 the artifact's tutor blamed the ㄼ — identical in both — when the slip was the initial ㅉ→ㅃ. These are the strings the tutor is handed instead. */ describe("letter-level marking", () => { it("spells a word out jamo by jamo, unpacking a double batchim", () => { expect(spellOut("짧다")).toBe("짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ"); }); it("names the wrong slot AND the right ones — the prompt's own example", () => { expect(letterDiff("짧다", "빫다")).toBe( "syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " + "CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)", ); }); it("catches a dropped half of a double batchim", () => { expect(letterDiff("닭", "닥")).toBe( "syllable 1 (닥 for 닭) — WRONG: batchim: wrote ㄱ, should be ㄺ (ㄹ+ㄱ) — " + "CORRECT, do not call these mistakes: first consonant ㄷ, vowel ㅏ", ); }); it("reports identity, a length mismatch, and says nothing about an empty side", () => { expect(letterDiff("학교", "학교")).toBe("identical"); expect(letterDiff("바다", "바")).toBe("length differs: 2 syllables expected, 1 written"); expect(letterDiff("", "x")).toBe(""); }); it("builds the block a marking message carries, skipping correct answers", () => { const block = letterCheck([ { prompt: "to be short (dictionary form)", expected: "짧다", written: "빫다" }, { prompt: "the sea", expected: "바다", written: "바다" }, ]); expect(block).toBe( [ "════ LETTER-LEVEL CHECK — computed by the app ════", "This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " + "which letter was wrong, and never call a letter wrong that is listed as correct.", "• to be short (dictionary form)", " wanted 짧다 [짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]", " wrote 빫다 [빫=ㅃ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]", " syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " + "CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)", ].join("\n"), ); }); it("is empty when nothing differs, so no block is sent at all", () => { expect(letterCheck([{ prompt: "the sea", expected: "바다", written: "바다" }])).toBe(""); }); });