# 工具函数(纯函数)测试计划 ## 概述 覆盖 `src/utils/` 下所有可独立单元测试的纯函数。这些函数无外部依赖,输入输出确定性强,是测试金字塔的底层基石。 ## 被测文件 | 文件 | 状态 | 关键导出 | |------|------|----------| | `src/utils/array.ts` | **已有测试** | intersperse, count, uniq | | `src/utils/set.ts` | **已有测试** | difference, intersects, every, union | | `src/utils/xml.ts` | 待测 | escapeXml, escapeXmlAttr | | `src/utils/hash.ts` | 待测 | djb2Hash, hashContent, hashPair | | `src/utils/stringUtils.ts` | 待测 | escapeRegExp, capitalize, plural, firstLineOf, countCharInString, normalizeFullWidthDigits, normalizeFullWidthSpace, safeJoinLines, truncateToLines, EndTruncatingAccumulator | | `src/utils/semver.ts` | 待测 | gt, gte, lt, lte, satisfies, order | | `src/utils/uuid.ts` | 待测 | validateUuid, createAgentId | | `src/utils/format.ts` | 待测 | formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime, formatRelativeTimeAgo | | `src/utils/json.ts` | 待测 | safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray | | `src/utils/truncate.ts` | 待测 | truncatePathMiddle, truncateToWidth, truncateStartToWidth, truncateToWidthNoEllipsis, truncate, wrapText | | `src/utils/diff.ts` | 待测 | adjustHunkLineNumbers, getPatchFromContents | | `src/utils/frontmatterParser.ts` | 待测 | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter, parseBooleanFrontmatter, parseShellFrontmatter | | `src/utils/file.ts` | 待测(纯函数部分) | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix, pathsEqual, normalizePathForComparison | | `src/utils/glob.ts` | 待测(纯函数部分) | extractGlobBaseDirectory | | `src/utils/tokens.ts` | 待测 | getTokenCountFromUsage | | `src/utils/path.ts` | 待测(纯函数部分) | containsPathTraversal, normalizePathForConfigKey | --- ## 测试用例 ### src/utils/xml.ts — 测试文件: `src/utils/__tests__/xml.test.ts` #### describe('escapeXml') - test('escapes ampersand') — `&` → `&` - test('escapes less-than') — `<` → `<` - test('escapes greater-than') — `>` → `>` - test('does not escape quotes') — `"` 和 `'` 保持原样 - test('handles empty string') — `""` → `""` - test('handles string with no special chars') — `"hello"` 原样返回 - test('escapes multiple special chars in one string') — `` → `<a & b>` #### describe('escapeXmlAttr') - test('escapes all xml chars plus quotes') — `"` → `"`, `'` → `'` - test('escapes double quotes') — `he said "hi"` 正确转义 - test('escapes single quotes') — `it's` 正确转义 --- ### src/utils/hash.ts — 测试文件: `src/utils/__tests__/hash.test.ts` #### describe('djb2Hash') - test('returns consistent hash for same input') — 相同输入返回相同结果 - test('returns different hashes for different inputs') — 不同输入大概率不同 - test('returns a 32-bit integer') — 结果在 int32 范围内 - test('handles empty string') — 空字符串有确定的哈希值 - test('handles unicode strings') — 中文/emoji 等正确处理 #### describe('hashContent') - test('returns consistent hash for same content') — 确定性 - test('returns string result') — 返回值为字符串 #### describe('hashPair') - test('returns consistent hash for same pair') — 确定性 - test('order matters') — hashPair(a, b) ≠ hashPair(b, a) - test('handles empty strings') --- ### src/utils/stringUtils.ts — 测试文件: `src/utils/__tests__/stringUtils.test.ts` #### describe('escapeRegExp') - test('escapes dots') — `.` → `\\.` - test('escapes asterisks') — `*` → `\\*` - test('escapes brackets') — `[` → `\\[` - test('escapes all special chars') — `.*+?^${}()|[]\` 全部转义 - test('leaves normal chars unchanged') — `hello` 原样 - test('escaped string works in RegExp') — `new RegExp(escapeRegExp('a.b'))` 精确匹配 `a.b` #### describe('capitalize') - test('uppercases first char') — `"foo"` → `"Foo"` - test('does NOT lowercase rest') — `"fooBar"` → `"FooBar"`(区别于 lodash capitalize) - test('handles single char') — `"a"` → `"A"` - test('handles empty string') — `""` → `""` - test('handles already capitalized') — `"Foo"` → `"Foo"` #### describe('plural') - test('returns singular for n=1') — `plural(1, 'file')` → `'file'` - test('returns plural for n=0') — `plural(0, 'file')` → `'files'` - test('returns plural for n>1') — `plural(3, 'file')` → `'files'` - test('uses custom plural form') — `plural(2, 'entry', 'entries')` → `'entries'` #### describe('firstLineOf') - test('returns first line of multi-line string') — `"a\nb\nc"` → `"a"` - test('returns full string when no newline') — `"hello"` → `"hello"` - test('handles empty string') — `""` → `""` - test('handles string starting with newline') — `"\nhello"` → `""` #### describe('countCharInString') - test('counts occurrences') — `countCharInString("aabac", "a")` → `3` - test('returns 0 when char not found') — `countCharInString("hello", "x")` → `0` - test('handles empty string') — `countCharInString("", "a")` → `0` - test('respects start position') — `countCharInString("aaba", "a", 2)` → `1` #### describe('normalizeFullWidthDigits') - test('converts full-width digits to half-width') — `"0123"` → `"0123"` - test('leaves half-width digits unchanged') — `"0123"` → `"0123"` - test('mixed content') — `"port 8080"` → `"port 8080"` #### describe('normalizeFullWidthSpace') - test('converts ideographic space to regular space') — `"\u3000"` → `" "` - test('converts multiple spaces') — `"a\u3000b\u3000c"` → `"a b c"` #### describe('safeJoinLines') - test('joins lines with default delimiter') — `["a","b"]` → `"a,b"` - test('truncates when exceeding maxSize') — 超限时截断并添加 `...[truncated]` - test('handles empty array') — `[]` → `""` - test('uses custom delimiter') — delimiter 为 `"\n"` 时按行连接 #### describe('truncateToLines') - test('returns full text when within limit') — 行数不超限时原样返回 - test('truncates and adds ellipsis') — 超限时截断并加 `…` - test('handles exact limit') — 刚好等于 maxLines 时不截断 - test('handles single line') — 单行文本不截断 #### describe('EndTruncatingAccumulator') - test('accumulates strings normally within limit') - test('truncates when exceeding maxSize') - test('reports truncated status correctly') - test('reports totalBytes including truncated content') - test('toString includes truncation marker') - test('clear resets all state') - test('append with Buffer works') — 接受 Buffer 类型 --- ### src/utils/semver.ts — 测试文件: `src/utils/__tests__/semver.test.ts` #### describe('gt / gte / lt / lte') - test('gt: 2.0.0 > 1.0.0') → true - test('gt: 1.0.0 > 1.0.0') → false - test('gte: 1.0.0 >= 1.0.0') → true - test('lt: 1.0.0 < 2.0.0') → true - test('lte: 1.0.0 <= 1.0.0') → true - test('handles pre-release versions') — `1.0.0-beta < 1.0.0` #### describe('satisfies') - test('version satisfies caret range') — `satisfies('1.2.3', '^1.0.0')` → true - test('version does not satisfy range') — `satisfies('2.0.0', '^1.0.0')` → false - test('exact match') — `satisfies('1.0.0', '1.0.0')` → true #### describe('order') - test('returns -1 for lesser') — `order('1.0.0', '2.0.0')` → -1 - test('returns 0 for equal') — `order('1.0.0', '1.0.0')` → 0 - test('returns 1 for greater') — `order('2.0.0', '1.0.0')` → 1 --- ### src/utils/uuid.ts — 测试文件: `src/utils/__tests__/uuid.test.ts` #### describe('validateUuid') - test('accepts valid v4 UUID') — `'550e8400-e29b-41d4-a716-446655440000'` → 返回 UUID - test('returns null for invalid format') — `'not-a-uuid'` → null - test('returns null for empty string') — `''` → null - test('returns null for null/undefined input') - test('accepts uppercase UUIDs') — 大写字母有效 #### describe('createAgentId') - test('returns string starting with "a"') — 前缀为 `a` - test('has correct length') — 前缀 + 16 hex 字符 - test('generates unique ids') — 连续两次调用结果不同 --- ### src/utils/format.ts — 测试文件: `src/utils/__tests__/format.test.ts` #### describe('formatFileSize') - test('formats bytes') — `500` → `"500 bytes"` - test('formats kilobytes') — `1536` → `"1.5KB"` - test('formats megabytes') — `1572864` → `"1.5MB"` - test('formats gigabytes') — `1610612736` → `"1.5GB"` - test('removes trailing .0') — `1024` → `"1KB"` (不是 `"1.0KB"`) #### describe('formatSecondsShort') - test('formats milliseconds to seconds') — `1234` → `"1.2s"` - test('formats zero') — `0` → `"0.0s"` #### describe('formatDuration') - test('formats seconds') — `5000` → `"5s"` - test('formats minutes and seconds') — `65000` → `"1m 5s"` - test('formats hours') — `3661000` → `"1h 1m 1s"` - test('formats days') — `90061000` → `"1d 1h 1m"` - test('returns "0s" for zero') — `0` → `"0s"` - test('hideTrailingZeros omits zero components') — `3600000` + `hideTrailingZeros` → `"1h"` - test('mostSignificantOnly returns largest unit') — `3661000` + `mostSignificantOnly` → `"1h"` #### describe('formatNumber') - test('formats thousands') — `1321` → `"1.3k"` - test('formats small numbers as-is') — `900` → `"900"` - test('lowercase output') — `1500` → `"1.5k"` (不是 `"1.5K"`) #### describe('formatTokens') - test('strips .0 suffix') — `1000` → `"1k"` (不是 `"1.0k"`) - test('keeps non-zero decimal') — `1500` → `"1.5k"` #### describe('formatRelativeTime') - test('formats past time') — now - 3600s → `"1h ago"` (narrow style) - test('formats future time') — now + 3600s → `"in 1h"` (narrow style) - test('formats less than 1 second') — now → `"0s ago"` - test('uses custom now parameter for deterministic output') --- ### src/utils/json.ts — 测试文件: `src/utils/__tests__/json.test.ts` #### describe('safeParseJSON') - test('parses valid JSON') — `'{"a":1}'` → `{ a: 1 }` - test('returns null for invalid JSON') — `'not json'` → null - test('returns null for null input') — `null` → null - test('returns null for undefined input') — `undefined` → null - test('returns null for empty string') — `""` → null - test('handles JSON with BOM') — BOM 前缀不影响解析 - test('caches results for repeated calls') — 同一输入不重复解析 #### describe('safeParseJSONC') - test('parses JSON with comments') — 含 `//` 注释的 JSON 正确解析 - test('parses JSON with trailing commas') — 宽松模式 - test('returns null for invalid input') - test('returns null for null input') #### describe('parseJSONL') - test('parses multiple JSON lines') — `'{"a":1}\n{"b":2}'` → `[{a:1}, {b:2}]` - test('skips malformed lines') — 含错误行时跳过该行 - test('handles empty input') — `""` → `[]` - test('handles trailing newline') — 尾部换行不产生空元素 - test('accepts Buffer input') — Buffer 类型同样工作 - test('handles BOM prefix') #### describe('addItemToJSONCArray') - test('adds item to existing array') — `[1, 2]` + 3 → `[1, 2, 3]` - test('creates new array for empty content') — `""` + item → `[item]` - test('creates new array for non-array content') — `'"hello"'` + item → `[item]` - test('preserves comments in JSONC') — 注释不被丢弃 - test('handles empty array') — `"[]"` + item → `[item]` --- ### src/utils/diff.ts — 测试文件: `src/utils/__tests__/diff.test.ts` #### describe('adjustHunkLineNumbers') - test('shifts line numbers by positive offset') — 所有 hunk 的 oldStart/newStart 增加 offset - test('shifts by negative offset') — 负 offset 减少行号 - test('handles empty hunk array') — `[]` → `[]` #### describe('getPatchFromContents') - test('returns empty array for identical content') — 相同内容无差异 - test('detects added lines') — 新内容多出行 - test('detects removed lines') — 旧内容缺少行 - test('detects modified lines') — 行内容变化 - test('handles empty old content') — 从空文件到有内容 - test('handles empty new content') — 删除所有内容 --- ### src/utils/frontmatterParser.ts — 测试文件: `src/utils/__tests__/frontmatterParser.test.ts` #### describe('parseFrontmatter') - test('extracts YAML frontmatter between --- delimiters') — 正确提取 frontmatter 并返回 body - test('returns empty frontmatter for content without ---') — 无 frontmatter 时 data 为空 - test('handles empty content') — `""` 正确处理 - test('handles frontmatter-only content') — 只有 frontmatter 无 body - test('falls back to quoting on YAML parse error') — 无效 YAML 不崩溃 #### describe('splitPathInFrontmatter') - test('splits comma-separated paths') — `"a.ts, b.ts"` → `["a.ts", "b.ts"]` - test('expands brace patterns') — `"*.{ts,tsx}"` → `["*.ts", "*.tsx"]` - test('handles string array input') — `["a.ts", "b.ts"]` → `["a.ts", "b.ts"]` - test('respects braces in comma splitting') — 大括号内的逗号不作为分隔符 #### describe('parsePositiveIntFromFrontmatter') - test('returns number for valid positive int') — `5` → `5` - test('returns undefined for negative') — `-1` → undefined - test('returns undefined for non-number') — `"abc"` → undefined - test('returns undefined for float') — `1.5` → undefined #### describe('parseBooleanFrontmatter') - test('returns true for true') — `true` → true - test('returns true for "true"') — `"true"` → true - test('returns false for false') — `false` → false - test('returns false for other values') — `"yes"`, `1` → false #### describe('parseShellFrontmatter') - test('returns bash for "bash"') — 正确识别 - test('returns powershell for "powershell"') - test('returns undefined for invalid value') — `"zsh"` → undefined --- ### src/utils/file.ts(纯函数部分)— 测试文件: `src/utils/__tests__/file.test.ts` #### describe('convertLeadingTabsToSpaces') - test('converts single tab to 2 spaces') — `"\thello"` → `" hello"` - test('converts multiple leading tabs') — `"\t\thello"` → `" hello"` - test('does not convert tabs within line') — `"a\tb"` 保持原样 - test('handles mixed content') #### describe('addLineNumbers') - test('adds line numbers starting from 1') — 每行添加 `N\t` 前缀 - test('respects startLine parameter') — 从指定行号开始 - test('handles empty content') #### describe('stripLineNumberPrefix') - test('strips tab-prefixed line number') — `"1\thello"` → `"hello"` - test('strips padded line number') — `" 1\thello"` → `"hello"` - test('returns line unchanged when no prefix') #### describe('pathsEqual') - test('returns true for identical paths') - test('handles trailing slashes') — 带/不带尾部斜杠视为相同 - test('handles case sensitivity based on platform') #### describe('normalizePathForComparison') - test('normalizes forward slashes') - test('resolves path for comparison') --- ### src/utils/glob.ts(纯函数部分)— 测试文件: `src/utils/__tests__/glob.test.ts` #### describe('extractGlobBaseDirectory') - test('extracts static prefix from glob') — `"src/**/*.ts"` → `{ baseDir: "src", relativePattern: "**/*.ts" }` - test('handles root-level glob') — `"*.ts"` → `{ baseDir: ".", relativePattern: "*.ts" }` - test('handles deep static path') — `"src/utils/model/*.ts"` → baseDir 为 `"src/utils/model"` - test('handles Windows drive root') — `"C:\\Users\\**\\*.ts"` 正确分割 --- ### src/utils/tokens.ts(纯函数部分)— 测试文件: `src/utils/__tests__/tokens.test.ts` #### describe('getTokenCountFromUsage') - test('sums input and output tokens') — `{ input_tokens: 100, output_tokens: 50 }` → 150 - test('includes cache tokens') — cache_creation + cache_read 加入总数 - test('handles zero values') — 全 0 时返回 0 --- ### src/utils/path.ts(纯函数部分)— 测试文件: `src/utils/__tests__/path.test.ts` #### describe('containsPathTraversal') - test('detects ../ traversal') — `"../etc/passwd"` → true - test('detects mid-path traversal') — `"foo/../../bar"` → true - test('returns false for safe paths') — `"src/utils/file.ts"` → false - test('returns false for paths containing .. in names') — `"foo..bar"` → false #### describe('normalizePathForConfigKey') - test('converts backslashes to forward slashes') — `"src\\utils"` → `"src/utils"` - test('leaves forward slashes unchanged') --- ## Mock 需求 本计划中的函数大部分为纯函数,**不需要 mock**。少数例外: | 函数 | 依赖 | 处理 | |------|------|------| | `hashContent` / `hashPair` | `Bun.hash` | Bun 运行时下自动可用 | | `formatRelativeTime` | `Date` | 使用 `now` 参数注入确定性时间 | | `safeParseJSON` | `logError` | 可通过 `shouldLogError: false` 跳过 | | `safeParseJSONC` | `logError` | mock `logError` 避免测试输出噪音 |