generated from nhcarrigan/template
feat: multiple UI improvements, font settings, and memory file display names (#175)
## Summary - **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load - **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach - **feat**: Add configurable max output tokens setting - **feat**: Use random creative names for conversation tabs - **test**: Significantly expanded frontend unit test coverage - **docs**: Require tests for all changes in CLAUDE.md - **feat**: Allow users to specify a custom terminal font (Closes #176) - **feat**: Display friendly names for memory files derived from the first heading (Closes #177) - **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs) - **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #175 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #175.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
|
||||
import {
|
||||
achievementsStore,
|
||||
unlockedAchievements,
|
||||
lockedAchievements,
|
||||
achievementsByRarity,
|
||||
achievementProgress,
|
||||
initAchievementsListener,
|
||||
} from "./achievements";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
import { playAchievementSound } from "$lib/sounds/achievement";
|
||||
|
||||
vi.mock("$lib/sounds/achievement", () => ({
|
||||
playAchievementSound: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to build a minimal unlock event
|
||||
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
|
||||
return {
|
||||
achievement: {
|
||||
id,
|
||||
name: "Test",
|
||||
description: "Test achievement",
|
||||
icon: "🏆",
|
||||
unlocked_at: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("achievementsStore initial state", () => {
|
||||
it("all achievements start as locked", () => {
|
||||
const state = get(achievementsStore);
|
||||
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
|
||||
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("totalUnlocked starts at 0", () => {
|
||||
expect(get(achievementsStore).totalUnlocked).toBe(0);
|
||||
});
|
||||
|
||||
it("lastUnlocked starts as null", () => {
|
||||
expect(get(achievementsStore).lastUnlocked).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores initial state", () => {
|
||||
it("unlockedAchievements is initially empty", () => {
|
||||
expect(get(unlockedAchievements)).toEqual([]);
|
||||
});
|
||||
|
||||
it("lockedAchievements contains all achievements initially", () => {
|
||||
const locked = get(lockedAchievements);
|
||||
const total = Object.keys(get(achievementsStore).achievements).length;
|
||||
expect(locked.length).toBe(total);
|
||||
});
|
||||
|
||||
it("achievementsByRarity groups achievements into rarity buckets", () => {
|
||||
const byRarity = get(achievementsByRarity);
|
||||
expect(byRarity.common).toBeInstanceOf(Array);
|
||||
expect(byRarity.rare).toBeInstanceOf(Array);
|
||||
expect(byRarity.epic).toBeInstanceOf(Array);
|
||||
expect(byRarity.legendary).toBeInstanceOf(Array);
|
||||
expect(byRarity.common.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("achievementProgress shows zero unlocked initially", () => {
|
||||
const progress = get(achievementProgress);
|
||||
expect(progress.unlocked).toBe(0);
|
||||
expect(progress.total).toBeGreaterThan(0);
|
||||
expect(progress.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("achievementsStore.unlockAchievement", () => {
|
||||
it("marks the achievement as unlocked and updates totalUnlocked", () => {
|
||||
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
|
||||
const state = get(achievementsStore);
|
||||
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
|
||||
expect(state.totalUnlocked).toBe(1);
|
||||
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
|
||||
});
|
||||
|
||||
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
|
||||
achievementsStore.unlockAchievement({
|
||||
achievement: {
|
||||
id: "BlossomingCoder",
|
||||
name: "Blossoming Coder",
|
||||
description: "100k tokens",
|
||||
icon: "🌸",
|
||||
unlocked_at: "2026-01-15T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const state = get(achievementsStore);
|
||||
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("does nothing when the achievement is already unlocked", () => {
|
||||
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||
const firstTotal = get(achievementsStore).totalUnlocked;
|
||||
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
|
||||
});
|
||||
|
||||
it("calls playAchievementSound when playSound is true (default)", () => {
|
||||
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
|
||||
expect(playAchievementSound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call playAchievementSound when playSound is false", () => {
|
||||
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
|
||||
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs an error when playAchievementSound throws", () => {
|
||||
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
|
||||
throw new Error("Sound failed");
|
||||
});
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores after unlocks", () => {
|
||||
it("unlockedAchievements includes previously unlocked achievements", () => {
|
||||
const unlocked = get(unlockedAchievements);
|
||||
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
|
||||
});
|
||||
|
||||
it("lockedAchievements excludes previously unlocked achievements", () => {
|
||||
const locked = get(lockedAchievements);
|
||||
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
|
||||
});
|
||||
|
||||
it("achievementProgress reflects the current unlocked count", () => {
|
||||
const progress = get(achievementProgress);
|
||||
expect(progress.unlocked).toBeGreaterThan(0);
|
||||
expect(progress.percentage).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("achievementsStore.updateProgress", () => {
|
||||
it("updates the progress value for an achievement", () => {
|
||||
achievementsStore.updateProgress("FirstMessage", 50);
|
||||
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("achievementsStore.reset", () => {
|
||||
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
|
||||
achievementsStore.reset();
|
||||
const state = get(achievementsStore);
|
||||
expect(state.totalUnlocked).toBe(0);
|
||||
expect(state.lastUnlocked).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initAchievementsListener", () => {
|
||||
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
|
||||
await initAchievementsListener();
|
||||
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
|
||||
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("loads saved achievements from the backend without playing sounds", async () => {
|
||||
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
|
||||
await initAchievementsListener();
|
||||
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
|
||||
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs an error when loading saved achievements fails", async () => {
|
||||
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await initAchievementsListener();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load saved achievements:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { characterState, characterInfo } from "./character";
|
||||
|
||||
describe("characterState store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
characterState.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts in idle state", () => {
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setState", () => {
|
||||
it("sets the character state", () => {
|
||||
characterState.setState("thinking");
|
||||
expect(get(characterState)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("can set any valid state", () => {
|
||||
const states = [
|
||||
"idle",
|
||||
"thinking",
|
||||
"typing",
|
||||
"coding",
|
||||
"searching",
|
||||
"mcp",
|
||||
"permission",
|
||||
"success",
|
||||
"error",
|
||||
] as const;
|
||||
for (const state of states) {
|
||||
characterState.setState(state);
|
||||
expect(get(characterState)).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels any active temporary state timer", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.setState("thinking");
|
||||
|
||||
// Advance past the temporary state duration — should stay as thinking
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(get(characterState)).toBe("thinking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTemporaryState", () => {
|
||||
it("sets the character state immediately", () => {
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
expect(get(characterState)).toBe("success");
|
||||
});
|
||||
|
||||
it("reverts to idle after the specified duration", () => {
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses 2000ms as the default duration", () => {
|
||||
characterState.setTemporaryState("error");
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(get(characterState)).toBe("error");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("cancels a previous temporary state timer when a new one is set", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.setTemporaryState("error", 1000);
|
||||
|
||||
// First timer would have fired at 5000ms but was cancelled
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets the state to idle", () => {
|
||||
characterState.setState("thinking");
|
||||
characterState.reset();
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
|
||||
it("cancels any pending temporary state timer", () => {
|
||||
characterState.setTemporaryState("success", 5000);
|
||||
characterState.reset();
|
||||
|
||||
// Should now be idle and should NOT revert again after timer fires
|
||||
expect(get(characterState)).toBe("idle");
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(get(characterState)).toBe("idle");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("characterInfo derived store", () => {
|
||||
beforeEach(() => {
|
||||
characterState.reset();
|
||||
});
|
||||
|
||||
it("returns the info object for the current state", () => {
|
||||
const info = get(characterInfo);
|
||||
expect(info).toBeDefined();
|
||||
expect(typeof info.label).toBe("string");
|
||||
});
|
||||
|
||||
it("updates when the character state changes", () => {
|
||||
characterState.setState("thinking");
|
||||
const thinkingInfo = get(characterInfo);
|
||||
|
||||
characterState.setState("idle");
|
||||
const idleInfo = get(characterInfo);
|
||||
|
||||
expect(thinkingInfo.label).not.toBe(idleInfo.label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
claudeStore,
|
||||
hasPermissionPending,
|
||||
hasQuestionPending,
|
||||
isClaudeProcessing,
|
||||
} from "./claude";
|
||||
import { conversationsStore } from "./conversations";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
|
||||
|
||||
describe("claudeStore (compatibility wrapper)", () => {
|
||||
afterEach(() => {
|
||||
conversationsStore.revokeAllTools();
|
||||
conversationsStore.clearPermission();
|
||||
conversationsStore.clearQuestion();
|
||||
conversationsStore.setConnectionStatus("disconnected");
|
||||
characterState.setState("idle");
|
||||
});
|
||||
|
||||
describe("getGrantedTools", () => {
|
||||
it("returns an empty array when no tools are granted", () => {
|
||||
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns granted tools as an array", () => {
|
||||
conversationsStore.grantTool("Read");
|
||||
conversationsStore.grantTool("Write");
|
||||
|
||||
const tools = claudeStore.getGrantedTools();
|
||||
|
||||
expect(tools).toContain("Read");
|
||||
expect(tools).toContain("Write");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears terminal lines and resets processing state", () => {
|
||||
conversationsStore.setProcessing(true);
|
||||
conversationsStore.grantTool("Edit");
|
||||
|
||||
claudeStore.reset();
|
||||
|
||||
expect(get(conversationsStore.isProcessing)).toBe(false);
|
||||
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPermissionPending derived store", () => {
|
||||
afterEach(() => {
|
||||
conversationsStore.clearPermission();
|
||||
});
|
||||
|
||||
it("is false when there are no pending permissions", () => {
|
||||
expect(get(hasPermissionPending)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when there is a pending permission request", () => {
|
||||
const request: PermissionRequest = {
|
||||
id: "perm-1",
|
||||
tool: "Bash",
|
||||
description: "Run a shell command",
|
||||
input: { command: "ls" },
|
||||
};
|
||||
|
||||
conversationsStore.requestPermission(request);
|
||||
|
||||
expect(get(hasPermissionPending)).toBe(true);
|
||||
|
||||
conversationsStore.clearPermission();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasQuestionPending derived store", () => {
|
||||
afterEach(() => {
|
||||
conversationsStore.clearQuestion();
|
||||
});
|
||||
|
||||
it("is false when there is no pending question", () => {
|
||||
expect(get(hasQuestionPending)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when there is a pending question", () => {
|
||||
const question: UserQuestionEvent = {
|
||||
id: "q-1",
|
||||
question: "Which approach do you prefer?",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
multi_select: false,
|
||||
};
|
||||
|
||||
conversationsStore.requestQuestion(question);
|
||||
|
||||
expect(get(hasQuestionPending)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClaudeProcessing derived store", () => {
|
||||
afterEach(() => {
|
||||
conversationsStore.setConnectionStatus("disconnected");
|
||||
characterState.setState("idle");
|
||||
});
|
||||
|
||||
it("is false when disconnected regardless of character state", () => {
|
||||
conversationsStore.setConnectionStatus("disconnected");
|
||||
characterState.setState("thinking");
|
||||
|
||||
expect(get(isClaudeProcessing)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when connected but in idle state", () => {
|
||||
conversationsStore.setConnectionStatus("connected");
|
||||
characterState.setState("idle");
|
||||
|
||||
expect(get(isClaudeProcessing)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when connected and in thinking state", () => {
|
||||
conversationsStore.setConnectionStatus("connected");
|
||||
characterState.setState("thinking");
|
||||
|
||||
expect(get(isClaudeProcessing)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when connected and in coding state", () => {
|
||||
conversationsStore.setConnectionStatus("connected");
|
||||
characterState.setState("coding");
|
||||
|
||||
expect(get(isClaudeProcessing)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when connected and in searching state", () => {
|
||||
conversationsStore.setConnectionStatus("connected");
|
||||
characterState.setState("searching");
|
||||
|
||||
expect(get(isClaudeProcessing)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* Clipboard Store Tests
|
||||
*
|
||||
* Tests the pure helper functions and store actions from the clipboard store:
|
||||
* - detectLanguage: identifies programming language from code content
|
||||
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
||||
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
|
||||
* - Derived stores: filteredEntries (language + search filtering), languages
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
|
||||
describe("detectLanguage", () => {
|
||||
describe("TypeScript detection", () => {
|
||||
it("detects import statements", () => {
|
||||
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects export statements", () => {
|
||||
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects const declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("detects interface declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
|
||||
"typescript"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects type aliases", () => {
|
||||
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Python detection", () => {
|
||||
it("detects def statements", () => {
|
||||
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects async def statements", () => {
|
||||
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects from imports", () => {
|
||||
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
|
||||
});
|
||||
|
||||
it("detects the __name__ guard", () => {
|
||||
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rust detection", () => {
|
||||
it("detects fn declarations", () => {
|
||||
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects impl blocks", () => {
|
||||
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
|
||||
"rust"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects struct declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects enum declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects mod declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
|
||||
});
|
||||
|
||||
it("detects pub visibility", () => {
|
||||
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Go detection", () => {
|
||||
it("detects package declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("package main")).toBe("go");
|
||||
});
|
||||
|
||||
it("detects func declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PHP detection", () => {
|
||||
it("detects the PHP open tag", () => {
|
||||
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL detection", () => {
|
||||
it("detects SELECT statements", () => {
|
||||
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
||||
});
|
||||
|
||||
it("detects INSERT statements", () => {
|
||||
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
|
||||
"sql"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects CREATE statements", () => {
|
||||
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
||||
});
|
||||
|
||||
it("detects SQL case-insensitively", () => {
|
||||
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML detection", () => {
|
||||
it("detects DOCTYPE declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects html tags", () => {
|
||||
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects div tags", () => {
|
||||
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
||||
});
|
||||
|
||||
it("detects span tags", () => {
|
||||
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON detection", () => {
|
||||
it("detects JSON object syntax", () => {
|
||||
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
||||
});
|
||||
|
||||
it("detects JSON with hyphenated keys", () => {
|
||||
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML detection", () => {
|
||||
it("detects YAML document separator", () => {
|
||||
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("C detection", () => {
|
||||
it("detects #include directives", () => {
|
||||
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
||||
});
|
||||
|
||||
it("detects #define directives", () => {
|
||||
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
|
||||
});
|
||||
|
||||
it("detects #ifdef directives", () => {
|
||||
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Java detection", () => {
|
||||
it("detects public class declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
||||
});
|
||||
|
||||
it("detects private static methods", () => {
|
||||
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
|
||||
});
|
||||
|
||||
it("detects protected interface declarations", () => {
|
||||
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bash detection", () => {
|
||||
it("detects shell variable assignments", () => {
|
||||
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
|
||||
});
|
||||
|
||||
it("detects variable assignments with underscores", () => {
|
||||
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown content", () => {
|
||||
it("returns null for plain text", () => {
|
||||
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(clipboardStore.detectLanguage("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mathematical expressions", () => {
|
||||
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a markdown heading", () => {
|
||||
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
const NOW = new Date("2026-03-03T12:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("'Just now' threshold (< 1 minute)", () => {
|
||||
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
||||
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns 'Just now' for the current moment", () => {
|
||||
const ts = NOW.toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
||||
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
||||
it("returns '1m ago' at exactly 1 minute", () => {
|
||||
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
|
||||
});
|
||||
|
||||
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
||||
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns '59m ago' just before the 1-hour threshold", () => {
|
||||
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xh ago' threshold (1–23 hours)", () => {
|
||||
it("returns '1h ago' at exactly 1 hour", () => {
|
||||
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
|
||||
});
|
||||
|
||||
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
||||
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("returns '23h ago' just before the 1-day threshold", () => {
|
||||
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Xd ago' threshold (1–6 days)", () => {
|
||||
it("returns '1d ago' at exactly 1 day", () => {
|
||||
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
|
||||
});
|
||||
|
||||
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
||||
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
|
||||
});
|
||||
|
||||
it("returns '6d ago' just before the 7-day threshold", () => {
|
||||
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
||||
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locale date string (7+ days ago)", () => {
|
||||
it("returns a locale date string for a 2-week-old timestamp", () => {
|
||||
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
||||
const result = clipboardStore.formatTimestamp(ts);
|
||||
// Should not be a relative time string
|
||||
expect(result).not.toContain("m ago");
|
||||
expect(result).not.toContain("h ago");
|
||||
expect(result).not.toContain("d ago");
|
||||
expect(result).not.toBe("Just now");
|
||||
});
|
||||
|
||||
it("returns a locale date string for a 1-month-old timestamp", () => {
|
||||
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
||||
const result = clipboardStore.formatTimestamp(ts);
|
||||
expect(result).not.toContain("ago");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboardStore - derived stores", () => {
|
||||
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "entry-1",
|
||||
content: "const x = 1;",
|
||||
language: "typescript",
|
||||
source: "test.ts",
|
||||
timestamp: "2026-03-03T12:00:00.000Z",
|
||||
is_pinned: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardStore.entries.set([]);
|
||||
clipboardStore.searchQuery.set("");
|
||||
clipboardStore.languageFilter.set(null);
|
||||
});
|
||||
|
||||
describe("filteredEntries - language filter", () => {
|
||||
it("returns all entries when no language filter is set", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ id: "1", language: "typescript" }),
|
||||
makeEntry({ id: "2", language: "python" }),
|
||||
]);
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters entries by language", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ id: "1", language: "typescript" }),
|
||||
makeEntry({ id: "2", language: "python" }),
|
||||
makeEntry({ id: "3", language: "typescript" }),
|
||||
]);
|
||||
clipboardStore.languageFilter.set("typescript");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array when filter matches nothing", () => {
|
||||
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
|
||||
clipboardStore.languageFilter.set("rust");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filteredEntries - search query", () => {
|
||||
it("filters by content (case-insensitive)", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ id: "1", content: "const HELLO = 1;" }),
|
||||
makeEntry({ id: "2", content: "let world = 2;" }),
|
||||
]);
|
||||
clipboardStore.searchQuery.set("hello");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("filters by language field", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
|
||||
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
|
||||
]);
|
||||
clipboardStore.searchQuery.set("python");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("2");
|
||||
});
|
||||
|
||||
it("filters by source field", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
|
||||
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
|
||||
]);
|
||||
clipboardStore.searchQuery.set("main.rs");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("returns all entries when search query is empty", () => {
|
||||
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
|
||||
clipboardStore.searchQuery.set("");
|
||||
const filtered = get(clipboardStore.filteredEntries);
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("languages derived store", () => {
|
||||
it("returns empty array when no entries", () => {
|
||||
clipboardStore.entries.set([]);
|
||||
expect(get(clipboardStore.languages)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns unique sorted languages", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ language: "typescript" }),
|
||||
makeEntry({ language: "python" }),
|
||||
makeEntry({ language: "typescript" }),
|
||||
makeEntry({ language: "rust" }),
|
||||
]);
|
||||
const langs = get(clipboardStore.languages);
|
||||
expect(langs).toEqual(["python", "rust", "typescript"]);
|
||||
});
|
||||
|
||||
it("excludes entries with null language", () => {
|
||||
clipboardStore.entries.set([
|
||||
makeEntry({ language: "typescript" }),
|
||||
makeEntry({ language: null }),
|
||||
]);
|
||||
const langs = get(clipboardStore.languages);
|
||||
expect(langs).toEqual(["typescript"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
|
||||
it("setSearchQuery updates the searchQuery store", () => {
|
||||
clipboardStore.setSearchQuery("hello world");
|
||||
expect(get(clipboardStore.searchQuery)).toBe("hello world");
|
||||
clipboardStore.setSearchQuery("");
|
||||
});
|
||||
|
||||
it("setLanguageFilter updates the languageFilter store", () => {
|
||||
clipboardStore.setLanguageFilter("python");
|
||||
expect(get(clipboardStore.languageFilter)).toBe("python");
|
||||
clipboardStore.setLanguageFilter(null);
|
||||
});
|
||||
|
||||
it("setLanguageFilter can be reset to null", () => {
|
||||
clipboardStore.setLanguageFilter("rust");
|
||||
clipboardStore.setLanguageFilter(null);
|
||||
expect(get(clipboardStore.languageFilter)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboardStore - store actions", () => {
|
||||
const mockEntry = {
|
||||
id: "entry-1",
|
||||
content: "const x = 1;",
|
||||
language: "typescript",
|
||||
source: "test.ts",
|
||||
timestamp: "2026-03-03T12:00:00.000Z",
|
||||
is_pinned: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clipboardStore.entries.set([]);
|
||||
});
|
||||
|
||||
describe("loadEntries", () => {
|
||||
it("loads entries from backend and updates the store", async () => {
|
||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||
await clipboardStore.loadEntries();
|
||||
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
|
||||
});
|
||||
|
||||
it("handles errors gracefully without crashing", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
|
||||
await clipboardStore.loadEntries();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to load clipboard entries:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("list_clipboard_entries", []);
|
||||
await clipboardStore.loadEntries();
|
||||
expect(get(clipboardStore.isLoading)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureClipboard", () => {
|
||||
it("captures clipboard entry with provided language", async () => {
|
||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
|
||||
expect(result).toEqual(mockEntry);
|
||||
});
|
||||
|
||||
it("auto-detects language when none provided", async () => {
|
||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||
expect(result).toEqual(mockEntry);
|
||||
});
|
||||
|
||||
it("returns null on error", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("capture_clipboard", new Error("Failed"));
|
||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||
expect(result).toBeNull();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteEntry", () => {
|
||||
it("removes entry from store on success", async () => {
|
||||
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
|
||||
setMockInvokeResult("delete_clipboard_entry", undefined);
|
||||
await clipboardStore.deleteEntry("entry-1");
|
||||
expect(get(clipboardStore.entries)).toHaveLength(1);
|
||||
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
|
||||
await clipboardStore.deleteEntry("entry-1");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to delete clipboard entry:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("updates entry pin status and sorts pinned first", async () => {
|
||||
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
|
||||
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
|
||||
clipboardStore.entries.set([unpinned1, unpinned2]);
|
||||
const pinned = { ...unpinned2, is_pinned: true };
|
||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||
await clipboardStore.togglePin("entry-2");
|
||||
const entries = get(clipboardStore.entries);
|
||||
expect(entries[0].id).toBe("entry-2");
|
||||
expect(entries[0].is_pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
|
||||
await clipboardStore.togglePin("entry-1");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exercises the unpinned-before-pinned sort branch", async () => {
|
||||
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
|
||||
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
|
||||
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
|
||||
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
|
||||
clipboardStore.entries.set([e1, e2, e3]);
|
||||
const pinned = { ...e2, is_pinned: true };
|
||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||
await clipboardStore.togglePin("e2");
|
||||
expect(get(clipboardStore.entries)[0].id).toBe("e2");
|
||||
});
|
||||
|
||||
it("sorts same-pin-status entries by timestamp descending", async () => {
|
||||
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
|
||||
const older = {
|
||||
...mockEntry,
|
||||
id: "older",
|
||||
is_pinned: false,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const newer = {
|
||||
...mockEntry,
|
||||
id: "newer",
|
||||
is_pinned: false,
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const pinned = {
|
||||
...mockEntry,
|
||||
id: "pinned",
|
||||
is_pinned: false,
|
||||
timestamp: "2026-01-03T00:00:00.000Z",
|
||||
};
|
||||
clipboardStore.entries.set([older, newer, pinned]);
|
||||
const pinnedResult = { ...pinned, is_pinned: true };
|
||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
|
||||
await clipboardStore.togglePin("pinned");
|
||||
const entries = get(clipboardStore.entries);
|
||||
expect(entries[0].id).toBe("pinned");
|
||||
expect(entries[1].id).toBe("newer");
|
||||
expect(entries[2].id).toBe("older");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearHistory", () => {
|
||||
it("removes unpinned entries and keeps pinned ones", async () => {
|
||||
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
|
||||
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
|
||||
clipboardStore.entries.set([pinned, unpinned]);
|
||||
setMockInvokeResult("clear_clipboard_history", undefined);
|
||||
await clipboardStore.clearHistory();
|
||||
const entries = get(clipboardStore.entries);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].id).toBe("pinned");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
|
||||
await clipboardStore.clearHistory();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to clear clipboard history:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLanguage", () => {
|
||||
it("updates entry language in the store", async () => {
|
||||
clipboardStore.entries.set([mockEntry]);
|
||||
const updated = { ...mockEntry, language: "javascript" };
|
||||
setMockInvokeResult("update_clipboard_language", updated);
|
||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
|
||||
});
|
||||
|
||||
it("leaves non-matching entries unchanged", async () => {
|
||||
const other = { ...mockEntry, id: "other", language: "python" };
|
||||
clipboardStore.entries.set([mockEntry, other]);
|
||||
const updated = { ...mockEntry, language: "javascript" };
|
||||
setMockInvokeResult("update_clipboard_language", updated);
|
||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||
const entries = get(clipboardStore.entries);
|
||||
expect(entries[1].language).toBe("python");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
|
||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
it("returns true and copies text on success", async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
const result = await clipboardStore.copyToClipboard("hello world");
|
||||
expect(result).toBe(true);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("returns false and logs error on failure", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
|
||||
});
|
||||
const result = await clipboardStore.copyToClipboard("hello world");
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to copy to clipboard:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
configStore,
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
applyCustomFont,
|
||||
applyCustomUiFont,
|
||||
applyTheme,
|
||||
applyCustomThemeColors,
|
||||
clearCustomThemeColors,
|
||||
isDarkTheme,
|
||||
isStreamerMode,
|
||||
isCompactMode,
|
||||
shouldHidePaths,
|
||||
showThinkingBlocks,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -15,12 +23,17 @@ import {
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("config store", () => {
|
||||
describe("font size constants", () => {
|
||||
it("has correct MIN_FONT_SIZE", () => {
|
||||
@@ -196,9 +209,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -247,9 +265,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -797,9 +820,14 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
@@ -840,4 +868,607 @@ describe("config store", () => {
|
||||
expect(lastSaved.font_size).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig error path", () => {
|
||||
it("resets to default config and logs error when loadConfig fails", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
|
||||
|
||||
await configStore.loadConfig();
|
||||
|
||||
expect(configStore.getConfig().theme).toBe("dark");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore sidebar methods", () => {
|
||||
it("openSidebar sets isSidebarOpen to true", () => {
|
||||
configStore.closeSidebar();
|
||||
configStore.openSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it("closeSidebar sets isSidebarOpen to false", () => {
|
||||
configStore.openSidebar();
|
||||
configStore.closeSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleSidebar switches from false to true", () => {
|
||||
configStore.closeSidebar();
|
||||
configStore.toggleSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleSidebar switches from true to false", () => {
|
||||
configStore.openSidebar();
|
||||
configStore.toggleSidebar();
|
||||
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore setTheme method", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("setTheme updates the theme via invoke", async () => {
|
||||
await configStore.setTheme("light");
|
||||
expect(configStore.getConfig().theme).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme with custom colors updates custom_theme_colors", async () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#001122",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
await configStore.setTheme("custom", colors);
|
||||
|
||||
expect(configStore.getConfig().theme).toBe("custom");
|
||||
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore setCustomThemeColors with custom theme active", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.setTheme("custom");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearCustomThemeColors();
|
||||
});
|
||||
|
||||
it("applies custom colors to DOM when current theme is custom", async () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#aabbcc",
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
};
|
||||
|
||||
await configStore.setCustomThemeColors(colors);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore font size methods", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("setFontSize updates to the given value", async () => {
|
||||
await configStore.setFontSize(18);
|
||||
expect(configStore.getConfig().font_size).toBe(18);
|
||||
});
|
||||
|
||||
it("setFontSize clamps to minimum", async () => {
|
||||
await configStore.setFontSize(1);
|
||||
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("setFontSize clamps to maximum", async () => {
|
||||
await configStore.setFontSize(99);
|
||||
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("increaseFontSize increases font size by 2", async () => {
|
||||
await configStore.increaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
|
||||
});
|
||||
|
||||
it("increaseFontSize does not exceed maximum", async () => {
|
||||
await configStore.setFontSize(MAX_FONT_SIZE);
|
||||
await configStore.increaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("decreaseFontSize decreases font size by 2", async () => {
|
||||
await configStore.decreaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
|
||||
});
|
||||
|
||||
it("decreaseFontSize does not go below minimum", async () => {
|
||||
await configStore.setFontSize(MIN_FONT_SIZE);
|
||||
await configStore.decreaseFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("resetFontSize restores the default font size", async () => {
|
||||
await configStore.setFontSize(20);
|
||||
await configStore.resetFontSize();
|
||||
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore removeAutoGrantedTool", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ auto_granted_tools: [] });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("removes an existing tool", async () => {
|
||||
await configStore.addAutoGrantedTool("Bash");
|
||||
await configStore.removeAutoGrantedTool("Bash");
|
||||
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
|
||||
});
|
||||
|
||||
it("is a no-op when the tool is not in the list", async () => {
|
||||
await configStore.removeAutoGrantedTool("NonExistentTool");
|
||||
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configStore toggle methods", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
|
||||
await configStore.toggleStreamerMode();
|
||||
expect(configStore.getConfig().streamer_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true });
|
||||
await configStore.toggleStreamerMode();
|
||||
expect(configStore.getConfig().streamer_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleCompactMode flips compact_mode from false to true", async () => {
|
||||
await configStore.toggleCompactMode();
|
||||
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleCompactMode flips compact_mode from true to false", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
await configStore.toggleCompactMode();
|
||||
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||
});
|
||||
|
||||
it("setCompactMode enables compact mode", async () => {
|
||||
await configStore.setCompactMode(true);
|
||||
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("setCompactMode disables compact mode", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
await configStore.setCompactMode(false);
|
||||
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores (live subscriptions)", () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("isDarkTheme is true when theme is dark", async () => {
|
||||
await configStore.updateConfig({ theme: "dark" });
|
||||
expect(get(isDarkTheme)).toBe(true);
|
||||
});
|
||||
|
||||
it("isDarkTheme is false when theme is not dark", async () => {
|
||||
await configStore.updateConfig({ theme: "light" });
|
||||
expect(get(isDarkTheme)).toBe(false);
|
||||
});
|
||||
|
||||
it("isStreamerMode reflects streamer_mode config", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true });
|
||||
expect(get(isStreamerMode)).toBe(true);
|
||||
await configStore.updateConfig({ streamer_mode: false });
|
||||
expect(get(isStreamerMode)).toBe(false);
|
||||
});
|
||||
|
||||
it("isCompactMode reflects compact_mode config", async () => {
|
||||
await configStore.updateConfig({ compact_mode: true });
|
||||
expect(get(isCompactMode)).toBe(true);
|
||||
await configStore.updateConfig({ compact_mode: false });
|
||||
expect(get(isCompactMode)).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
|
||||
expect(get(shouldHidePaths)).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
|
||||
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
|
||||
expect(get(shouldHidePaths)).toBe(false);
|
||||
});
|
||||
|
||||
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
|
||||
await configStore.updateConfig({ show_thinking_blocks: true });
|
||||
expect(get(showThinkingBlocks)).toBe(true);
|
||||
});
|
||||
|
||||
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
|
||||
await configStore.updateConfig({ show_thinking_blocks: false });
|
||||
expect(get(showThinkingBlocks)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
|
||||
beforeEach(() => {
|
||||
// Remove any style element left by previous tests
|
||||
document.getElementById("hikari-custom-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||
readFileMock.mockReset();
|
||||
});
|
||||
|
||||
it("removes CSS variable when both path and family are null", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await applyCustomFont(null, null);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS variable when both path and family are empty strings", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await applyCustomFont("", "");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("injects @import for a CSS stylesheet URL", async () => {
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain(
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'Fira Code', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
|
||||
expect(style?.textContent).toContain("'MyFont'");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'MyFont', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.ttf)", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
|
||||
expect(style?.textContent).toContain("'MyTtfFont'");
|
||||
});
|
||||
|
||||
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
|
||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||
});
|
||||
|
||||
it("reads local file and injects @font-face with data URL", async () => {
|
||||
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||
expect(style?.textContent).toContain("'MyFont'");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'MyFont', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
|
||||
const fakeData = new Uint8Array([1, 2, 3]);
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
|
||||
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||
expect(style?.textContent).toContain("font/woff2");
|
||||
});
|
||||
|
||||
it("sets CSS variable when only family is provided (no path)", async () => {
|
||||
await applyCustomFont("", "SystemFont");
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'SystemFont', monospace"
|
||||
);
|
||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces a previously injected style element", async () => {
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
|
||||
|
||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
|
||||
const styles = document.querySelectorAll("#hikari-custom-font");
|
||||
expect(styles.length).toBe(1);
|
||||
expect(styles[0].textContent).toContain("Roboto+Mono");
|
||||
});
|
||||
|
||||
it("uses correct MIME type for .otf local files", async () => {
|
||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("font/otf");
|
||||
});
|
||||
|
||||
it("falls back to font/ttf MIME for unknown extension local files", async () => {
|
||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
|
||||
const style = document.getElementById("hikari-custom-font");
|
||||
expect(style?.textContent).toContain("font/ttf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCustomFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||
readFileMock.mockReset();
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("saves config and applies the font", async () => {
|
||||
await configStore.setCustomFont(null, null);
|
||||
await configStore.setCustomFont(
|
||||
"https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||
"Fira Code"
|
||||
);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||
custom_font_family: "Fira Code",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||
"'Fira Code', monospace"
|
||||
);
|
||||
});
|
||||
|
||||
it("clears font when called with nulls", async () => {
|
||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||
await configStore.setCustomFont(null, null);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomUiFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--ui-font-family");
|
||||
document.body.style.removeProperty("font-family");
|
||||
readFileMock.mockReset();
|
||||
});
|
||||
|
||||
it("removes CSS variable and body font-family when both path and family are null", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await applyCustomUiFont(null, null);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
});
|
||||
|
||||
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await applyCustomUiFont("", "");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
||||
});
|
||||
|
||||
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain(
|
||||
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
|
||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain("@font-face");
|
||||
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
|
||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||
'"HikariCustomUiFont", sans-serif'
|
||||
);
|
||||
});
|
||||
|
||||
it("reads local file and embeds as data URL, applies font to body", async () => {
|
||||
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
||||
readFileMock.mockResolvedValueOnce(fakeData);
|
||||
|
||||
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
||||
const style = document.getElementById("hikari-custom-ui-font");
|
||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
|
||||
await applyCustomUiFont("", "SystemUiFont");
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'SystemUiFont', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||
'"SystemUiFont", sans-serif'
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a previously injected style element", async () => {
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
||||
|
||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
||||
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
||||
expect(styles.length).toBe(1);
|
||||
expect(styles[0].textContent).toContain("Roboto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCustomUiFont", () => {
|
||||
const readFileMock = vi.mocked(readFile);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
|
||||
beforeEach(() => {
|
||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||
document.documentElement.style.removeProperty("--ui-font-family");
|
||||
document.body.style.removeProperty("font-family");
|
||||
readFileMock.mockReset();
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("saves config and applies the UI font", async () => {
|
||||
await configStore.setCustomUiFont(null, null);
|
||||
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
||||
custom_ui_font_family: "Inter",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||
"'Inter', sans-serif"
|
||||
);
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||
});
|
||||
|
||||
it("clears UI font when called with nulls", async () => {
|
||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||
await configStore.setCustomUiFont(null, null);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"save_config",
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
export type BudgetAction = "warn" | "block";
|
||||
@@ -51,11 +52,19 @@ export interface HikariConfig {
|
||||
use_worktree: boolean;
|
||||
// Disable 1M context window
|
||||
disable_1m_context: boolean;
|
||||
// Max output tokens for Claude Code responses
|
||||
max_output_tokens: number | null;
|
||||
// Workspaces the user has explicitly trusted
|
||||
trusted_workspaces: string[];
|
||||
// Background image settings
|
||||
background_image_path: string | null;
|
||||
background_image_opacity: number;
|
||||
// Custom terminal font settings
|
||||
custom_font_path: string | null;
|
||||
custom_font_family: string | null;
|
||||
// Custom UI font settings
|
||||
custom_ui_font_path: string | null;
|
||||
custom_ui_font_family: string | null;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -98,9 +107,14 @@ const defaultConfig: HikariConfig = {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -237,6 +251,16 @@ function createConfigStore() {
|
||||
setCompactMode: async (enabled: boolean) => {
|
||||
await updateConfig({ compact_mode: enabled });
|
||||
},
|
||||
|
||||
setCustomFont: async (path: string | null, family: string | null) => {
|
||||
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
||||
await applyCustomFont(path, family);
|
||||
},
|
||||
|
||||
setCustomUiFont: async (path: string | null, family: string | null) => {
|
||||
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
||||
await applyCustomUiFont(path, family);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,6 +326,99 @@ export function clampFontSize(size: number): number {
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
}
|
||||
|
||||
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
||||
|
||||
const FONT_MIME_MAP: Record<string, string> = {
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
eot: "application/vnd.ms-fontobject",
|
||||
};
|
||||
|
||||
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
||||
} else {
|
||||
style.textContent = `@import url('${path}');`;
|
||||
}
|
||||
} else {
|
||||
const data = await readFile(path);
|
||||
const chunks: string[] = [];
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
||||
}
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
||||
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
||||
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
||||
}
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const styleId = "hikari-custom-font";
|
||||
const cssVar = "--terminal-font-family";
|
||||
const fallbackFamily = "HikariCustomFont";
|
||||
|
||||
document.getElementById(styleId)?.remove();
|
||||
|
||||
const trimmedPath = path?.trim() ?? "";
|
||||
const trimmedFamily = family?.trim() ?? "";
|
||||
|
||||
if (!trimmedPath && !trimmedFamily) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedPath) {
|
||||
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
||||
}
|
||||
|
||||
if (trimmedFamily) {
|
||||
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const styleId = "hikari-custom-ui-font";
|
||||
const cssVar = "--ui-font-family";
|
||||
const fallbackFamily = "HikariCustomUiFont";
|
||||
|
||||
document.getElementById(styleId)?.remove();
|
||||
|
||||
const trimmedPath = path?.trim() ?? "";
|
||||
const trimmedFamily = family?.trim() ?? "";
|
||||
|
||||
if (!trimmedPath && !trimmedFamily) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
document.body?.style.removeProperty("font-family");
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveFamily = trimmedFamily || fallbackFamily;
|
||||
|
||||
if (trimmedPath) {
|
||||
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
||||
}
|
||||
|
||||
const fontValue = `'${effectiveFamily}', sans-serif`;
|
||||
document.documentElement.style.setProperty(cssVar, fontValue);
|
||||
document.body?.style.setProperty("font-family", fontValue);
|
||||
}
|
||||
|
||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||
|
||||
export const configStore = createConfigStore();
|
||||
|
||||
@@ -43,6 +43,88 @@ export interface Conversation {
|
||||
draftText: string;
|
||||
}
|
||||
|
||||
const TAB_NAMES = [
|
||||
// Cosmic & celestial
|
||||
"Starfall",
|
||||
"Moonbeam",
|
||||
"Nebula",
|
||||
"Aurora",
|
||||
"Stardust",
|
||||
"Solstice",
|
||||
"Comet",
|
||||
"Eclipse",
|
||||
"Zenith",
|
||||
"Celestia",
|
||||
"Nova",
|
||||
"Quasar",
|
||||
"Lyra",
|
||||
"Andromeda",
|
||||
"Twilight",
|
||||
// Magical & fantastical
|
||||
"Camelot",
|
||||
"Reverie",
|
||||
"Arcane",
|
||||
"Spellbound",
|
||||
"Mirage",
|
||||
"Oracle",
|
||||
"Seraphim",
|
||||
"Ethereal",
|
||||
"Labyrinth",
|
||||
"Enchantment",
|
||||
// Nature & cosy
|
||||
"Sakura",
|
||||
"Ember",
|
||||
"Cascade",
|
||||
"Zephyr",
|
||||
"Serendipity",
|
||||
"Solace",
|
||||
"Blossom",
|
||||
"Whisper",
|
||||
"Dewdrop",
|
||||
"Sunbeam",
|
||||
"Willow",
|
||||
"Clover",
|
||||
"Honeybee",
|
||||
"Buttercup",
|
||||
"Dandelion",
|
||||
// Japanese/anime-inspired
|
||||
"Tsukimi",
|
||||
"Hanami",
|
||||
"Yozora",
|
||||
"Hoshi",
|
||||
"Koharu",
|
||||
"Akari",
|
||||
"Midori",
|
||||
// Adventure & epic
|
||||
"Odyssey",
|
||||
"Wanderer",
|
||||
"Horizon",
|
||||
"Voyage",
|
||||
"Pathfinder",
|
||||
"Frontier",
|
||||
// Whimsical & sweet
|
||||
"Bubblegum",
|
||||
"Marshmallow",
|
||||
"Daydream",
|
||||
"Whimsy",
|
||||
"Jellybean",
|
||||
"Sprinkle",
|
||||
"Cupcake",
|
||||
// Dreamy & poetic
|
||||
"Revenant",
|
||||
"Elysium",
|
||||
"Halcyon",
|
||||
"Ephemera",
|
||||
"Serenade",
|
||||
"Lullaby",
|
||||
"Nocturne",
|
||||
"Rhapsody",
|
||||
];
|
||||
|
||||
function pickRandomTabName(): string {
|
||||
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
const conversations = writable<Map<string, Conversation>>(new Map());
|
||||
const activeConversationId = writable<string | null>(null);
|
||||
@@ -63,7 +145,7 @@ function createConversationsStore() {
|
||||
const id = generateConversationId();
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${conversationCounter}`,
|
||||
name: name ?? pickRandomTabName(),
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected",
|
||||
@@ -91,7 +173,7 @@ function createConversationsStore() {
|
||||
function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const initialConversation = createNewConversation("Main");
|
||||
const initialConversation = createNewConversation();
|
||||
conversations.update((convs) => {
|
||||
convs.set(initialConversation.id, initialConversation);
|
||||
return convs;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
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 {
|
||||
formatCost,
|
||||
formatAlertType,
|
||||
getAlertMessage,
|
||||
costTrackingStore,
|
||||
formattedCosts,
|
||||
type AlertType,
|
||||
type CostAlert,
|
||||
} from "./costTracking";
|
||||
|
||||
vi.mock("$lib/notifications/notificationManager", () => ({
|
||||
notificationManager: {
|
||||
notifyCostAlert: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("formatCost", () => {
|
||||
it("formats amounts below $0.01 to 4 decimal places", () => {
|
||||
expect(formatCost(0)).toBe("$0.0000");
|
||||
expect(formatCost(0.001)).toBe("$0.0010");
|
||||
expect(formatCost(0.0099)).toBe("$0.0099");
|
||||
});
|
||||
|
||||
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
|
||||
expect(formatCost(0.01)).toBe("$0.010");
|
||||
expect(formatCost(0.123)).toBe("$0.123");
|
||||
expect(formatCost(0.999)).toBe("$0.999");
|
||||
});
|
||||
|
||||
it("formats amounts $1 and above to 2 decimal places", () => {
|
||||
expect(formatCost(1)).toBe("$1.00");
|
||||
expect(formatCost(1.5)).toBe("$1.50");
|
||||
expect(formatCost(100.99)).toBe("$100.99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAlertType", () => {
|
||||
it("formats Daily as Today", () => {
|
||||
expect(formatAlertType("Daily")).toBe("Today");
|
||||
});
|
||||
|
||||
it("formats Weekly as This Week", () => {
|
||||
expect(formatAlertType("Weekly")).toBe("This Week");
|
||||
});
|
||||
|
||||
it("formats Monthly as This Month", () => {
|
||||
expect(formatAlertType("Monthly")).toBe("This Month");
|
||||
});
|
||||
|
||||
it("handles all AlertType values", () => {
|
||||
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
|
||||
const results = types.map(formatAlertType);
|
||||
expect(results).toEqual(["Today", "This Week", "This Month"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAlertMessage", () => {
|
||||
it("generates a message for a Daily alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Daily",
|
||||
threshold: 1.0,
|
||||
current_cost: 1.5,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("Today");
|
||||
expect(message).toContain("$1.50");
|
||||
expect(message).toContain("$1.00");
|
||||
});
|
||||
|
||||
it("generates a message for a Weekly alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Weekly",
|
||||
threshold: 5.0,
|
||||
current_cost: 6.0,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("This Week");
|
||||
expect(message).toContain("$6.00");
|
||||
expect(message).toContain("$5.00");
|
||||
});
|
||||
|
||||
it("generates a message for a Monthly alert", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Monthly",
|
||||
threshold: 20.0,
|
||||
current_cost: 25.0,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("This Month");
|
||||
expect(message).toContain("$25.00");
|
||||
expect(message).toContain("$20.00");
|
||||
});
|
||||
|
||||
it("includes threshold and current cost in the message", () => {
|
||||
const alert: CostAlert = {
|
||||
alert_type: "Daily",
|
||||
threshold: 0.005,
|
||||
current_cost: 0.007,
|
||||
};
|
||||
const message = getAlertMessage(alert);
|
||||
expect(message).toContain("$0.0070");
|
||||
expect(message).toContain("$0.0050");
|
||||
});
|
||||
});
|
||||
|
||||
describe("costTrackingStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
costTrackingStore.reset();
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("loads cost data from the backend", async () => {
|
||||
setMockInvokeResult("get_today_cost", 1.5);
|
||||
setMockInvokeResult("get_week_cost", 5.25);
|
||||
setMockInvokeResult("get_month_cost", 20.0);
|
||||
setMockInvokeResult("get_cost_alerts", []);
|
||||
|
||||
await costTrackingStore.refresh();
|
||||
|
||||
const state = get(costTrackingStore);
|
||||
expect(state.todayCost).toBe(1.5);
|
||||
expect(state.weekCost).toBe(5.25);
|
||||
expect(state.monthCost).toBe(20.0);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("triggers notifications for any returned alerts", async () => {
|
||||
const { notificationManager } = await import("$lib/notifications/notificationManager");
|
||||
const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 };
|
||||
setMockInvokeResult("get_today_cost", 1.5);
|
||||
setMockInvokeResult("get_week_cost", 0);
|
||||
setMockInvokeResult("get_month_cost", 0);
|
||||
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||
|
||||
await costTrackingStore.refresh();
|
||||
|
||||
expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Today")
|
||||
);
|
||||
});
|
||||
|
||||
it("returns alerts from refresh", async () => {
|
||||
const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 };
|
||||
setMockInvokeResult("get_today_cost", 0);
|
||||
setMockInvokeResult("get_week_cost", 6.0);
|
||||
setMockInvokeResult("get_month_cost", 0);
|
||||
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||
|
||||
const result = await costTrackingStore.refresh();
|
||||
|
||||
expect(result).toEqual([alert]);
|
||||
});
|
||||
|
||||
it("handles refresh errors gracefully and returns empty array", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("get_today_cost", new Error("Backend error"));
|
||||
|
||||
const result = await costTrackingStore.refresh();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSummary", () => {
|
||||
it("fetches and stores a cost summary", async () => {
|
||||
const mockSummary = {
|
||||
period_days: 7,
|
||||
total_input_tokens: 1000,
|
||||
total_output_tokens: 500,
|
||||
total_cost: 0.05,
|
||||
total_messages: 20,
|
||||
total_sessions: 3,
|
||||
average_daily_cost: 0.007,
|
||||
daily_breakdown: [],
|
||||
};
|
||||
setMockInvokeResult("get_cost_summary", mockSummary);
|
||||
|
||||
const result = await costTrackingStore.getSummary(7);
|
||||
|
||||
expect(result).toEqual(mockSummary);
|
||||
expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 });
|
||||
});
|
||||
|
||||
it("returns null and logs error on failure", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("get_cost_summary", new Error("Summary unavailable"));
|
||||
|
||||
const result = await costTrackingStore.getSummary(30);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAlertThresholds", () => {
|
||||
it("persists new thresholds to the backend", async () => {
|
||||
const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 };
|
||||
|
||||
await costTrackingStore.setAlertThresholds(thresholds);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", {
|
||||
daily: 2.0,
|
||||
weekly: 10.0,
|
||||
monthly: 40.0,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs an error when the backend call fails", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed"));
|
||||
|
||||
await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportCsv", () => {
|
||||
it("returns the CSV string from the backend", async () => {
|
||||
setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50");
|
||||
|
||||
const result = await costTrackingStore.exportCsv(7);
|
||||
|
||||
expect(result).toBe("date,cost\n2026-01-01,1.50");
|
||||
expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 });
|
||||
});
|
||||
|
||||
it("returns null and logs error on failure", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("export_cost_csv", new Error("Export failed"));
|
||||
|
||||
const result = await costTrackingStore.exportCsv(30);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets costs back to zero", async () => {
|
||||
setMockInvokeResult("get_today_cost", 5.0);
|
||||
setMockInvokeResult("get_week_cost", 15.0);
|
||||
setMockInvokeResult("get_month_cost", 50.0);
|
||||
setMockInvokeResult("get_cost_alerts", []);
|
||||
await costTrackingStore.refresh();
|
||||
|
||||
costTrackingStore.reset();
|
||||
|
||||
const state = get(costTrackingStore);
|
||||
expect(state.todayCost).toBe(0);
|
||||
expect(state.weekCost).toBe(0);
|
||||
expect(state.monthCost).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formattedCosts", () => {
|
||||
beforeEach(() => {
|
||||
costTrackingStore.reset();
|
||||
});
|
||||
|
||||
it("formats the initial zero costs correctly", () => {
|
||||
const costs = get(formattedCosts);
|
||||
expect(costs.today).toBe("$0.0000");
|
||||
expect(costs.week).toBe("$0.0000");
|
||||
expect(costs.month).toBe("$0.0000");
|
||||
});
|
||||
|
||||
it("reflects updated costs after a refresh", async () => {
|
||||
setMockInvokeResult("get_today_cost", 1.5);
|
||||
setMockInvokeResult("get_week_cost", 5.0);
|
||||
setMockInvokeResult("get_month_cost", 20.0);
|
||||
setMockInvokeResult("get_cost_alerts", []);
|
||||
await costTrackingStore.refresh();
|
||||
|
||||
const costs = get(formattedCosts);
|
||||
expect(costs.today).toBe("$1.50");
|
||||
expect(costs.week).toBe("$5.00");
|
||||
expect(costs.month).toBe("$20.00");
|
||||
expect(costs.todayRaw).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { emitMockEvent } from "../../../vitest.setup";
|
||||
import { debugConsoleStore, filteredLogs } from "./debugConsole";
|
||||
|
||||
describe("debugConsoleStore", () => {
|
||||
beforeEach(() => {
|
||||
debugConsoleStore.clear();
|
||||
debugConsoleStore.close();
|
||||
debugConsoleStore.setFilterLevel("all");
|
||||
debugConsoleStore.setAutoScroll(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
debugConsoleStore.restoreConsole();
|
||||
debugConsoleStore.clear();
|
||||
});
|
||||
|
||||
it("initializes with correct default state", () => {
|
||||
const state = get(debugConsoleStore);
|
||||
expect(state.logs).toEqual([]);
|
||||
expect(state.isOpen).toBe(false);
|
||||
expect(state.maxLogs).toBe(1000);
|
||||
expect(state.filterLevel).toBe("all");
|
||||
expect(state.autoScroll).toBe(true);
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("opens when currently closed", () => {
|
||||
debugConsoleStore.toggle();
|
||||
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("closes when currently open", () => {
|
||||
debugConsoleStore.open();
|
||||
debugConsoleStore.toggle();
|
||||
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("open", () => {
|
||||
it("sets isOpen to true", () => {
|
||||
debugConsoleStore.open();
|
||||
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
it("sets isOpen to false", () => {
|
||||
debugConsoleStore.open();
|
||||
debugConsoleStore.close();
|
||||
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("removes all log entries", async () => {
|
||||
await debugConsoleStore.setupBackendLogsListener();
|
||||
emitMockEvent("debug:log", { level: "info", message: "test entry" });
|
||||
expect(get(debugConsoleStore).logs.length).toBe(1);
|
||||
debugConsoleStore.clear();
|
||||
expect(get(debugConsoleStore).logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFilterLevel", () => {
|
||||
it("updates filterLevel to the specified level", () => {
|
||||
debugConsoleStore.setFilterLevel("error");
|
||||
expect(get(debugConsoleStore).filterLevel).toBe("error");
|
||||
});
|
||||
|
||||
it("can reset filterLevel back to all", () => {
|
||||
debugConsoleStore.setFilterLevel("warn");
|
||||
debugConsoleStore.setFilterLevel("all");
|
||||
expect(get(debugConsoleStore).filterLevel).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAutoScroll", () => {
|
||||
it("disables autoScroll", () => {
|
||||
debugConsoleStore.setAutoScroll(false);
|
||||
expect(get(debugConsoleStore).autoScroll).toBe(false);
|
||||
});
|
||||
|
||||
it("re-enables autoScroll", () => {
|
||||
debugConsoleStore.setAutoScroll(false);
|
||||
debugConsoleStore.setAutoScroll(true);
|
||||
expect(get(debugConsoleStore).autoScroll).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupConsoleCapture", () => {
|
||||
afterEach(() => {
|
||||
debugConsoleStore.restoreConsole();
|
||||
});
|
||||
|
||||
it("captures console.log calls as info-level frontend logs", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.log("hello world");
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "hello world");
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.level).toBe("info");
|
||||
expect(captured?.source).toBe("frontend");
|
||||
});
|
||||
|
||||
it("captures console.info calls as info-level logs", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.info("info message");
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "info message");
|
||||
expect(captured?.level).toBe("info");
|
||||
});
|
||||
|
||||
it("captures console.warn calls as warn-level logs", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.warn("warning message");
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "warning message");
|
||||
expect(captured?.level).toBe("warn");
|
||||
});
|
||||
|
||||
it("captures console.error calls as error-level logs", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.error("error message");
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "error message");
|
||||
expect(captured?.level).toBe("error");
|
||||
});
|
||||
|
||||
it("captures console.debug calls as debug-level logs", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.debug("debug message");
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "debug message");
|
||||
expect(captured?.level).toBe("debug");
|
||||
});
|
||||
|
||||
it("joins multiple console arguments with spaces", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
console.log("hello", "world", 42);
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find((l) => l.message === "hello world 42");
|
||||
expect(captured).toBeDefined();
|
||||
});
|
||||
|
||||
it("captures unhandled window error events", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
const errorEvent = new ErrorEvent("error", {
|
||||
message: "Test unhandled error",
|
||||
filename: "test.js",
|
||||
lineno: 10,
|
||||
colno: 5,
|
||||
});
|
||||
window.dispatchEvent(errorEvent);
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find(
|
||||
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
|
||||
);
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.message).toContain("Test unhandled error");
|
||||
});
|
||||
|
||||
it("captures unhandled promise rejection events", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
|
||||
reason: "test rejection reason",
|
||||
});
|
||||
window.dispatchEvent(rejectionEvent);
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
const captured = logs.find(
|
||||
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
|
||||
);
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured?.message).toContain("test rejection reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreConsole", () => {
|
||||
it("stops capturing console output after restore", () => {
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
debugConsoleStore.restoreConsole();
|
||||
const countBefore = get(debugConsoleStore).logs.length;
|
||||
console.log("this should not be captured");
|
||||
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBackendLogsListener", () => {
|
||||
it("captures backend logs emitted via debug:log event", async () => {
|
||||
await debugConsoleStore.setupBackendLogsListener();
|
||||
emitMockEvent("debug:log", { level: "info", message: "backend message" });
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].level).toBe("info");
|
||||
expect(logs[0].message).toBe("backend message");
|
||||
expect(logs[0].source).toBe("backend");
|
||||
});
|
||||
|
||||
it("handles different log levels from backend", async () => {
|
||||
await debugConsoleStore.setupBackendLogsListener();
|
||||
emitMockEvent("debug:log", { level: "error", message: "backend error" });
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
expect(logs[0].level).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("circular buffer", () => {
|
||||
it("drops oldest log when exceeding 1000-entry limit", async () => {
|
||||
await debugConsoleStore.setupBackendLogsListener();
|
||||
for (let i = 0; i < 1001; i++) {
|
||||
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
|
||||
}
|
||||
const logs = get(debugConsoleStore).logs;
|
||||
expect(logs.length).toBe(1000);
|
||||
expect(logs[0].message).toBe("log 1");
|
||||
expect(logs[999].message).toBe("log 1000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filteredLogs derived store", () => {
|
||||
beforeEach(async () => {
|
||||
debugConsoleStore.clear();
|
||||
debugConsoleStore.setFilterLevel("all");
|
||||
await debugConsoleStore.setupBackendLogsListener();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
debugConsoleStore.clear();
|
||||
});
|
||||
|
||||
it("returns all logs when filterLevel is all", () => {
|
||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||
expect(get(filteredLogs).length).toBe(4);
|
||||
});
|
||||
|
||||
it("returns only error logs when filterLevel is error", () => {
|
||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||
debugConsoleStore.setFilterLevel("error");
|
||||
const logs = get(filteredLogs);
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].level).toBe("error");
|
||||
});
|
||||
|
||||
it("returns warn and error logs when filterLevel is warn", () => {
|
||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||
debugConsoleStore.setFilterLevel("warn");
|
||||
const logs = get(filteredLogs);
|
||||
expect(logs.length).toBe(2);
|
||||
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes debug logs when filterLevel is info", () => {
|
||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||
debugConsoleStore.setFilterLevel("info");
|
||||
const logs = get(filteredLogs);
|
||||
expect(logs.length).toBe(3);
|
||||
expect(logs.some((l) => l.level === "debug")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns all log levels when filterLevel is debug", () => {
|
||||
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||
debugConsoleStore.setFilterLevel("debug");
|
||||
expect(get(filteredLogs).length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,16 @@ describe("draftsStore", () => {
|
||||
const result = draftsStore.formatTimestamp(invalid);
|
||||
expect(result).toBe(invalid);
|
||||
});
|
||||
|
||||
it("falls back to raw string when toLocaleString throws", () => {
|
||||
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => {
|
||||
throw new Error("Locale not supported");
|
||||
});
|
||||
const ts = "2026-01-15T14:30:00.000Z";
|
||||
const result = draftsStore.formatTimestamp(ts);
|
||||
expect(result).toBe(ts);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
|
||||
const FILE_CONTENT = "// test file content";
|
||||
|
||||
// Reset tabs between tests
|
||||
afterEach(async () => {
|
||||
const tabs = get(editorStore.tabs);
|
||||
for (const tab of tabs) {
|
||||
editorStore.closeTab(tab.id);
|
||||
}
|
||||
editorStore.hideEditor();
|
||||
});
|
||||
|
||||
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
|
||||
const testCases: [string, string][] = [
|
||||
["file.ts", "typescript"],
|
||||
["file.tsx", "typescript"],
|
||||
["file.js", "javascript"],
|
||||
["file.jsx", "javascript"],
|
||||
["file.py", "python"],
|
||||
["file.rs", "rust"],
|
||||
["file.go", "go"],
|
||||
["file.java", "java"],
|
||||
["file.c", "c"],
|
||||
["file.cpp", "cpp"],
|
||||
["file.h", "c"],
|
||||
["file.hpp", "cpp"],
|
||||
["file.cs", "csharp"],
|
||||
["file.rb", "ruby"],
|
||||
["file.php", "php"],
|
||||
["file.swift", "swift"],
|
||||
["file.kt", "kotlin"],
|
||||
["file.scala", "scala"],
|
||||
["file.html", "html"],
|
||||
["file.htm", "html"],
|
||||
["file.css", "css"],
|
||||
["file.scss", "scss"],
|
||||
["file.sass", "sass"],
|
||||
["file.less", "less"],
|
||||
["file.json", "json"],
|
||||
["file.xml", "xml"],
|
||||
["file.yaml", "yaml"],
|
||||
["file.yml", "yaml"],
|
||||
["file.toml", "toml"],
|
||||
["file.md", "markdown"],
|
||||
["file.markdown", "markdown"],
|
||||
["file.sql", "sql"],
|
||||
["file.sh", "shell"],
|
||||
["file.bash", "shell"],
|
||||
["file.zsh", "shell"],
|
||||
["file.ps1", "powershell"],
|
||||
["file.svelte", "svelte"],
|
||||
["file.vue", "vue"],
|
||||
["file.graphql", "graphql"],
|
||||
["file.gql", "graphql"],
|
||||
["file.lua", "lua"],
|
||||
["file.r", "r"],
|
||||
["file.dart", "dart"],
|
||||
["file.elm", "elm"],
|
||||
["file.ex", "elixir"],
|
||||
["file.exs", "elixir"],
|
||||
["file.erl", "erlang"],
|
||||
["file.hs", "haskell"],
|
||||
["file.clj", "clojure"],
|
||||
["file.lisp", "lisp"],
|
||||
["file.ml", "ocaml"],
|
||||
["file.fs", "fsharp"],
|
||||
["file.zig", "zig"],
|
||||
["file.nim", "nim"],
|
||||
["file.v", "v"],
|
||||
["file.wasm", "wasm"],
|
||||
["file.wat", "wasm"],
|
||||
["dockerfile", "plaintext"],
|
||||
["file.unknown", "plaintext"],
|
||||
["file", "plaintext"],
|
||||
];
|
||||
|
||||
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile(`/path/to/${filename}`);
|
||||
const activeTab = get(editorStore.activeTab);
|
||||
expect(activeTab?.language).toBe(expectedLanguage);
|
||||
if (activeTab) editorStore.closeTab(activeTab.id);
|
||||
});
|
||||
|
||||
it("opens a new tab and makes it active", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
const tabs = get(editorStore.tabs);
|
||||
const activeTab = get(editorStore.activeTab);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(activeTab?.fileName).toBe("file.ts");
|
||||
expect(activeTab?.content).toBe(FILE_CONTENT);
|
||||
expect(activeTab?.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("switches to existing tab instead of opening a duplicate", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
const firstTabId = get(editorStore.activeTabId);
|
||||
|
||||
// Open another file to change active tab
|
||||
await editorStore.openFile("/path/to/other.ts");
|
||||
const secondTabId = get(editorStore.activeTabId);
|
||||
expect(secondTabId).not.toBe(firstTabId);
|
||||
|
||||
// Re-open first file — should switch to existing tab
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
expect(get(editorStore.activeTabId)).toBe(firstTabId);
|
||||
expect(get(editorStore.tabs)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles open file errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
await editorStore.openFile("/path/to/missing.ts");
|
||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||
expect(get(editorStore.saveError)).toContain("Failed to open file");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("extracts filename from path correctly", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/deep/nested/path/component.svelte");
|
||||
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - saveFile", () => {
|
||||
beforeEach(async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
// Make it dirty first
|
||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||
});
|
||||
|
||||
it("saves the active tab successfully", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
await editorStore.saveFile();
|
||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
|
||||
});
|
||||
|
||||
it("saves a specific tab by ID", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
const tabId = get(editorStore.activeTabId)!;
|
||||
await editorStore.saveFile(tabId);
|
||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when no tab ID matches", async () => {
|
||||
await editorStore.saveFile("non-existent-tab");
|
||||
// No error = pass
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - saveFile error", () => {
|
||||
it("sets saveError when write fails", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file-err.ts");
|
||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Permission denied"));
|
||||
await editorStore.saveFile().catch(() => {});
|
||||
expect(get(editorStore.saveError)).toContain("Failed to save file");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - updateTabContent", () => {
|
||||
beforeEach(async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
});
|
||||
|
||||
it("updates content and marks tab as dirty", () => {
|
||||
const tabId = get(editorStore.activeTabId)!;
|
||||
editorStore.updateTabContent(tabId, "new content");
|
||||
const tab = get(editorStore.activeTab);
|
||||
expect(tab?.content).toBe("new content");
|
||||
expect(tab?.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("marks tab as clean when content matches original", () => {
|
||||
const tabId = get(editorStore.activeTabId)!;
|
||||
editorStore.updateTabContent(tabId, "modified");
|
||||
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
|
||||
editorStore.updateTabContent(tabId, FILE_CONTENT);
|
||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("hasDirtyTabs is true when any tab is dirty", () => {
|
||||
const tabId = get(editorStore.activeTabId)!;
|
||||
expect(get(editorStore.hasDirtyTabs)).toBe(false);
|
||||
editorStore.updateTabContent(tabId, "dirty content");
|
||||
expect(get(editorStore.hasDirtyTabs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - closeTab", () => {
|
||||
beforeEach(async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file1.ts");
|
||||
await editorStore.openFile("/path/to/file2.ts");
|
||||
});
|
||||
|
||||
it("removes the specified tab", () => {
|
||||
const tabs = get(editorStore.tabs);
|
||||
editorStore.closeTab(tabs[0].id);
|
||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
|
||||
});
|
||||
|
||||
it("sets active tab to remaining tab when active tab is closed", () => {
|
||||
const activeTabId = get(editorStore.activeTabId)!;
|
||||
editorStore.closeTab(activeTabId);
|
||||
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
|
||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - setActiveTab", () => {
|
||||
beforeEach(async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file1.ts");
|
||||
await editorStore.openFile("/path/to/file2.ts");
|
||||
});
|
||||
|
||||
it("sets the active tab to the specified ID", () => {
|
||||
const tabs = get(editorStore.tabs);
|
||||
editorStore.setActiveTab(tabs[0].id);
|
||||
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - visibility controls", () => {
|
||||
it("showEditor sets isEditorVisible to true", () => {
|
||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||
editorStore.showEditor();
|
||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||
});
|
||||
|
||||
it("hideEditor sets isEditorVisible to false", () => {
|
||||
editorStore.showEditor();
|
||||
editorStore.hideEditor();
|
||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||
});
|
||||
|
||||
it("toggleEditor toggles isEditorVisible", () => {
|
||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||
editorStore.toggleEditor();
|
||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||
editorStore.toggleEditor();
|
||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - toggleFileBrowser", () => {
|
||||
it("toggles the file browser visibility", () => {
|
||||
const initial = get(editorStore.isFileBrowserOpen);
|
||||
editorStore.toggleFileBrowser();
|
||||
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
|
||||
editorStore.toggleFileBrowser();
|
||||
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - file system operations", () => {
|
||||
it("createFile invokes backend and refreshes directory", async () => {
|
||||
setMockInvokeResult("create_file", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.createFile("/path/to", "new-file.ts");
|
||||
// No error = success
|
||||
});
|
||||
|
||||
it("createFile handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("create_file", new Error("Permission denied"));
|
||||
await editorStore.createFile("/path/to", "new-file.ts");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("createDirectory invokes backend and refreshes", async () => {
|
||||
setMockInvokeResult("create_directory", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.createDirectory("/path/to", "new-dir");
|
||||
});
|
||||
|
||||
it("createDirectory handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("create_directory", new Error("Failed"));
|
||||
await editorStore.createDirectory("/path/to", "new-dir");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("deleteFile invokes backend and refreshes", async () => {
|
||||
setMockInvokeResult("delete_file", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.deleteFile("/path/to/file.ts");
|
||||
});
|
||||
|
||||
it("deleteFile handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_file", new Error("Failed"));
|
||||
await editorStore.deleteFile("/path/to/file.ts");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("deleteDirectory invokes backend and refreshes", async () => {
|
||||
setMockInvokeResult("delete_directory", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.deleteDirectory("/path/to/dir");
|
||||
});
|
||||
|
||||
it("deleteDirectory handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_directory", new Error("Failed"));
|
||||
await editorStore.deleteDirectory("/path/to/dir");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renamePath invokes backend and refreshes", async () => {
|
||||
setMockInvokeResult("rename_path", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||
});
|
||||
|
||||
it("renamePath handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("rename_path", new Error("Failed"));
|
||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - initializeFileTree", () => {
|
||||
it("loads directory entries and updates file tree", async () => {
|
||||
const entries = [
|
||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", entries);
|
||||
await editorStore.initializeFileTree("/path");
|
||||
expect(get(editorStore.fileTree)).toEqual(entries);
|
||||
expect(get(editorStore.currentDirectory)).toBe("/path");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_directory", new Error("Failed"));
|
||||
await editorStore.initializeFileTree("/path");
|
||||
expect(get(editorStore.isLoadingTree)).toBe(false);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - toggleDirectory", () => {
|
||||
beforeEach(async () => {
|
||||
const entries = [
|
||||
{
|
||||
path: "/path/dir",
|
||||
name: "dir",
|
||||
isDirectory: true,
|
||||
isExpanded: false,
|
||||
children: undefined,
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_directory", entries);
|
||||
await editorStore.initializeFileTree("/path");
|
||||
});
|
||||
|
||||
it("does nothing when entry is not a directory", async () => {
|
||||
const nonDirEntry = {
|
||||
path: "/path/file.ts",
|
||||
name: "file.ts",
|
||||
isDirectory: false,
|
||||
};
|
||||
const treeBefore = get(editorStore.fileTree);
|
||||
await editorStore.toggleDirectory(nonDirEntry);
|
||||
expect(get(editorStore.fileTree)).toEqual(treeBefore);
|
||||
});
|
||||
|
||||
it("expands a collapsed directory and loads children asynchronously", async () => {
|
||||
const children = [
|
||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", children);
|
||||
|
||||
const dirEntry = get(editorStore.fileTree)[0];
|
||||
await editorStore.toggleDirectory(dirEntry);
|
||||
|
||||
const updatedEntry = get(editorStore.fileTree)[0];
|
||||
expect(updatedEntry.isExpanded).toBe(true);
|
||||
expect(updatedEntry.children).toEqual(children);
|
||||
expect(updatedEntry.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses an expanded directory", async () => {
|
||||
const children = [
|
||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", children);
|
||||
|
||||
// Expand first
|
||||
const dirEntry = get(editorStore.fileTree)[0];
|
||||
await editorStore.toggleDirectory(dirEntry);
|
||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||
|
||||
// Now collapse
|
||||
const expandedEntry = get(editorStore.fileTree)[0];
|
||||
await editorStore.toggleDirectory(expandedEntry);
|
||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
|
||||
});
|
||||
|
||||
it("expands a directory that already has children without reloading", async () => {
|
||||
// Re-init with a directory that already has children
|
||||
const entries = [
|
||||
{
|
||||
path: "/path/dir",
|
||||
name: "dir",
|
||||
isDirectory: true,
|
||||
isExpanded: false,
|
||||
children: [
|
||||
{
|
||||
path: "/path/dir/child.ts",
|
||||
name: "child.ts",
|
||||
isDirectory: false,
|
||||
children: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_directory", entries);
|
||||
await editorStore.initializeFileTree("/path");
|
||||
|
||||
// Toggle should expand without loading (children already present)
|
||||
const dirEntry = get(editorStore.fileTree)[0];
|
||||
await editorStore.toggleDirectory(dirEntry);
|
||||
|
||||
const updatedEntry = get(editorStore.fileTree)[0];
|
||||
expect(updatedEntry.isExpanded).toBe(true);
|
||||
expect(updatedEntry.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
|
||||
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
|
||||
const entries = [
|
||||
{
|
||||
path: "/path/dir",
|
||||
name: "dir",
|
||||
isDirectory: true,
|
||||
isExpanded: false,
|
||||
children: undefined,
|
||||
},
|
||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", entries);
|
||||
await editorStore.initializeFileTree("/path");
|
||||
|
||||
const children: never[] = [];
|
||||
setMockInvokeResult("list_directory", children);
|
||||
const dirEntry = get(editorStore.fileTree)[0];
|
||||
await editorStore.toggleDirectory(dirEntry);
|
||||
|
||||
// The sibling file should remain unchanged
|
||||
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
|
||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
|
||||
// Tree: outer_dir → [inner_dir (no children), sibling_file]
|
||||
// Toggling inner_dir covers the recursive path through outer's children
|
||||
const entries = [
|
||||
{
|
||||
path: "/path/outer",
|
||||
name: "outer",
|
||||
isDirectory: true,
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
path: "/path/outer/inner",
|
||||
name: "inner",
|
||||
isDirectory: true,
|
||||
isExpanded: false,
|
||||
children: undefined,
|
||||
},
|
||||
{
|
||||
path: "/path/outer/sibling.ts",
|
||||
name: "sibling.ts",
|
||||
isDirectory: false,
|
||||
children: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_directory", entries);
|
||||
await editorStore.initializeFileTree("/path");
|
||||
|
||||
const innerChildren = [
|
||||
{
|
||||
path: "/path/outer/inner/deep.ts",
|
||||
name: "deep.ts",
|
||||
isDirectory: false,
|
||||
children: undefined,
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_directory", innerChildren);
|
||||
|
||||
const innerEntry = get(editorStore.fileTree)[0].children![0];
|
||||
await editorStore.toggleDirectory(innerEntry);
|
||||
|
||||
const outer = get(editorStore.fileTree)[0];
|
||||
expect(outer.children![0].isExpanded).toBe(true);
|
||||
expect(outer.children![0].children).toEqual(innerChildren);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - deleteFile closes open tab", () => {
|
||||
it("closes the tab when the deleted file is open", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/file.ts");
|
||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||
|
||||
setMockInvokeResult("delete_file", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.deleteFile("/path/to/file.ts");
|
||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - deleteDirectory closes open tabs", () => {
|
||||
it("closes all tabs inside the deleted directory", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/dir/file1.ts");
|
||||
await editorStore.openFile("/path/to/dir/nested/file2.ts");
|
||||
await editorStore.openFile("/path/to/other.ts");
|
||||
expect(get(editorStore.tabs)).toHaveLength(3);
|
||||
|
||||
setMockInvokeResult("delete_directory", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.deleteDirectory("/path/to/dir");
|
||||
|
||||
const remainingTabs = get(editorStore.tabs);
|
||||
expect(remainingTabs).toHaveLength(1);
|
||||
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - renamePath updates open tabs", () => {
|
||||
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/old-name.ts");
|
||||
|
||||
setMockInvokeResult("rename_path", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||
|
||||
const tab = get(editorStore.activeTab);
|
||||
expect(tab?.filePath).toBe("/path/to/new-name.ts");
|
||||
expect(tab?.fileName).toBe("new-name.ts");
|
||||
});
|
||||
|
||||
it("updates filePaths for tabs inside a renamed directory", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/old-dir/file.ts");
|
||||
|
||||
setMockInvokeResult("rename_path", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.renamePath("/path/to/old-dir", "new-dir");
|
||||
|
||||
const tab = get(editorStore.activeTab);
|
||||
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
|
||||
});
|
||||
|
||||
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
|
||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||
await editorStore.openFile("/path/to/unrelated.ts");
|
||||
await editorStore.openFile("/path/to/old-name.ts");
|
||||
|
||||
setMockInvokeResult("rename_path", undefined);
|
||||
setMockInvokeResult("list_directory", []);
|
||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||
|
||||
const tabs = get(editorStore.tabs);
|
||||
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
|
||||
expect(unrelated).toBeDefined();
|
||||
expect(unrelated?.fileName).toBe("unrelated.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorStore - refreshDirectory", () => {
|
||||
it("reloads the entire tree when refreshing the root directory", async () => {
|
||||
const initialEntries = [
|
||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", initialEntries);
|
||||
await editorStore.initializeFileTree("/root");
|
||||
expect(get(editorStore.fileTree)).toHaveLength(1);
|
||||
|
||||
const updatedEntries = [
|
||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", updatedEntries);
|
||||
await editorStore.refreshDirectory("/root");
|
||||
expect(get(editorStore.fileTree)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("updates a subdirectory entry in the tree", async () => {
|
||||
const initialEntries = [
|
||||
{
|
||||
path: "/root/subdir",
|
||||
name: "subdir",
|
||||
isDirectory: true,
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
setMockInvokeResult("list_directory", initialEntries);
|
||||
await editorStore.initializeFileTree("/root");
|
||||
|
||||
const subChildren = [
|
||||
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||
];
|
||||
setMockInvokeResult("list_directory", subChildren);
|
||||
await editorStore.refreshDirectory("/root/subdir");
|
||||
|
||||
const tree = get(editorStore.fileTree);
|
||||
expect(tree[0].children).toEqual(subChildren);
|
||||
expect(tree[0].isExpanded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
setSavedHistory,
|
||||
getShouldRestoreHistory,
|
||||
getSavedHistory,
|
||||
clearHistoryRestore,
|
||||
} from "./historyRestore";
|
||||
|
||||
describe("historyRestore module", () => {
|
||||
beforeEach(() => {
|
||||
clearHistoryRestore();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("shouldRestoreHistory is false by default", () => {
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
|
||||
it("savedHistory is null by default", () => {
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setShouldRestoreHistory", () => {
|
||||
it("sets shouldRestoreHistory to true", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
expect(getShouldRestoreHistory()).toBe(true);
|
||||
});
|
||||
|
||||
it("sets shouldRestoreHistory to false", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
setShouldRestoreHistory(false);
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSavedHistory", () => {
|
||||
it("sets the saved history string", () => {
|
||||
setSavedHistory("some history content");
|
||||
expect(getSavedHistory()).toBe("some history content");
|
||||
});
|
||||
|
||||
it("sets the saved history to null", () => {
|
||||
setSavedHistory("some history content");
|
||||
setSavedHistory(null);
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearHistoryRestore", () => {
|
||||
it("resets shouldRestoreHistory to false", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
clearHistoryRestore();
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
});
|
||||
|
||||
it("resets savedHistory to null", () => {
|
||||
setSavedHistory("some content");
|
||||
clearHistoryRestore();
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears both values at once", () => {
|
||||
setShouldRestoreHistory(true);
|
||||
setSavedHistory("history");
|
||||
clearHistoryRestore();
|
||||
expect(getShouldRestoreHistory()).toBe(false);
|
||||
expect(getSavedHistory()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { messageMode, getCurrentMode } from "./messageMode";
|
||||
|
||||
describe("messageMode store", () => {
|
||||
beforeEach(() => {
|
||||
messageMode.reset();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("defaults to chat mode", () => {
|
||||
expect(get(messageMode)).toBe("chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("sets the mode to the given value", () => {
|
||||
messageMode.set("plan");
|
||||
expect(get(messageMode)).toBe("plan");
|
||||
});
|
||||
|
||||
it("can set any arbitrary mode string", () => {
|
||||
messageMode.set("auto");
|
||||
expect(get(messageMode)).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets mode back to chat", () => {
|
||||
messageMode.set("plan");
|
||||
messageMode.reset();
|
||||
expect(get(messageMode)).toBe("chat");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentMode", () => {
|
||||
beforeEach(() => {
|
||||
messageMode.reset();
|
||||
});
|
||||
|
||||
it("returns chat when in default state", () => {
|
||||
expect(getCurrentMode()).toBe("chat");
|
||||
});
|
||||
|
||||
it("returns the currently set mode", () => {
|
||||
messageMode.set("plan");
|
||||
expect(getCurrentMode()).toBe("plan");
|
||||
});
|
||||
|
||||
it("returns chat after a reset", () => {
|
||||
messageMode.set("auto");
|
||||
messageMode.reset();
|
||||
expect(getCurrentMode()).toBe("chat");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const mockSetEnabled = vi.fn();
|
||||
const mockSetGlobalVolume = vi.fn();
|
||||
|
||||
vi.mock("$lib/notifications", () => ({
|
||||
soundPlayer: {
|
||||
setEnabled: mockSetEnabled,
|
||||
setGlobalVolume: mockSetGlobalVolume,
|
||||
},
|
||||
}));
|
||||
|
||||
// We need to control the config store's emitted values
|
||||
const configWritable = writable({
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
});
|
||||
|
||||
vi.mock("./config", () => ({
|
||||
configStore: {
|
||||
config: { subscribe: configWritable.subscribe },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("notifications sync store", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockSetEnabled.mockReset();
|
||||
mockSetGlobalVolume.mockReset();
|
||||
configWritable.set({ notifications_enabled: true, notification_volume: 0.7 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Re-import to clean up any lingering subscriptions
|
||||
const { cleanupNotificationSync } = await import("./notifications");
|
||||
cleanupNotificationSync();
|
||||
});
|
||||
|
||||
describe("initNotificationSync", () => {
|
||||
it("syncs soundPlayer enabled state from config on init", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("syncs soundPlayer volume from config on init", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7);
|
||||
});
|
||||
|
||||
it("updates soundPlayer when config changes", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
mockSetGlobalVolume.mockReset();
|
||||
|
||||
configWritable.set({ notifications_enabled: false, notification_volume: 0.3 });
|
||||
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3);
|
||||
});
|
||||
|
||||
it("does not register a duplicate subscription when called twice", async () => {
|
||||
const { initNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
initNotificationSync();
|
||||
|
||||
// Both calls should only produce one subscription (one initial sync)
|
||||
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupNotificationSync", () => {
|
||||
it("stops reacting to config changes after cleanup", async () => {
|
||||
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
cleanupNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
configWritable.set({ notifications_enabled: false, notification_volume: 0.5 });
|
||||
|
||||
expect(mockSetEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is safe to call when not initialised", async () => {
|
||||
const { cleanupNotificationSync } = await import("./notifications");
|
||||
expect(() => cleanupNotificationSync()).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows re-initialisation after cleanup", async () => {
|
||||
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||
initNotificationSync();
|
||||
cleanupNotificationSync();
|
||||
|
||||
mockSetEnabled.mockReset();
|
||||
initNotificationSync();
|
||||
|
||||
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { searchState, isSearchActive, searchQuery } from "./search";
|
||||
|
||||
describe("searchState store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts with empty query", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.query).toBe("");
|
||||
});
|
||||
|
||||
it("starts inactive", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("starts with zero match count", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.matchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("starts with zero current match index", () => {
|
||||
const state = get(searchState);
|
||||
expect(state.currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setQuery", () => {
|
||||
it("sets the query string", () => {
|
||||
searchState.setQuery("hello");
|
||||
expect(get(searchState).query).toBe("hello");
|
||||
});
|
||||
|
||||
it("activates the store when query is non-empty", () => {
|
||||
searchState.setQuery("hello");
|
||||
expect(get(searchState).isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("deactivates the store when query is empty", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.setQuery("");
|
||||
expect(get(searchState).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("resets currentMatchIndex to 0 on each query change", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.setQuery("world");
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMatchCount", () => {
|
||||
it("updates the match count", () => {
|
||||
searchState.setMatchCount(7);
|
||||
expect(get(searchState).matchCount).toBe(7);
|
||||
});
|
||||
|
||||
it("does not reset currentMatchIndex", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.setMatchCount(10);
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextMatch", () => {
|
||||
it("advances to the next match index", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps around to 0 after the last match", () => {
|
||||
searchState.setMatchCount(3);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("stays at 0 when match count is 0", () => {
|
||||
searchState.setMatchCount(0);
|
||||
searchState.nextMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousMatch", () => {
|
||||
it("goes to the previous match index", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps around to last match when at index 0", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(4);
|
||||
});
|
||||
|
||||
it("stays at 0 when match count is 0", () => {
|
||||
searchState.setMatchCount(0);
|
||||
searchState.previousMatch();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("resets query to empty string", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.clear();
|
||||
expect(get(searchState).query).toBe("");
|
||||
});
|
||||
|
||||
it("resets isActive to false", () => {
|
||||
searchState.setQuery("hello");
|
||||
searchState.clear();
|
||||
expect(get(searchState).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("resets matchCount to 0", () => {
|
||||
searchState.setMatchCount(10);
|
||||
searchState.clear();
|
||||
expect(get(searchState).matchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("resets currentMatchIndex to 0", () => {
|
||||
searchState.setMatchCount(5);
|
||||
searchState.nextMatch();
|
||||
searchState.nextMatch();
|
||||
searchState.clear();
|
||||
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSearchActive derived store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
it("is false initially", () => {
|
||||
expect(get(isSearchActive)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when query is set", () => {
|
||||
searchState.setQuery("test");
|
||||
expect(get(isSearchActive)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false after clearing", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.clear();
|
||||
expect(get(isSearchActive)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchQuery derived store", () => {
|
||||
beforeEach(() => {
|
||||
searchState.clear();
|
||||
});
|
||||
|
||||
it("is empty string initially", () => {
|
||||
expect(get(searchQuery)).toBe("");
|
||||
});
|
||||
|
||||
it("reflects the current query", () => {
|
||||
searchState.setQuery("my search");
|
||||
expect(get(searchQuery)).toBe("my search");
|
||||
});
|
||||
|
||||
it("is empty after clearing", () => {
|
||||
searchState.setQuery("test");
|
||||
searchState.clear();
|
||||
expect(get(searchQuery)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import type { SavedSession } from "$lib/stores/sessions";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
writeTextFile: vi.fn().mockResolvedValue(undefined),
|
||||
readTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||
openPath: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
|
||||
id: "session-1",
|
||||
name: "Test Session",
|
||||
created_at: "2026-03-03T10:00:00.000Z",
|
||||
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||
working_directory: "/home/naomi/code",
|
||||
message_count: 2,
|
||||
preview: "Hello world",
|
||||
messages: [
|
||||
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
|
||||
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeConversation = () => ({
|
||||
id: "conv-1",
|
||||
name: "Test Conversation",
|
||||
workingDirectory: "/home/naomi/code",
|
||||
createdAt: new Date("2026-03-03T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
|
||||
terminalLines: [
|
||||
{
|
||||
id: "line-1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2026-03-03T10:00:00.000Z"),
|
||||
toolName: undefined,
|
||||
},
|
||||
{
|
||||
id: "line-2",
|
||||
type: "assistant",
|
||||
content: "Hi",
|
||||
timestamp: new Date("2026-03-03T10:01:00.000Z"),
|
||||
toolName: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("sessionsStore - loadSessions", () => {
|
||||
it("loads sessions from backend and updates the store", async () => {
|
||||
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||
setMockInvokeResult("list_sessions", sessionList);
|
||||
await sessionsStore.loadSessions();
|
||||
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
||||
await sessionsStore.loadSessions();
|
||||
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
await sessionsStore.loadSessions();
|
||||
expect(get(sessionsStore.isLoading)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - loadSession", () => {
|
||||
it("returns session data on success", async () => {
|
||||
const session = makeSavedSession();
|
||||
setMockInvokeResult("load_session", session);
|
||||
const result = await sessionsStore.loadSession("session-1");
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("returns null on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", new Error("Not found"));
|
||||
const result = await sessionsStore.loadSession("session-1");
|
||||
expect(result).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - deleteSession", () => {
|
||||
it("deletes session and reloads the session list", async () => {
|
||||
setMockInvokeResult("delete_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
await sessionsStore.deleteSession("session-1");
|
||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_session", new Error("Failed"));
|
||||
await sessionsStore.deleteSession("session-1");
|
||||
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - searchSessions", () => {
|
||||
it("calls loadSessions when query is empty", async () => {
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
await sessionsStore.searchSessions("");
|
||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||
});
|
||||
|
||||
it("calls loadSessions when query is whitespace-only", async () => {
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
await sessionsStore.searchSessions(" ");
|
||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||
});
|
||||
|
||||
it("searches with the given query", async () => {
|
||||
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||
setMockInvokeResult("search_sessions", results);
|
||||
await sessionsStore.searchSessions("test");
|
||||
expect(get(sessionsStore.sessions)).toEqual(results);
|
||||
});
|
||||
|
||||
it("updates searchQuery store", async () => {
|
||||
setMockInvokeResult("search_sessions", []);
|
||||
await sessionsStore.searchSessions("hello");
|
||||
expect(get(sessionsStore.searchQuery)).toBe("hello");
|
||||
await sessionsStore.searchSessions("");
|
||||
});
|
||||
|
||||
it("handles search errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("search_sessions", new Error("Search failed"));
|
||||
await sessionsStore.searchSessions("error-query");
|
||||
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - clearAllSessions", () => {
|
||||
it("clears all sessions", async () => {
|
||||
setMockInvokeResult("clear_all_sessions", undefined);
|
||||
await sessionsStore.clearAllSessions();
|
||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
|
||||
await sessionsStore.clearAllSessions();
|
||||
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - saveConversation", () => {
|
||||
it("saves conversation to backend", async () => {
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
await sessionsStore.saveConversation(makeConversation() as never);
|
||||
});
|
||||
|
||||
it("handles save errors gracefully", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_session", new Error("Save failed"));
|
||||
await sessionsStore.saveConversation(makeConversation() as never);
|
||||
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty conversation", async () => {
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
const conv = { ...makeConversation(), terminalLines: [] };
|
||||
await sessionsStore.saveConversation(conv as never);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("schedules an auto-save after the debounce delay", async () => {
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
sessionsStore.scheduleAutoSave(makeConversation() as never);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
});
|
||||
|
||||
it("does not auto-save empty conversations", async () => {
|
||||
const conv = { ...makeConversation(), terminalLines: [] };
|
||||
sessionsStore.scheduleAutoSave(conv as never);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
it("cancels a pending auto-save", () => {
|
||||
const conv = makeConversation();
|
||||
sessionsStore.scheduleAutoSave(conv as never);
|
||||
sessionsStore.cancelAutoSave(conv.id);
|
||||
});
|
||||
|
||||
it("handles cancel when no auto-save is pending", () => {
|
||||
sessionsStore.cancelAutoSave("non-existent-id");
|
||||
});
|
||||
|
||||
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
const conv = makeConversation();
|
||||
sessionsStore.scheduleAutoSave(conv as never);
|
||||
// Schedule again before timer fires — hits clearTimeout branch (line 487)
|
||||
sessionsStore.scheduleAutoSave(conv as never);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - exportSessionAsJson", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns true on successful export", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when session not found", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", null);
|
||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when user cancels save dialog", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue(null);
|
||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeTextFile throws", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - exportSessionAsMarkdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("generates markdown with all message types", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const session = makeSavedSession({
|
||||
messages: [
|
||||
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
|
||||
{
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Assistant reply",
|
||||
timestamp: "2026-03-03T10:01:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "tool_use",
|
||||
content: "Tool input",
|
||||
timestamp: "2026-03-03T10:02:00Z",
|
||||
tool_name: "bash",
|
||||
},
|
||||
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
|
||||
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
|
||||
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
|
||||
],
|
||||
});
|
||||
setMockInvokeResult("load_session", session);
|
||||
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("User message");
|
||||
expect(content).toContain("Assistant reply");
|
||||
expect(content).toContain("Tool: bash");
|
||||
expect(content).toContain("Tool input");
|
||||
expect(content).toContain("Tool output");
|
||||
expect(content).toContain("System event");
|
||||
expect(content).toContain("Error message");
|
||||
});
|
||||
|
||||
it("returns false when session not found", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", null);
|
||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when user cancels dialog", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue(null);
|
||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeTextFile throws", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("generates HTML and returns true on success", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("<!DOCTYPE html>");
|
||||
expect(content).toContain("Test Session");
|
||||
});
|
||||
|
||||
it("escapes HTML characters in session name", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
await sessionsStore.exportSessionAsHtml("session-1");
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("<Test & 'Session'>");
|
||||
expect(content).not.toContain("<Test & 'Session'>");
|
||||
});
|
||||
|
||||
it("escapes HTML characters in message content", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult(
|
||||
"load_session",
|
||||
makeSavedSession({
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: '<script>alert("xss")</script>',
|
||||
timestamp: "2026-03-03T10:00:00Z",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
await sessionsStore.exportSessionAsHtml("session-1");
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("<script>alert("xss")</script>");
|
||||
expect(content).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("escapes HTML in tool names", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult(
|
||||
"load_session",
|
||||
makeSavedSession({
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "tool_use",
|
||||
content: "input",
|
||||
timestamp: "2026-03-03T10:00:00Z",
|
||||
tool_name: "<dangerous>",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
await sessionsStore.exportSessionAsHtml("session-1");
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("<dangerous>");
|
||||
expect(content).not.toContain("<dangerous>");
|
||||
});
|
||||
|
||||
it("excludes metadata when includeMetadata is false", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
await sessionsStore.exportSessionAsHtml("session-1", false);
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).not.toContain('class="metadata"');
|
||||
});
|
||||
|
||||
it("returns false when session not found", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", null);
|
||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when user cancels dialog", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue(null);
|
||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeTextFile throws", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("generates printable HTML and opens it in the browser", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { openPath } = await import("@tauri-apps/plugin-opener");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
|
||||
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
|
||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||
expect(content).toContain("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
it("excludes metadata when includeMetadata is false", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||
await sessionsStore.exportSessionAsPdf("session-1", false);
|
||||
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns false when session not found", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", null);
|
||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when user cancels dialog", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue(null);
|
||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when writeTextFile throws", async () => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("load_session", makeSavedSession());
|
||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionsStore - importSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("imports session from JSON file and returns true", async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
vi.mocked(open).mockResolvedValue("/input/session.json");
|
||||
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
|
||||
setMockInvokeResult("save_session", undefined);
|
||||
setMockInvokeResult("list_sessions", []);
|
||||
expect(await sessionsStore.importSession()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when user cancels file dialog", async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
vi.mocked(open).mockResolvedValue(null);
|
||||
expect(await sessionsStore.importSession()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when file has invalid format", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
vi.mocked(open).mockResolvedValue("/input/bad.json");
|
||||
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
|
||||
expect(await sessionsStore.importSession()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
|
||||
expect(await sessionsStore.importSession()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
stats,
|
||||
formattedStats,
|
||||
@@ -9,8 +11,13 @@ import {
|
||||
estimateMessageCost,
|
||||
formatTokenCount,
|
||||
MODEL_PRICING,
|
||||
checkBudget,
|
||||
getBudgetStatusMessage,
|
||||
getRemainingTokenBudget,
|
||||
getRemainingCostBudget,
|
||||
initStatsListener,
|
||||
} from "./stats";
|
||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
||||
import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
|
||||
|
||||
// Helper function to create ToolTokenStats for tests
|
||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||
@@ -30,6 +37,12 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./costTracking", () => ({
|
||||
costTrackingStore: {
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("stats store", () => {
|
||||
beforeEach(() => {
|
||||
// Reset stats to default before each test
|
||||
@@ -600,4 +613,282 @@ describe("stats store", () => {
|
||||
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 500,
|
||||
session_output_tokens: 500,
|
||||
session_cost_usd: 0.5,
|
||||
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,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns ok when budget is disabled", () => {
|
||||
const result = checkBudget(baseStats, false, 100, 1.0, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
|
||||
it("returns ok when under all budgets", () => {
|
||||
const result = checkBudget(baseStats, true, 10000, 10.0, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
|
||||
it("returns exceeded when token budget is reached", () => {
|
||||
const result = checkBudget(baseStats, true, 1000, null, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||
});
|
||||
|
||||
it("returns warning when token usage is above threshold", () => {
|
||||
// session tokens = 1000, budget = 1100, threshold = 0.8 → 1000/1100 ≈ 0.909 > 0.8
|
||||
const result = checkBudget(baseStats, true, 1100, null, 0.8);
|
||||
expect(result.type).toBe("warning");
|
||||
if (result.type === "warning") {
|
||||
expect(result.budget_type).toBe("token");
|
||||
expect(result.percent_used).toBeGreaterThan(80);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns exceeded when cost budget is reached", () => {
|
||||
const result = checkBudget(baseStats, true, null, 0.5, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "cost" });
|
||||
});
|
||||
|
||||
it("returns warning when cost usage is above threshold", () => {
|
||||
// session cost = 0.5, budget = 0.55, threshold = 0.8 → 0.5/0.55 ≈ 0.909 > 0.8
|
||||
const result = checkBudget(baseStats, true, null, 0.55, 0.8);
|
||||
expect(result.type).toBe("warning");
|
||||
if (result.type === "warning") {
|
||||
expect(result.budget_type).toBe("cost");
|
||||
}
|
||||
});
|
||||
|
||||
it("checks token budget before cost budget", () => {
|
||||
// Both budgets exceeded, but token should be reported first
|
||||
const result = checkBudget(baseStats, true, 500, 0.1, 0.8);
|
||||
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||
});
|
||||
|
||||
it("returns ok when no budgets are set", () => {
|
||||
const result = checkBudget(baseStats, true, null, null, 0.8);
|
||||
expect(result).toEqual({ type: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetStatusMessage", () => {
|
||||
it("returns null for ok status", () => {
|
||||
const status: BudgetStatus = { type: "ok" };
|
||||
expect(getBudgetStatusMessage(status)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns exceeded message for token budget", () => {
|
||||
const status: BudgetStatus = { type: "exceeded", budget_type: "token" };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("token");
|
||||
expect(message).toContain("exceeded");
|
||||
});
|
||||
|
||||
it("returns exceeded message for cost budget", () => {
|
||||
const status: BudgetStatus = { type: "exceeded", budget_type: "cost" };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("cost");
|
||||
expect(message).toContain("exceeded");
|
||||
});
|
||||
|
||||
it("returns warning message with percentage for token budget", () => {
|
||||
const status: BudgetStatus = { type: "warning", budget_type: "token", percent_used: 85.5 };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("token");
|
||||
expect(message).toContain("86%");
|
||||
});
|
||||
|
||||
it("returns warning message with percentage for cost budget", () => {
|
||||
const status: BudgetStatus = { type: "warning", budget_type: "cost", percent_used: 90 };
|
||||
const message = getBudgetStatusMessage(status);
|
||||
expect(message).toContain("cost");
|
||||
expect(message).toContain("90%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemainingTokenBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 300,
|
||||
session_output_tokens: 200,
|
||||
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,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns null when no token budget is set", () => {
|
||||
expect(getRemainingTokenBudget(baseStats, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the remaining tokens when under budget", () => {
|
||||
// session tokens = 500, budget = 1000 → remaining = 500
|
||||
expect(getRemainingTokenBudget(baseStats, 1000)).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 0 when at or over budget", () => {
|
||||
expect(getRemainingTokenBudget(baseStats, 500)).toBe(0);
|
||||
expect(getRemainingTokenBudget(baseStats, 400)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemainingCostBudget", () => {
|
||||
const baseStats: UsageStats = {
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0.3,
|
||||
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,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
it("returns null when no cost budget is set", () => {
|
||||
expect(getRemainingCostBudget(baseStats, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the remaining cost when under budget", () => {
|
||||
// session cost = 0.3, budget = 1.0 → remaining = 0.7
|
||||
expect(getRemainingCostBudget(baseStats, 1.0)).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
it("returns 0 when at or over budget", () => {
|
||||
expect(getRemainingCostBudget(baseStats, 0.3)).toBe(0);
|
||||
expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initStatsListener", () => {
|
||||
const emptyStats: UsageStats = {
|
||||
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,
|
||||
context_tokens_used: 0,
|
||||
context_window_limit: 200000,
|
||||
context_utilisation_percent: 0,
|
||||
potential_cache_hits: 0,
|
||||
potential_cache_savings_tokens: 0,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("registers a listener for the claude:stats event", async () => {
|
||||
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||
vi.mocked(invoke).mockResolvedValue(emptyStats);
|
||||
|
||||
await initStatsListener();
|
||||
|
||||
expect(listen).toHaveBeenCalledWith("claude:stats", expect.any(Function));
|
||||
});
|
||||
|
||||
it("updates the stats store when the listener callback fires", async () => {
|
||||
let capturedCallback: ((event: unknown) => void) | undefined;
|
||||
vi.mocked(listen).mockImplementation(async (_event, callback) => {
|
||||
capturedCallback = callback as (event: unknown) => void;
|
||||
return () => {};
|
||||
});
|
||||
vi.mocked(invoke).mockResolvedValue(emptyStats);
|
||||
|
||||
await initStatsListener();
|
||||
|
||||
const newStats = { ...emptyStats, total_input_tokens: 9999 };
|
||||
capturedCallback!({ payload: { stats: newStats } });
|
||||
|
||||
expect(get(stats).total_input_tokens).toBe(9999);
|
||||
});
|
||||
|
||||
it("loads persisted stats from the backend on initialisation", async () => {
|
||||
const persistedStats = { ...emptyStats, total_input_tokens: 5000 };
|
||||
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||
vi.mocked(invoke).mockResolvedValue(persistedStats);
|
||||
|
||||
await initStatsListener();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_persisted_stats");
|
||||
expect(get(stats).total_input_tokens).toBe(5000);
|
||||
});
|
||||
|
||||
it("handles a failed invoke gracefully and logs the error", async () => {
|
||||
const error = new Error("Failed to get stats");
|
||||
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||
vi.mocked(invoke).mockRejectedValue(error);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await initStatsListener();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load initial stats:", error);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { emitMockEvent } from "../../../vitest.setup";
|
||||
import { todos, initializeTodoListener, cleanupTodoListener } from "./todos";
|
||||
import type { TodoItem } from "./todos";
|
||||
|
||||
describe("todos store", () => {
|
||||
beforeEach(() => {
|
||||
cleanupTodoListener();
|
||||
todos.clear();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts with an empty list", () => {
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set and clear", () => {
|
||||
it("can set todos directly", () => {
|
||||
const items: TodoItem[] = [
|
||||
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||
{ content: "Task 2", status: "in_progress", activeForm: "Doing task 2" },
|
||||
];
|
||||
todos.set(items);
|
||||
expect(get(todos)).toEqual(items);
|
||||
});
|
||||
|
||||
it("clear resets to empty array", () => {
|
||||
todos.set([{ content: "Task", status: "completed", activeForm: "Doing task" }]);
|
||||
todos.clear();
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("can update todos with a callback", () => {
|
||||
const initial: TodoItem[] = [
|
||||
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||
];
|
||||
todos.set(initial);
|
||||
todos.update((current) => [
|
||||
...current,
|
||||
{ content: "Task 2", status: "completed", activeForm: "Doing task 2" },
|
||||
]);
|
||||
expect(get(todos)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeTodoListener", () => {
|
||||
it("listens for claude:todo-update events", async () => {
|
||||
await initializeTodoListener();
|
||||
|
||||
const newTodos: TodoItem[] = [
|
||||
{ content: "Backend task", status: "in_progress", activeForm: "Running backend task" },
|
||||
];
|
||||
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||
|
||||
expect(get(todos)).toEqual(newTodos);
|
||||
});
|
||||
|
||||
it("does not register a duplicate listener when called twice", async () => {
|
||||
await initializeTodoListener();
|
||||
await initializeTodoListener();
|
||||
|
||||
const newTodos: TodoItem[] = [
|
||||
{ content: "Task", status: "pending", activeForm: "Doing task" },
|
||||
];
|
||||
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||
|
||||
// Should only have been set once (not doubled)
|
||||
expect(get(todos)).toEqual(newTodos);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupTodoListener", () => {
|
||||
it("stops listening for events after cleanup", async () => {
|
||||
await initializeTodoListener();
|
||||
cleanupTodoListener();
|
||||
|
||||
emitMockEvent("claude:todo-update", {
|
||||
todos: [{ content: "Task", status: "pending", activeForm: "Doing task" }],
|
||||
});
|
||||
|
||||
// Should not have been updated since we cleaned up
|
||||
expect(get(todos)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is safe to call when not initialised", () => {
|
||||
expect(() => cleanupTodoListener()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user