refactor: 修复代码审查发现的问题
- Bug修复: formatChangeStatus 使用实际配置而非 defaultConfig - 统一 assembler 中所有错误抛出为 CommandError - 提取 writeIfChanged 到 adapters/utils.ts,消除 claude-code/opencode 重复代码 - 导出 SUPPORTED_TOOLS,cli.ts update 命令复用同一工具注册表 - 提取 mapError/mapCacError 函数,支持单元测试 - 补充 claude-code 适配器测试(10 个用例) - 补充 validateChangeName、formatChangeStatus、suggestNextStep、mapError 单元测试(18 个用例) - 共新增 3 个测试文件,测试从 96 增至 133,全部通过
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { STAGES } from "../types.ts";
|
||||
import { writeIfChanged } from "./utils.ts";
|
||||
|
||||
const COMMANDS_DIR = ".claude/commands";
|
||||
|
||||
@@ -50,21 +51,9 @@ export async function updateClaudeCode(projectRoot: string): Promise<void> {
|
||||
? "\n如果用户没有指定变更名称,请向用户确认。"
|
||||
: "";
|
||||
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`;
|
||||
await writeIfChangedClaude(commandPath, newContent);
|
||||
await writeIfChanged(commandPath, newContent);
|
||||
}
|
||||
|
||||
const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md");
|
||||
await writeIfChangedClaude(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
async function writeIfChangedClaude(filePath: string, newContent: string): Promise<void> {
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
if (existing === newContent) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 文件不存在,创建
|
||||
}
|
||||
await writeFile(filePath, newContent);
|
||||
await writeIfChanged(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { STAGES } from "../types.ts";
|
||||
|
||||
@@ -66,17 +66,7 @@ export async function updateOpenCode(projectRoot: string): Promise<void> {
|
||||
await writeIfChanged(statusSkillPath, generateStatusSkill());
|
||||
}
|
||||
|
||||
async function writeIfChanged(filePath: string, newContent: string): Promise<void> {
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
if (existing === newContent) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 文件不存在,创建
|
||||
}
|
||||
await writeFile(filePath, newContent);
|
||||
}
|
||||
import { writeIfChanged } from "./utils.ts";
|
||||
|
||||
function generateCommand(stage: string, hasChangeName: boolean): string {
|
||||
if (hasChangeName) {
|
||||
|
||||
13
src/adapters/utils.ts
Normal file
13
src/adapters/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
export async function writeIfChanged(filePath: string, newContent: string): Promise<void> {
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
if (existing === newContent) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 文件不存在,创建
|
||||
}
|
||||
await writeFile(filePath, newContent);
|
||||
}
|
||||
73
src/cli.ts
73
src/cli.ts
@@ -15,8 +15,8 @@ import { scanChanges } from "./core/scanner.ts";
|
||||
import { UsageError, ConfigError, CommandError, InternalError, CliError } from "./cli/errors.ts";
|
||||
import { printError } from "./cli/output.ts";
|
||||
import { showGlobalHelp, showCommandHelp } from "./cli/help.ts";
|
||||
import type { ChangeStatus } from "./types.ts";
|
||||
import { defaultConfig } from "./defaults/config.ts";
|
||||
import type { ChangeStatus, RuneConfig } from "./types.ts";
|
||||
|
||||
|
||||
function requireProjectRoot(): string {
|
||||
const root = findProjectRoot();
|
||||
@@ -26,7 +26,7 @@ function requireProjectRoot(): string {
|
||||
return root;
|
||||
}
|
||||
|
||||
function validateChangeName(name: string): void {
|
||||
export function validateChangeName(name: string): void {
|
||||
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
|
||||
throw new CommandError(
|
||||
`变更名 "${name}" 包含不支持的字符`,
|
||||
@@ -35,16 +35,17 @@ function validateChangeName(name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function formatChangeStatus(change: ChangeStatus): string {
|
||||
export function formatChangeStatus(change: ChangeStatus, config?: RuneConfig): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`变更:${change.name}`);
|
||||
|
||||
lines.push(" 规划阶段:");
|
||||
const planDocs = config?.stages.plan?.documents;
|
||||
for (const doc of change.documents) {
|
||||
if (doc.completed) {
|
||||
lines.push(` ${doc.name}.md ✓ 已完成`);
|
||||
} else {
|
||||
const docConfig = defaultConfig.stages.plan?.documents.find((d) => d.name === doc.name);
|
||||
const docConfig = planDocs?.find((d) => d.name === doc.name);
|
||||
const depInfo = !doc.dependMet && docConfig?.depend?.length
|
||||
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")})`
|
||||
: "";
|
||||
@@ -71,7 +72,7 @@ function formatChangeStatus(change: ChangeStatus): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function suggestNextStep(change: ChangeStatus): string {
|
||||
export function suggestNextStep(change: ChangeStatus): string {
|
||||
if (!change.planCompleted) {
|
||||
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
|
||||
if (nextDoc) {
|
||||
@@ -140,19 +141,20 @@ cli.command("update [...tools]", "更新已注入的工具配置").action(
|
||||
const root = requireProjectRoot();
|
||||
const { updateOpenCode } = await import("./adapters/opencode.ts");
|
||||
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
|
||||
const validators: Record<string, (root: string) => Promise<void>> = {
|
||||
const { SUPPORTED_TOOLS } = await import("./commands/init.ts");
|
||||
const updaters: Record<string, (root: string) => Promise<void>> = {
|
||||
opencode: updateOpenCode,
|
||||
"claude-code": updateClaudeCode,
|
||||
};
|
||||
for (const tool of tools) {
|
||||
if (!validators[tool]) {
|
||||
if (!SUPPORTED_TOOLS[tool]) {
|
||||
throw new CommandError(`不支持的工具: ${tool}`, {
|
||||
hint: `支持的工具: ${Object.keys(validators).join(", ")}`,
|
||||
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tool of tools) {
|
||||
await validators[tool](root);
|
||||
await updaters[tool](root);
|
||||
}
|
||||
console.log(`工具配置已更新:${tools.join(", ")}`);
|
||||
},
|
||||
@@ -252,51 +254,66 @@ cli.command("status [change-name]", "查看变更状态").action(
|
||||
hint: "运行 rune status 查看所有变更",
|
||||
});
|
||||
}
|
||||
console.log(formatChangeStatus(change));
|
||||
console.log(formatChangeStatus(change, config));
|
||||
} else {
|
||||
if (changes.length === 0) {
|
||||
console.log("当前无进行中的变更。");
|
||||
return;
|
||||
}
|
||||
for (const change of changes) {
|
||||
console.log(formatChangeStatus(change));
|
||||
console.log(formatChangeStatus(change, config));
|
||||
console.log("---\n");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleError(e: unknown): never {
|
||||
export function mapError(e: unknown): CliError {
|
||||
if (e instanceof CliError) {
|
||||
printError(e);
|
||||
} else if (e instanceof Error && e.message.includes("Unknown option")) {
|
||||
return e;
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
const err = mapCacError(e);
|
||||
if (err) return err;
|
||||
}
|
||||
return new InternalError();
|
||||
}
|
||||
|
||||
function mapCacError(e: Error): CliError | null {
|
||||
if (e.message.includes("Unknown option")) {
|
||||
const match = e.message.match(/Unknown option `([^`]+)`/);
|
||||
const flag = match ? match[1] : "未知选项";
|
||||
printError(new UsageError(`未知选项: ${flag}`, {
|
||||
return new UsageError(`未知选项: ${flag}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("Unknown command")) {
|
||||
});
|
||||
}
|
||||
if (e.message.includes("Unknown command")) {
|
||||
const match = e.message.match(/Unknown command `([^`]+)`/);
|
||||
const cmd = match ? match[1] : "未知命令";
|
||||
printError(new UsageError(`未知命令: ${cmd}`, {
|
||||
return new UsageError(`未知命令: ${cmd}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("Unused args")) {
|
||||
});
|
||||
}
|
||||
if (e.message.includes("Unused args")) {
|
||||
const match = e.message.match(/Unused args: (.+)/);
|
||||
const args = match ? match[1] : "未知参数";
|
||||
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
|
||||
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("missing required args")) {
|
||||
});
|
||||
}
|
||||
if (e.message.includes("missing required args")) {
|
||||
const match = e.message.match(/command `(\w+)/);
|
||||
const cmd = match ? match[1] : "未知命令";
|
||||
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
|
||||
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
|
||||
usage: `rune ${cmd} <change-name>`,
|
||||
hint: `运行 rune help ${cmd} 查看用法`,
|
||||
}));
|
||||
} else {
|
||||
printError(new InternalError());
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleError(e: unknown): never {
|
||||
printError(mapError(e));
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# # {{change-name}} 任务清单
|
||||
`;
|
||||
|
||||
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
|
||||
export const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
|
||||
opencode: injectOpenCode,
|
||||
"claude-code": injectClaudeCode,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,9 @@ import { parseTasks } from "./task-parser.ts";
|
||||
|
||||
export function assembleDiscussPrompt(config: RuneConfig): string {
|
||||
const discuss = config.stages.discuss;
|
||||
if (!discuss) throw new Error("discuss 阶段未配置");
|
||||
if (!discuss) throw new CommandError("讨论阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
|
||||
});
|
||||
return discuss.prompt;
|
||||
}
|
||||
|
||||
@@ -19,11 +21,15 @@ export async function assemblePlanPrompt(
|
||||
documentName: string,
|
||||
): Promise<string> {
|
||||
const plan = config.stages.plan;
|
||||
if (!plan) throw new Error("plan 阶段未配置");
|
||||
if (!plan) throw new CommandError("规划阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.plan",
|
||||
});
|
||||
|
||||
const doc = plan.documents.find((d) => d.name === documentName);
|
||||
if (!doc) {
|
||||
throw new Error(`文档 "${documentName}" 不在配置的 plan.documents 中`);
|
||||
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
|
||||
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
@@ -117,7 +123,9 @@ export async function assembleArchivePrompt(
|
||||
changeName: string,
|
||||
): Promise<string> {
|
||||
const archive = config.stages.archive;
|
||||
if (!archive) throw new Error("archive 阶段未配置");
|
||||
if (!archive) throw new CommandError("归档阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.archive",
|
||||
});
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
|
||||
127
tests/adapters/claude-code.test.ts
Normal file
127
tests/adapters/claude-code.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_claude_code_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("injectClaudeCode", () => {
|
||||
it("生成 discuss、plan、build、archive 的 command 文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||
expect(commands).toContain(`rune-${stage}.md`);
|
||||
}
|
||||
});
|
||||
|
||||
it("生成 rune-status command 文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
expect(commands).toContain("rune-status.md");
|
||||
});
|
||||
|
||||
it("command 文件包含 bash 命令", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
expect(content).toContain("```bash");
|
||||
});
|
||||
|
||||
it("plan/build/archive command 包含变更名称提示", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
for (const stage of ["plan", "build", "archive"]) {
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", `rune-${stage}.md`),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("变更名");
|
||||
expect(content).toContain("如果用户没有指定变更名称");
|
||||
}
|
||||
});
|
||||
|
||||
it("discuss command 不包含变更名称提示", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toContain("如果用户没有指定变更名称");
|
||||
});
|
||||
|
||||
it("重复注入时不覆盖已存在的文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateClaudeCode", () => {
|
||||
it("文件不存在时创建", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(
|
||||
existsSync(join(TMP_DIR, ".claude", "commands", "rune-discuss.md")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("文件存在且内容一致时不覆盖", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
|
||||
it("文件存在但内容不一致时覆盖", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
await writeFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"旧内容",
|
||||
);
|
||||
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toBe("旧内容");
|
||||
expect(content).toContain("rune discuss");
|
||||
});
|
||||
|
||||
it("更新 status 命令", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(
|
||||
existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
62
tests/cli/map-error.test.ts
Normal file
62
tests/cli/map-error.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
CliError,
|
||||
UsageError,
|
||||
ConfigError,
|
||||
CommandError,
|
||||
InternalError,
|
||||
} from "../../src/cli/errors.ts";
|
||||
import { mapError } from "../../src/cli.ts";
|
||||
|
||||
describe("mapError", () => {
|
||||
it("CliError 原样返回", () => {
|
||||
const err = new ConfigError("未初始化");
|
||||
const result = mapError(err);
|
||||
expect(result).toBe(err);
|
||||
expect(result.message).toBe("未初始化");
|
||||
});
|
||||
|
||||
it("Unknown option 转为 UsageError", () => {
|
||||
const err = new Error("Unknown option `--badflag`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("未知选项: --badflag");
|
||||
expect(result.hint).toBe("运行 rune help 查看所有命令");
|
||||
});
|
||||
|
||||
it("Unknown command 转为 UsageError", () => {
|
||||
const err = new Error("Unknown command `foo`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("未知命令: foo");
|
||||
});
|
||||
|
||||
it("Unused args 转为 UsageError", () => {
|
||||
const err = new Error("Unused args: --extra");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toContain("未知命令");
|
||||
expect(result.message).toContain("--extra");
|
||||
});
|
||||
|
||||
it("missing required args 转为 UsageError", () => {
|
||||
const err = new Error("missing required args for command `plan`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("命令 'plan' 缺少必填参数");
|
||||
expect(result.usage).toBe("rune plan <change-name>");
|
||||
expect(result.hint).toContain("rune help plan");
|
||||
});
|
||||
|
||||
it("未知 Error 转为 InternalError", () => {
|
||||
const err = new Error("something unexpected");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(InternalError);
|
||||
expect(result.message).toBe("发生了未预期的错误");
|
||||
});
|
||||
|
||||
it("非 Error 类型转为 InternalError", () => {
|
||||
const result = mapError("字符串错误");
|
||||
expect(result).toBeInstanceOf(InternalError);
|
||||
});
|
||||
});
|
||||
156
tests/cli/status.test.ts
Normal file
156
tests/cli/status.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { formatChangeStatus, suggestNextStep } from "../../src/cli.ts";
|
||||
import type { ChangeStatus, RuneConfig } from "../../src/types.ts";
|
||||
|
||||
function makeStatus(overrides: Partial<ChangeStatus> = {}): ChangeStatus {
|
||||
return {
|
||||
name: "test-change",
|
||||
documents: [],
|
||||
planCompleted: false,
|
||||
buildUnlocked: false,
|
||||
taskProgress: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatChangeStatus", () => {
|
||||
it("显示变更名", () => {
|
||||
const output = formatChangeStatus(makeStatus());
|
||||
expect(output).toContain("test-change");
|
||||
});
|
||||
|
||||
it("显示已完成和待完成文档", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: false, dependMet: true },
|
||||
],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("design.md ✓ 已完成");
|
||||
expect(output).toContain("task.md ○ 待完成");
|
||||
});
|
||||
|
||||
it("显示文档依赖信息(dependMet 为 false 且 config 中有依赖)", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "task", completed: false, dependMet: false },
|
||||
],
|
||||
});
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const output = formatChangeStatus(status, config);
|
||||
expect(output).toContain("依赖 design.md");
|
||||
});
|
||||
|
||||
it("dependMet 为 false 但无 config 时不显示文档依赖信息", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "task", completed: false, dependMet: false },
|
||||
],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).not.toContain("(依赖");
|
||||
});
|
||||
|
||||
it("显示规划进度", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: false, dependMet: true },
|
||||
],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("1/2 文档已完成");
|
||||
});
|
||||
|
||||
it("规划完成时显示构建已解锁", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
buildUnlocked: true,
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("已解锁");
|
||||
});
|
||||
|
||||
it("显示任务进度", () => {
|
||||
const status = makeStatus({
|
||||
taskProgress: { completed: 3, total: 5 },
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("3/5 已完成");
|
||||
});
|
||||
|
||||
it("包含下一步建议", () => {
|
||||
const output = formatChangeStatus(makeStatus());
|
||||
expect(output).toContain("建议下一步");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestNextStep", () => {
|
||||
it("规划未完成时返回下一个可规划文档", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: false, dependMet: true },
|
||||
],
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune plan test-change design");
|
||||
});
|
||||
|
||||
it("规划未完成且依赖未满足时提示完成前置依赖", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: false, dependMet: false },
|
||||
],
|
||||
});
|
||||
expect(suggestNextStep(status)).toBe("完成前置依赖后再规划文档");
|
||||
});
|
||||
|
||||
it("规划完成且有未完成任务时建议 build", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: { completed: 2, total: 5 },
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune build test-change");
|
||||
});
|
||||
|
||||
it("任务全部完成时建议 archive", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: { completed: 5, total: 5 },
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune archive test-change");
|
||||
});
|
||||
|
||||
it("规划完成但无 taskProgress 时建议 build", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: null,
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune build test-change");
|
||||
});
|
||||
});
|
||||
37
tests/cli/validate.test.ts
Normal file
37
tests/cli/validate.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { validateChangeName } from "../../src/cli.ts";
|
||||
import { CommandError } from "../../src/cli/errors.ts";
|
||||
|
||||
describe("validateChangeName", () => {
|
||||
it("英文名通过", () => {
|
||||
expect(() => validateChangeName("user-auth")).not.toThrow();
|
||||
expect(() => validateChangeName("addLogin")).not.toThrow();
|
||||
});
|
||||
|
||||
it("中文名通过", () => {
|
||||
expect(() => validateChangeName("用户登录")).not.toThrow();
|
||||
expect(() => validateChangeName("修复内存泄漏")).not.toThrow();
|
||||
});
|
||||
|
||||
it("中英混合通过", () => {
|
||||
expect(() => validateChangeName("用户-login")).not.toThrow();
|
||||
});
|
||||
|
||||
it("空格不通过", () => {
|
||||
expect(() => validateChangeName("my change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("下划线不通过", () => {
|
||||
expect(() => validateChangeName("my_change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("特殊符号不通过", () => {
|
||||
expect(() => validateChangeName("my-change!")).toThrow(CommandError);
|
||||
expect(() => validateChangeName("my.change")).toThrow(CommandError);
|
||||
expect(() => validateChangeName("my@change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("空字符串不通过", () => {
|
||||
expect(() => validateChangeName("")).toThrow(CommandError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user