Files
Hankan/test/lib/hangul.test.ts
MechaCat02 e72b77d6c2 chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:49:52 +02:00

199 lines
7.5 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,
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("");
});
});