From cad6409bfe14403ddcbe7c9caffc3a1daa1097f0 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 1 Apr 2026 22:03:02 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20Utils=20=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20(?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=AE=A1=E5=88=92=2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 xml, hash, stringUtils, semver, uuid, format, frontmatterParser, file, glob, diff 共 10 个模块的纯函数测试。 json.ts 因模块加载链路过重暂跳过。 共 190 个测试用例(含已有 array/set)全部通过。 Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/diff.test.ts | 77 +++++++ src/utils/__tests__/file.test.ts | 95 +++++++++ src/utils/__tests__/format.test.ts | 133 ++++++++++++ src/utils/__tests__/frontmatterParser.test.ts | 164 +++++++++++++++ src/utils/__tests__/glob.test.ts | 40 ++++ src/utils/__tests__/hash.test.ts | 57 +++++ src/utils/__tests__/semver.test.ts | 98 +++++++++ src/utils/__tests__/stringUtils.test.ts | 195 ++++++++++++++++++ src/utils/__tests__/uuid.test.ts | 34 +++ src/utils/__tests__/xml.test.ts | 42 ++++ 10 files changed, 935 insertions(+) create mode 100644 src/utils/__tests__/diff.test.ts create mode 100644 src/utils/__tests__/file.test.ts create mode 100644 src/utils/__tests__/format.test.ts create mode 100644 src/utils/__tests__/frontmatterParser.test.ts create mode 100644 src/utils/__tests__/glob.test.ts create mode 100644 src/utils/__tests__/hash.test.ts create mode 100644 src/utils/__tests__/semver.test.ts create mode 100644 src/utils/__tests__/stringUtils.test.ts create mode 100644 src/utils/__tests__/uuid.test.ts create mode 100644 src/utils/__tests__/xml.test.ts diff --git a/src/utils/__tests__/diff.test.ts b/src/utils/__tests__/diff.test.ts new file mode 100644 index 000000000..95bd1a9f7 --- /dev/null +++ b/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); + }); +}); diff --git a/src/utils/__tests__/file.test.ts b/src/utils/__tests__/file.test.ts new file mode 100644 index 000000000..82fb3fdd6 --- /dev/null +++ b/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); + }); +}); diff --git a/src/utils/__tests__/format.test.ts b/src/utils/__tests__/format.test.ts new file mode 100644 index 000000000..b313ecca1 --- /dev/null +++ b/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"); + }); +}); diff --git a/src/utils/__tests__/frontmatterParser.test.ts b/src/utils/__tests__/frontmatterParser.test.ts new file mode 100644 index 000000000..79b6049b2 --- /dev/null +++ b/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(); + }); +}); diff --git a/src/utils/__tests__/glob.test.ts b/src/utils/__tests__/glob.test.ts new file mode 100644 index 000000000..39994479e --- /dev/null +++ b/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"); + }); +}); diff --git a/src/utils/__tests__/hash.test.ts b/src/utils/__tests__/hash.test.ts new file mode 100644 index 000000000..abbbea170 --- /dev/null +++ b/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")); + }); +}); diff --git a/src/utils/__tests__/semver.test.ts b/src/utils/__tests__/semver.test.ts new file mode 100644 index 000000000..a170d00bd --- /dev/null +++ b/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); + }); +}); diff --git a/src/utils/__tests__/stringUtils.test.ts b/src/utils/__tests__/stringUtils.test.ts new file mode 100644 index 000000000..730374daf --- /dev/null +++ b/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"); + }); +}); diff --git a/src/utils/__tests__/uuid.test.ts b/src/utils/__tests__/uuid.test.ts new file mode 100644 index 000000000..74ee23268 --- /dev/null +++ b/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(); + }); +}); diff --git a/src/utils/__tests__/xml.test.ts b/src/utils/__tests__/xml.test.ts new file mode 100644 index 000000000..73ce9e5af --- /dev/null +++ b/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 & b"); + }); + + test("escapes less-than", () => { + expect(escapeXml("
")).toBe("<div>"); + }); + + test("escapes greater-than", () => { + expect(escapeXml("a > b")).toBe("a > b"); + }); + + test("escapes multiple special chars", () => { + expect(escapeXml("")).toBe("<a & b>"); + }); + + 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 "hello""); + }); + + test("escapes single quotes", () => { + expect(escapeXmlAttr("it's")).toBe("it's"); + }); + + test("escapes all special chars", () => { + expect(escapeXmlAttr('')).toBe("<a & "b">"); + }); +});