Преглед изворни кода

test: 添加 Utils 纯函数单元测试 (测试计划 02)

覆盖 xml, hash, stringUtils, semver, uuid, format, frontmatterParser,
file, glob, diff 共 10 个模块的纯函数测试。
json.ts 因模块加载链路过重暂跳过。
共 190 个测试用例(含已有 array/set)全部通过。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude-code-best пре 3 недеља
родитељ
комит
cad6409bfe

+ 77 - 0
src/utils/__tests__/diff.test.ts

@@ -0,0 +1,77 @@
+import { describe, expect, test } from "bun:test";
+import { adjustHunkLineNumbers, getPatchFromContents } from "../diff";
+
+describe("adjustHunkLineNumbers", () => {
+  test("shifts hunk line numbers by offset", () => {
+    const hunks = [
+      { oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [" a", "-b", "+c", "+d", " e"] },
+    ] as any[];
+    const result = adjustHunkLineNumbers(hunks, 10);
+    expect(result[0].oldStart).toBe(11);
+    expect(result[0].newStart).toBe(11);
+  });
+
+  test("returns original hunks for zero offset", () => {
+    const hunks = [
+      { oldStart: 5, oldLines: 2, newStart: 5, newLines: 2, lines: [] },
+    ] as any[];
+    const result = adjustHunkLineNumbers(hunks, 0);
+    expect(result).toBe(hunks); // same reference
+  });
+
+  test("handles negative offset", () => {
+    const hunks = [
+      { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [] },
+    ] as any[];
+    const result = adjustHunkLineNumbers(hunks, -5);
+    expect(result[0].oldStart).toBe(5);
+    expect(result[0].newStart).toBe(5);
+  });
+
+  test("handles empty hunks array", () => {
+    expect(adjustHunkLineNumbers([], 10)).toEqual([]);
+  });
+});
+
+describe("getPatchFromContents", () => {
+  test("returns hunks for different content", () => {
+    const hunks = getPatchFromContents({
+      filePath: "test.txt",
+      oldContent: "hello\nworld",
+      newContent: "hello\nplanet",
+    });
+    expect(hunks.length).toBeGreaterThan(0);
+    expect(hunks[0].lines.some((l: string) => l.startsWith("-"))).toBe(true);
+    expect(hunks[0].lines.some((l: string) => l.startsWith("+"))).toBe(true);
+  });
+
+  test("returns empty hunks for identical content", () => {
+    const hunks = getPatchFromContents({
+      filePath: "test.txt",
+      oldContent: "same content",
+      newContent: "same content",
+    });
+    expect(hunks).toEqual([]);
+  });
+
+  test("handles content with ampersands", () => {
+    const hunks = getPatchFromContents({
+      filePath: "test.txt",
+      oldContent: "a & b",
+      newContent: "a & c",
+    });
+    expect(hunks.length).toBeGreaterThan(0);
+    // Verify ampersands are unescaped in the output
+    const allLines = hunks.flatMap((h: any) => h.lines);
+    expect(allLines.some((l: string) => l.includes("&"))).toBe(true);
+  });
+
+  test("handles empty old content (new file)", () => {
+    const hunks = getPatchFromContents({
+      filePath: "test.txt",
+      oldContent: "",
+      newContent: "new content",
+    });
+    expect(hunks.length).toBeGreaterThan(0);
+  });
+});

+ 95 - 0
src/utils/__tests__/file.test.ts

