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