mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
test: 添加一大堆测试文件
This commit is contained in:
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collapseHookSummaries } from "../collapseHookSummaries";
|
||||
|
||||
function makeHookSummary(overrides: Partial<{
|
||||
hookLabel: string;
|
||||
hookCount: number;
|
||||
hookInfos: any[];
|
||||
hookErrors: any[];
|
||||
preventedContinuation: boolean;
|
||||
hasOutput: boolean;
|
||||
totalDurationMs: number;
|
||||
}> = {}): any {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "stop_hook_summary",
|
||||
hookLabel: overrides.hookLabel ?? "PostToolUse",
|
||||
hookCount: overrides.hookCount ?? 1,
|
||||
hookInfos: overrides.hookInfos ?? [],
|
||||
hookErrors: overrides.hookErrors ?? [],
|
||||
preventedContinuation: overrides.preventedContinuation ?? false,
|
||||
hasOutput: overrides.hasOutput ?? false,
|
||||
totalDurationMs: overrides.totalDurationMs ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNonHookMessage(): any {
|
||||
return { type: "user", message: { content: "hello" } };
|
||||
}
|
||||
|
||||
describe("collapseHookSummaries", () => {
|
||||
test("returns same messages when no hook summaries", () => {
|
||||
const messages = [makeNonHookMessage(), makeNonHookMessage()];
|
||||
expect(collapseHookSummaries(messages)).toEqual(messages);
|
||||
});
|
||||
|
||||
test("collapses consecutive messages with same hookLabel", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "PostToolUse", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "PostToolUse", hookCount: 2 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(3);
|
||||
});
|
||||
|
||||
test("does not collapse messages with different hookLabels", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "PostToolUse" }),
|
||||
makeHookSummary({ hookLabel: "PreToolUse" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("aggregates hookCount across collapsed messages", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookCount: 3 }),
|
||||
makeHookSummary({ hookLabel: "A", hookCount: 5 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookCount).toBe(8);
|
||||
});
|
||||
|
||||
test("merges hookInfos arrays", () => {
|
||||
const info1 = { tool: "Read" };
|
||||
const info2 = { tool: "Write" };
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookInfos: [info1] }),
|
||||
makeHookSummary({ hookLabel: "A", hookInfos: [info2] }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookInfos).toEqual([info1, info2]);
|
||||
});
|
||||
|
||||
test("merges hookErrors arrays", () => {
|
||||
const err1 = new Error("e1");
|
||||
const err2 = new Error("e2");
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", hookErrors: [err1] }),
|
||||
makeHookSummary({ hookLabel: "A", hookErrors: [err2] }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].hookErrors).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("takes max totalDurationMs", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 50 }),
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 100 }),
|
||||
makeHookSummary({ hookLabel: "A", totalDurationMs: 75 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].totalDurationMs).toBe(100);
|
||||
});
|
||||
|
||||
test("takes any truthy preventContinuation", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A", preventedContinuation: false }),
|
||||
makeHookSummary({ hookLabel: "A", preventedContinuation: true }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result[0].preventedContinuation).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves single hook summary unchanged", () => {
|
||||
const msg = makeHookSummary({ hookLabel: "PostToolUse", hookCount: 5 });
|
||||
const result = collapseHookSummaries([msg]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(5);
|
||||
});
|
||||
|
||||
test("handles three consecutive same-label summaries", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
makeHookSummary({ hookLabel: "X", hookCount: 1 }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].hookCount).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves non-hook messages in between", () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
makeNonHookMessage(),
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(collapseHookSummaries([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
94
src/utils/__tests__/collapseTeammateShutdowns.test.ts
Normal file
94
src/utils/__tests__/collapseTeammateShutdowns.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collapseTeammateShutdowns } from "../collapseTeammateShutdowns";
|
||||
|
||||
function makeShutdownMsg(uuid = "1"): any {
|
||||
return {
|
||||
type: "attachment",
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
attachment: {
|
||||
type: "task_status",
|
||||
taskType: "in_process_teammate",
|
||||
status: "completed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeNonShutdownMsg(): any {
|
||||
return { type: "user", message: { content: "hello" } };
|
||||
}
|
||||
|
||||
describe("collapseTeammateShutdowns", () => {
|
||||
test("returns same messages when no teammate shutdowns", () => {
|
||||
const msgs = [makeNonShutdownMsg(), makeNonShutdownMsg()];
|
||||
expect(collapseTeammateShutdowns(msgs)).toEqual(msgs);
|
||||
});
|
||||
|
||||
test("leaves single shutdown message unchanged", () => {
|
||||
const msgs = [makeShutdownMsg()];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(msgs[0]);
|
||||
});
|
||||
|
||||
test("collapses consecutive shutdown messages into batch", () => {
|
||||
const msgs = [makeShutdownMsg("1"), makeShutdownMsg("2")];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].attachment.type).toBe("teammate_shutdown_batch");
|
||||
});
|
||||
|
||||
test("batch attachment has correct count", () => {
|
||||
const msgs = [makeShutdownMsg("1"), makeShutdownMsg("2"), makeShutdownMsg("3")];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result[0].attachment.count).toBe(3);
|
||||
});
|
||||
|
||||
test("does not collapse non-consecutive shutdowns", () => {
|
||||
const msgs = [makeShutdownMsg("1"), makeNonShutdownMsg(), makeShutdownMsg("2")];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].attachment.type).toBe("task_status");
|
||||
expect(result[2].attachment.type).toBe("task_status");
|
||||
});
|
||||
|
||||
test("preserves non-shutdown messages between shutdowns", () => {
|
||||
const msgs = [makeShutdownMsg("1"), makeNonShutdownMsg(), makeShutdownMsg("2")];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result[1]).toEqual(makeNonShutdownMsg());
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
expect(collapseTeammateShutdowns([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles mixed message types", () => {
|
||||
const msgs = [makeNonShutdownMsg(), makeShutdownMsg("1"), makeShutdownMsg("2"), makeNonShutdownMsg()];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1].attachment.type).toBe("teammate_shutdown_batch");
|
||||
});
|
||||
|
||||
test("collapses more than 2 consecutive shutdowns", () => {
|
||||
const msgs = Array.from({ length: 5 }, (_, i) => makeShutdownMsg(String(i)));
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].attachment.count).toBe(5);
|
||||
});
|
||||
|
||||
test("non-teammate task_status messages are not collapsed", () => {
|
||||
const nonTeammate: any = {
|
||||
type: "attachment",
|
||||
uuid: "x",
|
||||
timestamp: Date.now(),
|
||||
attachment: {
|
||||
type: "task_status",
|
||||
taskType: "subagent",
|
||||
status: "completed",
|
||||
},
|
||||
};
|
||||
const msgs = [nonTeammate, { ...nonTeammate, uuid: "y" }];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
70
src/utils/__tests__/configConstants.test.ts
Normal file
70
src/utils/__tests__/configConstants.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
EDITOR_MODES,
|
||||
TEAMMATE_MODES,
|
||||
} from "../configConstants";
|
||||
|
||||
describe("NOTIFICATION_CHANNELS", () => {
|
||||
test("contains expected channels", () => {
|
||||
expect(NOTIFICATION_CHANNELS).toContain("auto");
|
||||
expect(NOTIFICATION_CHANNELS).toContain("iterm2");
|
||||
expect(NOTIFICATION_CHANNELS).toContain("terminal_bell");
|
||||
expect(NOTIFICATION_CHANNELS).toContain("kitty");
|
||||
expect(NOTIFICATION_CHANNELS).toContain("ghostty");
|
||||
});
|
||||
|
||||
test("is readonly array", () => {
|
||||
expect(Array.isArray(NOTIFICATION_CHANNELS)).toBe(true);
|
||||
// TypeScript enforces readonly at compile time; runtime is still a plain array
|
||||
expect(NOTIFICATION_CHANNELS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("includes all documented channels", () => {
|
||||
expect(NOTIFICATION_CHANNELS).toEqual([
|
||||
"auto",
|
||||
"iterm2",
|
||||
"iterm2_with_bell",
|
||||
"terminal_bell",
|
||||
"kitty",
|
||||
"ghostty",
|
||||
"notifications_disabled",
|
||||
]);
|
||||
});
|
||||
|
||||
test("has no duplicate entries", () => {
|
||||
const unique = new Set(NOTIFICATION_CHANNELS);
|
||||
expect(unique.size).toBe(NOTIFICATION_CHANNELS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EDITOR_MODES", () => {
|
||||
test("contains 'normal' and 'vim'", () => {
|
||||
expect(EDITOR_MODES).toContain("normal");
|
||||
expect(EDITOR_MODES).toContain("vim");
|
||||
});
|
||||
|
||||
test("has exactly 2 entries", () => {
|
||||
expect(EDITOR_MODES).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("is ordered: normal, vim", () => {
|
||||
expect(EDITOR_MODES).toEqual(["normal", "vim"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TEAMMATE_MODES", () => {
|
||||
test("contains 'auto', 'tmux', 'in-process'", () => {
|
||||
expect(TEAMMATE_MODES).toContain("auto");
|
||||
expect(TEAMMATE_MODES).toContain("tmux");
|
||||
expect(TEAMMATE_MODES).toContain("in-process");
|
||||
});
|
||||
|
||||
test("has exactly 3 entries", () => {
|
||||
expect(TEAMMATE_MODES).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("is ordered: auto, tmux, in-process", () => {
|
||||
expect(TEAMMATE_MODES).toEqual(["auto", "tmux", "in-process"]);
|
||||
});
|
||||
});
|
||||
108
src/utils/__tests__/detectRepository.test.ts
Normal file
108
src/utils/__tests__/detectRepository.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitRemote, parseGitHubRepository } from "../detectRepository";
|
||||
|
||||
describe("parseGitRemote", () => {
|
||||
// HTTPS
|
||||
test("parses HTTPS URL: https://github.com/owner/repo.git", () => {
|
||||
const result = parseGitRemote("https://github.com/owner/repo.git");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
test("parses HTTPS URL without .git suffix", () => {
|
||||
const result = parseGitRemote("https://github.com/owner/repo");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
test("parses HTTPS URL with subdirectory path (only takes first 2 segments)", () => {
|
||||
const result = parseGitRemote("https://github.com/owner/repo.git");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe("repo");
|
||||
});
|
||||
|
||||
// SSH
|
||||
test("parses SSH URL: git@github.com:owner/repo.git", () => {
|
||||
const result = parseGitRemote("git@github.com:owner/repo.git");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
test("parses SSH URL without .git suffix", () => {
|
||||
const result = parseGitRemote("git@github.com:owner/repo");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
// ssh://
|
||||
test("parses ssh:// URL: ssh://git@github.com/owner/repo.git", () => {
|
||||
const result = parseGitRemote("ssh://git@github.com/owner/repo.git");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
// git://
|
||||
test("parses git:// URL", () => {
|
||||
const result = parseGitRemote("git://github.com/owner/repo.git");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" });
|
||||
});
|
||||
|
||||
// Boundary
|
||||
test("returns null for invalid URL", () => {
|
||||
expect(parseGitRemote("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseGitRemote("")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles GHE hostname", () => {
|
||||
const result = parseGitRemote("https://ghe.corp.com/team/project.git");
|
||||
expect(result).toEqual({ host: "ghe.corp.com", owner: "team", name: "project" });
|
||||
});
|
||||
|
||||
test("handles port number in URL", () => {
|
||||
const result = parseGitRemote("https://github.com:443/owner/repo.git");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.owner).toBe("owner");
|
||||
expect(result!.name).toBe("repo");
|
||||
});
|
||||
|
||||
test("rejects SSH config alias without real hostname", () => {
|
||||
expect(parseGitRemote("git@github.com-work:owner/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles repo names with dots", () => {
|
||||
const result = parseGitRemote("https://github.com/owner/cc.kurs.web.git");
|
||||
expect(result).toEqual({ host: "github.com", owner: "owner", name: "cc.kurs.web" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitHubRepository", () => {
|
||||
test("extracts 'owner/repo' from valid remote URL", () => {
|
||||
expect(parseGitHubRepository("https://github.com/owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
test("handles plain 'owner/repo' string input", () => {
|
||||
expect(parseGitHubRepository("owner/repo")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
test("returns null for non-GitHub host", () => {
|
||||
expect(parseGitHubRepository("https://gitlab.com/owner/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid input", () => {
|
||||
expect(parseGitHubRepository("not-valid")).toBeNull();
|
||||
});
|
||||
|
||||
test("is case-sensitive for owner/repo", () => {
|
||||
expect(parseGitHubRepository("Owner/Repo")).toBe("Owner/Repo");
|
||||
});
|
||||
|
||||
test("handles SSH format for github.com", () => {
|
||||
expect(parseGitHubRepository("git@github.com:owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
test("returns null for GHE SSH URL", () => {
|
||||
expect(parseGitHubRepository("git@ghe.corp.com:owner/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles plain owner/repo with .git suffix", () => {
|
||||
expect(parseGitHubRepository("owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
});
|
||||
110
src/utils/__tests__/directMemberMessage.test.ts
Normal file
110
src/utils/__tests__/directMemberMessage.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseDirectMemberMessage, sendDirectMemberMessage } from "../directMemberMessage";
|
||||
|
||||
describe("parseDirectMemberMessage", () => {
|
||||
test("parses '@agent-name hello world'", () => {
|
||||
const result = parseDirectMemberMessage("@agent-name hello world");
|
||||
expect(result).toEqual({ recipientName: "agent-name", message: "hello world" });
|
||||
});
|
||||
|
||||
test("parses '@agent-name single-word'", () => {
|
||||
const result = parseDirectMemberMessage("@agent-name single-word");
|
||||
expect(result).toEqual({ recipientName: "agent-name", message: "single-word" });
|
||||
});
|
||||
|
||||
test("returns null for non-matching input", () => {
|
||||
expect(parseDirectMemberMessage("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseDirectMemberMessage("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for '@name' without message", () => {
|
||||
expect(parseDirectMemberMessage("@name")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles hyphenated agent names like '@my-agent msg'", () => {
|
||||
const result = parseDirectMemberMessage("@my-agent msg");
|
||||
expect(result).toEqual({ recipientName: "my-agent", message: "msg" });
|
||||
});
|
||||
|
||||
test("handles multiline message content", () => {
|
||||
const result = parseDirectMemberMessage("@agent line1\nline2");
|
||||
expect(result).toEqual({ recipientName: "agent", message: "line1\nline2" });
|
||||
});
|
||||
|
||||
test("extracts correct recipientName and message", () => {
|
||||
const result = parseDirectMemberMessage("@alice please fix the bug");
|
||||
expect(result?.recipientName).toBe("alice");
|
||||
expect(result?.message).toBe("please fix the bug");
|
||||
});
|
||||
|
||||
test("trims message whitespace", () => {
|
||||
const result = parseDirectMemberMessage("@agent hello ");
|
||||
expect(result?.message).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendDirectMemberMessage", () => {
|
||||
test("returns error when no team context", async () => {
|
||||
const result = await sendDirectMemberMessage("agent", "hello", null as any);
|
||||
expect(result).toEqual({ success: false, error: "no_team_context" });
|
||||
});
|
||||
|
||||
test("returns error for unknown recipient", async () => {
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { alice: { name: "alice" } },
|
||||
};
|
||||
const result = await sendDirectMemberMessage(
|
||||
"bob",
|
||||
"hello",
|
||||
teamContext as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "unknown_recipient",
|
||||
recipientName: "bob",
|
||||
});
|
||||
});
|
||||
|
||||
test("calls writeToMailbox with correct args for valid recipient", async () => {
|
||||
let mailboxArgs: any = null;
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { alice: { name: "alice" } },
|
||||
};
|
||||
const result = await sendDirectMemberMessage(
|
||||
"alice",
|
||||
"hello",
|
||||
teamContext as any,
|
||||
async (recipient, msg, team) => {
|
||||
mailboxArgs = { recipient, msg, team };
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ success: true, recipientName: "alice" });
|
||||
expect(mailboxArgs.recipient).toBe("alice");
|
||||
expect(mailboxArgs.msg.text).toBe("hello");
|
||||
expect(mailboxArgs.msg.from).toBe("user");
|
||||
expect(mailboxArgs.team).toBe("team1");
|
||||
});
|
||||
|
||||
test("returns success for valid message", async () => {
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { bob: { name: "bob" } },
|
||||
};
|
||||
const result = await sendDirectMemberMessage(
|
||||
"bob",
|
||||
"test message",
|
||||
teamContext as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.recipientName).toBe("bob");
|
||||
}
|
||||
});
|
||||
});
|
||||
122
src/utils/__tests__/fingerprint.test.ts
Normal file
122
src/utils/__tests__/fingerprint.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
FINGERPRINT_SALT,
|
||||
extractFirstMessageText,
|
||||
computeFingerprint,
|
||||
} from "../fingerprint";
|
||||
|
||||
describe("FINGERPRINT_SALT", () => {
|
||||
test("has expected value '59cf53e54c78'", () => {
|
||||
expect(FINGERPRINT_SALT).toBe("59cf53e54c78");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFirstMessageText", () => {
|
||||
test("extracts text from first user message", () => {
|
||||
const messages = [
|
||||
{ type: "user", message: { content: "hello world" } },
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("hello world");
|
||||
});
|
||||
|
||||
test("extracts text from single user message with array content", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "text", text: "hello" }, { type: "image", url: "x" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns empty string when no user messages", () => {
|
||||
const messages = [
|
||||
{ type: "assistant", message: { content: "hi" } },
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("");
|
||||
});
|
||||
|
||||
test("skips assistant messages", () => {
|
||||
const messages = [
|
||||
{ type: "assistant", message: { content: "hi" } },
|
||||
{ type: "user", message: { content: "hello" } },
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles mixed content blocks (text + image)", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", url: "http://example.com/img.png" },
|
||||
{ type: "text", text: "after image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("after image");
|
||||
});
|
||||
|
||||
test("returns empty string for empty array", () => {
|
||||
expect(extractFirstMessageText([])).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string when content has no text block", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "image", url: "x" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFingerprint", () => {
|
||||
test("returns deterministic 3-char hex string", () => {
|
||||
const result = computeFingerprint("test message", "1.0.0");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
|
||||
test("same input produces same fingerprint", () => {
|
||||
const a = computeFingerprint("same input", "1.0.0");
|
||||
const b = computeFingerprint("same input", "1.0.0");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("different message text produces different fingerprint", () => {
|
||||
const a = computeFingerprint("hello world from test one", "1.0.0");
|
||||
const b = computeFingerprint("goodbye world from test two", "1.0.0");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("different version produces different fingerprint", () => {
|
||||
const a = computeFingerprint("same text", "1.0.0");
|
||||
const b = computeFingerprint("same text", "2.0.0");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("handles short strings (length < 21)", () => {
|
||||
const result = computeFingerprint("hi", "1.0.0");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = computeFingerprint("", "1.0.0");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
|
||||
test("fingerprint is valid hex", () => {
|
||||
const result = computeFingerprint("any message here for testing", "3.5.1");
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
});
|
||||
114
src/utils/__tests__/generators.test.ts
Normal file
114
src/utils/__tests__/generators.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { lastX, returnValue, all, toArray, fromArray } from "../generators";
|
||||
|
||||
async function* range(n: number): AsyncGenerator<number, void> {
|
||||
for (let i = 0; i < n; i++) {
|
||||
yield i;
|
||||
}
|
||||
}
|
||||
|
||||
describe("lastX", () => {
|
||||
test("returns last yielded value", async () => {
|
||||
const result = await lastX(range(5));
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
test("returns only value from single-yield generator", async () => {
|
||||
const result = await lastX(range(1));
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
test("throws on empty generator", async () => {
|
||||
await expect(lastX(range(0))).rejects.toThrow("No items in generator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("returnValue", () => {
|
||||
test("returns generator return value", async () => {
|
||||
async function* gen(): AsyncGenerator<number, string> {
|
||||
yield 1;
|
||||
return "done";
|
||||
}
|
||||
const result = await returnValue(gen());
|
||||
expect(result).toBe("done");
|
||||
});
|
||||
|
||||
test("returns undefined for void return", async () => {
|
||||
async function* gen(): AsyncGenerator<number, void> {
|
||||
yield 1;
|
||||
}
|
||||
const result = await returnValue(gen());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toArray", () => {
|
||||
test("collects all yielded values", async () => {
|
||||
const result = await toArray(range(4));
|
||||
expect(result).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
test("returns empty array for empty generator", async () => {
|
||||
const result = await toArray(fromArray([]));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves order", async () => {
|
||||
const result = await toArray(fromArray(["c", "b", "a"]));
|
||||
expect(result).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromArray", () => {
|
||||
test("yields all array elements", async () => {
|
||||
const result = await toArray(fromArray([10, 20, 30]));
|
||||
expect(result).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
test("yields nothing for empty array", async () => {
|
||||
const result = await toArray(fromArray([]));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("all", () => {
|
||||
test("merges multiple generators preserving yield order", async () => {
|
||||
const gen1 = fromArray([1, 2]);
|
||||
const gen2 = fromArray([3, 4]);
|
||||
const result = await toArray(all([gen1, gen2]));
|
||||
// All values from both generators should be present
|
||||
expect(result.sort()).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("respects concurrency cap", async () => {
|
||||
const gen1 = fromArray([1]);
|
||||
const gen2 = fromArray([2]);
|
||||
const gen3 = fromArray([3]);
|
||||
const result = await toArray(all([gen1, gen2, gen3], 2));
|
||||
expect(result.sort()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("handles empty generator array", async () => {
|
||||
const result = await toArray(all([]));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles single generator", async () => {
|
||||
const result = await toArray(all([fromArray([42])]));
|
||||
expect(result).toEqual([42]);
|
||||
});
|
||||
|
||||
test("handles generators of different lengths", async () => {
|
||||
const gen1 = fromArray([1, 2, 3]);
|
||||
const gen2 = fromArray([10]);
|
||||
const result = await toArray(all([gen1, gen2]));
|
||||
// all() merges concurrently, just verify all values are present
|
||||
expect([...result].sort((a, b) => a - b)).toEqual([1, 2, 3, 10]);
|
||||
});
|
||||
|
||||
test("yields all values from all generators", async () => {
|
||||
const gens = [fromArray([1]), fromArray([2]), fromArray([3])];
|
||||
const result = await toArray(all(gens));
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
163
src/utils/__tests__/horizontalScroll.test.ts
Normal file
163
src/utils/__tests__/horizontalScroll.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { calculateHorizontalScrollWindow } from "../horizontalScroll";
|
||||
|
||||
describe("calculateHorizontalScrollWindow", () => {
|
||||
// Basic scenarios
|
||||
test("all items fit within available width", () => {
|
||||
const result = calculateHorizontalScrollWindow([10, 10, 10], 50, 3, 1);
|
||||
expect(result).toEqual({
|
||||
startIndex: 0,
|
||||
endIndex: 3,
|
||||
showLeftArrow: false,
|
||||
showRightArrow: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("single item selected within view", () => {
|
||||
const result = calculateHorizontalScrollWindow([20], 50, 3, 0);
|
||||
expect(result).toEqual({
|
||||
startIndex: 0,
|
||||
endIndex: 1,
|
||||
showLeftArrow: false,
|
||||
showRightArrow: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("selected item at beginning", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 25, 3, 0);
|
||||
expect(result.startIndex).toBe(0);
|
||||
expect(result.showLeftArrow).toBe(false);
|
||||
expect(result.showRightArrow).toBe(true);
|
||||
expect(result.endIndex).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("selected item at end", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 25, 3, 4);
|
||||
expect(result.endIndex).toBe(5);
|
||||
expect(result.showRightArrow).toBe(false);
|
||||
expect(result.showLeftArrow).toBe(true);
|
||||
});
|
||||
|
||||
test("selected item beyond visible range scrolls right", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 4);
|
||||
expect(result.startIndex).toBeLessThanOrEqual(4);
|
||||
expect(result.endIndex).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test("selected item before visible range scrolls left", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
// Select last item first (simulates initial scroll to end)
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 0);
|
||||
expect(result.startIndex).toBe(0);
|
||||
});
|
||||
|
||||
// Arrow indicators
|
||||
test("showLeftArrow when items hidden on left", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 15, 3, 4);
|
||||
expect(result.showLeftArrow).toBe(true);
|
||||
});
|
||||
|
||||
test("showRightArrow when items hidden on right", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 15, 3, 0);
|
||||
expect(result.showRightArrow).toBe(true);
|
||||
});
|
||||
|
||||
test("no arrows when all items visible", () => {
|
||||
const result = calculateHorizontalScrollWindow([10, 10], 50, 3, 0);
|
||||
expect(result.showLeftArrow).toBe(false);
|
||||
expect(result.showRightArrow).toBe(false);
|
||||
});
|
||||
|
||||
test("both arrows when items hidden on both sides", () => {
|
||||
const widths = [10, 10, 10, 10, 10, 10, 10];
|
||||
// Select middle item with limited width
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 3);
|
||||
// Both arrows may or may not show depending on exact fit
|
||||
expect(result.startIndex).toBeLessThanOrEqual(3);
|
||||
expect(result.endIndex).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
// Boundary conditions
|
||||
test("empty itemWidths array", () => {
|
||||
const result = calculateHorizontalScrollWindow([], 50, 3, 0);
|
||||
expect(result).toEqual({
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
showLeftArrow: false,
|
||||
showRightArrow: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("single item", () => {
|
||||
const result = calculateHorizontalScrollWindow([30], 50, 3, 0);
|
||||
expect(result).toEqual({
|
||||
startIndex: 0,
|
||||
endIndex: 1,
|
||||
showLeftArrow: false,
|
||||
showRightArrow: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("available width is 0", () => {
|
||||
const result = calculateHorizontalScrollWindow([10, 10], 0, 3, 0);
|
||||
// With 0 width, nothing fits except maybe the selected
|
||||
expect(result.startIndex).toBe(0);
|
||||
});
|
||||
|
||||
test("item wider than available width", () => {
|
||||
const result = calculateHorizontalScrollWindow([100], 50, 3, 0);
|
||||
// Total width > available, but only one item
|
||||
expect(result.startIndex).toBe(0);
|
||||
expect(result.endIndex).toBe(1);
|
||||
});
|
||||
|
||||
test("all items same width", () => {
|
||||
const widths = [10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 25, 3, 2);
|
||||
expect(result.startIndex).toBeLessThanOrEqual(2);
|
||||
expect(result.endIndex).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test("varying item widths", () => {
|
||||
const widths = [5, 20, 5, 20, 5];
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 2);
|
||||
expect(result.startIndex).toBeLessThanOrEqual(2);
|
||||
expect(result.endIndex).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test("firstItemHasSeparator adds separator width to first item", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const withSep = calculateHorizontalScrollWindow(widths, 20, 3, 4, true);
|
||||
const withoutSep = calculateHorizontalScrollWindow(widths, 20, 3, 4, false);
|
||||
// Both should include selected index 4
|
||||
expect(withSep.endIndex).toBe(5);
|
||||
expect(withoutSep.endIndex).toBe(5);
|
||||
});
|
||||
|
||||
test("selectedIdx in middle of overflow", () => {
|
||||
const widths = [10, 10, 10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 25, 3, 3);
|
||||
expect(result.startIndex).toBeLessThanOrEqual(3);
|
||||
expect(result.endIndex).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
test("scroll snaps to show selected at left edge", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
// Jump to last item which forces scroll
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 4);
|
||||
expect(result.startIndex).toBeLessThanOrEqual(4);
|
||||
expect(result.endIndex).toBe(5);
|
||||
});
|
||||
|
||||
test("scroll snaps to show selected at right edge", () => {
|
||||
const widths = [10, 10, 10, 10, 10];
|
||||
const result = calculateHorizontalScrollWindow(widths, 20, 3, 4);
|
||||
expect(result.endIndex).toBe(5);
|
||||
expect(result.startIndex).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
54
src/utils/__tests__/lazySchema.test.ts
Normal file
54
src/utils/__tests__/lazySchema.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { lazySchema } from "../lazySchema";
|
||||
|
||||
describe("lazySchema", () => {
|
||||
test("returns a function", () => {
|
||||
const factory = lazySchema(() => 42);
|
||||
expect(typeof factory).toBe("function");
|
||||
});
|
||||
|
||||
test("calls factory on first invocation", () => {
|
||||
let callCount = 0;
|
||||
const factory = lazySchema(() => {
|
||||
callCount++;
|
||||
return "result";
|
||||
});
|
||||
factory();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
test("returns cached result on subsequent invocations", () => {
|
||||
const factory = lazySchema(() => ({ value: Math.random() }));
|
||||
const first = factory();
|
||||
const second = factory();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
test("factory is called only once", () => {
|
||||
let callCount = 0;
|
||||
const factory = lazySchema(() => {
|
||||
callCount++;
|
||||
return "cached";
|
||||
});
|
||||
factory();
|
||||
factory();
|
||||
factory();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
test("works with different return types", () => {
|
||||
const numFactory = lazySchema(() => 123);
|
||||
expect(numFactory()).toBe(123);
|
||||
|
||||
const arrFactory = lazySchema(() => [1, 2, 3]);
|
||||
expect(arrFactory()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("each call to lazySchema returns independent cache", () => {
|
||||
const a = lazySchema(() => ({ id: "a" }));
|
||||
const b = lazySchema(() => ({ id: "b" }));
|
||||
expect(a()).not.toBe(b());
|
||||
expect(a().id).toBe("a");
|
||||
expect(b().id).toBe("b");
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/markdown.test.ts
Normal file
58
src/utils/__tests__/markdown.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { padAligned } from "../markdown";
|
||||
|
||||
describe("padAligned", () => {
|
||||
test("left-aligns: pads with spaces on right", () => {
|
||||
const result = padAligned("hello", 5, 10, "left");
|
||||
expect(result).toBe("hello ");
|
||||
expect(result.length).toBe(10);
|
||||
});
|
||||
|
||||
test("right-aligns: pads with spaces on left", () => {
|
||||
const result = padAligned("hello", 5, 10, "right");
|
||||
expect(result).toBe(" hello");
|
||||
expect(result.length).toBe(10);
|
||||
});
|
||||
|
||||
test("center-aligns: pads with spaces on both sides", () => {
|
||||
const result = padAligned("hi", 2, 6, "center");
|
||||
expect(result).toBe(" hi ");
|
||||
expect(result.length).toBe(6);
|
||||
});
|
||||
|
||||
test("no padding when displayWidth equals targetWidth", () => {
|
||||
const result = padAligned("hello", 5, 5, "left");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles content wider than targetWidth", () => {
|
||||
const result = padAligned("hello world", 11, 5, "left");
|
||||
expect(result).toBe("hello world");
|
||||
});
|
||||
|
||||
test("null/undefined align defaults to left", () => {
|
||||
expect(padAligned("hi", 2, 5, null)).toBe("hi ");
|
||||
expect(padAligned("hi", 2, 5, undefined)).toBe("hi ");
|
||||
});
|
||||
|
||||
test("handles empty string content", () => {
|
||||
const result = padAligned("", 0, 5, "center");
|
||||
expect(result).toBe(" ");
|
||||
});
|
||||
|
||||
test("handles zero displayWidth", () => {
|
||||
const result = padAligned("", 0, 3, "left");
|
||||
expect(result).toBe(" ");
|
||||
});
|
||||
|
||||
test("handles zero targetWidth", () => {
|
||||
const result = padAligned("hello", 5, 0, "left");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("center alignment with odd padding distribution", () => {
|
||||
const result = padAligned("hi", 2, 7, "center");
|
||||
expect(result).toBe(" hi ");
|
||||
expect(result.length).toBe(7);
|
||||
});
|
||||
});
|
||||
80
src/utils/__tests__/modelCost.test.ts
Normal file
80
src/utils/__tests__/modelCost.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// formatPrice and COST_TIER constants are pure data/functions from modelCost.ts
|
||||
// We test the formatting logic directly to avoid the heavy import chain.
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (Number.isInteger(price)) {
|
||||
return `$${price}`
|
||||
}
|
||||
return `$${price.toFixed(2)}`
|
||||
}
|
||||
|
||||
// Mirrors formatModelPricing from modelCost.ts
|
||||
function formatModelPricing(costs: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}): string {
|
||||
return `${formatPrice(costs.inputTokens)}/${formatPrice(costs.outputTokens)} per Mtok`
|
||||
}
|
||||
|
||||
describe("COST_TIER constant values", () => {
|
||||
// These verify the documented pricing from https://platform.claude.com/docs/en/about-claude/pricing
|
||||
test("COST_TIER_3_15: $3/$15 (Sonnet tier)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 3, outputTokens: 15 })).toBe(
|
||||
"$3/$15 per Mtok",
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_15_75: $15/$75 (Opus 4/4.1 tier)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 15, outputTokens: 75 })).toBe(
|
||||
"$15/$75 per Mtok",
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_5_25: $5/$25 (Opus 4.5/4.6 tier)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 5, outputTokens: 25 })).toBe(
|
||||
"$5/$25 per Mtok",
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_30_150: $30/$150 (Fast Opus 4.6)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 30, outputTokens: 150 })).toBe(
|
||||
"$30/$150 per Mtok",
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_HAIKU_35: $0.80/$4 (Haiku 3.5)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 0.8, outputTokens: 4 })).toBe(
|
||||
"$0.80/$4 per Mtok",
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_HAIKU_45: $1/$5 (Haiku 4.5)", () => {
|
||||
expect(formatModelPricing({ inputTokens: 1, outputTokens: 5 })).toBe(
|
||||
"$1/$5 per Mtok",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPrice", () => {
|
||||
test("formats integers without decimals: 3 → '$3'", () => {
|
||||
expect(formatPrice(3)).toBe("$3")
|
||||
})
|
||||
|
||||
test("formats floats with 2 decimals: 0.8 → '$0.80'", () => {
|
||||
expect(formatPrice(0.8)).toBe("$0.80")
|
||||
})
|
||||
|
||||
test("formats large integers: 150 → '$150'", () => {
|
||||
expect(formatPrice(150)).toBe("$150")
|
||||
})
|
||||
|
||||
test("formats 1 as integer: '$1'", () => {
|
||||
expect(formatPrice(1)).toBe("$1")
|
||||
})
|
||||
|
||||
test("formats mixed decimal: 22.5 → '$22.50'", () => {
|
||||
expect(formatPrice(22.5)).toBe("$22.50")
|
||||
})
|
||||
})
|
||||
110
src/utils/__tests__/privacyLevel.test.ts
Normal file
110
src/utils/__tests__/privacyLevel.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getPrivacyLevel,
|
||||
isEssentialTrafficOnly,
|
||||
isTelemetryDisabled,
|
||||
getEssentialTrafficOnlyReason,
|
||||
} from "../privacyLevel";
|
||||
|
||||
describe("getPrivacyLevel", () => {
|
||||
const originalDisableNonessential = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
const originalDisableTelemetry = process.env.DISABLE_TELEMETRY;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
if (originalDisableNonessential !== undefined) {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalDisableNonessential;
|
||||
}
|
||||
if (originalDisableTelemetry !== undefined) {
|
||||
process.env.DISABLE_TELEMETRY = originalDisableTelemetry;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns 'default' when no env vars set", () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
expect(getPrivacyLevel()).toBe("default");
|
||||
});
|
||||
|
||||
test("returns 'essential-traffic' when CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set", () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
expect(getPrivacyLevel()).toBe("essential-traffic");
|
||||
});
|
||||
|
||||
test("returns 'no-telemetry' when DISABLE_TELEMETRY is set", () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
process.env.DISABLE_TELEMETRY = "1";
|
||||
expect(getPrivacyLevel()).toBe("no-telemetry");
|
||||
});
|
||||
|
||||
test("'essential-traffic' takes priority over 'no-telemetry'", () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
process.env.DISABLE_TELEMETRY = "1";
|
||||
expect(getPrivacyLevel()).toBe("essential-traffic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEssentialTrafficOnly", () => {
|
||||
const original = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
if (original !== undefined) process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = original;
|
||||
});
|
||||
|
||||
test("returns true for 'essential-traffic' level", () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
expect(isEssentialTrafficOnly()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 'default' level", () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
expect(isEssentialTrafficOnly()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for 'no-telemetry' level", () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
process.env.DISABLE_TELEMETRY = "1";
|
||||
expect(isEssentialTrafficOnly()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTelemetryDisabled", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
});
|
||||
|
||||
test("returns true for 'no-telemetry' level", () => {
|
||||
process.env.DISABLE_TELEMETRY = "1";
|
||||
expect(isTelemetryDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'essential-traffic' level", () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
expect(isTelemetryDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 'default' level", () => {
|
||||
expect(isTelemetryDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEssentialTrafficOnlyReason", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
});
|
||||
|
||||
test("returns env var name when restricted", () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
expect(getEssentialTrafficOnlyReason()).toBe("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC");
|
||||
});
|
||||
|
||||
test("returns null when unrestricted", () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
expect(getEssentialTrafficOnlyReason()).toBeNull();
|
||||
});
|
||||
});
|
||||
48
src/utils/__tests__/semanticBoolean.test.ts
Normal file
48
src/utils/__tests__/semanticBoolean.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { z } from "zod/v4";
|
||||
import { semanticBoolean } from "../semanticBoolean";
|
||||
|
||||
describe("semanticBoolean", () => {
|
||||
test("parses boolean true to true", () => {
|
||||
expect(semanticBoolean().parse(true)).toBe(true);
|
||||
});
|
||||
|
||||
test("parses boolean false to false", () => {
|
||||
expect(semanticBoolean().parse(false)).toBe(false);
|
||||
});
|
||||
|
||||
test("parses string 'true' to true", () => {
|
||||
expect(semanticBoolean().parse("true")).toBe(true);
|
||||
});
|
||||
|
||||
test("parses string 'false' to false", () => {
|
||||
expect(semanticBoolean().parse("false")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects string 'TRUE' (case-sensitive)", () => {
|
||||
expect(() => semanticBoolean().parse("TRUE")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects string 'FALSE' (case-sensitive)", () => {
|
||||
expect(() => semanticBoolean().parse("FALSE")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects number 1", () => {
|
||||
expect(() => semanticBoolean().parse(1)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects null", () => {
|
||||
expect(() => semanticBoolean().parse(null)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects undefined", () => {
|
||||
expect(() => semanticBoolean().parse(undefined)).toThrow();
|
||||
});
|
||||
|
||||
test("works with custom inner schema (z.boolean().optional())", () => {
|
||||
const schema = semanticBoolean(z.boolean().optional());
|
||||
expect(schema.parse(true)).toBe(true);
|
||||
expect(schema.parse("false")).toBe(false);
|
||||
expect(schema.parse(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
52
src/utils/__tests__/semanticNumber.test.ts
Normal file
52
src/utils/__tests__/semanticNumber.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { z } from "zod/v4";
|
||||
import { semanticNumber } from "../semanticNumber";
|
||||
|
||||
describe("semanticNumber", () => {
|
||||
test("parses number 42", () => {
|
||||
expect(semanticNumber().parse(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("parses number 0", () => {
|
||||
expect(semanticNumber().parse(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("parses negative number -5", () => {
|
||||
expect(semanticNumber().parse(-5)).toBe(-5);
|
||||
});
|
||||
|
||||
test("parses float 3.14", () => {
|
||||
expect(semanticNumber().parse(3.14)).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
test("parses string '42' to 42", () => {
|
||||
expect(semanticNumber().parse("42")).toBe(42);
|
||||
});
|
||||
|
||||
test("parses string '-7.5' to -7.5", () => {
|
||||
expect(semanticNumber().parse("-7.5")).toBe(-7.5);
|
||||
});
|
||||
|
||||
test("rejects string 'abc'", () => {
|
||||
expect(() => semanticNumber().parse("abc")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects empty string ''", () => {
|
||||
expect(() => semanticNumber().parse("")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects null", () => {
|
||||
expect(() => semanticNumber().parse(null)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects boolean true", () => {
|
||||
expect(() => semanticNumber().parse(true)).toThrow();
|
||||
});
|
||||
|
||||
test("works with custom inner schema (z.number().int().min(0))", () => {
|
||||
const schema = semanticNumber(z.number().int().min(0));
|
||||
expect(schema.parse(5)).toBe(5);
|
||||
expect(schema.parse("10")).toBe(10);
|
||||
expect(() => schema.parse(-1)).toThrow();
|
||||
});
|
||||
});
|
||||
99
src/utils/__tests__/sequential.test.ts
Normal file
99
src/utils/__tests__/sequential.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sequential } from "../sequential";
|
||||
|
||||
describe("sequential", () => {
|
||||
test("wraps async function, returns same result", async () => {
|
||||
const fn = sequential(async (x: number) => x * 2);
|
||||
expect(await fn(5)).toBe(10);
|
||||
});
|
||||
|
||||
test("single call resolves normally", async () => {
|
||||
const fn = sequential(async () => "ok");
|
||||
expect(await fn()).toBe("ok");
|
||||
});
|
||||
|
||||
test("concurrent calls execute sequentially (FIFO order)", async () => {
|
||||
const order: number[] = [];
|
||||
const fn = sequential(async (n: number) => {
|
||||
order.push(n);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
return n;
|
||||
});
|
||||
|
||||
const results = await Promise.all([fn(1), fn(2), fn(3)]);
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("preserves arguments correctly", async () => {
|
||||
const fn = sequential(async (a: number, b: string) => `${a}-${b}`);
|
||||
expect(await fn(42, "test")).toBe("42-test");
|
||||
});
|
||||
|
||||
test("error in first call does not block subsequent calls", async () => {
|
||||
let callCount = 0;
|
||||
const fn = sequential(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error("first fail");
|
||||
return "ok";
|
||||
});
|
||||
|
||||
await expect(fn()).rejects.toThrow("first fail");
|
||||
expect(await fn()).toBe("ok");
|
||||
});
|
||||
|
||||
test("preserves rejection reason", async () => {
|
||||
const fn = sequential(async () => {
|
||||
throw new Error("specific error");
|
||||
});
|
||||
await expect(fn()).rejects.toThrow("specific error");
|
||||
});
|
||||
|
||||
test("multiple args passed correctly", async () => {
|
||||
const fn = sequential(async (a: number, b: number, c: number) => a + b + c);
|
||||
expect(await fn(1, 2, 3)).toBe(6);
|
||||
});
|
||||
|
||||
test("returns different wrapper for each call to sequential", () => {
|
||||
const fn1 = sequential(async () => 1);
|
||||
const fn2 = sequential(async () => 2);
|
||||
expect(fn1).not.toBe(fn2);
|
||||
});
|
||||
|
||||
test("handles rapid concurrent calls", async () => {
|
||||
const order: number[] = [];
|
||||
const fn = sequential(async (n: number) => {
|
||||
order.push(n);
|
||||
return n;
|
||||
});
|
||||
|
||||
const promises = Array.from({ length: 10 }, (_, i) => fn(i));
|
||||
const results = await Promise.all(promises);
|
||||
expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
expect(order).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
test("execution order matches call order", async () => {
|
||||
const log: string[] = [];
|
||||
const fn = sequential(async (label: string) => {
|
||||
log.push(`start:${label}`);
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
log.push(`end:${label}`);
|
||||
return label;
|
||||
});
|
||||
|
||||
await Promise.all([fn("a"), fn("b")]);
|
||||
expect(log[0]).toBe("start:a");
|
||||
expect(log[1]).toBe("end:a");
|
||||
expect(log[2]).toBe("start:b");
|
||||
expect(log[3]).toBe("end:b");
|
||||
});
|
||||
|
||||
test("works with functions returning different types", async () => {
|
||||
const fn = sequential(async (x: number): string | number => {
|
||||
return x > 0 ? "positive" : x;
|
||||
});
|
||||
expect(await fn(5)).toBe("positive");
|
||||
expect(await fn(-1)).toBe(-1);
|
||||
});
|
||||
});
|
||||
131
src/utils/__tests__/textHighlighting.test.ts
Normal file
131
src/utils/__tests__/textHighlighting.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { segmentTextByHighlights, type TextHighlight } from "../textHighlighting";
|
||||
|
||||
describe("segmentTextByHighlights", () => {
|
||||
// Basic
|
||||
test("returns single segment with no highlights", () => {
|
||||
const segments = segmentTextByHighlights("hello world", []);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].text).toBe("hello world");
|
||||
expect(segments[0].highlight).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns highlighted segment for single highlight", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 5, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("hello world", highlights);
|
||||
expect(segments.length).toBeGreaterThanOrEqual(2);
|
||||
expect(segments.some(s => s.highlight !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns three segments for highlight in the middle", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 3, end: 7, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("hello world", highlights);
|
||||
expect(segments.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("highlight covering entire text", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 5, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("hello", highlights);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].highlight).toBeDefined();
|
||||
});
|
||||
|
||||
// Multiple highlights
|
||||
test("handles non-overlapping highlights", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: undefined, priority: 0 },
|
||||
{ start: 6, end: 9, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abcXYZdef", highlights);
|
||||
const highlighted = segments.filter(s => s.highlight);
|
||||
expect(highlighted.length).toBe(2);
|
||||
});
|
||||
|
||||
test("handles overlapping highlights (priority-based)", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 5, color: undefined, priority: 0 },
|
||||
{ start: 3, end: 8, color: undefined, priority: 1 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("hello world", highlights);
|
||||
// Overlapping: higher priority wins or they don't overlap
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles adjacent highlights", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: undefined, priority: 0 },
|
||||
{ start: 3, end: 6, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abcdef", highlights);
|
||||
const highlighted = segments.filter(s => s.highlight);
|
||||
expect(highlighted.length).toBe(2);
|
||||
});
|
||||
|
||||
// Boundary
|
||||
test("highlight starting at 0", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abcdef", highlights);
|
||||
expect(segments[0].start).toBe(0);
|
||||
});
|
||||
|
||||
test("highlight ending at text length", () => {
|
||||
const text = "hello";
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 3, end: 5, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights(text, highlights);
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("empty highlights array returns single segment", () => {
|
||||
const segments = segmentTextByHighlights("text", []);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].highlight).toBeUndefined();
|
||||
});
|
||||
|
||||
// Properties
|
||||
test("preserves highlight color property", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: "primary" as any, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abc", highlights);
|
||||
const highlighted = segments.find(s => s.highlight);
|
||||
expect(highlighted?.highlight?.color).toBe("primary");
|
||||
});
|
||||
|
||||
test("preserves highlight priority property", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: undefined, priority: 5 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abc", highlights);
|
||||
const highlighted = segments.find(s => s.highlight);
|
||||
expect(highlighted?.highlight?.priority).toBe(5);
|
||||
});
|
||||
|
||||
test("preserves dimColor and inverse flags", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 0, end: 3, color: undefined, priority: 0, dimColor: true, inverse: true },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abc", highlights);
|
||||
const highlighted = segments.find(s => s.highlight);
|
||||
expect(highlighted?.highlight?.dimColor).toBe(true);
|
||||
expect(highlighted?.highlight?.inverse).toBe(true);
|
||||
});
|
||||
|
||||
test("highlights with start === end are skipped", () => {
|
||||
const highlights: TextHighlight[] = [
|
||||
{ start: 3, end: 3, color: undefined, priority: 0 },
|
||||
];
|
||||
const segments = segmentTextByHighlights("abcdef", highlights);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].highlight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
76
src/utils/__tests__/userPromptKeywords.test.ts
Normal file
76
src/utils/__tests__/userPromptKeywords.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
matchesNegativeKeyword,
|
||||
matchesKeepGoingKeyword,
|
||||
} from "../userPromptKeywords";
|
||||
|
||||
describe("matchesNegativeKeyword", () => {
|
||||
test("matches 'wtf'", () => {
|
||||
expect(matchesNegativeKeyword("wtf is going on")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'shit'", () => {
|
||||
expect(matchesNegativeKeyword("this is shit")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'fucking broken'", () => {
|
||||
expect(matchesNegativeKeyword("this is fucking broken")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match normal input like 'fix the bug'", () => {
|
||||
expect(matchesNegativeKeyword("fix the bug")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(matchesNegativeKeyword("WTF is this")).toBe(true);
|
||||
expect(matchesNegativeKeyword("This Sucks")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches partial word in sentence", () => {
|
||||
expect(matchesNegativeKeyword("please help, damn it")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesKeepGoingKeyword", () => {
|
||||
test("matches exact 'continue'", () => {
|
||||
expect(matchesKeepGoingKeyword("continue")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'keep going'", () => {
|
||||
expect(matchesKeepGoingKeyword("keep going")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'go on'", () => {
|
||||
expect(matchesKeepGoingKeyword("go on")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match 'cont'", () => {
|
||||
expect(matchesKeepGoingKeyword("cont")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not match empty string", () => {
|
||||
expect(matchesKeepGoingKeyword("")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches within larger sentence 'please continue'", () => {
|
||||
// 'continue' must be the entire prompt (lowercased), not a substring
|
||||
expect(matchesKeepGoingKeyword("please continue")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches 'keep going' in sentence", () => {
|
||||
expect(matchesKeepGoingKeyword("please keep going")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'go on' in sentence", () => {
|
||||
expect(matchesKeepGoingKeyword("yes, go on")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-insensitive for 'continue'", () => {
|
||||
expect(matchesKeepGoingKeyword("Continue")).toBe(true);
|
||||
expect(matchesKeepGoingKeyword("CONTINUE")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-insensitive for 'keep going'", () => {
|
||||
expect(matchesKeepGoingKeyword("Keep Going")).toBe(true);
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/withResolvers.test.ts
Normal file
58
src/utils/__tests__/withResolvers.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { withResolvers } from "../withResolvers";
|
||||
|
||||
describe("withResolvers", () => {
|
||||
test("returns object with promise, resolve, reject", () => {
|
||||
const result = withResolvers<string>();
|
||||
expect(result).toHaveProperty("promise");
|
||||
expect(result).toHaveProperty("resolve");
|
||||
expect(result).toHaveProperty("reject");
|
||||
expect(typeof result.resolve).toBe("function");
|
||||
expect(typeof result.reject).toBe("function");
|
||||
});
|
||||
|
||||
test("promise resolves when resolve is called", async () => {
|
||||
const { promise, resolve } = withResolvers<string>();
|
||||
resolve("hello");
|
||||
const result = await promise;
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("promise rejects when reject is called", async () => {
|
||||
const { promise, reject } = withResolvers<string>();
|
||||
reject(new Error("fail"));
|
||||
await expect(promise).rejects.toThrow("fail");
|
||||
});
|
||||
|
||||
test("resolve passes value through", async () => {
|
||||
const { promise, resolve } = withResolvers<number>();
|
||||
resolve(42);
|
||||
expect(await promise).toBe(42);
|
||||
});
|
||||
|
||||
test("reject passes error through", async () => {
|
||||
const { promise, reject } = withResolvers<void>();
|
||||
const err = new Error("custom error");
|
||||
reject(err);
|
||||
await expect(promise).rejects.toBe(err);
|
||||
});
|
||||
|
||||
test("promise is instanceof Promise", () => {
|
||||
const { promise } = withResolvers<void>();
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
test("works with generic type parameter", async () => {
|
||||
const { promise, resolve } = withResolvers<{ name: string }>();
|
||||
resolve({ name: "test" });
|
||||
const result = await promise;
|
||||
expect(result.name).toBe("test");
|
||||
});
|
||||
|
||||
test("resolve/reject can be called asynchronously", async () => {
|
||||
const { promise, resolve } = withResolvers<number>();
|
||||
setTimeout(() => resolve(99), 10);
|
||||
const result = await promise;
|
||||
expect(result).toBe(99);
|
||||
});
|
||||
});
|
||||
84
src/utils/__tests__/xdg.test.ts
Normal file
84
src/utils/__tests__/xdg.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getXDGStateHome,
|
||||
getXDGCacheHome,
|
||||
getXDGDataHome,
|
||||
getUserBinDir,
|
||||
} from "../xdg";
|
||||
|
||||
describe("getXDGStateHome", () => {
|
||||
test("returns ~/.local/state by default", () => {
|
||||
const result = getXDGStateHome({ homedir: "/home/user" });
|
||||
expect(result).toBe("/home/user/.local/state");
|
||||
});
|
||||
|
||||
test("respects XDG_STATE_HOME env var", () => {
|
||||
const result = getXDGStateHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_STATE_HOME: "/custom/state" },
|
||||
});
|
||||
expect(result).toBe("/custom/state");
|
||||
});
|
||||
|
||||
test("uses custom homedir from options", () => {
|
||||
const result = getXDGStateHome({ homedir: "/opt/home" });
|
||||
expect(result).toBe("/opt/home/.local/state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getXDGCacheHome", () => {
|
||||
test("returns ~/.cache by default", () => {
|
||||
const result = getXDGCacheHome({ homedir: "/home/user" });
|
||||
expect(result).toBe("/home/user/.cache");
|
||||
});
|
||||
|
||||
test("respects XDG_CACHE_HOME env var", () => {
|
||||
const result = getXDGCacheHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_CACHE_HOME: "/tmp/cache" },
|
||||
});
|
||||
expect(result).toBe("/tmp/cache");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getXDGDataHome", () => {
|
||||
test("returns ~/.local/share by default", () => {
|
||||
const result = getXDGDataHome({ homedir: "/home/user" });
|
||||
expect(result).toBe("/home/user/.local/share");
|
||||
});
|
||||
|
||||
test("respects XDG_DATA_HOME env var", () => {
|
||||
const result = getXDGDataHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_DATA_HOME: "/custom/data" },
|
||||
});
|
||||
expect(result).toBe("/custom/data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserBinDir", () => {
|
||||
test("returns ~/.local/bin", () => {
|
||||
const result = getUserBinDir({ homedir: "/home/user" });
|
||||
expect(result).toBe("/home/user/.local/bin");
|
||||
});
|
||||
|
||||
test("uses custom homedir from options", () => {
|
||||
const result = getUserBinDir({ homedir: "/opt/me" });
|
||||
expect(result).toBe("/opt/me/.local/bin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("path construction", () => {
|
||||
test("all paths end with correct subdirectory", () => {
|
||||
const home = "/home/test";
|
||||
expect(getXDGStateHome({ homedir: home })).toMatch(/\.local\/state$/);
|
||||
expect(getXDGCacheHome({ homedir: home })).toMatch(/\.cache$/);
|
||||
expect(getXDGDataHome({ homedir: home })).toMatch(/\.local\/share$/);
|
||||
expect(getUserBinDir({ homedir: home })).toMatch(/\.local\/bin$/);
|
||||
});
|
||||
|
||||
test("respects HOME via homedir override", () => {
|
||||
const result = getXDGStateHome({ homedir: "/Users/me" });
|
||||
expect(result).toBe("/Users/me/.local/state");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user