@@ -0,0 +1,95 @@
+import { describe, expect, test } from "bun:test";
+import {
+  convertLeadingTabsToSpaces,
+  addLineNumbers,
+  stripLineNumberPrefix,
+  pathsEqual,
+  normalizePathForComparison,
+} from "../file";
+
+describe("convertLeadingTabsToSpaces", () => {
+  test("converts leading tabs to 2 spaces each", () => {
+    expect(convertLeadingTabsToSpaces("\t\thello")).toBe("    hello");
+  });
+
+  test("only converts leading tabs", () => {
+    expect(convertLeadingTabsToSpaces("\thello\tworld")).toBe("  hello\tworld");
+  });
+
+  test("returns unchanged if no tabs", () => {
+    expect(convertLeadingTabsToSpaces("no tabs")).toBe("no tabs");
+  });
+
+  test("handles empty string", () => {
+    expect(convertLeadingTabsToSpaces("")).toBe("");
+  });
+
+  test("handles multiline content", () => {
+    const input = "\tline1\n\t\tline2\nline3";
+    const expected = "  line1\n    line2\nline3";
+    expect(convertLeadingTabsToSpaces(input)).toBe(expected);
+  });
+});
+
+describe("addLineNumbers", () => {
+  test("adds line numbers starting from 1", () => {
+    const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
+    expect(result).toContain("1");
+    expect(result).toContain("a");
+    expect(result).toContain("b");
+    expect(result).toContain("c");
+  });
+
+  test("returns empty string for empty content", () => {
+    expect(addLineNumbers({ content: "", startLine: 1 })).toBe("");
+  });
+
+  test("respects startLine offset", () => {
+    const result = addLineNumbers({ content: "hello", startLine: 10 });
+    expect(result).toContain("10");
+  });
+});
+
+describe("stripLineNumberPrefix", () => {
+  test("strips arrow-separated prefix", () => {
+    expect(stripLineNumberPrefix("     1→content")).toBe("content");
+  });
+
+  test("strips tab-separated prefix", () => {
+    expect(stripLineNumberPrefix("1\tcontent")).toBe("content");
+  });
+
+  test("returns line unchanged if no prefix", () => {
+    expect(stripLineNumberPrefix("no prefix")).toBe("no prefix");
+  });
+
+  test("handles large line numbers", () => {
+    expect(stripLineNumberPrefix("123456→content")).toBe("content");
+  });
+});
+
+describe("normalizePathForComparison", () => {
+  test("normalizes redundant separators", () => {
+    const result = normalizePathForComparison("/a//b/c");
+    expect(result).toBe("/a/b/c");
+  });
+
+  test("resolves dot segments", () => {
+    const result = normalizePathForComparison("/a/./b/../c");
+    expect(result).toBe("/a/c");
+  });
+});
+
+describe("pathsEqual", () => {
+  test("returns true for identical paths", () => {
+    expect(pathsEqual("/a/b/c", "/a/b/c")).toBe(true);
+  });
+
+  test("returns true for equivalent paths with dot segments", () => {
+    expect(pathsEqual("/a/./b", "/a/b")).toBe(true);
+  });
+
+  test("returns false for different paths", () => {
+    expect(pathsEqual("/a/b", "/a/c")).toBe(false);
+  });
+});

+ 133 - 0
src/utils/__tests__/format.test.ts

