import { afterAll, beforeAll, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCourseRepository, type CourseRepository } from "@stc/course-format"; import { buildGeneratorPrompt, gatherMaterial, generateDraft, parseFiles, checkHouseStyle, type GenerateRequest } from "../src/generator/generate"; import { createFsDraftSink, type DraftSink } from "../src/generator/draft-sink"; import { fakeProvider } from "./helpers"; const FIXTURES = join(import.meta.dir, "../../../fixtures/courses"); let repo: CourseRepository; let dataDir: string; beforeAll(async () => { repo = await createCourseRepository([FIXTURES]); dataDir = mkdtempSync(join(tmpdir(), "stc-ai-gen-")); }); afterAll(() => rmSync(dataDir, { recursive: true, force: true })); const req = (over: Partial = {}): GenerateRequest => ({ courseId: "sample", kind: "document", style: "pure-snake", topic: "Adding many numbers", sources: [{ kind: "lesson", lessonId: "m2-text" }, { kind: "text", label: "Author notes", text: "Learners confuse sum and count." }], ...over, }); test("Pure Snake style prompt carries the module shape and hint tiers", async () => { const course = repo.get("sample")!; const material = await gatherMaterial(repo, course, req().sources, dataDir); const p = buildGeneratorPrompt(req(), material.text, course); for (const s of ["## Why this module", "## Learning objectives", "## Concepts", "Think about it first, then open the explanation", "From everyday programming", "— nudge", "— stronger hint", "— near-solution", "## Check your understanding", "## In production", "## Further reading", "=== FILE:"]) { expect(p.system).toContain(s); } expect(p.user).toContain("Functions take values and return values."); expect(p.user).toContain("Learners confuse sum and count."); expect(p.user).toContain("course/adding-many-numbers.md"); }); test("Systemic Design style prompt carries the beats", () => { const course = repo.get("sample")!; const p = buildGeneratorPrompt(req({ style: "systemic-design" }), "material", course); const beats = ["## Observe", "## Predict", "## Spec", "## Build", "## Name it", "## Expansion Drop", "## Further reading"]; let last = -1; for (const b of beats) { const i = p.system.indexOf(b); expect(i).toBeGreaterThan(last); last = i; } for (const s of ["Hint 1 — nudge", "Hint 2 — concept", "Hint 3 — pseudo-code", "what this drop measures", "scenarios.md"]) expect(p.system).toContain(s); }); test("exercise and question prompts", () => { const course = repo.get("sample")!; const ex = buildGeneratorPrompt(req({ kind: "exercise" }), "m", course); for (const s of ["starter.js", "tests.js", "solution.js", "export const tests", "fixtures/", "nudge", "stronger hint", "near-solution"]) expect(ex.system + ex.user).toContain(s); const q = buildGeneratorPrompt(req({ kind: "questions" }), "m", course); expect(q.system).toContain("Check your understanding"); expect(q.system).toMatch(/predict/i); expect(q.system).toMatch(/what would break if/i); }); test("parseFiles reads file blocks and rejects unsafe paths", () => { const out = parseFiles("intro\n=== FILE: course/a.md ===\n# A\n=== END FILE ===\n=== FILE: ../escape.md ===\nx\n=== END FILE ===\n=== FILE: /abs.md ===\ny\n=== END FILE ===\n=== FILE: exercises/b/starter.js ===\nexport const b = 1\n=== END FILE ==="); expect(out.files).toEqual([{ path: "course/a.md", content: "# A\n" }, { path: "exercises/b/starter.js", content: "export const b = 1\n" }]); expect(out.warnings.join("\n")).toMatch(/escape\.md/); expect(out.warnings.join("\n")).toMatch(/abs\.md/); expect(parseFiles("no files here").warnings[0]).toMatch(/no files/i); }); test("house style check flags missing tiers", () => { const w = checkHouseStyle(req(), [{ path: "course/x.md", content: "# M\n## Why this module\n" }]); expect(w.some((x) => x.includes("Hints"))).toBe(true); expect(w.some((x) => x.includes("near-solution"))).toBe(true); }); test("generation writes drafts through the sink and never touches the course", async () => { const before = readdirSync(join(FIXTURES, "sample"), { recursive: true }).length; const doc = [ "# Module 3 — Adding many numbers", "## Why this module", "x", "## Learning objectives", "## Concepts", "
Think about it first, then open the explanation", "y", "
", "## Exercises", "## Hints", "
Exercise 3.1 (sum) — nudge
", "
Exercise 3.1 (sum) — stronger hint
", "
Exercise 3.1 (sum) — near-solution (try the others first)
", "## Check your understanding", "1. Predict…", "## In production", "## Further reading", ].join("\n"); const { provider, seen } = fakeProvider([() => ({ text: `Here you go.\n=== FILE: course/adding-many-numbers.md ===\n${doc}\n=== END FILE ===\n` })]); const sink = createFsDraftSink(dataDir); const r = await generateDraft({ provider, repo, req: req(), sink, authorId: "author-1", dataDir }); expect(seen[0]!.tools).toBeUndefined(); expect(r.paths).toEqual(["course/adding-many-numbers.md"]); expect(r.location.startsWith(join(dataDir, "drafts", "sample"))).toBe(true); expect(r.warnings).toEqual([]); const written = readFileSync(join(r.location, "course/adding-many-numbers.md"), "utf8"); expect(written.startsWith("