/* One tutor turn, enforced — with a scripted tutor in place of a model. The client refuses what the prompt only asks for: an exercise built from a word he has not been given, an explanation written in Korean. A refused draft is never returned; the tutor is asked again and told why; after GATE_TRIES the reply is shown with its stray words named. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { readFileSync } from "node:fs"; import type { Db } from "@app/db/types.js"; import { GATE_TRIES } from "@lib/gate.js"; import { gateFor, metWords } from "@app/domain/gate.js"; import { noteAnswer, readProgress } from "@app/domain/progress.js"; import { applyReply, readRecent, runTurn, trimHistory } from "@app/domain/turn.js"; import type { Sample, SampleRequest } from "@app/domain/stub-tutor.js"; import { dictionaryDb } from "../helpers/dict-db.js"; const TEMPLATE = readFileSync(new URL("../../prompt/tutor-system.md", import.meta.url), "utf8"); const TODAY = 20_000; let db: Db; beforeEach(async () => { db = await dictionaryDb(1); }); afterEach(async () => { vi.unstubAllGlobals(); await db.close(); }); /** A tutor that replies from a script, and remembers what it was sent. */ function scripted(...replies: string[]) { const seen: SampleRequest[] = []; const sample: Sample = async (req) => { seen.push(req); return { text: replies[Math.min(seen.length - 1, replies.length - 1)]! }; }; return { sample, seen }; } /* Unit 1.1 on a fresh install: 나무 is its own word; 마셔 belongs to 2.3. */ const CLEAN = "Read these.\n\n::task translate\n나무\n바다\n::"; const STRAY = "Read these.\n\n::task translate\n나무 마셔\n::"; const KOREAN = `${"한글은 소리를 적는 글자예요. ".repeat(4)}\n\n::task translate\n나무\n::`; async function turn(sample: Sample, onRetry = vi.fn()) { const progress = await readProgress(db); const gate = gateFor({ progress, met: await metWords(db) }); const result = await runTurn({ db, sample, template: TEMPLATE, gate, progress, focus: "auto", recent: [], history: [], message: "Let's start unit 1.1.", strays: [], onRetry, }); return { result, onRetry }; } describe("the gate, enforced", () => { it("passes a reply built from allowed words at the first attempt", async () => { const { sample, seen } = scripted(CLEAN); const { result } = await turn(sample); expect(seen).toHaveLength(1); expect(result).toMatchObject({ text: CLEAN, findings: [], korean: false, retries: 0 }); }); it("refuses a draft with an ungated word, never returns it, and tells the tutor why", async () => { const { sample, seen } = scripted(STRAY, CLEAN); const { result, onRetry } = await turn(sample); expect(result.text).toBe(CLEAN); expect(result.retries).toBe(1); expect(onRetry).toHaveBeenCalledOnce(); expect(seen[0]!.systemTail).not.toContain("NOT DELIVERED"); expect(seen[1]!.systemTail).toContain("THAT MESSAGE WAS NOT DELIVERED"); expect(seen[1]!.systemTail).toContain("마셔 (belongs to unit 2.3)"); }); it("gives up after GATE_TRIES retries and shows the reply with its words named", async () => { const { sample, seen } = scripted(STRAY); const { result } = await turn(sample); expect(seen).toHaveLength(GATE_TRIES + 1); expect(result.retries).toBe(GATE_TRIES); expect(result.findings.map((f) => f.word)).toEqual(["마셔"]); }); it("sends back an explanation written in Korean", async () => { const { sample, seen } = scripted(KOREAN, CLEAN); const { result } = await turn(sample); expect(result.text).toBe(CLEAN); expect(seen[1]!.systemTail).toContain("You wrote your explanation in Korean"); }); it("keeps the shipped prompt stable and puts the round in the tail", async () => { const { sample, seen } = scripted(STRAY, CLEAN); await turn(sample); expect(seen[0]!.system).toBe(seen[1]!.system); expect(seen[0]!.system).toContain("WHAT HE KNOWS"); expect(seen[0]!.systemTail).toContain("VOCABULARY DUE — WORK THESE IN"); }); }); describe("the transcript sent", () => { it("keeps the newest turns that fit, and opens with the learner", () => { const turns = [ { role: "assistant" as const, content: "opening" }, { role: "user" as const, content: "a".repeat(30) }, { role: "assistant" as const, content: "b".repeat(30) }, ]; expect(trimHistory(turns, 1000)[0]).toEqual({ role: "user", content: "Let's continue the lesson." }); expect(trimHistory(turns, 65).map((t) => t.content)).toEqual(["a".repeat(30), "b".repeat(30)]); }); }); describe("an accepted reply", () => { it("applies marking, then earned progress, and remembers the exercise type", async () => { await noteAnswer(db, await readProgress(db), "My answers:\n나무 → tree\n바다 → sea"); const reply = [ "Two for two.", "::result", "나무 | ok", "바다 | wrong | 바지", "::", "::task recall", "tree", "::", "::progress 95 | good start", ].join("\n"); const { parseMessage } = await import("@app/domain/gloss.js"); const applied = await applyReply(db, parseMessage(reply), { lookups: [], today: TODAY }); expect(applied.results?.recorded).toEqual(["나무", "바다"]); expect(applied.progress).toMatchObject({ stored: 25, ignored: false, clamped: true }); expect(await readRecent(db)).toEqual(["recall"]); expect((await readProgress(db)).notes["1.1"]).toBe("good start"); }); });