@@ -0,0 +1,133 @@
+import { describe, expect, test } from "bun:test";
+import {
+  formatFileSize,
+  formatSecondsShort,
+  formatDuration,
+  formatNumber,
+  formatTokens,
+  formatRelativeTime,
+} from "../format";
+
+describe("formatFileSize", () => {
+  test("formats bytes", () => {
+    expect(formatFileSize(500)).toBe("500 bytes");
+  });
+
+  test("formats kilobytes", () => {
+    expect(formatFileSize(1536)).toBe("1.5KB");
+  });
+
+  test("formats megabytes", () => {
+    expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1.5MB");
+  });
+
+  test("formats gigabytes", () => {
+    expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe("2GB");
+  });
+
+  test("removes trailing .0", () => {
+    expect(formatFileSize(1024)).toBe("1KB");
+  });
+});
+
+describe("formatSecondsShort", () => {
+  test("formats milliseconds to seconds", () => {
+    expect(formatSecondsShort(1234)).toBe("1.2s");
+  });
+
+  test("formats zero", () => {
+    expect(formatSecondsShort(0)).toBe("0.0s");
+  });
+
+  test("formats sub-second", () => {
+    expect(formatSecondsShort(500)).toBe("0.5s");
+  });
+});
+
+describe("formatDuration", () => {
+  test("formats 0 as 0s", () => {
+    expect(formatDuration(0)).toBe("0s");
+  });
+
+  test("formats seconds", () => {
+    expect(formatDuration(5000)).toBe("5s");
+  });
+
+  test("formats minutes and seconds", () => {
+    expect(formatDuration(125000)).toBe("2m 5s");
+  });
+
+  test("formats hours", () => {
+    expect(formatDuration(3661000)).toBe("1h 1m 1s");
+  });
+
+  test("formats days", () => {
+    expect(formatDuration(90000000)).toBe("1d 1h 0m");
+  });
+
+  test("hideTrailingZeros removes zero components", () => {
+    expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe("1h");
+    expect(formatDuration(60000, { hideTrailingZeros: true })).toBe("1m");
+  });
+
+  test("mostSignificantOnly returns largest unit", () => {
+    expect(formatDuration(90000000, { mostSignificantOnly: true })).toBe("1d");
+    expect(formatDuration(3661000, { mostSignificantOnly: true })).toBe("1h");
+  });
+});
+
+describe("formatNumber", () => {
+  test("formats small numbers as-is", () => {
+    expect(formatNumber(900)).toBe("900");
+  });
+
+  test("formats thousands with k suffix", () => {
+    const result = formatNumber(1321);
+    expect(result).toContain("k");
+  });
+
+  test("formats millions", () => {
+    const result = formatNumber(1500000);
+    expect(result).toContain("m");
+  });
+});
+
+describe("formatTokens", () => {
+  test("removes .0 from formatted number", () => {
+    const result = formatTokens(1000);
+    expect(result).not.toContain(".0");
+  });
+
+  test("formats small numbers", () => {
+    expect(formatTokens(500)).toBe("500");
+  });
+});
+
+describe("formatRelativeTime", () => {
+  const now = new Date("2026-01-15T12:00:00Z");
+
+  test("formats seconds ago", () => {
+    const date = new Date("2026-01-15T11:59:30Z");
+    const result = formatRelativeTime(date, { now });
+    expect(result).toContain("30");
+    expect(result).toContain("ago");
+  });
+
+  test("formats minutes ago", () => {
+    const date = new Date("2026-01-15T11:55:00Z");
+    const result = formatRelativeTime(date, { now });
+    expect(result).toContain("5");
+    expect(result).toContain("ago");
+  });
+
+  test("formats future time", () => {
+    const date = new Date("2026-01-15T13:00:00Z");
+    const result = formatRelativeTime(date, { now });
+    expect(result).toContain("in");
+  });
+
+  test("handles zero difference", () => {
+    const result = formatRelativeTime(now, { now });
+    expect(result).toContain("0");
+  });
+});

+ 164 - 0
src/utils/__tests__/frontmatterParser.test.ts

