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