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>
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
/* Golden tests pinning lib/conjugation.js. This module encodes the seven
|
|
Korean irregular classes and is the reason the app ships no runtime
|
|
morphological analyser — surfaceForms() generates the index at build time.
|
|
It ships unchanged; these tests exist so it stays that way. */
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
haeche,
|
|
past,
|
|
polite,
|
|
irregularClass,
|
|
explain,
|
|
surfaceForms,
|
|
IRREGULAR_FORMS,
|
|
deconjugate,
|
|
deconjugateCandidates,
|
|
} from "@lib/conjugation.js";
|
|
|
|
describe("haeche — the 아/어 rule", () => {
|
|
const regular: [string, string][] = [
|
|
["먹다", "먹어"], // consonant-final, dark vowel
|
|
["앉다", "앉아"], // consonant-final, bright vowel
|
|
["좋다", "좋아"],
|
|
["읽다", "읽어"],
|
|
["가다", "가"], // ㅏ-final stem contracts
|
|
["서다", "서"], // ㅓ-final stem contracts
|
|
["오다", "와"], // ㅗ + 아 → ㅘ
|
|
["주다", "줘"], // ㅜ + 어 → ㅝ
|
|
["마시다", "마셔"], // ㅣ + 어 → ㅕ
|
|
];
|
|
it.each(regular)("%s → %s", (dict, want) => {
|
|
expect(haeche(dict)).toBe(want);
|
|
});
|
|
|
|
it("returns null for anything that is not a dictionary form", () => {
|
|
expect(haeche("학교")).toBeNull();
|
|
expect(haeche("")).toBeNull();
|
|
expect(haeche("다")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the seven irregular classes", () => {
|
|
const cases: [string, string, string][] = [
|
|
// dict, 해체, class
|
|
["크다", "커", "ㅡ"], // ㅡ drops
|
|
["바쁘다", "바빠", "ㅡ"], // ㅡ drops, preceding vowel is bright
|
|
["슬프다", "슬퍼", "ㅡ"],
|
|
["덥다", "더워", "ㅂ"], // ㅂ → 우
|
|
["어렵다", "어려워", "ㅂ"],
|
|
["듣다", "들어", "ㄷ"], // ㄷ → ㄹ
|
|
["걷다", "걸어", "ㄷ"],
|
|
["모르다", "몰라", "르"], // 르 doubles
|
|
["빠르다", "빨라", "르"],
|
|
["낫다", "나아", "ㅅ"], // ㅅ drops
|
|
["짓다", "지어", "ㅅ"],
|
|
["그렇다", "그래", "ㅎ"], // ㅎ drops, vowel shifts
|
|
["어떻다", "어때", "ㅎ"],
|
|
["하다", "해", "special"],
|
|
["되다", "돼", "special"],
|
|
["이다", "야", "special"],
|
|
];
|
|
|
|
it.each(cases)("%s → %s (%s 불규칙)", (dict, form, cls) => {
|
|
expect(haeche(dict)).toBe(form);
|
|
expect(irregularClass(dict)).toBe(cls);
|
|
});
|
|
|
|
it("covers all seven classes plus the specials", () => {
|
|
const seen = new Set(cases.map(([, , c]) => c));
|
|
expect(seen).toEqual(new Set(["ㅡ", "ㅂ", "ㄷ", "르", "ㅅ", "ㅎ", "special"]));
|
|
});
|
|
|
|
it("every listed irregular form is reachable through haeche()", () => {
|
|
for (const [dict, form] of Object.entries(IRREGULAR_FORMS)) {
|
|
expect(haeche(dict), dict).toBe(form);
|
|
}
|
|
});
|
|
|
|
it("calls ordinary stems regular", () => {
|
|
for (const d of ["먹다", "가다", "읽다", "좋다"]) expect(irregularClass(d), d).toBe("regular");
|
|
});
|
|
});
|
|
|
|
describe("past and polite", () => {
|
|
const cases: [string, string][] = [
|
|
["먹어", "먹었어"], // final present → append 었어
|
|
["가", "갔어"], // open syllable takes ㅆ
|
|
["해", "했어"],
|
|
["와", "왔어"],
|
|
["몰라", "몰랐어"],
|
|
];
|
|
it.each(cases)("%s → %s", (present, want) => {
|
|
expect(past(present)).toBe(want);
|
|
});
|
|
|
|
it("polite just adds 요", () => {
|
|
expect(polite("먹어")).toBe("먹어요");
|
|
expect(polite(null)).toBeNull();
|
|
});
|
|
|
|
it("past of nothing is nothing", () => {
|
|
expect(past(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("explain — which rule was missed", () => {
|
|
it("names the irregular class when there is one", () => {
|
|
expect(explain("듣다")).toBe("ㄷ 불규칙");
|
|
expect(explain("크다")).toBe("ㅡ 불규칙");
|
|
});
|
|
it("names the 하다 contraction", () => {
|
|
expect(explain("공부하다")).toBe("하다 → 해");
|
|
});
|
|
it("otherwise explains the vowel choice", () => {
|
|
expect(explain("먹다")).toContain("어");
|
|
expect(explain("앉다")).toContain("아");
|
|
});
|
|
});
|
|
|
|
describe("surfaceForms — the build-time index generator", () => {
|
|
it("emits 반말, polite and past for a regular verb", () => {
|
|
const out = surfaceForms("먹다", "to eat");
|
|
expect(out.map((s) => s.form)).toEqual(["먹어", "먹어요", "먹었어"]);
|
|
// "to eat" is stripped to "eat"; the past form is marked as such.
|
|
expect(out[0]!.gloss).toBe("eat");
|
|
expect(out[2]!.gloss).toBe("eat (past)");
|
|
// Every form carries its lemma so the index can point back.
|
|
for (const s of out) expect(s.note).toContain("먹다");
|
|
});
|
|
|
|
it("strips a leading 'to be' as well as 'to'", () => {
|
|
expect(surfaceForms("좋다", "to be good")[0]!.gloss).toBe("good");
|
|
});
|
|
|
|
it("handles irregulars through the same path", () => {
|
|
expect(surfaceForms("듣다", "to listen").map((s) => s.form)).toEqual([
|
|
"들어",
|
|
"들어요",
|
|
"들었어",
|
|
]);
|
|
});
|
|
|
|
it("emits nothing for a non-verb", () => {
|
|
expect(surfaceForms("학교", "school")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/* Reading an inflected form back to its dictionary entry. Without it every
|
|
one of 660 realistic inflections of the curriculum's verbs failed to
|
|
resolve, and the student was told a taught word was not in the list. */
|
|
describe("deconjugate", () => {
|
|
const verbs = new Set(["가다", "앉다", "먹다", "마시다", "좋다"]);
|
|
const isVerb = (d: string) => verbs.has(d);
|
|
|
|
it.each([
|
|
["갑니다", "가다"],
|
|
["먹습니다", "먹다"],
|
|
["앉으면", "앉다"],
|
|
["가고", "가다"],
|
|
["가면", "가다"],
|
|
["가네", "가다"],
|
|
["간다", "가다"],
|
|
["먹었어요", "먹다"],
|
|
["좋아서", "좋다"],
|
|
])("%s → %s", (form, dict) => {
|
|
expect(deconjugate(form, isVerb)).toBe(dict);
|
|
});
|
|
|
|
it("proposes every plausible dictionary form, unguarded", () => {
|
|
expect(deconjugateCandidates("갑니다")).toEqual(["가다", "갑니다"]);
|
|
expect(deconjugateCandidates("앉으면")).toEqual(["앉다", "앉으다"]);
|
|
});
|
|
|
|
it("accepts nothing the lexicon does not hold as a verb — 가지 is an eggplant", () => {
|
|
expect(deconjugateCandidates("가지")).toContain("가다");
|
|
expect(deconjugate("가지", () => false)).toBeNull();
|
|
});
|
|
});
|