claude-code/docs/test-plans/02-utils-pure-functions.md
2026-04-01 21:19:41 +08:00

17 KiB
Raw Permalink Blame History

工具函数(纯函数)测试计划

概述

覆盖 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') — <&lt;
  • test('escapes greater-than') — >&gt;
  • 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>&lt;a &amp; b&gt;

describe('escapeXmlAttr')

  • test('escapes all xml chars plus quotes') — "&quot;, '&apos;
  • 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"
  • test('leaves half-width digits unchanged') — "0123""0123"
  • test('mixed content') — "port ""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') — 55
  • 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 避免测试输出噪音