@@ -0,0 +1,164 @@
+import { describe, expect, test } from "bun:test";
+import {
+  parseFrontmatter,
+  splitPathInFrontmatter,
+  parsePositiveIntFromFrontmatter,
+  parseBooleanFrontmatter,
+  parseShellFrontmatter,
+} from "../frontmatterParser";
+
+describe("parseFrontmatter", () => {
+  test("parses valid frontmatter", () => {
+    const md = `---
+description: A test
+type: user
+---
+Content here`;
+    const result = parseFrontmatter(md);
+    expect(result.frontmatter.description).toBe("A test");
+    expect(result.frontmatter.type).toBe("user");
+    expect(result.content).toBe("Content here");
+  });
+
+  test("returns empty frontmatter when none exists", () => {
+    const md = "Just content, no frontmatter";
+    const result = parseFrontmatter(md);
+    expect(result.frontmatter).toEqual({});
+    expect(result.content).toBe(md);
+  });
+
+  test("handles empty frontmatter block", () => {
+    const md = `---
+---
+Content`;
+    const result = parseFrontmatter(md);
+    expect(result.frontmatter).toEqual({});
+    expect(result.content).toBe("Content");
+  });
+
+  test("handles frontmatter with list values", () => {
+    const md = `---
+allowed-tools:
+  - Bash
+  - Read
+---
+Content`;
+    const result = parseFrontmatter(md);
+    expect(result.frontmatter["allowed-tools"]).toEqual(["Bash", "Read"]);
+  });
+});
+
+describe("splitPathInFrontmatter", () => {
+  test("splits comma-separated paths", () => {
+    expect(splitPathInFrontmatter("a, b, c")).toEqual(["a", "b", "c"]);
+  });
+
+  test("expands brace patterns", () => {
+    expect(splitPathInFrontmatter("src/*.{ts,tsx}")).toEqual([
+      "src/*.ts",
+      "src/*.tsx",
+    ]);
+  });
+
+  test("handles nested brace expansion", () => {
+    expect(splitPathInFrontmatter("{a,b}/{c,d}")).toEqual([
+      "a/c", "a/d", "b/c", "b/d",
+    ]);
+  });
+
+  test("handles array input", () => {
+    expect(splitPathInFrontmatter(["a", "b"])).toEqual(["a", "b"]);
+  });
+
+  test("returns empty array for non-string", () => {
+    expect(splitPathInFrontmatter(123 as any)).toEqual([]);
+  });
+
+  test("preserves braces in comma-separated list", () => {
+    expect(splitPathInFrontmatter("a, src/*.{ts,tsx}")).toEqual([
+      "a",
+      "src/*.ts",
+      "src/*.tsx",
+    ]);
+  });
+});
+
+describe("parsePositiveIntFromFrontmatter", () => {
+  test("returns number for positive integer", () => {
+    expect(parsePositiveIntFromFrontmatter(5)).toBe(5);
+  });
+
+  test("parses string number", () => {
+    expect(parsePositiveIntFromFrontmatter("10")).toBe(10);
+  });
+
+  test("returns undefined for zero", () => {
+    expect(parsePositiveIntFromFrontmatter(0)).toBeUndefined();
+  });
+
+  test("returns undefined for negative number", () => {
+    expect(parsePositiveIntFromFrontmatter(-1)).toBeUndefined();
+  });
+
+  test("returns undefined for float", () => {
+    expect(parsePositiveIntFromFrontmatter(1.5)).toBeUndefined();
+  });
+
+  test("returns undefined for null/undefined", () => {
+    expect(parsePositiveIntFromFrontmatter(null)).toBeUndefined();
+    expect(parsePositiveIntFromFrontmatter(undefined)).toBeUndefined();
+  });
+
+  test("returns undefined for non-numeric string", () => {
+    expect(parsePositiveIntFromFrontmatter("abc")).toBeUndefined();
+  });
+});
+
+describe("parseBooleanFrontmatter", () => {
+  test("returns true for boolean true", () => {
+    expect(parseBooleanFrontmatter(true)).toBe(true);
+  });
+
+  test("returns true for string 'true'", () => {
+    expect(parseBooleanFrontmatter("true")).toBe(true);
+  });
+
+  test("returns false for boolean false", () => {
+    expect(parseBooleanFrontmatter(false)).toBe(false);
+  });
+
+  test("returns false for string 'false'", () => {
+    expect(parseBooleanFrontmatter("false")).toBe(false);
+  });
+
+  test("returns false for null/undefined", () => {
+    expect(parseBooleanFrontmatter(null)).toBe(false);
+    expect(parseBooleanFrontmatter(undefined)).toBe(false);
+  });
+});
+
+describe("parseShellFrontmatter", () => {
+  test("returns bash for 'bash'", () => {
+    expect(parseShellFrontmatter("bash", "test")).toBe("bash");
+  });
+
+  test("returns powershell for 'powershell'", () => {
+    expect(parseShellFrontmatter("powershell", "test")).toBe("powershell");
+  });
+
+  test("returns undefined for null", () => {
+    expect(parseShellFrontmatter(null, "test")).toBeUndefined();
+  });
+
+  test("returns undefined for unrecognized value", () => {
+    expect(parseShellFrontmatter("zsh", "test")).toBeUndefined();
+  });
+
+  test("is case insensitive", () => {
+    expect(parseShellFrontmatter("BASH", "test")).toBe("bash");
+  });
+
+  test("returns undefined for empty string", () => {
+    expect(parseShellFrontmatter("", "test")).toBeUndefined();
+  });
+});

