Quellcode durchsuchen

test: 添加 Tool 系统单元测试 (测试计划 01)

覆盖 buildTool、toolMatchesName、findToolByName、getEmptyToolPermissionContext、
filterToolProgressMessages、parseToolPreset、parseGitCommitId、detectGitOperation
共 46 个测试用例全部通过。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude-code-best vor 3 Wochen
Ursprung
Commit
67baea3c7f

+ 201 - 0
src/__tests__/Tool.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test";
+import {
+  buildTool,
+  toolMatchesName,
+  findToolByName,
+  getEmptyToolPermissionContext,
+  filterToolProgressMessages,
+} from "../Tool";
+
+// Minimal tool definition for testing buildTool
+function makeMinimalToolDef(overrides: Record<string, unknown> = {}) {
+  return {
+    name: "TestTool",
+    inputSchema: { type: "object" as const } as any,
+    maxResultSizeChars: 10000,
+    call: async () => ({ data: "ok" }),
+    description: async () => "A test tool",
+    prompt: async () => "test prompt",
+    mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({
+      type: "tool_result" as const,
+      tool_use_id: toolUseID,
+      content: String(content),
+    }),
+    renderToolUseMessage: () => null,
+    ...overrides,
+  };
+}
+
+describe("buildTool", () => {
+  test("fills in default isEnabled as true", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.isEnabled()).toBe(true);
+  });
+
+  test("fills in default isConcurrencySafe as false", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.isConcurrencySafe({})).toBe(false);
+  });
+
+  test("fills in default isReadOnly as false", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.isReadOnly({})).toBe(false);
+  });
+
+  test("fills in default isDestructive as false", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.isDestructive!({})).toBe(false);
+  });
+
+  test("fills in default checkPermissions as allow", async () => {
+    const tool = buildTool(makeMinimalToolDef());
+    const input = { foo: "bar" };
+    const result = await tool.checkPermissions(input, {} as any);
+    expect(result).toEqual({ behavior: "allow", updatedInput: input });
+  });
+
+  test("fills in default userFacingName from tool name", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.userFacingName(undefined)).toBe("TestTool");
+  });
+
+  test("fills in default toAutoClassifierInput as empty string", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.toAutoClassifierInput({})).toBe("");
+  });
+
+  test("preserves explicitly provided methods", () => {
+    const tool = buildTool(
+      makeMinimalToolDef({
+        isEnabled: () => false,
+        isConcurrencySafe: () => true,
+        isReadOnly: () => true,
+      })
+    );
+    expect(tool.isEnabled()).toBe(false);
+    expect(tool.isConcurrencySafe({})).toBe(true);
+    expect(tool.isReadOnly({})).toBe(true);
+  });
+
+  test("preserves all non-defaultable properties", () => {
+    const tool = buildTool(makeMinimalToolDef());
+    expect(tool.name).toBe("TestTool");
+    expect(tool.maxResultSizeChars).toBe(10000);
+    expect(typeof tool.call).toBe("function");
+    expect(typeof tool.description).toBe("function");
+    expect(typeof tool.prompt).toBe("function");
+  });
+});
+
+describe("toolMatchesName", () => {
+  test("returns true for exact name match", () => {
+    expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true);
+  });
+
+  test("returns false for non-matching name", () => {
+    expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false);
+  });
+
+  test("returns true when name matches an alias", () => {
+    expect(
+      toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool")
+    ).toBe(true);
+  });
+
+  test("returns false when aliases is undefined", () => {
+    expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false);
+  });
+
+  test("returns false when aliases is empty", () => {
+    expect(
+      toolMatchesName({ name: "Bash", aliases: [] }, "BashTool")
+    ).toBe(false);
+  });
+});
+
+describe("findToolByName", () => {
+  const mockTools = [
+    buildTool(makeMinimalToolDef({ name: "Bash" })),
+    buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })),
+    buildTool(makeMinimalToolDef({ name: "Edit" })),
+  ];
+
+  test("finds tool by primary name", () => {
+    const tool = findToolByName(mockTools, "Bash");
+    expect(tool).toBeDefined();
+    expect(tool!.name).toBe("Bash");
+  });
+
+  test("finds tool by alias", () => {
+    const tool = findToolByName(mockTools, "FileRead");
+    expect(tool).toBeDefined();
+    expect(tool!.name).toBe("Read");
+  });
+
+  test("returns undefined when no match", () => {
+    expect(findToolByName(mockTools, "NonExistent")).toBeUndefined();
+  });
+
+  test("returns first match when duplicates exist", () => {
+    const dupeTools = [
+      buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })),
+      buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })),
+    ];
+    const tool = findToolByName(dupeTools, "Bash");
+    expect(tool!.maxResultSizeChars).toBe(100);
+  });
+});
+
+describe("getEmptyToolPermissionContext", () => {
+  test("returns default permission mode", () => {
+    const ctx = getEmptyToolPermissionContext();
+    expect(ctx.mode).toBe("default");
+  });
+
+  test("returns empty maps and arrays", () => {
+    const ctx = getEmptyToolPermissionContext();
+    expect(ctx.additionalWorkingDirectories.size).toBe(0);
+    expect(ctx.alwaysAllowRules).toEqual({});
+    expect(ctx.alwaysDenyRules).toEqual({});
+    expect(ctx.alwaysAskRules).toEqual({});
+  });
+
+  test("returns isBypassPermissionsModeAvailable as false", () => {
+    const ctx = getEmptyToolPermissionContext();
+    expect(ctx.isBypassPermissionsModeAvailable).toBe(false);
+  });
+});
+
+describe("filterToolProgressMessages", () => {
+  test("filters out hook_progress messages", () => {
+    const messages = [
+      { data: { type: "hook_progress", hookName: "pre" } },
+      { data: { type: "tool_progress", toolName: "Bash" } },
+    ] as any[];
+    const result = filterToolProgressMessages(messages);
+    expect(result).toHaveLength(1);
+    expect((result[0]!.data as any).type).toBe("tool_progress");
+  });
+
+  test("keeps tool progress messages", () => {
+    const messages = [
+      { data: { type: "tool_progress", toolName: "Bash" } },
+      { data: { type: "tool_progress", toolName: "Read" } },
+    ] as any[];
+    const result = filterToolProgressMessages(messages);
+    expect(result).toHaveLength(2);
+  });
+
+  test("returns empty array for empty input", () => {
+    expect(filterToolProgressMessages([])).toEqual([]);
+  });
+
+  test("handles messages without type field", () => {
+    const messages = [
+      { data: { toolName: "Bash" } },
+      { data: { type: "hook_progress" } },
+    ] as any[];
+    const result = filterToolProgressMessages(messages);
+    expect(result).toHaveLength(1);
+  });
+});

