/* 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"); } }); });