+ 40 - 0
src/utils/__tests__/glob.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test";
+import { extractGlobBaseDirectory } from "../glob";
+
+describe("extractGlobBaseDirectory", () => {
+  test("extracts base dir from glob with *", () => {
+    const result = extractGlobBaseDirectory("src/utils/*.ts");
+    expect(result.baseDir).toBe("src/utils");
+    expect(result.relativePattern).toBe("*.ts");
+  });
+
+  test("extracts base dir from glob with **", () => {
+    const result = extractGlobBaseDirectory("src/**/*.ts");
+    expect(result.baseDir).toBe("src");
+    expect(result.relativePattern).toBe("**/*.ts");
+  });
+
+  test("returns dirname for literal path", () => {
+    const result = extractGlobBaseDirectory("src/utils/file.ts");
+    expect(result.baseDir).toBe("src/utils");
+    expect(result.relativePattern).toBe("file.ts");
+  });
+
+  test("handles glob starting with pattern", () => {
+    const result = extractGlobBaseDirectory("*.ts");
+    expect(result.baseDir).toBe("");
+    expect(result.relativePattern).toBe("*.ts");
+  });
+
+  test("handles braces pattern", () => {
+    const result = extractGlobBaseDirectory("src/{a,b}/*.ts");
+    expect(result.baseDir).toBe("src");
+    expect(result.relativePattern).toBe("{a,b}/*.ts");
+  });
+
+  test("handles question mark pattern", () => {
+    const result = extractGlobBaseDirectory("src/?.ts");
+    expect(result.baseDir).toBe("src");
+    expect(result.relativePattern).toBe("?.ts");
+  });
+});

+ 57 - 0
src/utils/__tests__/hash.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test";
+import { djb2Hash, hashContent, hashPair } from "../hash";
+
+describe("djb2Hash", () => {
+  test("returns a number", () => {
+    expect(typeof djb2Hash("hello")).toBe("number");
+  });
+
+  test("returns 0 for empty string", () => {
+    expect(djb2Hash("")).toBe(0);
+  });
+
+  test("is deterministic", () => {
+    expect(djb2Hash("test")).toBe(djb2Hash("test"));
+  });
+
+  test("different strings produce different hashes", () => {
+    expect(djb2Hash("abc")).not.toBe(djb2Hash("def"));
+  });
+
+  test("returns 32-bit integer", () => {
+    const hash = djb2Hash("some long string to hash");
+    expect(hash).toBe(hash | 0); // bitwise OR with 0 preserves 32-bit int
+  });
+});
+
+describe("hashContent", () => {
+  test("returns a string", () => {
+    expect(typeof hashContent("hello")).toBe("string");
+  });
+
+  test("is deterministic", () => {
+    expect(hashContent("test")).toBe(hashContent("test"));
+  });
+
+  test("different strings produce different hashes", () => {
+    expect(hashContent("abc")).not.toBe(hashContent("def"));
+  });
+});
+
+describe("hashPair", () => {
+  test("returns a string", () => {
+    expect(typeof hashPair("a", "b")).toBe("string");
+  });
+
+  test("is deterministic", () => {
+    expect(hashPair("a", "b")).toBe(hashPair("a", "b"));
+  });
+
+  test("order matters", () => {
+    expect(hashPair("a", "b")).not.toBe(hashPair("b", "a"));
+  });
+
+  test("disambiguates different splits", () => {
+    expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
+  });
+});

