Files
Rune-Spec/tests/core/assembler.test.ts

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("包含已有文档内容(重复 plan 场景)", 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("在此基础上修订");
});
it("替换模板中的 {{change-name}}", async () => {
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("user-auth 设计文档");
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: "# {{change-name}} 规格",
},
],
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature", "spec");
expect(prompt).toContain("spec");
expect(prompt).toContain("my-feature 规格");
expect(prompt).not.toContain("design");
});
});
describe("assembleBuildPrompt", () => {
it("包含待执行任务列表", 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("任务二");
expect(prompt).toContain("待执行任务");
expect(prompt).toContain("共 2 项");
});
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 时读取 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("未完成");
expect(prompt).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");
});
});