91 lines
5.8 KiB
TypeScript
91 lines
5.8 KiB
TypeScript
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 } 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> = {}): 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", "<details><summary>Think about it first, then open the explanation</summary>", "y", "</details>",
|
|
"## Exercises", "## Hints", "<details><summary>Exercise 3.1 (sum) — nudge</summary></details>", "<details><summary>Exercise 3.1 (sum) — stronger hint</summary></details>",
|
|
"<details><summary>Exercise 3.1 (sum) — near-solution (try the others first)</summary></details>", "## 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("<!-- Draft generated by STCourses AI")).toBe(true);
|
|
expect(written).toContain("## Why this module");
|
|
const meta = JSON.parse(readFileSync(join(r.location, "DRAFT.json"), "utf8"));
|
|
expect(meta).toMatchObject({ courseId: "sample", authorId: "author-1", published: false });
|
|
expect(readdirSync(join(FIXTURES, "sample"), { recursive: true }).length).toBe(before);
|
|
expect(existsSync(join(FIXTURES, "sample", "course"))).toBe(false);
|
|
});
|