lib/ ships unchanged, so its types live in types/lib/ and are wired up by a
tsconfig path mapping. Adding types costs nothing; reimplementing the logic
would cost the two things that make this port possible.
The tests exist so a later refactor cannot silently drift them:
hangul the nine Composer cases named in the export README —
먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace
conjugation all seven irregular classes, and every form in
IRREGULAR_FORMS reachable through haeche()
srs the SM-2 transitions, ease and interval clamps
blocks parse → answerText round-trip for all four task types
101 tests.
One is a pinned defect rather than a guarantee. blocks.parse() never closes
a ::gloss block on its "=" line, so a multi-sentence gloss — which the tutor
prompt explicitly invites — collapses into one run-on line keeping only the
last translation. lib/ ships unchanged, so the test records the real
behaviour and the app works around it at the call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
8.0 KiB
TypeScript
215 lines
8.0 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("truncates the body at ::words — the block is contractually last", () => {
|
|
const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk");
|
|
expect(p.body).toBe("Read this.");
|
|
});
|
|
});
|
|
|
|
describe("parse — the four task types", () => {
|
|
it("translate", () => {
|
|
const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::");
|
|
expect(p.task).toEqual({ type: "translate", items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }] });
|
|
expect(p.body).toBe("Try these.");
|
|
});
|
|
|
|
it("match", () => {
|
|
const p = parse("::task match\n친구 | friend\n물 | water\n::");
|
|
expect(p.task).toEqual({
|
|
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).toEqual({
|
|
type: "build",
|
|
items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }],
|
|
});
|
|
});
|
|
|
|
it("choice", () => {
|
|
const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::");
|
|
expect(p.task).toEqual({
|
|
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.
|
|
expect(parse("::task choice\nonly a question\n::").task).toEqual({ type: "choice", items: [] });
|
|
expect(parse("::task match\n친구\n::").task).toEqual({ type: "match", pairs: [] });
|
|
});
|
|
|
|
it("is null when there is no task block", () => {
|
|
expect(parse("Just talking.").task).toBeNull();
|
|
});
|
|
});
|
|
|
|
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.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
/* KNOWN LIMITATION, pinned deliberately.
|
|
The system prompt tells the tutor it may put several sentences in one
|
|
::gloss block, separated by their = lines. parse() sets `en` on the
|
|
current block when it meets "=", but never closes the block, so the
|
|
parts of every sentence pile into one run-on line and only the last
|
|
translation survives.
|
|
|
|
lib/ ships unchanged, so this is not fixed here. The app splits a
|
|
::gloss block on its = lines and calls parse() once per sentence —
|
|
see app/src/domain/gloss.ts. If lib/blocks.js is ever revised, the
|
|
one-line fix is `cur = null` after setting `en`, and this test and
|
|
that workaround both go away. */
|
|
it("does NOT close a block on the = line (see gloss.ts for the workaround)", () => {
|
|
const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::");
|
|
expect(p.gloss).toHaveLength(1);
|
|
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가", "밥"]);
|
|
expect(p.gloss![0]!.en).toBe("Rice."); // "I go." is lost
|
|
});
|
|
|
|
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 — ::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 먹다",
|
|
"::",
|
|
"::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.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("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 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");
|
|
}
|
|
});
|
|
});
|