Retrieval now reads <dataDir>/cache/decks/<courseId>/<deckId>/text.json and
<dataDir>/cache/transcripts/<courseId>/<lessonId>.json (skipped when the lesson
has a transcript file). DraftSink returns { location?, branch?, commit? }, so
the studio's createDraftWriter(ctx) needs no adapter; generateDraft computes
paths itself and reports the draft branch.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
113 lines
7.4 KiB
TypeScript
113 lines
7.4 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, 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> = {}): 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);
|
|
});
|
|
|
|
// The studio's DraftWriter (`createDraftWriter(ctx)` in @stc/feature-studio), reproduced structurally: it commits
|
|
// to `draft/<authorId>` and returns { branch, commit }. It must be usable as a DraftSink without an adapter.
|
|
interface StudioDraftWriterShape {
|
|
writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>;
|
|
}
|
|
|
|
test("a studio DraftWriter is a DraftSink as is, and the result names the draft branch", async () => {
|
|
const calls: { courseId: string; authorId: string; paths: string[]; message: string }[] = [];
|
|
const writer: StudioDraftWriterShape = {
|
|
async writeDraftFiles(courseId, authorId, files, message) {
|
|
calls.push({ courseId, authorId, paths: files.map((f) => f.path), message });
|
|
return { branch: `draft/${authorId}`, commit: "0123456789abcdef0123456789abcdef01234567" };
|
|
},
|
|
};
|
|
const sink: DraftSink = writer;
|
|
const { provider } = fakeProvider([() => ({ text: "=== FILE: questions/adding.md ===\n## Check your understanding\n=== END FILE ===\n" })]);
|
|
const r = await generateDraft({ provider, repo, req: req({ kind: "questions" }), sink, authorId: "author-1", dataDir });
|
|
expect(calls).toEqual([{ courseId: "sample", authorId: "author-1", paths: ["questions/adding.md"], message: expect.stringContaining("AI draft") }]);
|
|
expect(r).toMatchObject({ branch: "draft/author-1", commit: "0123456789abcdef0123456789abcdef01234567", paths: ["questions/adding.md"] });
|
|
expect(r.location).toBe("draft/author-1 @ 0123456");
|
|
});
|