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

330 lines
12 KiB
TypeScript

/* Golden tests pinning lib/blocks.js — the protocol the tutor drives the UI
with. parse() turns a reply into renderable structure; answerText() turns a
completed task back into the message the student sends. */
import { describe, it, expect } from "vitest";
import { parse, answerText, ROLES } from "@lib/blocks.js";
import type { TaskState } from "@lib/blocks.js";
describe("parse — ::words", () => {
it("reads 한글 | meaning | note rows", () => {
const p = parse("Here you go.\n::words\n먹어 | eat | 반말, from 먹다\n밥 | rice\n::");
expect(p.words).toEqual([
{ ko: "먹어", gloss: "eat", note: "반말, from 먹다" },
{ ko: "밥", gloss: "rice", note: "" },
]);
});
it("strips a closed block without truncating what follows it", () => {
// The old parse() cut the body at ::words; every block is now removed
// on its own, so prose after a properly closed block survives.
const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk");
expect(p.body).toBe("Read this.\n\ntrailing junk");
});
});
describe("parse — the five task types", () => {
it("translate — and every task carries its raw rows and a retraction count", () => {
const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::");
expect(p.task).toEqual({
type: "translate",
items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }],
retracted: 0,
rows: ["우리 밥 먹어", "학교 작아"],
});
expect(p.body).toBe("Try these.");
});
it("recall — English prompt, optional hint in the second field", () => {
const p = parse("::task recall\nchicken | double batchim\nthe sea\n::");
expect(p.task).toEqual({
type: "recall",
items: [
{ q: "chicken", hint: "double batchim" },
{ q: "the sea", hint: "" },
],
retracted: 0,
rows: ["chicken | double batchim", "the sea"],
});
});
it("match", () => {
const p = parse("::task match\n친구 | friend\n물 | water\n::");
expect(p.task).toMatchObject({
type: "match",
pairs: [
{ ko: "친구", gloss: "friend" },
{ ko: "물", gloss: "water" },
],
});
});
it("build — first field is the English, the rest are chips in order", () => {
const p = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::");
expect(p.task).toMatchObject({
type: "build",
items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }],
});
});
it("choice", () => {
const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::");
expect(p.task).toMatchObject({
type: "choice",
items: [{ q: "나 학교 ___ 가", options: ["에", "에서", "을"] }],
});
});
it("drops malformed rows rather than emitting half a task", () => {
// A choice needs more than one option; a match needs both sides. The raw
// rows are kept regardless — the gate reads those, not the parsed items.
expect(parse("::task choice\nonly a question\n::").task).toEqual({
type: "choice",
items: [],
retracted: 0,
rows: ["only a question"],
});
expect(parse("::task match\n친구\n::").task).toMatchObject({ type: "match", pairs: [] });
});
it("is null when there is no task block", () => {
expect(parse("Just talking.").task).toBeNull();
});
});
/* The tutor sometimes writes an exercise, notices it broke the gate, and
writes a corrected one below. Matching the FIRST block handed the student
the draft that had just been withdrawn. */
describe("parse — several blocks of one kind", () => {
const reply = [
"Draft:",
"::task translate",
"원 없어",
"::",
"Sorry, 원 is not his yet. Here:",
"::task translate",
"물 없어",
"::",
"::words",
"물 | water",
"::",
"::words",
"물 | WATER",
"없어 | there is none",
"::",
"::progress 30 | a",
"::progress 40 | b",
].join("\n");
it("takes the LAST task and counts the drafts it replaced", () => {
const p = parse(reply);
expect(p.task).toMatchObject({ type: "translate", items: [{ q: "물 없어" }], retracted: 1 });
});
it("accumulates words, the first gloss of a term winning", () => {
expect(parse(reply).words).toEqual([
{ ko: "물", gloss: "water", note: "" },
{ ko: "없어", gloss: "there is none", note: "" },
]);
});
it("takes the last progress line", () => {
expect(parse(reply).progress).toEqual({ score: 40, note: "b" });
});
it("strips every block from the body, not only the one it used", () => {
expect(parse(reply).body).toBe("Draft:\n\nSorry, 원 is not his yet. Here:");
});
});
describe("parse — ::gloss", () => {
it("reads parts and the = line, defaulting the role to N", () => {
const p = parse(
"Look:\n::gloss\n저는 | T | I | 는\n학교에 | P | to school | 에\n갔어 | V | went | 었\n= I went to school.\n::",
);
expect(p.gloss).toEqual([
{
parts: [
{ ko: "저는", role: "T", gloss: "I", highlight: "는" },
{ ko: "학교에", role: "P", gloss: "to school", highlight: "에" },
{ ko: "갔어", role: "V", gloss: "went", highlight: "었" },
],
en: "I went to school.",
},
]);
});
it("closes a sentence at its = line, so one block can gloss several", () => {
const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss).toHaveLength(2);
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
expect(p.gloss![0]!.en).toBe("I go.");
expect(p.gloss![1]!.en).toBe("Rice.");
});
it("accumulates separate gloss blocks", () => {
const p = parse("::gloss\n나 | S | I\n= I.\n::\nand\n::gloss\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss!.map((g) => g.en)).toEqual(["I.", "Rice."]);
});
it("defaults a missing role to N and an absent highlight to empty", () => {
const p = parse("::gloss\n밥 | | rice\n= Rice.\n::");
expect(p.gloss![0]!.parts[0]).toEqual({ ko: "밥", role: "N", gloss: "rice", highlight: "" });
});
});
describe("parse — ::result", () => {
it("reads item | outcome | mistaken-for", () => {
expect(parse("::result\n닭 | ok\n여덟 | wrong | 여덜\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
{ item: "여덟", ok: false, mistakenFor: "여덜" },
]);
});
it("counts only a literal ok as correct", () => {
// The prompt asks for "ok or wrong". Anything else is not a pass.
expect(parse("::result\n값 | right\n::").results![0]!.ok).toBe(false);
expect(parse("::result\n값 | OK\n::").results![0]!.ok).toBe(true);
});
it("takes the last block", () => {
expect(parse("::result\n닭 | wrong\n::\n::result\n닭 | ok\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
]);
});
it("is null when there is no result block", () => {
expect(parse("Nice.").results).toBeNull();
});
});
describe("parse — ::confirmed", () => {
it("reads the first column, keeping a leading minus", () => {
expect(parse("::confirmed\n연음\n-닭\n값 | extra\n::").confirmed).toEqual(["연음", "-닭", "값"]);
});
it("takes the last block", () => {
expect(parse("::confirmed\n연음\n::\n::confirmed\n닭\n::").confirmed).toEqual(["닭"]);
});
});
describe("parse — ::progress", () => {
it("reads the score and the note", () => {
const p = parse("Nice work.\n::progress 72 | particles are landing");
expect(p.progress).toEqual({ score: 72, note: "particles are landing" });
expect(p.body).toBe("Nice work.");
});
it("clamps to 0..100", () => {
expect(parse("::progress 999").progress!.score).toBe(100);
});
it("tolerates a missing note", () => {
expect(parse("::progress 40").progress).toEqual({ score: 40, note: "" });
});
});
describe("parse — a full reply", () => {
const reply = [
"You have this. Two right, one to look at.",
"",
"::gloss",
"나 | S | I",
"밥 | O | rice",
"먹어 | V | eat",
"= I eat rice.",
"::",
"",
"::task translate",
"우리 밥 먹어",
"::",
"::words",
"밥 | rice",
"먹어 | eat | from 먹다",
"::",
"::result",
"밥 | ok",
"::",
"::progress 64 | word order is solid",
].join("\n");
it("pulls every block out and leaves clean prose behind", () => {
const p = parse(reply);
expect(p.body).toBe("You have this. Two right, one to look at.");
expect(p.task!.type).toBe("translate");
expect(p.words).toHaveLength(2);
expect(p.gloss).toHaveLength(1);
expect(p.results).toHaveLength(1);
expect(p.progress!.score).toBe(64);
});
});
describe("ROLES", () => {
it("covers every role a gloss part can carry", () => {
expect(Object.keys(ROLES).sort()).toEqual(["C", "M", "N", "O", "P", "Q", "S", "T", "V"]);
// N is the unmarked role and deliberately has no label, so it stays out
// of the legend.
expect(ROLES.N).toBe("");
});
});
describe("answerText — the message the student sends back", () => {
it("translate", () => {
const task = parse("::task translate\n우리 밥 먹어\n학교 작아\n::").task!;
const out = answerText(task, ["we eat rice", ""], ["학교"]);
expect(out).toBe(
"My answers:\n우리 밥 먹어 → we eat rice\n학교 작아 → (not sure)\n\n(I had to look up: 학교)",
);
});
it("recall — the letter-level block rides between the answers and the lookups", () => {
const task = parse("::task recall\nchicken | double batchim\nthe sea\n::").task!;
expect(answerText(task, ["닭", ""], ["닭"], "BLOCK")).toBe(
"My written answers:\nchicken → 닭\nthe sea → (not sure)\n\nBLOCK\n\n(I had to look up: 닭)",
);
expect(answerText(task, ["닭", "바다"], [])).toBe(
"My written answers:\nchicken → 닭\nthe sea → 바다\n\n(No lookups.)",
);
});
it("match", () => {
const task = parse("::task match\n친구 | friend\n물 | water\n::").task!;
const out = answerText(task, { pairs: [{ ko: "친구", gloss: "friend" }] }, []);
expect(out).toBe("My pairings:\n친구 = friend\n\n(No lookups.)");
});
it("build", () => {
const task = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::").task!;
expect(answerText(task, [["우리", "밥", "먹어"]], [])).toBe(
"My sentences:\nWe eat rice. → 우리 밥 먹어\n\n(No lookups.)",
);
});
it("choice — an unanswered item reads as not sure", () => {
const task = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::").task!;
expect(answerText(task, [0], [])).toContain("나 학교 ___ 가 → 에");
expect(answerText(task, [null], [])).toContain("나 학교 ___ 가 → (not sure)");
});
it("always reports the lookup state — that closed loop drives the next exercise", () => {
const task = parse("::task translate\n밥\n::").task!;
expect(answerText(task, ["rice"], [])).toContain("(No lookups.)");
expect(answerText(task, ["rice"], ["밥", "먹어"])).toContain("(I had to look up: 밥, 먹어)");
});
it("round-trips: every task type parses and answers without throwing", () => {
const blocks: [string, TaskState][] = [
["::task translate\n밥\n::", ["rice"]],
["::task recall\nrice\n::", ["밥"]],
["::task match\n밥 | rice\n::", { pairs: [] }],
["::task build\nRice. | 밥\n::", [["밥"]]],
["::task choice\n___ | 밥 | 물\n::", [1]],
];
for (const [src, state] of blocks) {
const t = parse(src).task!;
expect(t, src).not.toBeNull();
expect(typeof answerText(t, state, []), src).toBe("string");
}
});
});