import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { assembleDiscussPrompt, assemblePlanPrompt, assembleBuildPrompt, assembleArchivePrompt, } from "../../src/core/assembler.ts"; import type { RuneConfig } from "../../src/types.ts"; import { defaultConfig } from "../../src/defaults/config.ts"; const TMP_DIR = join(import.meta.dir, "__tmp_assembler_test__"); beforeEach(async () => { await mkdir(TMP_DIR, { recursive: true }); }); afterEach(async () => { await rm(TMP_DIR, { recursive: true, force: true }); }); describe("assembleDiscussPrompt", () => { it("返回默认 discuss 提示词", () => { const prompt = assembleDiscussPrompt(defaultConfig); expect(prompt).toBeTruthy(); expect(prompt).toContain("探索模式"); expect(prompt).toContain("立场"); expect(prompt).toContain("护栏"); }); it("返回自定义 discuss 提示词", () => { const config: RuneConfig = { stages: { discuss: { prompt: "自定义讨论" } }, }; const prompt = assembleDiscussPrompt(config); expect(prompt).toBe("自定义讨论"); }); }); describe("assemblePlanPrompt", () => { it("包含指定文档名称和提示词", async () => { const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design"); expect(prompt).toContain("user-auth"); expect(prompt).toContain("design"); expect(prompt).not.toContain("task"); }); it("已有文档时引导 AI 读取而非内嵌内容", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "design.md"), "# 已有设计"); const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design"); expect(prompt).toContain("已有内容"); expect(prompt).toContain("design.md"); expect(prompt).not.toContain("# 已有设计"); }); it("包含格式模板(纯静态文本)", async () => { const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design"); expect(prompt).toContain("格式模板"); expect(prompt).toContain("# 设计文档"); expect(prompt).not.toContain("{{change-name}}"); }); it("包含依赖说明(有依赖时)", async () => { const config: RuneConfig = { stages: { plan: { documents: [ { name: "design", prompt: "生成设计" }, { name: "task", prompt: "生成任务", depend: ["design"] }, ], }, }, }; const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task"); expect(prompt).toContain("依赖说明"); expect(prompt).toContain("design.md"); }); it("无依赖时不包含依赖说明", async () => { const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design"); expect(prompt).not.toContain("依赖说明"); }); it("依赖说明标注完成状态", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "design.md"), "# 设计"); const config: RuneConfig = { stages: { plan: { documents: [ { name: "design", prompt: "生成设计" }, { name: "task", prompt: "生成任务", depend: ["design"] }, ], }, }, }; const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task"); expect(prompt).toContain("已完成"); }); it("使用自定义 plan 配置", async () => { const config: RuneConfig = { stages: { plan: { documents: [ { name: "spec", prompt: "生成规格", template: "# 规格文档", }, ], }, }, }; const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature", "spec"); expect(prompt).toContain("spec"); expect(prompt).toContain("规格文档"); expect(prompt).not.toContain("design"); }); }); describe("assembleBuildPrompt", () => { it("引导 AI 读取 task.md 而非内嵌任务内容", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`); const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth"); expect(prompt).toContain("task.md"); expect(prompt).toContain("2"); expect(prompt).not.toContain("任务二"); expect(prompt).not.toContain("任务三"); }); it("所有任务完成时提示可归档", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [x] 任务二`); const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth"); expect(prompt).toContain("已完成"); expect(prompt).toContain("归档"); }); it("task.md 不存在时抛出 CommandError 并附带提示", async () => { try { await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"); expect.unreachable(); } catch (e: any) { expect(e.message).toContain("尚未完成规划"); expect(e.message).toContain("nonexistent"); expect(e.hint).toContain("plan nonexistent"); } }); it("tracked=false 时只输出通用提示词", async () => { const config: RuneConfig = { stages: { build: { prompt: "按规划文档逐步实现功能" } }, metadata: { tracked: false }, }; const prompt = await assembleBuildPrompt(config, TMP_DIR, "user-auth"); expect(prompt).toBe("按规划文档逐步实现功能"); }); it("tracked=true 且 task.md 格式不合法时抛错", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), "# 标题\n无 checkbox"); const config: RuneConfig = { stages: { build: { prompt: "构建阶段" } }, metadata: { tracked: true }, }; try { await assembleBuildPrompt(config, TMP_DIR, "user-auth"); expect.unreachable(); } catch (e: any) { expect(e.message).toContain("task.md"); expect(e.message).toContain("checkbox"); } }); it("tracked=true 且 task.md 有空 checkbox 文本时抛错", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), "- [ ] \n- [x] 有内容"); const config: RuneConfig = { stages: { build: { prompt: "构建阶段" } }, metadata: { tracked: true }, }; try { await assembleBuildPrompt(config, TMP_DIR, "user-auth"); expect.unreachable(); } catch (e: any) { expect(e.message).toContain("checkbox"); } }); }); describe("assembleArchivePrompt", () => { it("返回归档提示词", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); const prompt = await assembleArchivePrompt(defaultConfig, TMP_DIR, "user-auth"); expect(prompt).toContain("user-auth"); expect(prompt).toContain("归档"); }); it("tracked=false 时不读取 task.md,只输出通用提示词", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务"); const config: RuneConfig = { stages: { archive: { prompt: "确认归档" } }, metadata: { tracked: false }, }; const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth"); expect(prompt).toContain("确认归档"); expect(prompt).not.toContain("未完成"); }); it("tracked=true 时引导 AI 检查 task.md 而非内嵌任务内容", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务"); const config: RuneConfig = { stages: { archive: { prompt: "归档阶段" } }, metadata: { tracked: true }, }; const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth"); expect(prompt).toContain("task.md"); expect(prompt).toContain("未完成"); expect(prompt).not.toContain("- [ ] 未完成任务"); }); it("tracked=true 且所有任务完成时不注入警告", async () => { const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); await mkdir(changeDir, { recursive: true }); await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务"); const config: RuneConfig = { stages: { archive: { prompt: "归档阶段" } }, metadata: { tracked: true }, }; const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth"); expect(prompt).not.toContain("未完成"); }); }); describe("命令前缀替换", () => { it("assembleDiscussPrompt 替换 rune 为配置前缀", () => { const config: RuneConfig = { stages: { discuss: { prompt: "执行 rune status 查看状态" } }, metadata: { command: "bunx @lanyuanxiaoyao/rune" }, }; const prompt = assembleDiscussPrompt(config); expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status"); expect(prompt).not.toContain("执行 rune "); }); it("assembleDiscussPrompt 无配置时追加降级说明", () => { const config: RuneConfig = { stages: { discuss: { prompt: "执行 rune status 查看" } }, }; const prompt = assembleDiscussPrompt(config); expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status"); expect(prompt).toContain("如果没有安装 bun"); }); it("assembleBuildPrompt 错误提示使用动态前缀", async () => { const config: RuneConfig = { stages: { build: { prompt: "构建阶段" } }, metadata: { command: "pnpx @lanyuanxiaoyao/rune", tracked: true }, }; try { await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build"); expect.unreachable(); } catch (e: any) { expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune plan nonexistent-build"); } }); it("不替换 /rune- 形式", () => { const config: RuneConfig = { stages: { discuss: { prompt: "使用 /rune-plan 进入规划,然后 rune build 构建" } }, metadata: { command: "bunx @lanyuanxiaoyao/rune" }, }; const prompt = assembleDiscussPrompt(config); expect(prompt).toContain("/rune-plan"); expect(prompt).toContain("bunx @lanyuanxiaoyao/rune build"); }); });