+ 98 - 0
src/utils/__tests__/semver.test.ts

@@ -0,0 +1,98 @@
+import { describe, expect, test } from "bun:test";
+import { gt, gte, lt, lte, satisfies, order } from "../semver";
+
+describe("gt", () => {
+  test("returns true when a > b", () => {
+    expect(gt("2.0.0", "1.0.0")).toBe(true);
+  });
+
+  test("returns false when a < b", () => {
+    expect(gt("1.0.0", "2.0.0")).toBe(false);
+  });
+
+  test("returns false when equal", () => {
+    expect(gt("1.0.0", "1.0.0")).toBe(false);
+  });
+});
+
+describe("gte", () => {
+  test("returns true when a > b", () => {
+    expect(gte("2.0.0", "1.0.0")).toBe(true);
+  });
+
+  test("returns true when equal", () => {
+    expect(gte("1.0.0", "1.0.0")).toBe(true);
+  });
+
+  test("returns false when a < b", () => {
+    expect(gte("1.0.0", "2.0.0")).toBe(false);
+  });
+});
+
+describe("lt", () => {
+  test("returns true when a < b", () => {
+    expect(lt("1.0.0", "2.0.0")).toBe(true);
+  });
+
+  test("returns false when a > b", () => {
+    expect(lt("2.0.0", "1.0.0")).toBe(false);
+  });
+
+  test("returns false when equal", () => {
+    expect(lt("1.0.0", "1.0.0")).toBe(false);
+  });
+});
+
+describe("lte", () => {
+  test("returns true when a < b", () => {
+    expect(lte("1.0.0", "2.0.0")).toBe(true);
+  });
+
+  test("returns true when equal", () => {
+    expect(lte("1.0.0", "1.0.0")).toBe(true);
+  });
+
+  test("returns false when a > b", () => {
+    expect(lte("2.0.0", "1.0.0")).toBe(false);
+  });
+});
+
+describe("satisfies", () => {
+  test("matches exact version", () => {
+    expect(satisfies("1.2.3", "1.2.3")).toBe(true);
+  });
+
+  test("matches range", () => {
+    expect(satisfies("1.2.3", ">=1.0.0")).toBe(true);
+  });
+
+  test("does not match out-of-range version", () => {
+    expect(satisfies("0.9.0", ">=1.0.0")).toBe(false);
+  });
+
+  test("matches caret range", () => {
+    expect(satisfies("1.2.3", "^1.0.0")).toBe(true);
+  });
+
+  test("does not match major bump in caret", () => {
+    expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
+  });
+});
+
+describe("order", () => {
+  test("returns 1 when a > b", () => {
+    expect(order("2.0.0", "1.0.0")).toBe(1);
+  });
+
+  test("returns -1 when a < b", () => {
+    expect(order("1.0.0", "2.0.0")).toBe(-1);
+  });
+
+  test("returns 0 when equal", () => {
+    expect(order("1.0.0", "1.0.0")).toBe(0);
+  });
+
+  test("compares patch versions", () => {
+    expect(order("1.0.1", "1.0.0")).toBe(1);
+  });
+});

+ 195 - 0
src/utils/__tests__/stringUtils.test.ts

