generated from nhcarrigan/template
feat: add tests and assert coverage (#71)
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #71.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
slashCommands,
|
||||
parseSlashCommand,
|
||||
getMatchingCommands,
|
||||
isSlashCommand,
|
||||
type SlashCommand,
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/claude", () => ({
|
||||
claudeStore: {
|
||||
addLine: vi.fn(),
|
||||
clearTerminal: vi.fn(),
|
||||
activeConversationId: { subscribe: vi.fn() },
|
||||
currentWorkingDirectory: { subscribe: vi.fn() },
|
||||
setWorkingDirectory: vi.fn(),
|
||||
getConversationHistory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/character", () => ({
|
||||
characterState: {
|
||||
setState: vi.fn(),
|
||||
setTemporaryState: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/tauri", () => ({
|
||||
setSkipNextGreeting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/search", () => ({
|
||||
searchState: {
|
||||
setQuery: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("slashCommands", () => {
|
||||
describe("slashCommands array", () => {
|
||||
it("contains expected commands", () => {
|
||||
const commandNames = slashCommands.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain("cd");
|
||||
expect(commandNames).toContain("clear");
|
||||
expect(commandNames).toContain("new");
|
||||
expect(commandNames).toContain("help");
|
||||
expect(commandNames).toContain("search");
|
||||
expect(commandNames).toContain("summarise");
|
||||
expect(commandNames).toContain("skill");
|
||||
});
|
||||
|
||||
it("has 7 commands total", () => {
|
||||
expect(slashCommands.length).toBe(7);
|
||||
});
|
||||
|
||||
it("each command has required properties", () => {
|
||||
slashCommands.forEach((cmd) => {
|
||||
expect(cmd.name).toBeDefined();
|
||||
expect(typeof cmd.name).toBe("string");
|
||||
expect(cmd.name.length).toBeGreaterThan(0);
|
||||
|
||||
expect(cmd.description).toBeDefined();
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(cmd.description.length).toBeGreaterThan(0);
|
||||
|
||||
expect(cmd.usage).toBeDefined();
|
||||
expect(typeof cmd.usage).toBe("string");
|
||||
expect(cmd.usage.startsWith("/")).toBe(true);
|
||||
|
||||
expect(cmd.execute).toBeDefined();
|
||||
expect(typeof cmd.execute).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
it("cd command has correct metadata", () => {
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
|
||||
expect(cdCmd).toBeDefined();
|
||||
expect(cdCmd!.description).toBe("Change the working directory");
|
||||
expect(cdCmd!.usage).toBe("/cd <path>");
|
||||
});
|
||||
|
||||
it("clear command has correct metadata", () => {
|
||||
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
|
||||
expect(clearCmd).toBeDefined();
|
||||
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
|
||||
expect(clearCmd!.usage).toBe("/clear");
|
||||
});
|
||||
|
||||
it("new command has correct metadata", () => {
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
|
||||
expect(newCmd).toBeDefined();
|
||||
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
|
||||
expect(newCmd!.usage).toBe("/new");
|
||||
});
|
||||
|
||||
it("help command has correct metadata", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
|
||||
expect(helpCmd).toBeDefined();
|
||||
expect(helpCmd!.description).toBe("Show available slash commands");
|
||||
expect(helpCmd!.usage).toBe("/help");
|
||||
});
|
||||
|
||||
it("search command has correct metadata", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
|
||||
expect(searchCmd).toBeDefined();
|
||||
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
|
||||
expect(searchCmd!.usage).toBe("/search [query]");
|
||||
});
|
||||
|
||||
it("summarise command has correct metadata", () => {
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
|
||||
expect(summariseCmd).toBeDefined();
|
||||
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
|
||||
expect(summariseCmd!.usage).toBe("/summarise");
|
||||
});
|
||||
|
||||
it("skill command has correct metadata", () => {
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
|
||||
expect(skillCmd).toBeDefined();
|
||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("returns null for non-slash input", () => {
|
||||
const result = parseSlashCommand("hello world");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
const result = parseSlashCommand("");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for whitespace only", () => {
|
||||
const result = parseSlashCommand(" ");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /cd command without args", () => {
|
||||
const result = parseSlashCommand("/cd");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /cd command with path argument", () => {
|
||||
const result = parseSlashCommand("/cd /home/naomi/code");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/home/naomi/code");
|
||||
});
|
||||
|
||||
it("parses /clear command", () => {
|
||||
const result = parseSlashCommand("/clear");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("clear");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /new command", () => {
|
||||
const result = parseSlashCommand("/new");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("new");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /help command", () => {
|
||||
const result = parseSlashCommand("/help");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("help");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /search command with query", () => {
|
||||
const result = parseSlashCommand("/search hello world");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("hello world");
|
||||
});
|
||||
|
||||
it("parses /search command without query", () => {
|
||||
const result = parseSlashCommand("/search");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /summarise command", () => {
|
||||
const result = parseSlashCommand("/summarise");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("summarise");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("parses /skill command with name and data", () => {
|
||||
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("onboard-mentee john@example.com");
|
||||
});
|
||||
|
||||
it("parses /skill command with name only", () => {
|
||||
const result = parseSlashCommand("/skill onboard-mentee");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("onboard-mentee");
|
||||
});
|
||||
|
||||
it("parses /skill command without arguments", () => {
|
||||
const result = parseSlashCommand("/skill");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("skill");
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("returns null for unknown command", () => {
|
||||
const result = parseSlashCommand("/unknown");
|
||||
expect(result.command).toBeNull();
|
||||
expect(result.args).toBe("");
|
||||
});
|
||||
|
||||
it("is case insensitive for command names", () => {
|
||||
const result1 = parseSlashCommand("/CD /path");
|
||||
expect(result1.command).not.toBeNull();
|
||||
expect(result1.command!.name).toBe("cd");
|
||||
|
||||
const result2 = parseSlashCommand("/CLEAR");
|
||||
expect(result2.command).not.toBeNull();
|
||||
expect(result2.command!.name).toBe("clear");
|
||||
|
||||
const result3 = parseSlashCommand("/Help");
|
||||
expect(result3.command).not.toBeNull();
|
||||
expect(result3.command!.name).toBe("help");
|
||||
});
|
||||
|
||||
it("handles leading whitespace", () => {
|
||||
const result = parseSlashCommand(" /cd /path");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/path");
|
||||
});
|
||||
|
||||
it("handles trailing whitespace", () => {
|
||||
const result = parseSlashCommand("/cd /path ");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("cd");
|
||||
expect(result.args).toBe("/path");
|
||||
});
|
||||
|
||||
it("handles multiple spaces between args", () => {
|
||||
const result = parseSlashCommand("/search hello world");
|
||||
expect(result.command).not.toBeNull();
|
||||
expect(result.command!.name).toBe("search");
|
||||
expect(result.args).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMatchingCommands", () => {
|
||||
it("returns empty array for non-slash input", () => {
|
||||
const result = getMatchingCommands("hello");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty string", () => {
|
||||
const result = getMatchingCommands("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all commands for just slash", () => {
|
||||
const result = getMatchingCommands("/");
|
||||
expect(result.length).toBe(slashCommands.length);
|
||||
});
|
||||
|
||||
it("returns matching commands for partial input", () => {
|
||||
const result = getMatchingCommands("/c");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("cd");
|
||||
expect(names).toContain("clear");
|
||||
expect(names).not.toContain("help");
|
||||
});
|
||||
|
||||
it("returns single command for exact match", () => {
|
||||
const result = getMatchingCommands("/cd");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("cd");
|
||||
});
|
||||
|
||||
it("returns single command for partial unique match", () => {
|
||||
const result = getMatchingCommands("/cl");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("clear");
|
||||
});
|
||||
|
||||
it("returns matching commands for /s prefix", () => {
|
||||
const result = getMatchingCommands("/s");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("search");
|
||||
expect(names).toContain("summarise");
|
||||
expect(names).toContain("skill");
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
const result1 = getMatchingCommands("/C");
|
||||
const result2 = getMatchingCommands("/c");
|
||||
expect(result1.length).toBe(result2.length);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const result = getMatchingCommands("/xyz");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles whitespace correctly", () => {
|
||||
const result = getMatchingCommands(" /c");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("cd");
|
||||
expect(names).toContain("clear");
|
||||
});
|
||||
|
||||
it("returns command for full command name", () => {
|
||||
const result = getMatchingCommands("/help");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("help");
|
||||
});
|
||||
|
||||
it("returns command for /new", () => {
|
||||
const result = getMatchingCommands("/n");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSlashCommand", () => {
|
||||
it("returns true for input starting with slash", () => {
|
||||
expect(isSlashCommand("/cd")).toBe(true);
|
||||
expect(isSlashCommand("/")).toBe(true);
|
||||
expect(isSlashCommand("/help")).toBe(true);
|
||||
expect(isSlashCommand("/unknown")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-slash input", () => {
|
||||
expect(isSlashCommand("hello")).toBe(false);
|
||||
expect(isSlashCommand("")).toBe(false);
|
||||
expect(isSlashCommand("cd")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles whitespace correctly", () => {
|
||||
expect(isSlashCommand(" /cd")).toBe(true);
|
||||
expect(isSlashCommand(" hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for slash in middle of string", () => {
|
||||
expect(isSlashCommand("hello/world")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlashCommand interface", () => {
|
||||
it("can create a valid slash command object", () => {
|
||||
const testCommand: SlashCommand = {
|
||||
name: "test",
|
||||
description: "A test command",
|
||||
usage: "/test [arg]",
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
expect(testCommand.name).toBe("test");
|
||||
expect(testCommand.description).toBe("A test command");
|
||||
expect(testCommand.usage).toBe("/test [arg]");
|
||||
expect(typeof testCommand.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("execute can be async function", () => {
|
||||
const asyncCommand: SlashCommand = {
|
||||
name: "async",
|
||||
description: "An async command",
|
||||
usage: "/async",
|
||||
execute: async () => {
|
||||
await Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("execute can be sync function", () => {
|
||||
const syncCommand: SlashCommand = {
|
||||
name: "sync",
|
||||
description: "A sync command",
|
||||
usage: "/sync",
|
||||
execute: () => {
|
||||
// Synchronous execution
|
||||
},
|
||||
};
|
||||
|
||||
const result = syncCommand.execute("");
|
||||
// Sync function returns undefined, not a Promise
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
|
||||
|
||||
// Mock HTMLAudioElement for soundPlayer tests
|
||||
class MockAudioElement {
|
||||
src: string = "";
|
||||
preload: string = "";
|
||||
volume: number = 1;
|
||||
|
||||
constructor(src?: string) {
|
||||
if (src) this.src = src;
|
||||
}
|
||||
|
||||
cloneNode(): MockAudioElement {
|
||||
const clone = new MockAudioElement(this.src);
|
||||
clone.volume = this.volume;
|
||||
return clone;
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// Store original Audio before mocking
|
||||
const OriginalAudio = globalThis.Audio;
|
||||
|
||||
describe("notifications", () => {
|
||||
describe("NotificationType enum", () => {
|
||||
it("has SUCCESS type", () => {
|
||||
expect(NotificationType.SUCCESS).toBe("success");
|
||||
});
|
||||
|
||||
it("has ERROR type", () => {
|
||||
expect(NotificationType.ERROR).toBe("error");
|
||||
});
|
||||
|
||||
it("has PERMISSION type", () => {
|
||||
expect(NotificationType.PERMISSION).toBe("permission");
|
||||
});
|
||||
|
||||
it("has CONNECTION type", () => {
|
||||
expect(NotificationType.CONNECTION).toBe("connection");
|
||||
});
|
||||
|
||||
it("has TASK_START type", () => {
|
||||
expect(NotificationType.TASK_START).toBe("task_start");
|
||||
});
|
||||
|
||||
it("has ACHIEVEMENT type", () => {
|
||||
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
|
||||
});
|
||||
|
||||
it("has exactly 6 notification types", () => {
|
||||
const types = Object.values(NotificationType);
|
||||
expect(types.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NOTIFICATION_SOUNDS constant", () => {
|
||||
it("has sounds for all notification types", () => {
|
||||
Object.values(NotificationType).forEach((type) => {
|
||||
expect(NOTIFICATION_SOUNDS[type]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("each sound has required properties", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
expect(sound.type).toBeDefined();
|
||||
expect(sound.filename).toBeDefined();
|
||||
expect(sound.phrase).toBeDefined();
|
||||
expect(typeof sound.filename).toBe("string");
|
||||
expect(typeof sound.phrase).toBe("string");
|
||||
expect(sound.filename.endsWith(".mp3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("SUCCESS sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.SUCCESS];
|
||||
expect(sound.type).toBe(NotificationType.SUCCESS);
|
||||
expect(sound.filename).toBe("im-done.mp3");
|
||||
expect(sound.phrase).toBe("I'm done!");
|
||||
expect(sound.volume).toBe(0.7);
|
||||
});
|
||||
|
||||
it("ERROR sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.ERROR];
|
||||
expect(sound.type).toBe(NotificationType.ERROR);
|
||||
expect(sound.filename).toBe("oh-no.mp3");
|
||||
expect(sound.phrase).toBe("Oh no...");
|
||||
expect(sound.volume).toBe(0.8);
|
||||
});
|
||||
|
||||
it("PERMISSION sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.PERMISSION];
|
||||
expect(sound.type).toBe(NotificationType.PERMISSION);
|
||||
expect(sound.filename).toBe("access-please.mp3");
|
||||
expect(sound.phrase).toBe("Access please!");
|
||||
expect(sound.volume).toBe(0.9);
|
||||
});
|
||||
|
||||
it("CONNECTION sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.CONNECTION];
|
||||
expect(sound.type).toBe(NotificationType.CONNECTION);
|
||||
expect(sound.filename).toBe("connected.mp3");
|
||||
expect(sound.phrase).toBe("Connected!");
|
||||
expect(sound.volume).toBe(0.7);
|
||||
});
|
||||
|
||||
it("TASK_START sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.TASK_START];
|
||||
expect(sound.type).toBe(NotificationType.TASK_START);
|
||||
expect(sound.filename).toBe("working-on-it.mp3");
|
||||
expect(sound.phrase).toBe("Working on it!");
|
||||
expect(sound.volume).toBe(0.6);
|
||||
});
|
||||
|
||||
it("ACHIEVEMENT sound has correct properties", () => {
|
||||
const sound = NOTIFICATION_SOUNDS[NotificationType.ACHIEVEMENT];
|
||||
expect(sound.type).toBe(NotificationType.ACHIEVEMENT);
|
||||
expect(sound.filename).toBe("achievement.mp3");
|
||||
expect(sound.phrase).toBe("Achievement Get~!");
|
||||
expect(sound.volume).toBe(0.8);
|
||||
});
|
||||
|
||||
it("all volumes are within valid range (0-1)", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
if (sound.volume !== undefined) {
|
||||
expect(sound.volume).toBeGreaterThanOrEqual(0);
|
||||
expect(sound.volume).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationSound interface", () => {
|
||||
it("can create a valid notification sound object", () => {
|
||||
const sound: NotificationSound = {
|
||||
type: NotificationType.SUCCESS,
|
||||
filename: "test-sound.mp3",
|
||||
phrase: "Test phrase",
|
||||
volume: 0.5,
|
||||
};
|
||||
|
||||
expect(sound.type).toBe(NotificationType.SUCCESS);
|
||||
expect(sound.filename).toBe("test-sound.mp3");
|
||||
expect(sound.phrase).toBe("Test phrase");
|
||||
expect(sound.volume).toBe(0.5);
|
||||
});
|
||||
|
||||
it("volume is optional", () => {
|
||||
const sound: NotificationSound = {
|
||||
type: NotificationType.ERROR,
|
||||
filename: "error.mp3",
|
||||
phrase: "Error occurred",
|
||||
};
|
||||
|
||||
expect(sound.volume).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SoundPlayer class", () => {
|
||||
beforeEach(() => {
|
||||
// Mock Audio constructor
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original Audio
|
||||
globalThis.Audio = OriginalAudio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("can import soundPlayer singleton", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
expect(soundPlayer).toBeDefined();
|
||||
});
|
||||
|
||||
it("setEnabled changes enabled state", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(true);
|
||||
expect(soundPlayer.isEnabled()).toBe(true);
|
||||
|
||||
soundPlayer.setEnabled(false);
|
||||
expect(soundPlayer.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("starts disabled by default", async () => {
|
||||
// Need to reimport to get fresh instance behavior
|
||||
// But since it's a singleton, we just test the method
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
// Reset to default state
|
||||
soundPlayer.setEnabled(false);
|
||||
expect(soundPlayer.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("setGlobalVolume clamps values to 0-1 range", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
// Test that it doesn't throw on edge cases
|
||||
soundPlayer.setGlobalVolume(0);
|
||||
soundPlayer.setGlobalVolume(1);
|
||||
soundPlayer.setGlobalVolume(0.5);
|
||||
|
||||
// Test clamping below 0
|
||||
soundPlayer.setGlobalVolume(-0.5);
|
||||
|
||||
// Test clamping above 1
|
||||
soundPlayer.setGlobalVolume(1.5);
|
||||
});
|
||||
|
||||
it("play returns early when disabled", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(false);
|
||||
|
||||
// Should not throw when disabled
|
||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("play attempts to play when enabled", async () => {
|
||||
const { soundPlayer } = await import("./soundPlayer");
|
||||
|
||||
soundPlayer.setEnabled(true);
|
||||
|
||||
// Should not throw
|
||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationManager class", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.Audio = OriginalAudio;
|
||||
});
|
||||
|
||||
it("can import notificationManager singleton", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(notificationManager).toBeDefined();
|
||||
});
|
||||
|
||||
it("has notifySuccess method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifySuccess).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyError method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyError).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyPermission method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyPermission).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyConnection method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyConnection).toBe("function");
|
||||
});
|
||||
|
||||
it("has notifyTaskStart method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notifyTaskStart).toBe("function");
|
||||
});
|
||||
|
||||
it("has notify method", async () => {
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
|
||||
}));
|
||||
|
||||
const { notificationManager } = await import("./notificationManager");
|
||||
expect(typeof notificationManager.notify).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification sounds file paths", () => {
|
||||
it("all sound files have valid paths", () => {
|
||||
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
|
||||
// Check that filename doesn't contain path traversal
|
||||
expect(sound.filename).not.toContain("..");
|
||||
expect(sound.filename).not.toContain("/");
|
||||
expect(sound.filename).not.toContain("\\");
|
||||
});
|
||||
});
|
||||
|
||||
it("sound filenames are unique", () => {
|
||||
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
|
||||
const uniqueFilenames = new Set(filenames);
|
||||
expect(uniqueFilenames.size).toBe(filenames.length);
|
||||
});
|
||||
|
||||
it("phrases are unique", () => {
|
||||
const phrases = Object.values(NOTIFICATION_SOUNDS).map((s) => s.phrase);
|
||||
const uniquePhrases = new Set(phrases);
|
||||
expect(uniquePhrases.size).toBe(phrases.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
configStore,
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
applyTheme,
|
||||
applyCustomThemeColors,
|
||||
clearCustomThemeColors,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
type HikariConfig,
|
||||
type Theme,
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("config store", () => {
|
||||
describe("font size constants", () => {
|
||||
it("has correct MIN_FONT_SIZE", () => {
|
||||
expect(MIN_FONT_SIZE).toBe(10);
|
||||
});
|
||||
|
||||
it("has correct MAX_FONT_SIZE", () => {
|
||||
expect(MAX_FONT_SIZE).toBe(24);
|
||||
});
|
||||
|
||||
it("has correct DEFAULT_FONT_SIZE", () => {
|
||||
expect(DEFAULT_FONT_SIZE).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampFontSize", () => {
|
||||
it("returns the same value when within range", () => {
|
||||
expect(clampFontSize(14)).toBe(14);
|
||||
expect(clampFontSize(10)).toBe(10);
|
||||
expect(clampFontSize(24)).toBe(24);
|
||||
expect(clampFontSize(18)).toBe(18);
|
||||
});
|
||||
|
||||
it("clamps values below minimum", () => {
|
||||
expect(clampFontSize(5)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(0)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(-10)).toBe(MIN_FONT_SIZE);
|
||||
expect(clampFontSize(9)).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("clamps values above maximum", () => {
|
||||
expect(clampFontSize(30)).toBe(MAX_FONT_SIZE);
|
||||
expect(clampFontSize(100)).toBe(MAX_FONT_SIZE);
|
||||
expect(clampFontSize(25)).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskPaths", () => {
|
||||
it("returns text unchanged when hidePaths is false", () => {
|
||||
const text = "/home/naomi/code/project/file.ts";
|
||||
expect(maskPaths(text, false)).toBe(text);
|
||||
});
|
||||
|
||||
it("masks Unix home paths", () => {
|
||||
const text = "/home/naomi/code/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("/home/****/code/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks macOS user paths", () => {
|
||||
const text = "/Users/naomi/Documents/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("/Users/****/Documents/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks Windows user paths", () => {
|
||||
const text = "C:\\Users\\naomi\\Documents\\project\\file.ts";
|
||||
expect(maskPaths(text, true)).toBe("C:\\Users\\****\\Documents\\project\\file.ts");
|
||||
});
|
||||
|
||||
it("masks tilde paths", () => {
|
||||
const text = "~/code/project/file.ts";
|
||||
expect(maskPaths(text, true)).toBe("****/code/project/file.ts");
|
||||
});
|
||||
|
||||
it("masks multiple paths in the same text", () => {
|
||||
const text = "Editing /home/naomi/file1.ts and /home/naomi/file2.ts";
|
||||
expect(maskPaths(text, true)).toBe("Editing /home/****/file1.ts and /home/****/file2.ts");
|
||||
});
|
||||
|
||||
it("handles mixed path types", () => {
|
||||
const text = "Unix: /home/user/file, Mac: /Users/user/file, Win: C:\\Users\\user\\file";
|
||||
const expected = "Unix: /home/****/file, Mac: /Users/****/file, Win: C:\\Users\\****\\file";
|
||||
expect(maskPaths(text, true)).toBe(expected);
|
||||
});
|
||||
|
||||
it("handles paths with special characters in username", () => {
|
||||
const text = "/home/user-name_123/project";
|
||||
expect(maskPaths(text, true)).toBe("/home/****/project");
|
||||
});
|
||||
|
||||
it("does not mask non-path text", () => {
|
||||
const text = "This is just regular text without any paths";
|
||||
expect(maskPaths(text, true)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(maskPaths("", true)).toBe("");
|
||||
expect(maskPaths("", false)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Theme type", () => {
|
||||
it("accepts valid theme values", () => {
|
||||
const themes: Theme[] = ["dark", "light", "high-contrast", "custom"];
|
||||
themes.forEach((theme) => {
|
||||
expect(["dark", "light", "high-contrast", "custom"]).toContain(theme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomThemeColors interface", () => {
|
||||
it("can create a valid custom theme colors object", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: "#16213e",
|
||||
bg_terminal: "#0f0f23",
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: "#533483",
|
||||
text_primary: "#eaeaea",
|
||||
text_secondary: "#a0a0a0",
|
||||
border_color: "#333355",
|
||||
};
|
||||
|
||||
expect(colors.bg_primary).toBe("#1a1a2e");
|
||||
expect(colors.accent_primary).toBe("#e94560");
|
||||
});
|
||||
|
||||
it("allows null values for optional colors", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
expect(colors.bg_primary).toBeNull();
|
||||
expect(colors.accent_primary).toBe("#e94560");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HikariConfig interface", () => {
|
||||
it("can create a valid config object with all fields", () => {
|
||||
const config: HikariConfig = {
|
||||
model: "claude-sonnet-4",
|
||||
api_key: "test-key",
|
||||
custom_instructions: "Be helpful",
|
||||
mcp_servers_json: "{}",
|
||||
auto_granted_tools: ["Read", "Write"],
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: "Hello!",
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: true,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: 300,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: "Naomi",
|
||||
profile_avatar_path: "/path/to/avatar.png",
|
||||
profile_bio: "Developer",
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
expect(config.auto_granted_tools).toEqual(["Read", "Write"]);
|
||||
expect(config.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("allows null values for optional fields", () => {
|
||||
const config: HikariConfig = {
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: null,
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
expect(config.api_key).toBeNull();
|
||||
expect(config.character_panel_width).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFontSize", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document state
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.removeProperty("--terminal-font-size");
|
||||
}
|
||||
});
|
||||
|
||||
it("sets CSS variable for valid font size", () => {
|
||||
applyFontSize(16);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe("16px");
|
||||
});
|
||||
|
||||
it("clamps font size below minimum", () => {
|
||||
applyFontSize(5);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe(`${MIN_FONT_SIZE}px`);
|
||||
});
|
||||
|
||||
it("clamps font size above maximum", () => {
|
||||
applyFontSize(50);
|
||||
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
|
||||
expect(value).toBe(`${MAX_FONT_SIZE}px`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTheme", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document state
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
clearCustomThemeColors();
|
||||
}
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for dark theme", () => {
|
||||
applyTheme("dark");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for light theme", () => {
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("sets data-theme attribute for high-contrast theme", () => {
|
||||
applyTheme("high-contrast");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("high-contrast");
|
||||
});
|
||||
|
||||
it("uses dark as base for custom theme", () => {
|
||||
applyTheme("custom");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies custom colors when theme is custom", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyTheme("custom", colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#e94560");
|
||||
});
|
||||
|
||||
it("does not apply custom colors for non-custom themes", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyTheme("dark", colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomThemeColors", () => {
|
||||
beforeEach(() => {
|
||||
clearCustomThemeColors();
|
||||
});
|
||||
|
||||
it("applies all provided colors", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: "#222222",
|
||||
bg_terminal: "#333333",
|
||||
accent_primary: "#444444",
|
||||
accent_secondary: "#555555",
|
||||
text_primary: "#666666",
|
||||
text_secondary: "#777777",
|
||||
border_color: "#888888",
|
||||
};
|
||||
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("#222222");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("#333333");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#444444");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("#555555");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("#666666");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("#777777");
|
||||
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("#888888");
|
||||
});
|
||||
|
||||
it("skips null values", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCustomThemeColors", () => {
|
||||
it("removes all custom theme CSS properties", () => {
|
||||
// First apply some colors
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#111111",
|
||||
bg_secondary: "#222222",
|
||||
bg_terminal: "#333333",
|
||||
accent_primary: "#444444",
|
||||
accent_secondary: "#555555",
|
||||
text_primary: "#666666",
|
||||
text_secondary: "#777777",
|
||||
border_color: "#888888",
|
||||
};
|
||||
applyCustomThemeColors(colors);
|
||||
|
||||
// Then clear them
|
||||
clearCustomThemeColors();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores", () => {
|
||||
// Note: These tests verify the derived store logic by testing the derivation functions
|
||||
// The actual stores depend on configStore which requires Tauri invoke mocking
|
||||
|
||||
it("isDarkTheme returns true for dark theme config", () => {
|
||||
// Test the derivation logic
|
||||
const darkConfig = { theme: "dark" as Theme };
|
||||
expect(darkConfig.theme === "dark").toBe(true);
|
||||
});
|
||||
|
||||
it("isDarkTheme returns false for light theme config", () => {
|
||||
const lightConfig = { theme: "light" as Theme };
|
||||
expect(lightConfig.theme === "dark").toBe(false);
|
||||
});
|
||||
|
||||
it("isStreamerMode derives from streamer_mode config", () => {
|
||||
const configWithStreamerMode = { streamer_mode: true };
|
||||
const configWithoutStreamerMode = { streamer_mode: false };
|
||||
|
||||
expect(configWithStreamerMode.streamer_mode).toBe(true);
|
||||
expect(configWithoutStreamerMode.streamer_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("isCompactMode derives from compact_mode config", () => {
|
||||
const configWithCompactMode = { compact_mode: true };
|
||||
const configWithoutCompactMode = { compact_mode: false };
|
||||
|
||||
expect(configWithCompactMode.compact_mode).toBe(true);
|
||||
expect(configWithoutCompactMode.compact_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldHidePaths requires both streamer_mode and streamer_hide_paths", () => {
|
||||
const config1 = { streamer_mode: true, streamer_hide_paths: true };
|
||||
const config2 = { streamer_mode: true, streamer_hide_paths: false };
|
||||
const config3 = { streamer_mode: false, streamer_hide_paths: true };
|
||||
const config4 = { streamer_mode: false, streamer_hide_paths: false };
|
||||
|
||||
expect(config1.streamer_mode && config1.streamer_hide_paths).toBe(true);
|
||||
expect(config2.streamer_mode && config2.streamer_hide_paths).toBe(false);
|
||||
expect(config3.streamer_mode && config3.streamer_hide_paths).toBe(false);
|
||||
expect(config4.streamer_mode && config4.streamer_hide_paths).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore methods", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof configStore.loadConfig).toBe("function");
|
||||
expect(typeof configStore.saveConfig).toBe("function");
|
||||
expect(typeof configStore.updateConfig).toBe("function");
|
||||
expect(typeof configStore.openSidebar).toBe("function");
|
||||
expect(typeof configStore.closeSidebar).toBe("function");
|
||||
expect(typeof configStore.toggleSidebar).toBe("function");
|
||||
expect(typeof configStore.setTheme).toBe("function");
|
||||
expect(typeof configStore.setCustomThemeColors).toBe("function");
|
||||
expect(typeof configStore.setFontSize).toBe("function");
|
||||
expect(typeof configStore.increaseFontSize).toBe("function");
|
||||
expect(typeof configStore.decreaseFontSize).toBe("function");
|
||||
expect(typeof configStore.resetFontSize).toBe("function");
|
||||
expect(typeof configStore.addAutoGrantedTool).toBe("function");
|
||||
expect(typeof configStore.removeAutoGrantedTool).toBe("function");
|
||||
expect(typeof configStore.getConfig).toBe("function");
|
||||
expect(typeof configStore.toggleStreamerMode).toBe("function");
|
||||
expect(typeof configStore.toggleCompactMode).toBe("function");
|
||||
expect(typeof configStore.setCompactMode).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof configStore.config.subscribe).toBe("function");
|
||||
expect(typeof configStore.isLoading.subscribe).toBe("function");
|
||||
expect(typeof configStore.isSidebarOpen.subscribe).toBe("function");
|
||||
expect(typeof configStore.saveError.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Test the Conversation interface and store behavior
|
||||
describe("Conversation interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const conversation = {
|
||||
id: "conv-123",
|
||||
name: "Test Conversation",
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
expect(conversation.id).toBe("conv-123");
|
||||
expect(conversation.name).toBe("Test Conversation");
|
||||
expect(conversation.terminalLines).toEqual([]);
|
||||
expect(conversation.sessionId).toBeNull();
|
||||
expect(conversation.connectionStatus).toBe("disconnected");
|
||||
expect(conversation.workingDirectory).toBe("");
|
||||
expect(conversation.characterState).toBe("idle");
|
||||
expect(conversation.isProcessing).toBe(false);
|
||||
expect(conversation.grantedTools.size).toBe(0);
|
||||
expect(conversation.pendingPermission).toBeNull();
|
||||
expect(conversation.pendingQuestion).toBeNull();
|
||||
expect(conversation.scrollPosition).toBe(-1);
|
||||
expect(conversation.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles terminal lines array", () => {
|
||||
const lines = [
|
||||
{
|
||||
id: "line-1",
|
||||
type: "user" as const,
|
||||
content: "Hello",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: "line-2",
|
||||
type: "assistant" as const,
|
||||
content: "Hi there!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0].type).toBe("user");
|
||||
expect(lines[1].type).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation ID generation", () => {
|
||||
it("generates unique IDs with timestamp prefix", () => {
|
||||
let counter = 0;
|
||||
const generateId = () => `conv-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line ID generation", () => {
|
||||
it("generates unique line IDs", () => {
|
||||
let counter = 0;
|
||||
const generateLineId = () => `line-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateLineId();
|
||||
const id2 = generateLineId();
|
||||
|
||||
expect(id1).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection status types", () => {
|
||||
it("supports all connection status values", () => {
|
||||
const statuses = ["connected", "disconnected", "connecting", "error"] as const;
|
||||
|
||||
statuses.forEach((status) => {
|
||||
expect(typeof status).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("character state types", () => {
|
||||
it("supports all character state values", () => {
|
||||
const states = [
|
||||
"idle",
|
||||
"thinking",
|
||||
"typing",
|
||||
"searching",
|
||||
"coding",
|
||||
"mcp",
|
||||
"permission",
|
||||
"success",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
states.forEach((state) => {
|
||||
expect(typeof state).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal line types", () => {
|
||||
it("supports all terminal line types", () => {
|
||||
const types = ["user", "assistant", "system", "tool", "error"] as const;
|
||||
|
||||
types.forEach((type) => {
|
||||
expect(typeof type).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates terminal line with required fields", () => {
|
||||
const line = {
|
||||
id: "line-123",
|
||||
type: "user" as const,
|
||||
content: "Test message",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
};
|
||||
|
||||
expect(line.id).toBe("line-123");
|
||||
expect(line.type).toBe("user");
|
||||
expect(line.content).toBe("Test message");
|
||||
expect(line.timestamp).toBeInstanceOf(Date);
|
||||
expect(line.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates terminal line with tool name", () => {
|
||||
const line = {
|
||||
id: "line-456",
|
||||
type: "tool" as const,
|
||||
content: "Tool output",
|
||||
timestamp: new Date(),
|
||||
toolName: "Read",
|
||||
};
|
||||
|
||||
expect(line.toolName).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission request structure", () => {
|
||||
it("creates valid permission request", () => {
|
||||
const request = {
|
||||
id: "perm-123",
|
||||
tool: "Bash",
|
||||
description: "Run shell command",
|
||||
input: '{"command": "ls"}',
|
||||
};
|
||||
|
||||
expect(request.id).toBe("perm-123");
|
||||
expect(request.tool).toBe("Bash");
|
||||
expect(request.description).toBe("Run shell command");
|
||||
expect(request.input).toContain("command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user question structure", () => {
|
||||
it("creates valid question event", () => {
|
||||
const question = {
|
||||
id: "q-123",
|
||||
question: "Which option?",
|
||||
options: [
|
||||
{ label: "A", description: "Option A" },
|
||||
{ label: "B", description: "Option B" },
|
||||
],
|
||||
conversation_id: "conv-123",
|
||||
};
|
||||
|
||||
expect(question.id).toBe("q-123");
|
||||
expect(question.question).toBe("Which option?");
|
||||
expect(question.options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment structure", () => {
|
||||
it("creates valid file attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-123",
|
||||
type: "file" as const,
|
||||
name: "test.txt",
|
||||
path: "/tmp/test.txt",
|
||||
size: 1024,
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
|
||||
expect(attachment.id).toBe("att-123");
|
||||
expect(attachment.type).toBe("file");
|
||||
expect(attachment.name).toBe("test.txt");
|
||||
});
|
||||
|
||||
it("creates valid image attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-456",
|
||||
type: "image" as const,
|
||||
name: "screenshot.png",
|
||||
path: "/tmp/screenshot.png",
|
||||
size: 50000,
|
||||
mimeType: "image/png",
|
||||
previewUrl: "data:image/png;base64,...",
|
||||
};
|
||||
|
||||
expect(attachment.type).toBe("image");
|
||||
expect(attachment.previewUrl).toContain("data:image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation management operations", () => {
|
||||
it("creates new conversation with default values", () => {
|
||||
let counter = 1;
|
||||
const createNewConversation = (name?: string) => {
|
||||
const id = `conv-${Date.now()}-${counter++}`;
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${counter}`,
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
};
|
||||
|
||||
const conv = createNewConversation("My Chat");
|
||||
expect(conv.name).toBe("My Chat");
|
||||
expect(conv.connectionStatus).toBe("disconnected");
|
||||
expect(conv.characterState).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses default name when not provided", () => {
|
||||
const counter = 5;
|
||||
const createNewConversation = (name?: string) => ({
|
||||
name: name || `Conversation ${counter}`,
|
||||
});
|
||||
|
||||
const conv = createNewConversation();
|
||||
expect(conv.name).toBe("Conversation 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation history formatting", () => {
|
||||
it("formats user and assistant messages for history", () => {
|
||||
const lines = [
|
||||
{ type: "user" as const, content: "Hello" },
|
||||
{ type: "assistant" as const, content: "Hi there!" },
|
||||
{ type: "system" as const, content: "Connected" },
|
||||
{ type: "user" as const, content: "How are you?" },
|
||||
];
|
||||
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
const history = relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
expect(history).toContain("User: Hello");
|
||||
expect(history).toContain("Assistant: Hi there!");
|
||||
expect(history).toContain("User: How are you?");
|
||||
expect(history).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("returns empty string for no messages", () => {
|
||||
const lines: Array<{ type: string; content: string }> = [];
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
expect(relevantLines.length).toBe(0);
|
||||
const history = relevantLines.length === 0 ? "" : "has content";
|
||||
expect(history).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool granting", () => {
|
||||
it("tracks granted tools with Set", () => {
|
||||
const grantedTools = new Set<string>();
|
||||
|
||||
grantedTools.add("Read");
|
||||
grantedTools.add("Write");
|
||||
grantedTools.add("Bash");
|
||||
|
||||
expect(grantedTools.has("Read")).toBe(true);
|
||||
expect(grantedTools.has("Write")).toBe(true);
|
||||
expect(grantedTools.has("Bash")).toBe(true);
|
||||
expect(grantedTools.has("Edit")).toBe(false);
|
||||
expect(grantedTools.size).toBe(3);
|
||||
});
|
||||
|
||||
it("clears all granted tools", () => {
|
||||
const grantedTools = new Set<string>(["Read", "Write"]);
|
||||
expect(grantedTools.size).toBe(2);
|
||||
|
||||
grantedTools.clear();
|
||||
expect(grantedTools.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll position handling", () => {
|
||||
it("uses -1 for auto-scroll (scroll to bottom)", () => {
|
||||
const scrollPosition = -1;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(true);
|
||||
});
|
||||
|
||||
it("uses positive values for manual scroll position", () => {
|
||||
const scrollPosition: number = 500;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(false);
|
||||
expect(scrollPosition).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation deletion rules", () => {
|
||||
it("prevents deletion of last conversation", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1", name: "Main" }]]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it("allows deletion when multiple conversations exist", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to remaining conversation after deletion", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const activeId = "conv-1";
|
||||
conversations.delete(activeId);
|
||||
|
||||
const remaining = Array.from(conversations.keys());
|
||||
expect(remaining).toEqual(["conv-2"]);
|
||||
expect(remaining[0]).toBe("conv-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity timestamp tracking", () => {
|
||||
it("updates lastActivityAt on changes", () => {
|
||||
const before = new Date();
|
||||
|
||||
// Simulate a small delay
|
||||
const after = new Date(before.getTime() + 100);
|
||||
|
||||
expect(after.getTime()).toBeGreaterThan(before.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Map operations for conversations", () => {
|
||||
it("stores and retrieves conversations by ID", () => {
|
||||
const conversations = new Map<string, { id: string; name: string }>();
|
||||
|
||||
conversations.set("conv-1", { id: "conv-1", name: "First" });
|
||||
conversations.set("conv-2", { id: "conv-2", name: "Second" });
|
||||
|
||||
expect(conversations.get("conv-1")?.name).toBe("First");
|
||||
expect(conversations.get("conv-2")?.name).toBe("Second");
|
||||
expect(conversations.get("conv-3")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks if conversation exists", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1" }]]);
|
||||
|
||||
expect(conversations.has("conv-1")).toBe(true);
|
||||
expect(conversations.has("conv-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("iterates over all conversations", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "First" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const names: string[] = [];
|
||||
conversations.forEach((conv) => names.push(conv.name));
|
||||
|
||||
expect(names).toContain("First");
|
||||
expect(names).toContain("Second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation rename", () => {
|
||||
it("updates conversation name", () => {
|
||||
const conversation = { id: "conv-1", name: "Old Name", lastActivityAt: new Date() };
|
||||
|
||||
conversation.name = "New Name";
|
||||
conversation.lastActivityAt = new Date();
|
||||
|
||||
expect(conversation.name).toBe("New Name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment management", () => {
|
||||
it("adds attachment to array", () => {
|
||||
const attachments: Array<{ id: string; name: string }> = [];
|
||||
|
||||
attachments.push({ id: "att-1", name: "file1.txt" });
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
attachments.push({ id: "att-2", name: "file2.txt" });
|
||||
expect(attachments).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes attachment by ID", () => {
|
||||
const attachments = [
|
||||
{ id: "att-1", name: "file1.txt" },
|
||||
{ id: "att-2", name: "file2.txt" },
|
||||
];
|
||||
|
||||
const filtered = attachments.filter((a) => a.id !== "att-1");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("att-2");
|
||||
});
|
||||
|
||||
it("clears all attachments", () => {
|
||||
const cleared: Array<{ id: string }> = [];
|
||||
expect(cleared).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived store behavior", () => {
|
||||
it("derives connection status from active conversation", () => {
|
||||
const activeConv = { connectionStatus: "connected" as const };
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("connected");
|
||||
});
|
||||
|
||||
it("defaults to disconnected when no active conversation", () => {
|
||||
const activeConv = null as { connectionStatus?: string } | null;
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("derives terminal lines from active conversation", () => {
|
||||
const activeConv = {
|
||||
terminalLines: [
|
||||
{ id: "1", content: "Hello" },
|
||||
{ id: "2", content: "World" },
|
||||
],
|
||||
};
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("defaults to empty array when no active conversation", () => {
|
||||
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line update operations", () => {
|
||||
it("updates line content by ID", () => {
|
||||
const lines = [
|
||||
{ id: "line-1", content: "Original" },
|
||||
{ id: "line-2", content: "Other" },
|
||||
];
|
||||
|
||||
const line = lines.find((l) => l.id === "line-1");
|
||||
if (line) {
|
||||
line.content = "Updated";
|
||||
}
|
||||
|
||||
expect(lines[0].content).toBe("Updated");
|
||||
expect(lines[1].content).toBe("Other");
|
||||
});
|
||||
|
||||
it("appends to line content", () => {
|
||||
const line = { id: "line-1", content: "Hello" };
|
||||
|
||||
line.content += " World";
|
||||
|
||||
expect(line.content).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending retry message", () => {
|
||||
it("stores and clears retry message", () => {
|
||||
let pendingRetryMessage: string | null = null;
|
||||
|
||||
pendingRetryMessage = "Retry this message";
|
||||
expect(pendingRetryMessage).toBe("Retry this message");
|
||||
|
||||
pendingRetryMessage = null;
|
||||
expect(pendingRetryMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { quickActionsStore, type QuickAction } from "./quickActions";
|
||||
|
||||
describe("QuickAction interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Run Tests",
|
||||
prompt: "Please run the tests",
|
||||
icon: "play",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(action.id).toBe("action-123");
|
||||
expect(action.name).toBe("Run Tests");
|
||||
expect(action.prompt).toBe("Please run the tests");
|
||||
expect(action.icon).toBe("play");
|
||||
expect(action.is_default).toBe(false);
|
||||
expect(action.created_at).toBe("2024-01-01T00:00:00Z");
|
||||
expect(action.updated_at).toBe("2024-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("supports default actions", () => {
|
||||
const defaultAction: QuickAction = {
|
||||
id: "default-review-pr",
|
||||
name: "Review PR",
|
||||
prompt: "Please review this pull request",
|
||||
icon: "git-pull-request",
|
||||
is_default: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(defaultAction.is_default).toBe(true);
|
||||
expect(defaultAction.id).toContain("default-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quickActionsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof quickActionsStore.loadQuickActions).toBe("function");
|
||||
expect(typeof quickActionsStore.saveQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.createQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.updateQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.deleteQuickAction).toBe("function");
|
||||
expect(typeof quickActionsStore.resetDefaults).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof quickActionsStore.actions.subscribe).toBe("function");
|
||||
expect(typeof quickActionsStore.isLoading.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadQuickActions", () => {
|
||||
it("loads quick actions from backend", async () => {
|
||||
const mockActions: QuickAction[] = [
|
||||
{
|
||||
id: "action-1",
|
||||
name: "Action 1",
|
||||
prompt: "prompt 1",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_quick_actions", mockActions);
|
||||
|
||||
await quickActionsStore.loadQuickActions();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_quick_actions");
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_quick_actions", new Error("Failed to load"));
|
||||
|
||||
await quickActionsStore.loadQuickActions();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load quick actions:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveQuickAction", () => {
|
||||
it("saves action and reloads list", async () => {
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Test",
|
||||
prompt: "test prompt",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", [action]);
|
||||
|
||||
const result = await quickActionsStore.saveQuickAction(action);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("save_quick_action", { action });
|
||||
});
|
||||
|
||||
it("handles save errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_quick_action", new Error("Failed to save"));
|
||||
|
||||
const action: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Test",
|
||||
prompt: "test prompt",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const result = await quickActionsStore.saveQuickAction(action);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save quick action:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQuickAction", () => {
|
||||
it("creates new action with generated ID and timestamps", async () => {
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.createQuickAction("My Action", "Do something", "star");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_quick_action",
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
name: "My Action",
|
||||
prompt: "Do something",
|
||||
icon: "star",
|
||||
is_default: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateQuickAction", () => {
|
||||
it("updates existing action preserving created_at", async () => {
|
||||
const existingAction: QuickAction = {
|
||||
id: "action-123",
|
||||
name: "Old Name",
|
||||
prompt: "old prompt",
|
||||
icon: "old-icon",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("list_quick_actions", [existingAction]);
|
||||
setMockInvokeResult("save_quick_action", undefined);
|
||||
|
||||
const result = await quickActionsStore.updateQuickAction(
|
||||
"action-123",
|
||||
"New Name",
|
||||
"new prompt",
|
||||
"new-icon"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_quick_action",
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
id: "action-123",
|
||||
name: "New Name",
|
||||
prompt: "new prompt",
|
||||
icon: "new-icon",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for non-existent action", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.updateQuickAction(
|
||||
"non-existent",
|
||||
"Name",
|
||||
"prompt",
|
||||
"icon"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Quick action not found for update");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteQuickAction", () => {
|
||||
it("deletes action by ID", async () => {
|
||||
setMockInvokeResult("delete_quick_action", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.deleteQuickAction("action-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_quick_action", { actionId: "action-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_quick_action", new Error("Cannot delete default action"));
|
||||
|
||||
const result = await quickActionsStore.deleteQuickAction("default-1");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete quick action:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetDefaults", () => {
|
||||
it("resets default actions", async () => {
|
||||
setMockInvokeResult("reset_default_quick_actions", undefined);
|
||||
setMockInvokeResult("list_quick_actions", []);
|
||||
|
||||
const result = await quickActionsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("reset_default_quick_actions");
|
||||
});
|
||||
|
||||
it("handles reset errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("reset_default_quick_actions", new Error("Reset failed"));
|
||||
|
||||
const result = await quickActionsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to reset default quick actions:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("quick action ID generation", () => {
|
||||
it("generates unique custom action IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action validation", () => {
|
||||
it("requires non-empty name", () => {
|
||||
const action = { name: "" };
|
||||
const isValid = action.name.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty prompt", () => {
|
||||
const action = { prompt: " " };
|
||||
const isValid = action.prompt.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty icon", () => {
|
||||
const action = { icon: "star" };
|
||||
const isValid = action.icon.trim().length > 0;
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action icons", () => {
|
||||
it("supports various icon names", () => {
|
||||
const validIcons = [
|
||||
"git-pull-request",
|
||||
"play",
|
||||
"file-text",
|
||||
"alert-circle",
|
||||
"check-square",
|
||||
"refresh-cw",
|
||||
"star",
|
||||
"code",
|
||||
"terminal",
|
||||
];
|
||||
|
||||
validIcons.forEach((icon) => {
|
||||
expect(typeof icon).toBe("string");
|
||||
expect(icon.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("action sorting", () => {
|
||||
it("sorts default actions before custom actions", () => {
|
||||
const actions = [
|
||||
{ id: "custom-1", name: "Custom", is_default: false },
|
||||
{ id: "default-1", name: "Default", is_default: true },
|
||||
];
|
||||
|
||||
const sorted = [...actions].sort((a, b) => {
|
||||
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
if (defaultCmp !== 0) return defaultCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
expect(sorted[0].is_default).toBe(true);
|
||||
expect(sorted[1].is_default).toBe(false);
|
||||
});
|
||||
|
||||
it("sorts alphabetically within same default status", () => {
|
||||
const actions = [
|
||||
{ id: "default-2", name: "Zebra", is_default: true },
|
||||
{ id: "default-1", name: "Apple", is_default: true },
|
||||
];
|
||||
|
||||
const sorted = [...actions].sort((a, b) => {
|
||||
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
if (defaultCmp !== 0) return defaultCmp;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
expect(sorted[0].name).toBe("Apple");
|
||||
expect(sorted[1].name).toBe("Zebra");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { snippetsStore, type Snippet } from "./snippets";
|
||||
|
||||
describe("Snippet interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Git Status",
|
||||
content: "git status",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(snippet.id).toBe("snippet-123");
|
||||
expect(snippet.name).toBe("Git Status");
|
||||
expect(snippet.content).toBe("git status");
|
||||
expect(snippet.category).toBe("Git");
|
||||
expect(snippet.is_default).toBe(false);
|
||||
expect(snippet.created_at).toBe("2024-01-01T00:00:00Z");
|
||||
expect(snippet.updated_at).toBe("2024-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("supports default snippets", () => {
|
||||
const defaultSnippet: Snippet = {
|
||||
id: "default-git-status",
|
||||
name: "Git Status",
|
||||
content: "git status --short",
|
||||
category: "Git",
|
||||
is_default: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
expect(defaultSnippet.is_default).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippetsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof snippetsStore.loadSnippets).toBe("function");
|
||||
expect(typeof snippetsStore.saveSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.createSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.updateSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.deleteSnippet).toBe("function");
|
||||
expect(typeof snippetsStore.resetDefaults).toBe("function");
|
||||
expect(typeof snippetsStore.setSelectedCategory).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof snippetsStore.snippets.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.categories.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.filteredSnippets.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.isLoading.subscribe).toBe("function");
|
||||
expect(typeof snippetsStore.selectedCategory.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSnippets", () => {
|
||||
it("loads snippets and categories from backend", async () => {
|
||||
const mockSnippets: Snippet[] = [
|
||||
{
|
||||
id: "snippet-1",
|
||||
name: "Snippet 1",
|
||||
content: "content 1",
|
||||
category: "Git",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
const mockCategories = ["Git", "Shell", "Docker"];
|
||||
|
||||
setMockInvokeResult("list_snippets", mockSnippets);
|
||||
setMockInvokeResult("get_snippet_categories", mockCategories);
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_snippets");
|
||||
expect(invoke).toHaveBeenCalledWith("get_snippet_categories");
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_snippets", new Error("Failed to load"));
|
||||
|
||||
await snippetsStore.loadSnippets();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load snippets:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSnippet", () => {
|
||||
it("saves snippet and reloads list", async () => {
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Test",
|
||||
content: "test content",
|
||||
category: "Test",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", [snippet]);
|
||||
setMockInvokeResult("get_snippet_categories", ["Test"]);
|
||||
|
||||
const result = await snippetsStore.saveSnippet(snippet);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("save_snippet", { snippet });
|
||||
});
|
||||
|
||||
it("handles save errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_snippet", new Error("Failed to save"));
|
||||
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Test",
|
||||
content: "test content",
|
||||
category: "Test",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const result = await snippetsStore.saveSnippet(snippet);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save snippet:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSnippet", () => {
|
||||
it("creates new snippet with generated ID and timestamps", async () => {
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", ["Shell"]);
|
||||
|
||||
const result = await snippetsStore.createSnippet("My Snippet", "echo hello", "Shell");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_snippet",
|
||||
expect.objectContaining({
|
||||
snippet: expect.objectContaining({
|
||||
name: "My Snippet",
|
||||
content: "echo hello",
|
||||
category: "Shell",
|
||||
is_default: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSnippet", () => {
|
||||
it("updates existing snippet preserving created_at", async () => {
|
||||
const existingSnippet: Snippet = {
|
||||
id: "snippet-123",
|
||||
name: "Old Name",
|
||||
content: "old content",
|
||||
category: "Old Category",
|
||||
is_default: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
setMockInvokeResult("list_snippets", [existingSnippet]);
|
||||
setMockInvokeResult("save_snippet", undefined);
|
||||
setMockInvokeResult("get_snippet_categories", ["New Category"]);
|
||||
|
||||
const result = await snippetsStore.updateSnippet(
|
||||
"snippet-123",
|
||||
"New Name",
|
||||
"new content",
|
||||
"New Category"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"save_snippet",
|
||||
expect.objectContaining({
|
||||
snippet: expect.objectContaining({
|
||||
id: "snippet-123",
|
||||
name: "New Name",
|
||||
content: "new content",
|
||||
category: "New Category",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for non-existent snippet", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
|
||||
const result = await snippetsStore.updateSnippet(
|
||||
"non-existent",
|
||||
"Name",
|
||||
"content",
|
||||
"Category"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Snippet not found for update");
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSnippet", () => {
|
||||
it("deletes snippet by ID", async () => {
|
||||
setMockInvokeResult("delete_snippet", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", []);
|
||||
|
||||
const result = await snippetsStore.deleteSnippet("snippet-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_snippet", { snippetId: "snippet-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_snippet", new Error("Cannot delete default snippet"));
|
||||
|
||||
const result = await snippetsStore.deleteSnippet("default-1");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete snippet:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetDefaults", () => {
|
||||
it("resets default snippets", async () => {
|
||||
setMockInvokeResult("reset_default_snippets", undefined);
|
||||
setMockInvokeResult("list_snippets", []);
|
||||
setMockInvokeResult("get_snippet_categories", []);
|
||||
|
||||
const result = await snippetsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("reset_default_snippets");
|
||||
});
|
||||
|
||||
it("handles reset errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("reset_default_snippets", new Error("Reset failed"));
|
||||
|
||||
const result = await snippetsStore.resetDefaults();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to reset default snippets:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSelectedCategory", () => {
|
||||
it("updates selected category", () => {
|
||||
snippetsStore.setSelectedCategory("Git");
|
||||
expect(get(snippetsStore.selectedCategory)).toBe("Git");
|
||||
});
|
||||
|
||||
it("can be cleared with null", () => {
|
||||
snippetsStore.setSelectedCategory("Git");
|
||||
snippetsStore.setSelectedCategory(null);
|
||||
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet ID generation", () => {
|
||||
it("generates unique custom snippet IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet validation", () => {
|
||||
it("requires non-empty name", () => {
|
||||
const snippet = { name: "" };
|
||||
const isValid = snippet.name.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty content", () => {
|
||||
const snippet = { content: " " };
|
||||
const isValid = snippet.content.trim().length > 0;
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("requires non-empty category", () => {
|
||||
const snippet = { category: "Git" };
|
||||
const isValid = snippet.category.trim().length > 0;
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippet content types", () => {
|
||||
it("supports multiline content", () => {
|
||||
const snippet = {
|
||||
content: `git add .
|
||||
git commit -m "message"
|
||||
git push`,
|
||||
};
|
||||
|
||||
expect(snippet.content.includes("\n")).toBe(true);
|
||||
expect(snippet.content.split("\n")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("supports content with special characters", () => {
|
||||
const snippet = {
|
||||
content: "echo \"Hello, World!\" && echo 'Single quotes'",
|
||||
};
|
||||
|
||||
expect(snippet.content).toContain('"');
|
||||
expect(snippet.content).toContain("'");
|
||||
expect(snippet.content).toContain("&&");
|
||||
});
|
||||
|
||||
it("supports content with variables", () => {
|
||||
const snippet = {
|
||||
content: "docker run -v $PWD:/app ${IMAGE_NAME}:${TAG}",
|
||||
};
|
||||
|
||||
expect(snippet.content).toContain("$PWD");
|
||||
expect(snippet.content).toContain("${IMAGE_NAME}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { stats, formattedStats, resetSessionStats } from "./stats";
|
||||
import type { UsageStats } from "./stats";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("stats store", () => {
|
||||
beforeEach(() => {
|
||||
// Reset stats to default before each test
|
||||
stats.set({
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats writable store", () => {
|
||||
it("has correct default values", () => {
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.total_input_tokens).toBe(0);
|
||||
expect(currentStats.total_output_tokens).toBe(0);
|
||||
expect(currentStats.total_cost_usd).toBe(0);
|
||||
expect(currentStats.model).toBeNull();
|
||||
});
|
||||
|
||||
it("can be updated with set", () => {
|
||||
const newStats: UsageStats = {
|
||||
total_input_tokens: 1000,
|
||||
total_output_tokens: 2000,
|
||||
total_cost_usd: 0.05,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 1000,
|
||||
session_cost_usd: 0.025,
|
||||
model: "claude-sonnet-4",
|
||||
messages_exchanged: 10,
|
||||
session_messages_exchanged: 5,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 2,
|
||||
files_edited: 5,
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
};
|
||||
|
||||
stats.set(newStats);
|
||||
const currentStats = get(stats);
|
||||
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
});
|
||||
|
||||
it("can be updated with update function", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 500,
|
||||
session_messages_exchanged: 3,
|
||||
}));
|
||||
|
||||
const currentStats = get(stats);
|
||||
expect(currentStats.total_input_tokens).toBe(500);
|
||||
expect(currentStats.session_messages_exchanged).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetSessionStats", () => {
|
||||
it("resets all session fields to zero", () => {
|
||||
// First set some values
|
||||
stats.set({
|
||||
total_input_tokens: 1000,
|
||||
total_output_tokens: 2000,
|
||||
total_cost_usd: 0.05,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 1000,
|
||||
session_cost_usd: 0.025,
|
||||
model: "claude-sonnet-4",
|
||||
messages_exchanged: 10,
|
||||
session_messages_exchanged: 5,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 2,
|
||||
files_edited: 5,
|
||||
session_files_edited: 2,
|
||||
files_created: 1,
|
||||
session_files_created: 1,
|
||||
tools_usage: { Read: 5, Edit: 3 },
|
||||
session_tools_usage: { Read: 2, Edit: 1 },
|
||||
session_duration_seconds: 300,
|
||||
});
|
||||
|
||||
// Reset session stats
|
||||
resetSessionStats();
|
||||
|
||||
const currentStats = get(stats);
|
||||
|
||||
// Total stats should be preserved
|
||||
expect(currentStats.total_input_tokens).toBe(1000);
|
||||
expect(currentStats.total_output_tokens).toBe(2000);
|
||||
expect(currentStats.total_cost_usd).toBe(0.05);
|
||||
expect(currentStats.messages_exchanged).toBe(10);
|
||||
expect(currentStats.code_blocks_generated).toBe(3);
|
||||
expect(currentStats.files_edited).toBe(5);
|
||||
expect(currentStats.files_created).toBe(1);
|
||||
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
|
||||
expect(currentStats.model).toBe("claude-sonnet-4");
|
||||
|
||||
// Session stats should be reset
|
||||
expect(currentStats.session_input_tokens).toBe(0);
|
||||
expect(currentStats.session_output_tokens).toBe(0);
|
||||
expect(currentStats.session_cost_usd).toBe(0);
|
||||
expect(currentStats.session_messages_exchanged).toBe(0);
|
||||
expect(currentStats.session_code_blocks_generated).toBe(0);
|
||||
expect(currentStats.session_files_edited).toBe(0);
|
||||
expect(currentStats.session_files_created).toBe(0);
|
||||
expect(currentStats.session_tools_usage).toEqual({});
|
||||
expect(currentStats.session_duration_seconds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formattedStats derived store", () => {
|
||||
it("formats token numbers with locale string", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 1234567,
|
||||
total_output_tokens: 7654321,
|
||||
session_input_tokens: 12345,
|
||||
session_output_tokens: 54321,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalTokens).toBe("8,888,888");
|
||||
expect(formatted.totalInputTokens).toBe("1,234,567");
|
||||
expect(formatted.totalOutputTokens).toBe("7,654,321");
|
||||
expect(formatted.sessionTokens).toBe("66,666");
|
||||
expect(formatted.sessionInputTokens).toBe("12,345");
|
||||
expect(formatted.sessionOutputTokens).toBe("54,321");
|
||||
});
|
||||
|
||||
it("formats cost with 4 decimal places", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_cost_usd: 1.23456,
|
||||
session_cost_usd: 0.00123,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalCost).toBe("$1.2346");
|
||||
expect(formatted.sessionCost).toBe("$0.0012");
|
||||
});
|
||||
|
||||
it("formats duration seconds only", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 45,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("45s");
|
||||
});
|
||||
|
||||
it("formats duration minutes and seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 125, // 2m 5s
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("2m 5s");
|
||||
});
|
||||
|
||||
it("formats duration hours, minutes, and seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 3725, // 1h 2m 5s
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("1h 2m 5s");
|
||||
});
|
||||
|
||||
it("formats duration with zero seconds", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_duration_seconds: 3600, // exactly 1h
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.sessionDuration).toBe("1h 0m 0s");
|
||||
});
|
||||
|
||||
it("shows model name when available", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
model: "claude-opus-4-5",
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("shows placeholder when model is null", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
model: null,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.model).toBe("No model selected");
|
||||
});
|
||||
|
||||
it("formats message counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
messages_exchanged: 100,
|
||||
session_messages_exchanged: 10,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.messagesTotal).toBe("100");
|
||||
expect(formatted.messagesSession).toBe("10");
|
||||
});
|
||||
|
||||
it("formats code block counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
code_blocks_generated: 50,
|
||||
session_code_blocks_generated: 5,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.codeBlocksTotal).toBe("50");
|
||||
expect(formatted.codeBlocksSession).toBe("5");
|
||||
});
|
||||
|
||||
it("formats file counts", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
files_edited: 25,
|
||||
session_files_edited: 3,
|
||||
files_created: 10,
|
||||
session_files_created: 2,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.filesEditedTotal).toBe("25");
|
||||
expect(formatted.filesEditedSession).toBe("3");
|
||||
expect(formatted.filesCreatedTotal).toBe("10");
|
||||
expect(formatted.filesCreatedSession).toBe("2");
|
||||
});
|
||||
|
||||
it("exposes tools usage directly", () => {
|
||||
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
|
||||
const sessionToolsUsage = { Read: 2, Edit: 1 };
|
||||
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
tools_usage: toolsUsage,
|
||||
session_tools_usage: sessionToolsUsage,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.toolsUsage).toEqual(toolsUsage);
|
||||
expect(formatted.sessionToolsUsage).toEqual(sessionToolsUsage);
|
||||
});
|
||||
|
||||
it("handles zero values correctly", () => {
|
||||
const formatted = get(formattedStats);
|
||||
|
||||
expect(formatted.totalTokens).toBe("0");
|
||||
expect(formatted.totalCost).toBe("$0.0000");
|
||||
expect(formatted.sessionDuration).toBe("0s");
|
||||
expect(formatted.messagesTotal).toBe("0");
|
||||
});
|
||||
|
||||
it("handles large numbers with proper formatting", () => {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
total_input_tokens: 1000000000, // 1 billion
|
||||
messages_exchanged: 999999,
|
||||
}));
|
||||
|
||||
const formatted = get(formattedStats);
|
||||
expect(formatted.totalInputTokens).toBe("1,000,000,000");
|
||||
expect(formatted.messagesTotal).toBe("999,999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsageStats interface", () => {
|
||||
it("supports all expected fields", () => {
|
||||
const fullStats: UsageStats = {
|
||||
total_input_tokens: 100,
|
||||
total_output_tokens: 200,
|
||||
total_cost_usd: 0.01,
|
||||
session_input_tokens: 50,
|
||||
session_output_tokens: 100,
|
||||
session_cost_usd: 0.005,
|
||||
model: "test-model",
|
||||
messages_exchanged: 5,
|
||||
session_messages_exchanged: 2,
|
||||
code_blocks_generated: 3,
|
||||
session_code_blocks_generated: 1,
|
||||
files_edited: 2,
|
||||
session_files_edited: 1,
|
||||
files_created: 1,
|
||||
session_files_created: 0,
|
||||
tools_usage: { Read: 3 },
|
||||
session_tools_usage: { Read: 1 },
|
||||
session_duration_seconds: 60,
|
||||
};
|
||||
|
||||
stats.set(fullStats);
|
||||
const currentStats = get(stats);
|
||||
|
||||
// Verify all fields are present and correct
|
||||
expect(currentStats).toEqual(fullStats);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { emitMockEvent, setMockInvokeResult } from "../../vitest.setup";
|
||||
|
||||
// We need to test the helper functions that are exported
|
||||
// The main listener initialization is tested through integration
|
||||
|
||||
describe("tauri helpers", () => {
|
||||
describe("getTimeOfDay (inferred from greeting behavior)", () => {
|
||||
it("returns morning for hours 5-11", () => {
|
||||
const date = new Date();
|
||||
date.setHours(8, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
// The getTimeOfDay function is private, but we can verify the greeting prompt includes the time
|
||||
// This tests the logic indirectly
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(5);
|
||||
expect(date.getHours()).toBeLessThan(12);
|
||||
});
|
||||
|
||||
it("returns afternoon for hours 12-16", () => {
|
||||
const date = new Date();
|
||||
date.setHours(14, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(12);
|
||||
expect(date.getHours()).toBeLessThan(17);
|
||||
});
|
||||
|
||||
it("returns evening for hours 17-20", () => {
|
||||
const date = new Date();
|
||||
date.setHours(19, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(17);
|
||||
expect(date.getHours()).toBeLessThan(21);
|
||||
});
|
||||
|
||||
it("returns late night for hours 21-4", () => {
|
||||
const date = new Date();
|
||||
date.setHours(23, 0, 0, 0);
|
||||
vi.setSystemTime(date);
|
||||
|
||||
expect(date.getHours() >= 21 || date.getHours() < 5).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tauri event handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("connection events", () => {
|
||||
it("emits connected status payload", () => {
|
||||
const payload = {
|
||||
status: "connected",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
// Verify payload structure
|
||||
expect(payload.status).toBe("connected");
|
||||
expect(payload.conversation_id).toBe("test-conv-1");
|
||||
});
|
||||
|
||||
it("emits disconnected status payload", () => {
|
||||
const payload = {
|
||||
status: "disconnected",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("emits error status payload", () => {
|
||||
const payload = {
|
||||
status: "error",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("state change events", () => {
|
||||
it("maps idle state correctly", () => {
|
||||
const payload = {
|
||||
state: "idle",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("idle");
|
||||
});
|
||||
|
||||
it("maps thinking state correctly", () => {
|
||||
const payload = {
|
||||
state: "thinking",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("thinking");
|
||||
});
|
||||
|
||||
it("maps typing state correctly", () => {
|
||||
const payload = {
|
||||
state: "typing",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("typing");
|
||||
});
|
||||
|
||||
it("maps searching state correctly", () => {
|
||||
const payload = {
|
||||
state: "searching",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("searching");
|
||||
});
|
||||
|
||||
it("maps coding state correctly", () => {
|
||||
const payload = {
|
||||
state: "coding",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("coding");
|
||||
});
|
||||
|
||||
it("maps mcp state correctly", () => {
|
||||
const payload = {
|
||||
state: "mcp",
|
||||
tool_name: "some-tool",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("mcp");
|
||||
expect(payload.tool_name).toBe("some-tool");
|
||||
});
|
||||
|
||||
it("maps permission state correctly", () => {
|
||||
const payload = {
|
||||
state: "permission",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("permission");
|
||||
});
|
||||
|
||||
it("maps success state correctly", () => {
|
||||
const payload = {
|
||||
state: "success",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("success");
|
||||
});
|
||||
|
||||
it("maps error state correctly", () => {
|
||||
const payload = {
|
||||
state: "error",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.state.toLowerCase()).toBe("error");
|
||||
});
|
||||
|
||||
it("defaults unknown state to idle", () => {
|
||||
const stateMap: Record<string, string> = {
|
||||
idle: "idle",
|
||||
thinking: "thinking",
|
||||
typing: "typing",
|
||||
searching: "searching",
|
||||
coding: "coding",
|
||||
mcp: "mcp",
|
||||
permission: "permission",
|
||||
success: "success",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
const unknownState = "unknown-state";
|
||||
const mappedState = stateMap[unknownState.toLowerCase()] || "idle";
|
||||
expect(mappedState).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("output events", () => {
|
||||
it("handles user output type", () => {
|
||||
const payload = {
|
||||
line_type: "user",
|
||||
content: "Hello, world!",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("user");
|
||||
expect(payload.content).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("handles assistant output type", () => {
|
||||
const payload = {
|
||||
line_type: "assistant",
|
||||
content: "Hi there!",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("assistant");
|
||||
});
|
||||
|
||||
it("handles system output type", () => {
|
||||
const payload = {
|
||||
line_type: "system",
|
||||
content: "Connected to Claude Code",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("system");
|
||||
});
|
||||
|
||||
it("handles tool output type with tool name", () => {
|
||||
const payload = {
|
||||
line_type: "tool",
|
||||
content: "Tool executed successfully",
|
||||
tool_name: "Read",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("tool");
|
||||
expect(payload.tool_name).toBe("Read");
|
||||
});
|
||||
|
||||
it("handles error output type", () => {
|
||||
const payload = {
|
||||
line_type: "error",
|
||||
content: "An error occurred",
|
||||
tool_name: null,
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.line_type).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session events", () => {
|
||||
it("handles session payload with conversation id", () => {
|
||||
const payload = {
|
||||
session_id: "sess-12345678",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.session_id).toBe("sess-12345678");
|
||||
expect(payload.conversation_id).toBe("test-conv-1");
|
||||
});
|
||||
|
||||
it("creates truncated session display", () => {
|
||||
const sessionId = "sess-12345678-90ab-cdef";
|
||||
const display = `Session: ${sessionId.substring(0, 8)}...`;
|
||||
|
||||
expect(display).toBe("Session: sess-123...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("working directory events", () => {
|
||||
it("handles cwd payload", () => {
|
||||
const payload = {
|
||||
directory: "/home/user/project",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.directory).toBe("/home/user/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission events", () => {
|
||||
it("handles permission payload structure", () => {
|
||||
const payload = {
|
||||
id: "perm-123",
|
||||
tool_name: "Bash",
|
||||
tool_input: '{"command": "ls -la"}',
|
||||
description: "Run shell command",
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.id).toBe("perm-123");
|
||||
expect(payload.tool_name).toBe("Bash");
|
||||
expect(payload.tool_input).toContain("command");
|
||||
expect(payload.description).toBe("Run shell command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("question events", () => {
|
||||
it("handles question payload structure", () => {
|
||||
const payload = {
|
||||
id: "q-123",
|
||||
question: "Which option would you like?",
|
||||
options: [
|
||||
{ label: "Option A", description: "First option" },
|
||||
{ label: "Option B", description: "Second option" },
|
||||
],
|
||||
conversation_id: "test-conv-1",
|
||||
};
|
||||
|
||||
expect(payload.id).toBe("q-123");
|
||||
expect(payload.question).toBe("Which option would you like?");
|
||||
expect(payload.options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mock event system", () => {
|
||||
it("can emit events through mock system", () => {
|
||||
// The emitMockEvent function should work
|
||||
expect(typeof emitMockEvent).toBe("function");
|
||||
});
|
||||
|
||||
it("can set mock invoke results", () => {
|
||||
setMockInvokeResult("test_command", { result: "success" });
|
||||
// This verifies the mock setup is working
|
||||
expect(typeof setMockInvokeResult).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greeting system", () => {
|
||||
it("generates greeting prompt with time of day", () => {
|
||||
const timeOfDay = "morning";
|
||||
const prompt = `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||
|
||||
expect(prompt).toContain("morning");
|
||||
expect(prompt).toContain("greet the user");
|
||||
});
|
||||
|
||||
it("uses custom greeting prompt when provided", () => {
|
||||
const customPrompt = "Say hello in pirate speak!";
|
||||
const greetingPrompt = customPrompt.trim() || "default greeting";
|
||||
|
||||
expect(greetingPrompt).toBe("Say hello in pirate speak!");
|
||||
});
|
||||
|
||||
it("uses default prompt when custom is empty", () => {
|
||||
const customPrompt = " ";
|
||||
const defaultPrompt = "default greeting";
|
||||
const greetingPrompt = customPrompt.trim() || defaultPrompt;
|
||||
|
||||
expect(greetingPrompt).toBe(defaultPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation tracking", () => {
|
||||
it("tracks connected conversations with Set", () => {
|
||||
const connectedConversations = new Set<string>();
|
||||
|
||||
connectedConversations.add("conv-1");
|
||||
expect(connectedConversations.has("conv-1")).toBe(true);
|
||||
expect(connectedConversations.size).toBe(1);
|
||||
|
||||
connectedConversations.add("conv-2");
|
||||
expect(connectedConversations.size).toBe(2);
|
||||
|
||||
connectedConversations.delete("conv-1");
|
||||
expect(connectedConversations.has("conv-1")).toBe(false);
|
||||
expect(connectedConversations.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skip greeting flag", () => {
|
||||
it("flag can be set and reset", () => {
|
||||
let skipNextGreeting = false;
|
||||
|
||||
skipNextGreeting = true;
|
||||
expect(skipNextGreeting).toBe(true);
|
||||
|
||||
// Simulate reset after use
|
||||
if (skipNextGreeting) {
|
||||
skipNextGreeting = false;
|
||||
}
|
||||
expect(skipNextGreeting).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user