222 lines
8.5 KiB
TypeScript
222 lines
8.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, writeFile, rm, rename } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { runInit } from "../../src/commands/init.ts";
|
|
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
|
import {
|
|
assembleDiscussPrompt,
|
|
assemblePlanPrompt,
|
|
assembleBuildPrompt,
|
|
assembleArchivePrompt,
|
|
} from "../../src/core/assembler.ts";
|
|
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
|
|
|
const TMP_DIR = join(import.meta.dir, "__tmp_flow_test__");
|
|
|
|
beforeEach(async () => {
|
|
await mkdir(TMP_DIR, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(TMP_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("完整 SDD 流程", () => {
|
|
it("init → discuss → plan → build → archive 完整流程", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
|
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
|
|
|
const config = await loadConfig(TMP_DIR);
|
|
const discussPrompt = assembleDiscussPrompt(config);
|
|
expect(discussPrompt).toContain("探索模式");
|
|
|
|
const changeName = "user-auth";
|
|
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
|
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName, "design");
|
|
expect(planPrompt).toContain("user-auth");
|
|
|
|
const changeDir = getChangeDir(TMP_DIR, changeName);
|
|
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
|
|
await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
|
|
|
|
const changes = await scanChanges(TMP_DIR, config);
|
|
expect(changes).toHaveLength(1);
|
|
expect(changes[0].name).toBe("user-auth");
|
|
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
|
|
expect(changes[0].planCompleted).toBe(true);
|
|
expect(changes[0].buildUnlocked).toBe(true);
|
|
expect(changes[0].documents.length).toBeGreaterThan(0);
|
|
|
|
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
|
expect(buildPrompt).toContain("实现登录 API");
|
|
expect(buildPrompt).toContain("共 2 项");
|
|
|
|
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
|
|
|
|
const updatedChanges = await scanChanges(TMP_DIR, config);
|
|
expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 });
|
|
expect(updatedChanges[0].planCompleted).toBe(true);
|
|
expect(updatedChanges[0].buildUnlocked).toBe(true);
|
|
|
|
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
|
expect(buildPrompt2).toContain("已完成");
|
|
|
|
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
|
|
expect(archivePrompt).toContain("归档");
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const src = getChangeDir(TMP_DIR, changeName);
|
|
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
|
await mkdir(join(TMP_DIR, ".rune", "archive"), { recursive: true });
|
|
await rename(src, dest);
|
|
|
|
expect(existsSync(dest)).toBe(true);
|
|
expect(existsSync(src)).toBe(false);
|
|
|
|
const archives = await scanArchives(TMP_DIR);
|
|
expect(archives).toContain(`${today}-${changeName}`);
|
|
|
|
const postArchiveChanges = await scanChanges(TMP_DIR, config);
|
|
expect(postArchiveChanges).toHaveLength(0);
|
|
});
|
|
|
|
it("多变更并行", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
for (const name of ["auth", "payment"]) {
|
|
const changeDir = getChangeDir(TMP_DIR, name);
|
|
await mkdir(changeDir, { recursive: true });
|
|
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
|
|
}
|
|
|
|
const changes = await scanChanges(TMP_DIR, config);
|
|
expect(changes).toHaveLength(2);
|
|
|
|
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
|
|
expect(authPrompt).toContain("auth 任务");
|
|
|
|
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
|
|
expect(paymentPrompt).toContain("payment 任务");
|
|
});
|
|
|
|
it("自定义配置覆盖默认配置", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
|
|
await writeFile(
|
|
join(TMP_DIR, ".rune", "config.yaml"),
|
|
`metadata:
|
|
tracked: false
|
|
stages:
|
|
discuss:
|
|
prompt: 自定义讨论
|
|
plan:
|
|
documents:
|
|
- name: spec
|
|
prompt: 生成规格文档
|
|
template: "# {{change-name}} 规格"
|
|
`,
|
|
);
|
|
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
const discussPrompt = assembleDiscussPrompt(config);
|
|
expect(discussPrompt).toBe("自定义讨论");
|
|
|
|
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
|
|
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test", "spec");
|
|
expect(planPrompt).toContain("spec");
|
|
expect(planPrompt).toContain("test 规格");
|
|
expect(planPrompt).not.toContain("design");
|
|
|
|
expect(config.stages.build).toBeDefined();
|
|
expect(config.stages.archive).toBeDefined();
|
|
});
|
|
|
|
it("scanChanges 返回文档依赖状态", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
const changeDir = getChangeDir(TMP_DIR, "dep-test");
|
|
await mkdir(changeDir, { recursive: true });
|
|
await writeFile(join(changeDir, "design.md"), "# 设计文档");
|
|
|
|
const changes = await scanChanges(TMP_DIR, config);
|
|
expect(changes).toHaveLength(1);
|
|
|
|
const designDoc = changes[0].documents.find((d) => d.name === "design");
|
|
expect(designDoc).toBeDefined();
|
|
expect(designDoc!.completed).toBe(true);
|
|
|
|
const taskDoc = changes[0].documents.find((d) => d.name === "task");
|
|
expect(taskDoc).toBeDefined();
|
|
expect(taskDoc!.completed).toBe(false);
|
|
expect(taskDoc!.dependMet).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("变更名校验", () => {
|
|
it("合法变更名(中文、英文、短横线)通过校验", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
await mkdir(getChangeDir(TMP_DIR, "用户-login"), { recursive: true });
|
|
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "design.md"), "# 设计");
|
|
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "task.md"), "- [ ] 任务");
|
|
const prompt = await assemblePlanPrompt(config, TMP_DIR, "用户-login", "design");
|
|
expect(prompt).toContain("用户-login");
|
|
});
|
|
|
|
it("非法变更名(空格、下划线、特殊符号)被拒绝", () => {
|
|
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
|
|
expect(validRegex.test("my change")).toBe(false);
|
|
expect(validRegex.test("my_change")).toBe(false);
|
|
expect(validRegex.test("my-change!")).toBe(false);
|
|
expect(validRegex.test("my.change")).toBe(false);
|
|
expect(validRegex.test("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("archive 校验", () => {
|
|
it("task 未全部完成时注入警告提示词", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
const changeDir = getChangeDir(TMP_DIR, "incomplete-task");
|
|
await mkdir(changeDir, { recursive: true });
|
|
await writeFile(join(changeDir, "design.md"), "# 设计");
|
|
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务\n- [ ] 未完成任务");
|
|
|
|
const prompt = await assembleArchivePrompt(config, TMP_DIR, "incomplete-task");
|
|
expect(prompt).toContain("警告");
|
|
expect(prompt).toContain("未完成任务");
|
|
expect(prompt).toContain("是否确认");
|
|
});
|
|
|
|
it("task 全部完成时不注入警告", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
const changeDir = getChangeDir(TMP_DIR, "complete-task");
|
|
await mkdir(changeDir, { recursive: true });
|
|
await writeFile(join(changeDir, "design.md"), "# 设计");
|
|
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
|
|
|
|
const prompt = await assembleArchivePrompt(config, TMP_DIR, "complete-task");
|
|
expect(prompt).not.toContain("警告");
|
|
expect(prompt).toContain("归档阶段");
|
|
});
|
|
|
|
it("task.md 不存在时不追加警告", async () => {
|
|
await runInit(TMP_DIR, ["opencode"]);
|
|
const config = await loadConfig(TMP_DIR);
|
|
|
|
const changeDir = getChangeDir(TMP_DIR, "no-task");
|
|
await mkdir(changeDir, { recursive: true });
|
|
await writeFile(join(changeDir, "design.md"), "# 设计");
|
|
|
|
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task");
|
|
expect(prompt).not.toContain("警告");
|
|
});
|
|
});
|