@@ -0,0 +1,195 @@
+import { describe, expect, test } from "bun:test";
+import {
+  escapeRegExp,
+  capitalize,
+  plural,
+  firstLineOf,
+  countCharInString,
+  normalizeFullWidthDigits,
+  normalizeFullWidthSpace,
+  safeJoinLines,
+  EndTruncatingAccumulator,
+  truncateToLines,
+} from "../stringUtils";
+
+describe("escapeRegExp", () => {
+  test("escapes special regex chars", () => {
+    expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d");
+  });
+
+  test("escapes brackets and parens", () => {
+    expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)");
+  });
+
+  test("escapes all special chars", () => {
+    expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
+      "\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
+    );
+  });
+
+  test("returns normal string unchanged", () => {
+    expect(escapeRegExp("hello")).toBe("hello");
+  });
+});
+
+describe("capitalize", () => {
+  test("uppercases first char", () => {
+    expect(capitalize("hello")).toBe("Hello");
+  });
+
+  test("does NOT lowercase rest", () => {
+    expect(capitalize("fooBar")).toBe("FooBar");
+  });
+
+  test("handles single char", () => {
+    expect(capitalize("a")).toBe("A");
+  });
+
+  test("handles empty string", () => {
+    expect(capitalize("")).toBe("");
+  });
+});
+
+describe("plural", () => {
+  test("returns singular for 1", () => {
+    expect(plural(1, "file")).toBe("file");
+  });
+
+  test("returns plural for 0", () => {
+    expect(plural(0, "file")).toBe("files");
+  });
+
+  test("returns plural for many", () => {
+    expect(plural(3, "file")).toBe("files");
+  });
+
+  test("uses custom plural form", () => {
+    expect(plural(2, "entry", "entries")).toBe("entries");
+  });
+});
+
+describe("firstLineOf", () => {
+  test("returns first line of multiline string", () => {
+    expect(firstLineOf("line1\nline2\nline3")).toBe("line1");
+  });
+
+  test("returns whole string if no newline", () => {
+    expect(firstLineOf("single line")).toBe("single line");
+  });
+
+  test("returns empty string for leading newline", () => {
+    expect(firstLineOf("\nline2")).toBe("");
+  });
+});
+
+describe("countCharInString", () => {
+  test("counts occurrences of a character", () => {
+    expect(countCharInString("hello world", "l")).toBe(3);
+  });
+
+  test("returns 0 for no match", () => {
+    expect(countCharInString("hello", "z")).toBe(0);
+  });
+
+  test("counts from start offset", () => {
+    expect(countCharInString("aabaa", "a", 2)).toBe(2);
+  });
+
+  test("returns 0 for empty string", () => {
+    expect(countCharInString("", "a")).toBe(0);
+  });
+});
+
+describe("normalizeFullWidthDigits", () => {
+  test("converts full-width digits to half-width", () => {
+    expect(normalizeFullWidthDigits("0123456789")).toBe("0123456789");
+  });
+
+  test("leaves half-width digits unchanged", () => {
+    expect(normalizeFullWidthDigits("0123")).toBe("0123");
+  });
+
+  test("handles mixed content", () => {
+    expect(normalizeFullWidthDigits("test123")).toBe("test123");
+  });
+});
+
+describe("normalizeFullWidthSpace", () => {
+  test("converts full-width space to half-width", () => {
+    expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b");
+  });
+
+  test("leaves normal spaces unchanged", () => {
+    expect(normalizeFullWidthSpace("a b")).toBe("a b");
+  });
+});
+
+describe("safeJoinLines", () => {
+  test("joins lines with delimiter", () => {
+    expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c");
+  });
+
+  test("truncates when exceeding maxSize", () => {
+    const result = safeJoinLines(["hello", "world", "foo"], ",", 12);
+    expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length);
+    expect(result).toContain("...[truncated]");
+  });
+
+  test("returns empty string for empty input", () => {
+    expect(safeJoinLines([])).toBe("");
+  });
+});
+
+describe("EndTruncatingAccumulator", () => {
+  test("accumulates text", () => {
+    const acc = new EndTruncatingAccumulator(100);
+    acc.append("hello ");
+    acc.append("world");
+    expect(acc.toString()).toBe("hello world");
+  });
+
+  test("truncates when exceeding maxSize", () => {
+    const acc = new EndTruncatingAccumulator(10);
+    acc.append("12345678901234567890");
+    expect(acc.truncated).toBe(true);
+    expect(acc.length).toBe(10);
+  });
+
+  test("reports total bytes received", () => {
+    const acc = new EndTruncatingAccumulator(5);
+    acc.append("1234567890");
+    expect(acc.totalBytes).toBe(10);
+  });
+
+  test("clear resets state", () => {
+    const acc = new EndTruncatingAccumulator(100);
+    acc.append("hello");
+    acc.clear();
+    expect(acc.toString()).toBe("");
+    expect(acc.length).toBe(0);
+    expect(acc.truncated).toBe(false);
+  });
+
+  test("stops accepting data once truncated and full", () => {
+    const acc = new EndTruncatingAccumulator(5);
+    acc.append("12345");
+    acc.append("67890");
+    expect(acc.length).toBe(5);
+    acc.append("more");
+    expect(acc.length).toBe(5);
+  });
+});
+
+describe("truncateToLines", () => {
+  test("returns text unchanged if within limit", () => {
+    expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc");
+  });
+
+  test("truncates text exceeding limit", () => {
+    expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…");
+  });
+
+  test("handles single line", () => {
+    expect(truncateToLines("hello", 1)).toBe("hello");
+  });
+});