+ 24 - 0
src/__tests__/tools.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test";
+import { parseToolPreset } from "../tools";
+
+describe("parseToolPreset", () => {
+  test('returns "default" for "default" input', () => {
+    expect(parseToolPreset("default")).toBe("default");
+  });
+
+  test('returns "default" for "Default" input (case-insensitive)', () => {
+    expect(parseToolPreset("Default")).toBe("default");
+  });
+
+  test("returns null for unknown preset", () => {
+    expect(parseToolPreset("unknown")).toBeNull();
+  });
+
+  test("returns null for empty string", () => {
+    expect(parseToolPreset("")).toBeNull();
+  });
+
+  test("returns null for random string", () => {
+    expect(parseToolPreset("custom-preset")).toBeNull();
+  });
+});

+ 134 - 0
src/tools/shared/__tests__/gitOperationTracking.test.ts

@@ -0,0 +1,134 @@
+import { describe, expect, test } from "bun:test";
+import { parseGitCommitId, detectGitOperation } from "../gitOperationTracking";
+
+describe("parseGitCommitId", () => {
+  test("extracts commit hash from git commit output", () => {
+    expect(parseGitCommitId("[main abc1234] fix: some message")).toBe("abc1234");
+  });
+
+  test("extracts hash from root commit output", () => {
+    expect(
+      parseGitCommitId("[main (root-commit) abc1234] initial commit")
+    ).toBe("abc1234");
+  });
+
+  test("returns undefined for non-commit output", () => {
+    expect(parseGitCommitId("nothing to commit")).toBeUndefined();
+  });
+
+  test("handles various branch name formats", () => {
+    expect(parseGitCommitId("[feature/foo abc1234] message")).toBe("abc1234");
+    expect(parseGitCommitId("[fix/bar-baz abc1234] message")).toBe("abc1234");
+    expect(parseGitCommitId("[v1.0.0 abc1234] message")).toBe("abc1234");
+  });
+
+  test("returns undefined for empty string", () => {
+    expect(parseGitCommitId("")).toBeUndefined();
+  });
+});
+
+describe("detectGitOperation", () => {
+  test("detects git commit operation", () => {
+    const result = detectGitOperation(
+      "git commit -m 'fix bug'",
+      "[main abc1234] fix bug"
+    );
+    expect(result.commit).toBeDefined();
+    expect(result.commit!.sha).toBe("abc123");
+    expect(result.commit!.kind).toBe("committed");
+  });
+
+  test("detects git commit --amend operation", () => {
+    const result = detectGitOperation(
+      "git commit --amend -m 'updated'",
+      "[main def5678] updated"
+    );
+    expect(result.commit).toBeDefined();
+    expect(result.commit!.kind).toBe("amended");
+  });
+
+  test("detects git cherry-pick operation", () => {
+    const result = detectGitOperation(
+      "git cherry-pick abc1234",
+      "[main def5678] cherry picked commit"
+    );
+    expect(result.commit).toBeDefined();
+    expect(result.commit!.kind).toBe("cherry-picked");
+  });
+
+  test("detects git push operation", () => {
+    const result = detectGitOperation(
+      "git push origin main",
+      "   abc1234..def5678  main -> main"
+    );
+    expect(result.push).toBeDefined();
+    expect(result.push!.branch).toBe("main");
+  });
+
+  test("detects git merge operation", () => {
+    const result = detectGitOperation(
+      "git merge feature-branch",
+      "Merge made by the 'ort' strategy."
+    );
+    expect(result.branch).toBeDefined();
+    expect(result.branch!.action).toBe("merged");
+    expect(result.branch!.ref).toBe("feature-branch");
+  });
+
+  test("detects git rebase operation", () => {
+    const result = detectGitOperation(
+      "git rebase main",
+      "Successfully rebased and updated refs/heads/feature."
+    );
+    expect(result.branch).toBeDefined();
+    expect(result.branch!.action).toBe("rebased");
+    expect(result.branch!.ref).toBe("main");
+  });
+
+  test("returns null for non-git commands", () => {
+    const result = detectGitOperation("ls -la", "total 100\ndrwxr-xr-x");
+    expect(result.commit).toBeUndefined();
+    expect(result.push).toBeUndefined();
+    expect(result.branch).toBeUndefined();
+    expect(result.pr).toBeUndefined();
+  });
+
+  test("detects gh pr create operation", () => {
+    const result = detectGitOperation(
+      "gh pr create --title 'fix' --body 'desc'",
+      "https://github.com/owner/repo/pull/42"
+    );
+    expect(result.pr).toBeDefined();
+    expect(result.pr!.number).toBe(42);
+    expect(result.pr!.action).toBe("created");
+  });
+
+  test("detects gh pr merge operation", () => {
+    const result = detectGitOperation(
+      "gh pr merge 42",
+      "✓ Merged pull request owner/repo#42"
+    );
+    expect(result.pr).toBeDefined();
+    expect(result.pr!.number).toBe(42);
+    expect(result.pr!.action).toBe("merged");
+  });
+
+  test("handles git commit with -c options", () => {
+    const result = detectGitOperation(
+      "git -c commit.gpgsign=false commit -m 'msg'",
+      "[main aaa1111] msg"
+    );
+    expect(result.commit).toBeDefined();
+    expect(result.commit!.sha).toBe("aaa111");
+  });
+
+  test("detects fast-forward merge", () => {
+    const result = detectGitOperation(
+      "git merge develop",
+      "Fast-forward\n file.txt | 1 +\n 1 file changed"
+    );
+    expect(result.branch).toBeDefined();
+    expect(result.branch!.action).toBe("merged");
+    expect(result.branch!.ref).toBe("develop");
+  });
+});