Skip to content

Commit 4f323ef

Browse files
test: Phase 5 — 添加 12 个测试文件 (+209 tests, 1177 total)
新增覆盖: effort, tokenBudget, displayTags, taggedId, controlMessageCompat, MCP normalization/envExpansion, gitConfigParser, formatBriefTimestamp, hyperlink, windowsPaths, notebook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 28e40dd commit 4f323ef

13 files changed

Lines changed: 1560 additions & 2 deletions

docs/testing-spec.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ bun test --watch
300300

301301
## 11. 当前测试覆盖状态
302302

303-
> 更新日期:2026-04-02 | 总计:**968 tests, 52 files, 0 failures**
303+
> 更新日期:2026-04-02 | 总计:**1177 tests, 64 files, 0 failures**
304304
305305
### P0 — 核心模块
306306

@@ -383,6 +383,23 @@ bun test --watch
383383
| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) |
384384
| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) |
385385

386+
### P6 — Phase 5 扩展覆盖
387+
388+
| 测试文件 | 测试数 | 覆盖范围 |
389+
|----------|--------|----------|
390+
| `src/utils/__tests__/tokenBudget.test.ts` | 20 | parseTokenBudget, findTokenBudgetPositions, getBudgetContinuationMessage |
391+
| `src/utils/__tests__/displayTags.test.ts` | 17 | stripDisplayTags, stripDisplayTagsAllowEmpty, stripIdeContextTags |
392+
| `src/utils/__tests__/taggedId.test.ts` | 10 | toTaggedId (prefix/uniqueness/format) |
393+
| `src/utils/__tests__/controlMessageCompat.test.ts` | 15 | normalizeControlMessageKeys (snake_case→camelCase 转换) |
394+
| `src/services/mcp/__tests__/normalization.test.ts` | 11 | normalizeNameForMCP (特殊字符/截断/空字符串/Unicode) |
395+
| `src/services/mcp/__tests__/envExpansion.test.ts` | 14 | expandEnvVarsInString ($VAR/${VAR}/嵌套/未定义/转义) |
396+
| `src/utils/git/__tests__/gitConfigParser.test.ts` | 20 | parseConfigString (key=value/section/subsection/多行/注释/引号) |
397+
| `src/utils/__tests__/formatBriefTimestamp.test.ts` | 10 | formatBriefTimestamp (秒/分/时/天/周/月/年) |
398+
| `src/utils/__tests__/hyperlink.test.ts` | 10 | createHyperlink (OSC 8 序列/file:///path/fallback) |
399+
| `src/utils/__tests__/windowsPaths.test.ts` | 20 | windowsPathToPosixPath, posixPathToWindowsPath (驱动器/UNC/相对路径) |
400+
| `src/utils/__tests__/notebook.test.ts` | 14 | parseCellId, mapNotebookCellsToToolResult (code/markdown/output) |
401+
| `src/utils/__tests__/effort.test.ts` | 38 | isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, resolvePickerEffortPersistence |
402+
386403
### 已知限制
387404

388405
以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试:
@@ -405,14 +422,20 @@ bun test --watch
405422
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts |
406423
| `src/utils/debug.ts` | envValidation.ts, outputLimits.ts |
407424
| `src/utils/bash/commands.ts` | commandSemantics.ts |
425+
| `src/utils/thinking.js` | effort.ts |
426+
| `src/utils/settings/settings.js` | effort.ts |
427+
| `src/utils/auth.js` | effort.ts |
428+
| `src/services/analytics/growthbook.js` | effort.ts, tokenBudget.ts |
429+
| `src/utils/model/modelSupportOverrides.js` | effort.ts |
408430

409431
**关键约束**`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。
410432

411433
## 12. 后续测试覆盖计划
412434

413-
> **已完成**实际增加 321 tests,从 647 → 968 tests / 52 files
435+
> **已完成**Phase 1-4 增加 321 tests (647 → 968),Phase 5 增加 209 tests (968 → 1177)
414436
>
415437
> Phase 1-4 全部完成,详见上方 P3-P5 表格。
438+
> Phase 5 新增 12 个测试文件覆盖:effort、tokenBudget、displayTags、taggedId、controlMessageCompat、MCP normalization/envExpansion、gitConfigParser、formatBriefTimestamp、hyperlink、windowsPaths、notebook,详见 P6 表格。
416439
> 实际调整:Phase 3 中 `context.ts` 因极重依赖链(bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null,替换为 `envValidation.ts`(更实用);Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`
417440
418441
### 不纳入计划的模块
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2+
import { expandEnvVarsInString } from "../envExpansion";
3+
4+
describe("expandEnvVarsInString", () => {
5+
// Save and restore env vars touched by tests
6+
const savedEnv: Record<string, string | undefined> = {};
7+
const trackedKeys = [
8+
"TEST_HOME",
9+
"MISSING",
10+
"TEST_A",
11+
"TEST_B",
12+
"TEST_EMPTY",
13+
"TEST_X",
14+
"VAR",
15+
"TEST_FOUND",
16+
];
17+
18+
beforeEach(() => {
19+
for (const key of trackedKeys) {
20+
savedEnv[key] = process.env[key];
21+
}
22+
});
23+
24+
afterEach(() => {
25+
for (const key of trackedKeys) {
26+
if (savedEnv[key] === undefined) {
27+
delete process.env[key];
28+
} else {
29+
process.env[key] = savedEnv[key];
30+
}
31+
}
32+
});
33+
34+
test("expands a single env var that exists", () => {
35+
process.env.TEST_HOME = "/home/user";
36+
const result = expandEnvVarsInString("${TEST_HOME}");
37+
expect(result.expanded).toBe("/home/user");
38+
expect(result.missingVars).toEqual([]);
39+
});
40+
41+
test("returns original placeholder and tracks missing var when not found", () => {
42+
delete process.env.MISSING;
43+
const result = expandEnvVarsInString("${MISSING}");
44+
expect(result.expanded).toBe("${MISSING}");
45+
expect(result.missingVars).toEqual(["MISSING"]);
46+
});
47+
48+
test("uses default value when var is missing and default is provided", () => {
49+
delete process.env.MISSING;
50+
const result = expandEnvVarsInString("${MISSING:-fallback}");
51+
expect(result.expanded).toBe("fallback");
52+
expect(result.missingVars).toEqual([]);
53+
});
54+
55+
test("expands multiple vars", () => {
56+
process.env.TEST_A = "hello";
57+
process.env.TEST_B = "world";
58+
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
59+
expect(result.expanded).toBe("hello/world");
60+
expect(result.missingVars).toEqual([]);
61+
});
62+
63+
test("handles mix of found and missing vars", () => {
64+
process.env.TEST_FOUND = "yes";
65+
delete process.env.MISSING;
66+
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
67+
expect(result.expanded).toBe("yes-${MISSING}");
68+
expect(result.missingVars).toEqual(["MISSING"]);
69+
});
70+
71+
test("returns plain string unchanged with empty missingVars", () => {
72+
const result = expandEnvVarsInString("plain string");
73+
expect(result.expanded).toBe("plain string");
74+
expect(result.missingVars).toEqual([]);
75+
});
76+
77+
test("expands empty env var value", () => {
78+
process.env.TEST_EMPTY = "";
79+
const result = expandEnvVarsInString("${TEST_EMPTY}");
80+
expect(result.expanded).toBe("");
81+
expect(result.missingVars).toEqual([]);
82+
});
83+
84+
test("prefers env var value over default when var exists", () => {
85+
process.env.TEST_X = "real";
86+
const result = expandEnvVarsInString("${TEST_X:-default}");
87+
expect(result.expanded).toBe("real");
88+
expect(result.missingVars).toEqual([]);
89+
});
90+
91+
test("handles default value containing colons", () => {
92+
// split(':-', 2) means only the first :- is the delimiter
93+
delete process.env.TEST_X;
94+
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
95+
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
96+
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
97+
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
98+
expect(result.expanded).toBe("value");
99+
expect(result.missingVars).toEqual([]);
100+
});
101+
102+
test("handles nested-looking syntax as literal (not supported)", () => {
103+
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
104+
// so varName would be "${VAR" which won't be found in env
105+
delete process.env.VAR;
106+
const result = expandEnvVarsInString("${${VAR}}");
107+
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
108+
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
109+
expect(result.missingVars).toEqual(["${VAR"]);
110+
expect(result.expanded).toBe("${${VAR}}");
111+
});
112+
113+
test("handles empty string input", () => {
114+
const result = expandEnvVarsInString("");
115+
expect(result.expanded).toBe("");
116+
expect(result.missingVars).toEqual([]);
117+
});
118+
119+
test("handles var surrounded by text", () => {
120+
process.env.TEST_A = "middle";
121+
const result = expandEnvVarsInString("before-${TEST_A}-after");
122+
expect(result.expanded).toBe("before-middle-after");
123+
expect(result.missingVars).toEqual([]);
124+
});
125+
126+
test("handles default value that is empty string", () => {
127+
delete process.env.MISSING;
128+
const result = expandEnvVarsInString("${MISSING:-}");
129+
expect(result.expanded).toBe("");
130+
expect(result.missingVars).toEqual([]);
131+
});
132+
133+
test("does not expand $VAR without braces", () => {
134+
process.env.TEST_A = "value";
135+
const result = expandEnvVarsInString("$TEST_A");
136+
expect(result.expanded).toBe("$TEST_A");
137+
expect(result.missingVars).toEqual([]);
138+
});
139+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { normalizeNameForMCP } from "../normalization";
3+
4+
describe("normalizeNameForMCP", () => {
5+
test("returns simple valid name unchanged", () => {
6+
expect(normalizeNameForMCP("my-server")).toBe("my-server");
7+
});
8+
9+
test("replaces dots with underscores", () => {
10+
expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name");
11+
});
12+
13+
test("replaces spaces with underscores", () => {
14+
expect(normalizeNameForMCP("my server")).toBe("my_server");
15+
});
16+
17+
test("replaces special characters with underscores", () => {
18+
expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_");
19+
});
20+
21+
test("returns already valid name unchanged", () => {
22+
expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123");
23+
});
24+
25+
test("returns empty string for empty input", () => {
26+
expect(normalizeNameForMCP("")).toBe("");
27+
});
28+
29+
test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => {
30+
// "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server"
31+
// starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server"
32+
expect(normalizeNameForMCP("claude.ai My Server")).toBe(
33+
"claude_ai_My_Server"
34+
);
35+
});
36+
37+
test("handles claude.ai prefix with consecutive invalid chars", () => {
38+
// "claude.ai ...test..." -> replace invalid -> "claude_ai____test___"
39+
// collapse consecutive _ -> "claude_ai_test_"
40+
// strip leading/trailing _ -> "claude_ai_test"
41+
expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test");
42+
});
43+
44+
test("non-claude.ai name preserves consecutive underscores", () => {
45+
// "a..b" -> "a__b", no claude.ai prefix so no collapse
46+
expect(normalizeNameForMCP("a..b")).toBe("a__b");
47+
});
48+
49+
test("non-claude.ai name preserves trailing underscores", () => {
50+
expect(normalizeNameForMCP("name!")).toBe("name_");
51+
});
52+
53+
test("handles claude.ai prefix that results in only underscores", () => {
54+
// "claude.ai ..." -> replace invalid -> "claude_ai____"
55+
// collapse -> "claude_ai_"
56+
// strip trailing -> "claude_ai"
57+
expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai");
58+
});
59+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { normalizeControlMessageKeys } from "../controlMessageCompat";
3+
4+
describe("normalizeControlMessageKeys", () => {
5+
// --- basic camelCase to snake_case ---
6+
test("converts requestId to request_id", () => {
7+
const obj = { requestId: "123" };
8+
const result = normalizeControlMessageKeys(obj);
9+
expect(result).toEqual({ request_id: "123" });
10+
expect((result as any).requestId).toBeUndefined();
11+
});
12+
13+
test("leaves request_id unchanged", () => {
14+
const obj = { request_id: "123" };
15+
normalizeControlMessageKeys(obj);
16+
expect(obj).toEqual({ request_id: "123" });
17+
});
18+
19+
// --- both present: snake_case wins ---
20+
test("keeps snake_case when both requestId and request_id exist", () => {
21+
const obj = { requestId: "camel", request_id: "snake" };
22+
const result = normalizeControlMessageKeys(obj) as any;
23+
expect(result.request_id).toBe("snake");
24+
// requestId is NOT deleted when request_id already exists
25+
// because the condition `!('request_id' in record)` prevents the branch
26+
expect(result.requestId).toBe("camel");
27+
});
28+
29+
// --- nested response ---
30+
test("normalizes nested response.requestId", () => {
31+
const obj = { response: { requestId: "456" } };
32+
normalizeControlMessageKeys(obj);
33+
expect((obj as any).response.request_id).toBe("456");
34+
expect((obj as any).response.requestId).toBeUndefined();
35+
});
36+
37+
test("leaves nested response.request_id unchanged", () => {
38+
const obj = { response: { request_id: "789" } };
39+
normalizeControlMessageKeys(obj);
40+
expect((obj as any).response.request_id).toBe("789");
41+
});
42+
43+
test("nested response: snake_case wins when both present", () => {
44+
const obj = {
45+
response: { requestId: "camel", request_id: "snake" },
46+
};
47+
normalizeControlMessageKeys(obj);
48+
expect((obj as any).response.request_id).toBe("snake");
49+
expect((obj as any).response.requestId).toBe("camel");
50+
});
51+
52+
// --- non-object inputs ---
53+
test("returns null as-is", () => {
54+
expect(normalizeControlMessageKeys(null)).toBeNull();
55+
});
56+
57+
test("returns undefined as-is", () => {
58+
expect(normalizeControlMessageKeys(undefined)).toBeUndefined();
59+
});
60+
61+
test("returns string as-is", () => {
62+
expect(normalizeControlMessageKeys("hello")).toBe("hello");
63+
});
64+
65+
test("returns number as-is", () => {
66+
expect(normalizeControlMessageKeys(42)).toBe(42);
67+
});
68+
69+
// --- empty and edge cases ---
70+
test("empty object is unchanged", () => {
71+
const obj = {};
72+
normalizeControlMessageKeys(obj);
73+
expect(obj).toEqual({});
74+
});
75+
76+
test("mutates the original object in place", () => {
77+
const obj = { requestId: "abc", other: "data" };
78+
const result = normalizeControlMessageKeys(obj);
79+
expect(result).toBe(obj); // same reference
80+
expect(obj).toEqual({ request_id: "abc", other: "data" });
81+
});
82+
83+
test("does not affect other keys on the object", () => {
84+
const obj = { requestId: "123", type: "control_request", payload: {} };
85+
normalizeControlMessageKeys(obj);
86+
expect((obj as any).type).toBe("control_request");
87+
expect((obj as any).payload).toEqual({});
88+
expect((obj as any).request_id).toBe("123");
89+
});
90+
91+
test("handles response being null", () => {
92+
const obj = { response: null, requestId: "x" };
93+
normalizeControlMessageKeys(obj);
94+
expect((obj as any).request_id).toBe("x");
95+
expect((obj as any).response).toBeNull();
96+
});
97+
98+
test("handles response being a non-object (string)", () => {
99+
const obj = { response: "not-an-object" };
100+
normalizeControlMessageKeys(obj);
101+
expect((obj as any).response).toBe("not-an-object");
102+
});
103+
});

0 commit comments

Comments
 (0)