+ 34 - 0
src/utils/__tests__/uuid.test.ts

@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import { validateUuid } from "../uuid";
+
+describe("validateUuid", () => {
+  test("validates correct UUID", () => {
+    const result = validateUuid("550e8400-e29b-41d4-a716-446655440000");
+    expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
+  });
+
+  test("validates uppercase UUID", () => {
+    const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
+    expect(result).not.toBeNull();
+  });
+
+  test("returns null for non-string", () => {
+    expect(validateUuid(123)).toBeNull();
+    expect(validateUuid(null)).toBeNull();
+    expect(validateUuid(undefined)).toBeNull();
+  });
+
+  test("returns null for invalid UUID format", () => {
+    expect(validateUuid("not-a-uuid")).toBeNull();
+    expect(validateUuid("550e8400-e29b-41d4-a716")).toBeNull();
+    expect(validateUuid("550e8400e29b41d4a716446655440000")).toBeNull();
+  });
+
+  test("returns null for empty string", () => {
+    expect(validateUuid("")).toBeNull();
+  });
+
+  test("returns null for UUID with invalid chars", () => {
+    expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
+  });
+});

+ 42 - 0
src/utils/__tests__/xml.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test";
+import { escapeXml, escapeXmlAttr } from "../xml";
+
+describe("escapeXml", () => {
+  test("escapes ampersand", () => {
+    expect(escapeXml("a & b")).toBe("a &amp; b");
+  });
+
+  test("escapes less-than", () => {
+    expect(escapeXml("<div>")).toBe("&lt;div&gt;");
+  });
+
+  test("escapes greater-than", () => {
+    expect(escapeXml("a > b")).toBe("a &gt; b");
+  });
+
+  test("escapes multiple special chars", () => {
+    expect(escapeXml("<a & b>")).toBe("&lt;a &amp; b&gt;");
+  });
+
+  test("returns empty string unchanged", () => {
+    expect(escapeXml("")).toBe("");
+  });
+
+  test("returns normal text unchanged", () => {
+    expect(escapeXml("hello world")).toBe("hello world");
+  });
+});
+
+describe("escapeXmlAttr", () => {
+  test("escapes double quotes", () => {
+    expect(escapeXmlAttr('say "hello"')).toBe("say &quot;hello&quot;");
+  });
+
+  test("escapes single quotes", () => {
+    expect(escapeXmlAttr("it's")).toBe("it&apos;s");
+  });
+
+  test("escapes all special chars", () => {
+    expect(escapeXmlAttr('<a & "b">')).toBe("&lt;a &amp; &quot;b&quot;&gt;");
+  });
+});