mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
feat: 完成测试 16-17
This commit is contained in:
106
src/utils/__tests__/abortController.test.ts
Normal file
106
src/utils/__tests__/abortController.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createAbortController,
|
||||
createChildAbortController,
|
||||
} from "../abortController";
|
||||
|
||||
describe("createAbortController", () => {
|
||||
test("returns an AbortController that is not aborted", () => {
|
||||
const controller = createAbortController();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("aborting the controller sets signal.aborted", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort();
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("abort reason is propagated", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort("custom reason");
|
||||
expect(controller.signal.reason).toBe("custom reason");
|
||||
});
|
||||
|
||||
test("accepts custom maxListeners without error", () => {
|
||||
const controller = createAbortController(100);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChildAbortController", () => {
|
||||
test("child is not aborted initially", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("parent abort propagates to child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
parent.abort("parent reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("parent reason");
|
||||
});
|
||||
|
||||
test("child abort does NOT propagate to parent", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("already-aborted parent immediately aborts child", () => {
|
||||
const parent = createAbortController();
|
||||
parent.abort("pre-abort");
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("pre-abort");
|
||||
});
|
||||
|
||||
test("multiple children are independent", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
child1.abort("child1");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(false);
|
||||
// Aborting child1 did not affect child2 or parent
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("parent abort propagates to all children", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
parent.abort("all go down");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("grandchild abort propagation", () => {
|
||||
const grandparent = createAbortController();
|
||||
const parent = createChildAbortController(grandparent);
|
||||
const child = createChildAbortController(parent);
|
||||
grandparent.abort("chain");
|
||||
expect(parent.signal.aborted).toBe(true);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("child abort then parent abort — child stays aborted with original reason", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child first");
|
||||
parent.abort("parent later");
|
||||
expect(child.signal.reason).toBe("child first");
|
||||
expect(parent.signal.reason).toBe("parent later");
|
||||
});
|
||||
|
||||
test("accepts custom maxListeners for child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent, 200);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
117
src/utils/__tests__/bufferedWriter.test.ts
Normal file
117
src/utils/__tests__/bufferedWriter.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createBufferedWriter } from "../bufferedWriter";
|
||||
|
||||
describe("createBufferedWriter", () => {
|
||||
test("immediateMode calls writeFn directly", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
immediateMode: true,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
expect(written).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("buffered mode accumulates until flush", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("hello ");
|
||||
writer.write("world");
|
||||
expect(written).toEqual([]);
|
||||
writer.flush();
|
||||
expect(written).toEqual(["hello world"]);
|
||||
});
|
||||
|
||||
test("flush with empty buffer does not call writeFn", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.flush();
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("flush clears the buffer", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("data");
|
||||
writer.flush();
|
||||
writer.flush(); // second flush should be no-op
|
||||
expect(written).toEqual(["data"]);
|
||||
});
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferSize reached", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 2,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
// 2 writes = maxBufferSize, triggers flushDeferred via setImmediate
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferBytes reached", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferBytes: 5,
|
||||
});
|
||||
writer.write("abc");
|
||||
writer.write("def");
|
||||
// total 6 bytes > 5, triggers flushDeferred
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose flushes remaining buffer", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("final");
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["final"]);
|
||||
});
|
||||
|
||||
test("dispose flushes pending overflow", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("overflow-data");
|
||||
// overflow triggered but deferred; dispose should flush it synchronously
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["overflow-data"]);
|
||||
});
|
||||
|
||||
test("coalesced overflow — multiple overflows merge before write", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("a"); // triggers first overflow (deferred)
|
||||
writer.write("b"); // pendingOverflow exists, coalesces
|
||||
writer.dispose(); // flushes coalesced overflow
|
||||
expect(written).toEqual(["ab"]);
|
||||
});
|
||||
|
||||
test("multiple flushes produce concatenated writes", () => {
|
||||
const written: string[] = [];
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("batch1");
|
||||
writer.flush();
|
||||
writer.write("batch2");
|
||||
writer.flush();
|
||||
expect(written).toEqual(["batch1", "batch2"]);
|
||||
});
|
||||
});
|
||||
@@ -83,4 +83,24 @@ describe("validateBoundedIntEnvVar", () => {
|
||||
expect(result.effective).toBe(500);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
|
||||
test("value=1 with high defaultValue returns 1 (no lower bound enforcement)", () => {
|
||||
// Function only checks parsed > 0 and parsed <= upperLimit
|
||||
// It does NOT enforce that parsed >= defaultValue
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
||||
expect(result.effective).toBe(1);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
|
||||
test("caps very large number at upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "999999999", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("capped");
|
||||
});
|
||||
|
||||
test("treats NaN-producing strings as invalid", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "NaN", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,14 @@ describe("formatNumber", () => {
|
||||
test("formats millions", () => {
|
||||
expect(formatNumber(1500000)).toBe("1.5m");
|
||||
});
|
||||
|
||||
test("formats 0 as-is", () => {
|
||||
expect(formatNumber(0)).toBe("0");
|
||||
});
|
||||
|
||||
test("formats billions", () => {
|
||||
expect(formatNumber(1500000000)).toBe("1.5b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokens", () => {
|
||||
@@ -98,6 +106,14 @@ describe("formatTokens", () => {
|
||||
test("formats small numbers", () => {
|
||||
expect(formatTokens(500)).toBe("500");
|
||||
});
|
||||
|
||||
test("formats 1000 without .0", () => {
|
||||
expect(formatTokens(1000)).toBe("1k");
|
||||
});
|
||||
|
||||
test("formats 1500 as 1.5k", () => {
|
||||
expect(formatTokens(1500)).toBe("1.5k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
@@ -121,4 +137,19 @@ describe("formatRelativeTime", () => {
|
||||
test("handles zero difference", () => {
|
||||
expect(formatRelativeTime(now, { now })).toBe("0s ago");
|
||||
});
|
||||
|
||||
test("formats hours ago", () => {
|
||||
const date = new Date("2026-01-15T09:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("3h ago");
|
||||
});
|
||||
|
||||
test("formats days ago", () => {
|
||||
const date = new Date("2026-01-13T12:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("2d ago");
|
||||
});
|
||||
|
||||
test("formats weeks ago", () => {
|
||||
const date = new Date("2026-01-01T12:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("2w ago");
|
||||
});
|
||||
});
|
||||
|
||||
286
src/utils/__tests__/gitDiff.test.ts
Normal file
286
src/utils/__tests__/gitDiff.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitNumstat, parseGitDiff, parseShortstat } from "../gitDiff";
|
||||
|
||||
describe("parseGitNumstat", () => {
|
||||
test("parses single file", () => {
|
||||
const result = parseGitNumstat("5\t3\tsrc/foo.ts");
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 5,
|
||||
linesRemoved: 3,
|
||||
});
|
||||
expect(result.perFileStats.get("src/foo.ts")).toEqual({
|
||||
added: 5,
|
||||
removed: 3,
|
||||
isBinary: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses multiple files", () => {
|
||||
const input = "10\t2\ta.ts\n3\t0\tb.ts\n0\t7\tc.ts";
|
||||
const result = parseGitNumstat(input);
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 3,
|
||||
linesAdded: 13,
|
||||
linesRemoved: 9,
|
||||
});
|
||||
expect(result.perFileStats.size).toBe(3);
|
||||
});
|
||||
|
||||
test("handles binary file with dash counts", () => {
|
||||
const result = parseGitNumstat("-\t-\timage.png");
|
||||
expect(result.perFileStats.get("image.png")).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
isBinary: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles rename format", () => {
|
||||
const result = parseGitNumstat("1\t0\told.txt => new.txt");
|
||||
const entry = result.perFileStats.get("old.txt => new.txt");
|
||||
expect(entry).not.toBeUndefined();
|
||||
expect(entry!.added).toBe(1);
|
||||
expect(entry!.isBinary).toBe(false);
|
||||
});
|
||||
|
||||
test("handles filename with tabs", () => {
|
||||
const result = parseGitNumstat('1\t0\t"tab\tfile.txt"');
|
||||
// parts.slice(2).join('\t') preserves the rest
|
||||
expect(result.stats.filesCount).toBe(1);
|
||||
});
|
||||
|
||||
test("returns empty for empty string", () => {
|
||||
const result = parseGitNumstat("");
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
expect(result.perFileStats.size).toBe(0);
|
||||
});
|
||||
|
||||
test("skips lines with fewer than 3 tab-separated parts", () => {
|
||||
const result = parseGitNumstat("invalid-line\n5\t3\tsrc/foo.ts");
|
||||
expect(result.stats.filesCount).toBe(1);
|
||||
});
|
||||
|
||||
test("handles zero additions and zero deletions", () => {
|
||||
const result = parseGitNumstat("0\t0\tempty-change.ts");
|
||||
expect(result.perFileStats.get("empty-change.ts")).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitDiff", () => {
|
||||
test("parses single file with one hunk", () => {
|
||||
const input = [
|
||||
"diff --git a/foo.ts b/foo.ts",
|
||||
"index abc..def 100644",
|
||||
"--- a/foo.ts",
|
||||
"+++ b/foo.ts",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" line1",
|
||||
"+added",
|
||||
" line2",
|
||||
" line3",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
expect(result.size).toBe(1);
|
||||
const hunks = result.get("foo.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0].oldStart).toBe(1);
|
||||
expect(hunks[0].oldLines).toBe(3);
|
||||
expect(hunks[0].newStart).toBe(1);
|
||||
expect(hunks[0].newLines).toBe(4);
|
||||
expect(hunks[0].lines).toEqual([" line1", "+added", " line2", " line3"]);
|
||||
});
|
||||
|
||||
test("parses multiple hunks in one file", () => {
|
||||
const input = [
|
||||
"diff --git a/bar.ts b/bar.ts",
|
||||
"index abc..def 100644",
|
||||
"--- a/bar.ts",
|
||||
"+++ b/bar.ts",
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" a",
|
||||
"+b",
|
||||
" c",
|
||||
"@@ -10,2 +11,2 @@",
|
||||
" d",
|
||||
"-e",
|
||||
"+f",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("bar.ts")!;
|
||||
expect(hunks).toHaveLength(2);
|
||||
expect(hunks[0].oldStart).toBe(1);
|
||||
expect(hunks[1].oldStart).toBe(10);
|
||||
});
|
||||
|
||||
test("skips binary files marker", () => {
|
||||
const input = [
|
||||
"diff --git a/img.png b/img.png",
|
||||
"Binary files a/img.png and b/img.png differ",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
// Binary file has no hunks, so it's not in the result
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("parses new file mode", () => {
|
||||
const input = [
|
||||
"diff --git a/new.ts b/new.ts",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/new.ts",
|
||||
"@@ -0,0 +1,2 @@",
|
||||
"+line1",
|
||||
"+line2",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("new.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0].lines).toEqual(["+line1", "+line2"]);
|
||||
});
|
||||
|
||||
test("parses deleted file", () => {
|
||||
const input = [
|
||||
"diff --git a/old.ts b/old.ts",
|
||||
"deleted file mode 100644",
|
||||
"--- a/old.ts",
|
||||
"+++ /dev/null",
|
||||
"@@ -1,2 +0,0 @@",
|
||||
"-line1",
|
||||
"-line2",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("old.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns empty map for empty input", () => {
|
||||
const result = parseGitDiff("");
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("handles multiple files", () => {
|
||||
const input = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-x",
|
||||
"+y",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.has("a.ts")).toBe(true);
|
||||
expect(result.has("b.ts")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips hunk without comma (single line)", () => {
|
||||
const input = [
|
||||
"diff --git a/solo.ts b/solo.ts",
|
||||
"--- a/solo.ts",
|
||||
"+++ b/solo.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
].join("\n");
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("solo.ts")!;
|
||||
expect(hunks[0].oldLines).toBe(1); // default when no comma
|
||||
expect(hunks[0].newLines).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseShortstat", () => {
|
||||
test("parses full shortstat with insertions and deletions", () => {
|
||||
const result = parseShortstat(" 3 files changed, 10 insertions(+), 5 deletions(-)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 3,
|
||||
linesAdded: 10,
|
||||
linesRemoved: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses single file", () => {
|
||||
const result = parseShortstat(" 1 file changed, 2 insertions(+), 1 deletion(-)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 2,
|
||||
linesRemoved: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses insertions only", () => {
|
||||
const result = parseShortstat(" 2 files changed, 5 insertions(+)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 2,
|
||||
linesAdded: 5,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses deletions only", () => {
|
||||
const result = parseShortstat(" 1 file changed, 3 deletions(-)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses files changed only (no insertions or deletions)", () => {
|
||||
const result = parseShortstat(" 2 files changed");
|
||||
expect(result).toEqual({
|
||||
filesCount: 2,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseShortstat("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching string", () => {
|
||||
expect(parseShortstat("nothing to see here")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles large numbers", () => {
|
||||
const result = parseShortstat(" 100 files changed, 50000 insertions(+), 30000 deletions(-)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 100,
|
||||
linesAdded: 50000,
|
||||
linesRemoved: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles zero insertions and deletions explicitly", () => {
|
||||
// git can output "0 insertions(+), 0 deletions(-)"
|
||||
const result = parseShortstat(" 1 file changed, 0 insertions(+), 0 deletions(-)");
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
108
src/utils/__tests__/sliceAnsi.test.ts
Normal file
108
src/utils/__tests__/sliceAnsi.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock ink/stringWidth to avoid heavy Ink import chain
|
||||
mock.module("src/ink/stringWidth.js", () => ({
|
||||
stringWidth: (str: string) => {
|
||||
// Simplified width calculation for test purposes
|
||||
let width = 0;
|
||||
for (const char of str) {
|
||||
const code = char.codePointAt(0)!;
|
||||
// CJK Unified Ideographs and common full-width ranges
|
||||
if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK
|
||||
(code >= 0x3000 && code <= 0x303f) || // CJK Symbols
|
||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
|
||||
(code >= 0xf900 && code <= 0xfaff) // CJK Compatibility
|
||||
) {
|
||||
width += 2;
|
||||
} else if (code > 0) {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
},
|
||||
}));
|
||||
|
||||
const sliceAnsi = (await import("../sliceAnsi")).default;
|
||||
|
||||
describe("sliceAnsi", () => {
|
||||
test("plain text slice identical to String.slice", () => {
|
||||
expect(sliceAnsi("hello world", 0, 5)).toBe("hello");
|
||||
expect(sliceAnsi("hello world", 6)).toBe("world");
|
||||
});
|
||||
|
||||
test("slice entire string", () => {
|
||||
expect(sliceAnsi("abc", 0)).toBe("abc");
|
||||
});
|
||||
|
||||
test("empty slice (start === end)", () => {
|
||||
expect(sliceAnsi("abc", 2, 2)).toBe("");
|
||||
});
|
||||
|
||||
test("preserves ANSI color codes within slice", () => {
|
||||
const input = "\x1b[31mred\x1b[0m normal";
|
||||
const result = sliceAnsi(input, 0, 3);
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toContain("red");
|
||||
});
|
||||
|
||||
test("closes opened ANSI styles at slice end", () => {
|
||||
const input = "\x1b[31mhello world\x1b[0m";
|
||||
const result = sliceAnsi(input, 0, 5);
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toContain("hello");
|
||||
// undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground)
|
||||
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m"));
|
||||
// The result should start with open code and end with a close code
|
||||
const withoutText = result.replace("hello", "");
|
||||
// Should have at least one open and one close code
|
||||
expect(withoutText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("slice starting mid-ANSI skips codes before start", () => {
|
||||
const input = "\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m";
|
||||
const result = sliceAnsi(input, 6, 11);
|
||||
expect(result).toContain("world");
|
||||
expect(result).toContain("\x1b[32m");
|
||||
expect(result).not.toContain("\x1b[31m");
|
||||
});
|
||||
|
||||
test("slice of plain text from middle", () => {
|
||||
expect(sliceAnsi("abcdefgh", 2, 5)).toBe("cde");
|
||||
});
|
||||
|
||||
test("slice past end of string returns everything", () => {
|
||||
expect(sliceAnsi("abc", 0, 100)).toBe("abc");
|
||||
});
|
||||
|
||||
test("slice starting at end returns empty", () => {
|
||||
expect(sliceAnsi("abc", 3)).toBe("");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(sliceAnsi("", 0, 5)).toBe("");
|
||||
});
|
||||
|
||||
test("multiple ANSI codes nested", () => {
|
||||
const input = "\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m";
|
||||
const result = sliceAnsi(input, 0, 4);
|
||||
expect(result).toContain("bold");
|
||||
// Both styles should be opened and then closed
|
||||
expect(result).toContain("\x1b[1m");
|
||||
expect(result).toContain("\x1b[31m");
|
||||
});
|
||||
|
||||
test("slice with no end parameter returns to end of string", () => {
|
||||
expect(sliceAnsi("hello world", 6)).toBe("world");
|
||||
});
|
||||
|
||||
test("ANSI codes at boundaries are handled correctly", () => {
|
||||
const input = "a\x1b[31mb\x1b[0mc";
|
||||
// "abc" visually, position: a=0, b=1, c=2
|
||||
const result = sliceAnsi(input, 1, 2);
|
||||
// undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m
|
||||
expect(result).toContain("b");
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m.*\\x1b\\[\\d+m")); // open + close codes
|
||||
});
|
||||
});
|
||||
162
src/utils/__tests__/stream.test.ts
Normal file
162
src/utils/__tests__/stream.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Stream } from "../stream";
|
||||
|
||||
describe("Stream", () => {
|
||||
test("enqueue then read resolves with the value", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.enqueue(42);
|
||||
const result = await stream.next();
|
||||
expect(result).toEqual({ done: false, value: 42 });
|
||||
});
|
||||
|
||||
test("enqueue multiple then drain in order", async () => {
|
||||
const stream = new Stream<string>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.enqueue("a");
|
||||
stream.enqueue("b");
|
||||
stream.enqueue("c");
|
||||
expect(await stream.next()).toEqual({ done: false, value: "a" });
|
||||
expect(await stream.next()).toEqual({ done: false, value: "b" });
|
||||
expect(await stream.next()).toEqual({ done: false, value: "c" });
|
||||
});
|
||||
|
||||
test("next() blocks until enqueue provides a value", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
// Not resolved yet — enqueue after a microtask
|
||||
stream.enqueue(99);
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ done: false, value: 99 });
|
||||
});
|
||||
|
||||
test("done() resolves pending reader with done:true", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
stream.done();
|
||||
expect(await promise).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("done() with no pending reader — subsequent next returns done:true", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("error() rejects pending reader", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
stream.error(new Error("boom"));
|
||||
expect(promise).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
test("error() after done — hasError is set but next returns done:true (isDone checked first)", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
stream.error(new Error("late error"));
|
||||
// next() checks isDone before hasError, so it returns done:true
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("enqueue after done — queue is checked before isDone, value is consumed", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
stream.enqueue(1);
|
||||
// next() checks queue.length > 0 first, so enqueued value is returned
|
||||
expect(await stream.next()).toEqual({ done: false, value: 1 });
|
||||
// After draining queue, done takes effect
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("return() marks stream as done and calls returned callback", async () => {
|
||||
let called = false;
|
||||
const stream = new Stream<number>(() => { called = true; });
|
||||
stream[Symbol.asyncIterator]();
|
||||
const result = await stream.return();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
expect(called).toBe(true);
|
||||
// Subsequent next returns done
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("return() without callback still works", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const result = await stream.return();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
test("Symbol.asyncIterator throws on second call", () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
expect(() => stream[Symbol.asyncIterator]()).toThrow(
|
||||
"Stream can only be iterated once"
|
||||
);
|
||||
});
|
||||
|
||||
test("for-await-of iteration drains queued values then ends", async () => {
|
||||
const stream = new Stream<string>();
|
||||
stream.enqueue("x");
|
||||
stream.enqueue("y");
|
||||
stream.done();
|
||||
const results: string[] = [];
|
||||
for await (const value of stream) {
|
||||
results.push(value);
|
||||
}
|
||||
expect(results).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
test("for-await-of blocks until done", async () => {
|
||||
const stream = new Stream<number>();
|
||||
const results: number[] = [];
|
||||
|
||||
const iterPromise = (async () => {
|
||||
for await (const v of stream) {
|
||||
results.push(v);
|
||||
}
|
||||
})();
|
||||
|
||||
// Enqueue after a tick
|
||||
await Promise.resolve();
|
||||
stream.enqueue(1);
|
||||
stream.enqueue(2);
|
||||
stream.done();
|
||||
|
||||
await iterPromise;
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("error during for-await-of rejects the loop", async () => {
|
||||
const stream = new Stream<number>();
|
||||
const iterPromise = (async () => {
|
||||
for await (const _ of stream) {
|
||||
// will error before any value
|
||||
}
|
||||
})();
|
||||
stream.error(new Error("stream broken"));
|
||||
expect(iterPromise).rejects.toThrow("stream broken");
|
||||
});
|
||||
|
||||
test("concurrent enqueue from multiple sources does not lose data", async () => {
|
||||
const stream = new Stream<number>();
|
||||
// Rapid sequential enqueue
|
||||
for (let i = 0; i < 100; i++) {
|
||||
stream.enqueue(i);
|
||||
}
|
||||
stream.done();
|
||||
|
||||
const results: number[] = [];
|
||||
for await (const v of stream) {
|
||||
results.push(v);
|
||||
}
|
||||
expect(results.length).toBe(100);
|
||||
expect(results[0]).toBe(0);
|
||||
expect(results[99]).toBe(99);
|
||||
});
|
||||
});
|
||||
109
src/utils/__tests__/treeify.test.ts
Normal file
109
src/utils/__tests__/treeify.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("figures", () => ({
|
||||
default: {
|
||||
lineUpDownRight: "├",
|
||||
lineUpRight: "└",
|
||||
lineVertical: "│",
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("src/components/design-system/color.js", () => ({
|
||||
color: (colorKey: string, themeName: string) => (text: string) => text,
|
||||
}));
|
||||
|
||||
const { treeify } = await import("../treeify");
|
||||
|
||||
describe("treeify", () => {
|
||||
test("renders flat tree with two keys", () => {
|
||||
const result = treeify({ a: "value-a", b: "value-b" });
|
||||
const lines = result.split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toContain("a");
|
||||
expect(lines[0]).toContain("value-a");
|
||||
expect(lines[1]).toContain("b");
|
||||
expect(lines[1]).toContain("value-b");
|
||||
});
|
||||
|
||||
test("uses branch character for non-last items", () => {
|
||||
const result = treeify({ a: "1", b: "2" });
|
||||
// First item uses ├ (branch), last uses └ (lastBranch)
|
||||
expect(result).toContain("├");
|
||||
expect(result).toContain("└");
|
||||
});
|
||||
|
||||
test("uses lastBranch for single item", () => {
|
||||
const result = treeify({ only: "val" });
|
||||
expect(result).toContain("└");
|
||||
expect(result).not.toContain("├");
|
||||
});
|
||||
|
||||
test("renders nested objects", () => {
|
||||
const result = treeify({ parent: { child: "val" } });
|
||||
expect(result).toContain("parent");
|
||||
expect(result).toContain("child");
|
||||
expect(result).toContain("val");
|
||||
});
|
||||
|
||||
test("renders arrays with length", () => {
|
||||
const result = treeify({ items: [1, 2, 3] });
|
||||
expect(result).toContain("items");
|
||||
expect(result).toContain("[Array(3)]");
|
||||
});
|
||||
|
||||
test("detects circular references", () => {
|
||||
const obj: Record<string, unknown> = { name: "root" };
|
||||
obj.self = obj;
|
||||
const result = treeify(obj);
|
||||
expect(result).toContain("[Circular]");
|
||||
});
|
||||
|
||||
test("returns (empty) for empty object", () => {
|
||||
const result = treeify({});
|
||||
expect(result).toBe("(empty)");
|
||||
});
|
||||
|
||||
test("hideFunctions filters out function values", () => {
|
||||
const obj = { name: "test", fn: () => {} };
|
||||
const result = treeify(obj, { hideFunctions: true });
|
||||
expect(result).toContain("name");
|
||||
expect(result).not.toContain("fn");
|
||||
});
|
||||
|
||||
test("showValues false hides leaf values", () => {
|
||||
const obj = { name: "test" };
|
||||
const result = treeify(obj, { showValues: false });
|
||||
expect(result).toContain("name");
|
||||
expect(result).not.toContain("test");
|
||||
});
|
||||
|
||||
test("showValues true shows function as [Function]", () => {
|
||||
const obj = { fn: () => {} };
|
||||
const result = treeify(obj, { showValues: true });
|
||||
expect(result).toContain("[Function]");
|
||||
});
|
||||
|
||||
test("deep nesting produces correct indentation", () => {
|
||||
const obj = { a: { b: { c: "deep" } } };
|
||||
const result = treeify(obj);
|
||||
const lines = result.split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
// Each level adds indentation
|
||||
expect(lines[2].length).toBeGreaterThan(lines[1].length);
|
||||
});
|
||||
|
||||
test("handles empty string key with string value", () => {
|
||||
const obj = { " ": "whitespace-key" };
|
||||
const result = treeify(obj);
|
||||
expect(result).toContain("whitespace-key");
|
||||
});
|
||||
|
||||
test("handles mixed object and primitive values", () => {
|
||||
const obj = { name: "test", nested: { inner: "val" }, count: 5 };
|
||||
const result = treeify(obj);
|
||||
expect(result).toContain("name");
|
||||
expect(result).toContain("nested");
|
||||
expect(result).toContain("inner");
|
||||
expect(result).toContain("count");
|
||||
});
|
||||
});
|
||||
85
src/utils/__tests__/words.test.ts
Normal file
85
src/utils/__tests__/words.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateWordSlug, generateShortWordSlug } from "../words";
|
||||
|
||||
describe("generateWordSlug", () => {
|
||||
test("returns three-part hyphenated slug", () => {
|
||||
const slug = generateWordSlug();
|
||||
const parts = slug.split("-");
|
||||
expect(parts.length).toBe(3);
|
||||
});
|
||||
|
||||
test("all parts are non-empty", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
const parts = slug.split("-");
|
||||
for (const part of parts) {
|
||||
expect(part.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all parts are lowercase", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).toBe(slug.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
test("no consecutive hyphens", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).not.toContain("--");
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple calls produce varied results", () => {
|
||||
const slugs = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
slugs.add(generateWordSlug());
|
||||
}
|
||||
// With 50+ adjectives × 50+ verbs × 50+ nouns, 20 calls should produce mostly unique slugs
|
||||
expect(slugs.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test("slug matches adjective-verb-noun pattern", () => {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShortWordSlug", () => {
|
||||
test("returns two-part hyphenated slug", () => {
|
||||
const slug = generateShortWordSlug();
|
||||
const parts = slug.split("-");
|
||||
expect(parts.length).toBe(2);
|
||||
});
|
||||
|
||||
test("all parts are non-empty", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
const parts = slug.split("-");
|
||||
for (const part of parts) {
|
||||
expect(part.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all parts are lowercase", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).toBe(slug.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
test("slug matches adjective-noun pattern", () => {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+$/);
|
||||
});
|
||||
|
||||
test("no consecutive hyphens", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).not.toContain("--");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user