Files
Hankan/test/lib/conjugation.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

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();
});
});