From c5d1df351c8cbdd5ebbf0131c7619c90f7d34b9b Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 2 Mar 2026 13:36:23 -0800 Subject: [PATCH 01/22] fix: persist show_thinking_blocks setting across sessions --- src-tauri/src/config.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b9af21a..8a345ef 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -135,6 +135,9 @@ pub struct HikariConfig { #[serde(default = "default_background_image_opacity")] pub background_image_opacity: f32, + + #[serde(default)] + pub show_thinking_blocks: bool, } impl Default for HikariConfig { @@ -172,6 +175,7 @@ impl Default for HikariConfig { trusted_workspaces: Vec::new(), background_image_path: None, background_image_opacity: 0.3, + show_thinking_blocks: false, } } } @@ -286,6 +290,7 @@ mod tests { assert!(!config.use_worktree); assert!(!config.disable_1m_context); assert!(config.trusted_workspaces.is_empty()); + assert!(!config.show_thinking_blocks); } #[test] @@ -323,6 +328,7 @@ mod tests { trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], background_image_path: Some("/home/naomi/bg.png".to_string()), background_image_opacity: 0.25, + show_thinking_blocks: true, }; let json = serde_json::to_string(&config).unwrap(); -- 2.52.0 From 08f7ca2d558405725ebb6392c190c83cd47d8ee4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 2 Mar 2026 15:08:19 -0800 Subject: [PATCH 02/22] feat: restyle tool calls as collapsible blocks matching thinking block aesthetic --- src/lib/components/Terminal.svelte | 72 +++--------- src/lib/components/ToolCallBlock.svelte | 141 ++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 59 deletions(-) create mode 100644 src/lib/components/ToolCallBlock.svelte diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 1d1f665..19135b6 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -7,6 +7,7 @@ import Markdown from "./Markdown.svelte"; import HighlightedText from "./HighlightedText.svelte"; import ThinkingBlock from "./ThinkingBlock.svelte"; + import ToolCallBlock from "./ToolCallBlock.svelte"; import { searchState, searchQuery } from "$lib/stores/search"; import { clipboardStore } from "$lib/stores/clipboard"; import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config"; @@ -208,22 +209,6 @@ if (!currentConversationId) return; await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" }); } - - // Collapsible tool lines - const TOOL_COLLAPSE_THRESHOLD = 60; - let expandedToolLines: Record = {}; - - function isToolContentLong(content: string): boolean { - return content.length > TOOL_COLLAPSE_THRESHOLD; - } - - function truncateToolContent(content: string): string { - return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…"; - } - - function toggleToolLine(id: string) { - expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] }; - }
{/if} + {:else if line.type === "tool"} +
+ +
{:else}
{getLinePrefix(line.type)} {/if} - {#if line.toolName} - [{line.toolName}] - {/if} {#if line.type === "compact-prompt"}
- {:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))} - - - - {:else} diff --git a/src/lib/components/ToolCallBlock.svelte b/src/lib/components/ToolCallBlock.svelte new file mode 100644 index 0000000..78c996b --- /dev/null +++ b/src/lib/components/ToolCallBlock.svelte @@ -0,0 +1,141 @@ + + +
+ + + {#if isExpanded} +
+ {content} +
+ {/if} +
+ + -- 2.52.0 From 19e28b7ec7cac18ec4f43ffcdc9791ab7d640635 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 09:47:15 -0800 Subject: [PATCH 03/22] feat: add configurable max output tokens setting --- src-tauri/src/config.rs | 8 ++++++++ src-tauri/src/wsl_bridge.rs | 10 ++++++++++ src/lib/commands/slashCommands.ts | 2 ++ src/lib/components/ConfigSidebar.svelte | 20 ++++++++++++++++++++ src/lib/components/StatusBar.svelte | 3 +++ src/lib/stores/config.test.ts | 3 +++ src/lib/stores/config.ts | 3 +++ 7 files changed, 49 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 8a345ef..bdb10c5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -31,6 +31,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub disable_1m_context: bool, + + #[serde(default)] + pub max_output_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -126,6 +129,9 @@ pub struct HikariConfig { #[serde(default)] pub disable_1m_context: bool, + #[serde(default)] + pub max_output_tokens: Option, + #[serde(default)] pub trusted_workspaces: Vec, @@ -172,6 +178,7 @@ impl Default for HikariConfig { discord_rpc_enabled: true, use_worktree: false, disable_1m_context: false, + max_output_tokens: None, trusted_workspaces: Vec::new(), background_image_path: None, background_image_opacity: 0.3, @@ -325,6 +332,7 @@ mod tests { discord_rpc_enabled: true, use_worktree: true, disable_1m_context: false, + max_output_tokens: Some(32000), trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()], background_image_path: Some("/home/naomi/bg.png".to_string()), background_image_opacity: 0.25, diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 5df9852..c2da718 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -296,6 +296,11 @@ impl WslBridge { cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1"); } + // Set max output tokens if specified + if let Some(max_tokens) = options.max_output_tokens { + cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string()); + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -343,6 +348,11 @@ impl WslBridge { claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 "); } + // Set max output tokens if specified + if let Some(max_tokens) = options.max_output_tokens { + claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens)); + } + claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 8d252c8..3e97bb2 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise { allowed_tools: allAllowedTools, use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, + max_output_tokens: config.max_output_tokens ?? null, }, }); @@ -139,6 +140,7 @@ async function startNewConversation(): Promise { allowed_tools: allAllowedTools, use_worktree: config.use_worktree ?? false, disable_1m_context: config.disable_1m_context ?? false, + max_output_tokens: config.max_output_tokens ?? null, }, }); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index dd26102..0ea8270 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -56,6 +56,7 @@ 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, @@ -533,6 +534,25 @@ context window

+ + +
+ + +

+ Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS — increase if responses are + being cut off mid-reply +

+
diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index bbc3d60..29621fe 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -107,6 +107,7 @@ 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, @@ -185,6 +186,7 @@ allowed_tools: allAllowedTools, use_worktree: currentConfig.use_worktree ?? false, disable_1m_context: currentConfig.disable_1m_context ?? false, + max_output_tokens: currentConfig.max_output_tokens ?? null, }, }); @@ -344,6 +346,7 @@ allowed_tools: allAllowedTools, use_worktree: currentConfig.use_worktree ?? false, disable_1m_context: currentConfig.disable_1m_context ?? false, + max_output_tokens: currentConfig.max_output_tokens ?? null, }, }); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index 664858c..acb6904 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -196,6 +196,7 @@ 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, @@ -247,6 +248,7 @@ 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, @@ -797,6 +799,7 @@ 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, diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 174537d..55e73c5 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -51,6 +51,8 @@ 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 @@ -98,6 +100,7 @@ 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, -- 2.52.0 From 66c65a6ab8aec75cee5a58f35d79fec397df1122 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 10:56:44 -0800 Subject: [PATCH 04/22] feat: use random creative names for conversation tabs --- CLAUDE.md | 11 ++++- src/lib/stores/conversations.ts | 86 ++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c6459de..380a241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,14 +20,21 @@ When working with issues, pull requests, or other repository operations for this When asked to commit changes for this project: - **Always commit as Hikari** using: `--author="Hikari "` -- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up +- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808` - **Never add `Co-Authored-By` lines** for Gitea commits - **Always ask for confirmation** before committing +- **Always ask for confirmation** before pushing Example commit command: ```bash -git commit --author="Hikari " --no-gpg-sign -m "your commit message" +git commit --author="Hikari " --gpg-sign=5380E4EE7307C808 -m "your commit message" +``` + +Example push command: + +```bash +git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git ``` ## Testing Requirements diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 00e2644..a3968b4 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -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>(new Map()); const activeConversationId = writable(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; -- 2.52.0 From fd3122e080c722f456047e00d44e0729b0804bd3 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 12:05:31 -0800 Subject: [PATCH 05/22] test: expand frontend unit test coverage to 30% --- src/lib/sounds/achievement.test.ts | 48 ++++++ src/lib/stores/character.test.ts | 115 +++++++++++++++ src/lib/stores/costTracking.test.ts | 98 +++++++++++++ src/lib/stores/historyRestore.test.ts | 72 +++++++++ src/lib/stores/messageMode.test.ts | 56 +++++++ src/lib/stores/notifications.test.ts | 105 +++++++++++++ src/lib/stores/search.test.ts | 188 ++++++++++++++++++++++++ src/lib/stores/stats.test.ts | 203 +++++++++++++++++++++++++- src/lib/stores/todos.test.ts | 93 ++++++++++++ 9 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 src/lib/sounds/achievement.test.ts create mode 100644 src/lib/stores/character.test.ts create mode 100644 src/lib/stores/costTracking.test.ts create mode 100644 src/lib/stores/historyRestore.test.ts create mode 100644 src/lib/stores/messageMode.test.ts create mode 100644 src/lib/stores/notifications.test.ts create mode 100644 src/lib/stores/search.test.ts create mode 100644 src/lib/stores/todos.test.ts diff --git a/src/lib/sounds/achievement.test.ts b/src/lib/sounds/achievement.test.ts new file mode 100644 index 0000000..5a023f5 --- /dev/null +++ b/src/lib/sounds/achievement.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotificationType } from "$lib/notifications/types"; + +const mockPlay = vi.fn(); + +vi.mock("$lib/notifications", () => ({ + soundPlayer: { + play: mockPlay, + }, +})); + +describe("achievement sounds", () => { + beforeEach(() => { + mockPlay.mockReset(); + }); + + describe("playAchievementSound", () => { + it("plays the achievement notification sound", async () => { + const { playAchievementSound } = await import("./achievement"); + playAchievementSound(); + expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT); + }); + }); + + describe("testAchievementSound", () => { + it("calls playAchievementSound without throwing", async () => { + const { testAchievementSound } = await import("./achievement"); + expect(() => testAchievementSound()).not.toThrow(); + expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT); + }); + + it("catches errors from the sound player gracefully", async () => { + mockPlay.mockImplementation(() => { + throw new Error("Audio not available"); + }); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { testAchievementSound } = await import("./achievement"); + expect(() => testAchievementSound()).not.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error playing achievement sound:", + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/stores/character.test.ts b/src/lib/stores/character.test.ts new file mode 100644 index 0000000..4d3d794 --- /dev/null +++ b/src/lib/stores/character.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +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); + }); +}); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts new file mode 100644 index 0000000..b573856 --- /dev/null +++ b/src/lib/stores/costTracking.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { describe, it, expect } from "vitest"; +import { + formatCost, + formatAlertType, + getAlertMessage, + type AlertType, + type CostAlert, +} from "./costTracking"; + +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"); + }); +}); diff --git a/src/lib/stores/historyRestore.test.ts b/src/lib/stores/historyRestore.test.ts new file mode 100644 index 0000000..e298a64 --- /dev/null +++ b/src/lib/stores/historyRestore.test.ts @@ -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(); + }); + }); +}); diff --git a/src/lib/stores/messageMode.test.ts b/src/lib/stores/messageMode.test.ts new file mode 100644 index 0000000..7b3a703 --- /dev/null +++ b/src/lib/stores/messageMode.test.ts @@ -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"); + }); +}); diff --git a/src/lib/stores/notifications.test.ts b/src/lib/stores/notifications.test.ts new file mode 100644 index 0000000..78132dd --- /dev/null +++ b/src/lib/stores/notifications.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/init-declarations -- Variables reassigned in beforeEach */ +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); + }); + }); +}); diff --git a/src/lib/stores/search.test.ts b/src/lib/stores/search.test.ts new file mode 100644 index 0000000..f46d3aa --- /dev/null +++ b/src/lib/stores/search.test.ts @@ -0,0 +1,188 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +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(""); + }); +}); diff --git a/src/lib/stores/stats.test.ts b/src/lib/stores/stats.test.ts index 37259b7..c1da40b 100644 --- a/src/lib/stores/stats.test.ts +++ b/src/lib/stores/stats.test.ts @@ -9,8 +9,12 @@ import { estimateMessageCost, formatTokenCount, MODEL_PRICING, + checkBudget, + getBudgetStatusMessage, + getRemainingTokenBudget, + getRemainingCostBudget, } 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 { @@ -600,4 +604,201 @@ 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); + }); + }); }); diff --git a/src/lib/stores/todos.test.ts b/src/lib/stores/todos.test.ts new file mode 100644 index 0000000..0c65886 --- /dev/null +++ b/src/lib/stores/todos.test.ts @@ -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(); + }); + }); +}); -- 2.52.0 From 514e137590ae23e4b0a41d04ecf3694cea366a3e Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 12:21:47 -0800 Subject: [PATCH 06/22] test: add component logic tests and clean up eslint directives Adds mirror-function tests for five Svelte components (HighlightedText, CliVersion, AchievementNotification, StatusBar, ConversationTabs) and removes stale eslint-disable comments from existing store test files. --- .../AchievementNotification.test.ts | 153 ++++++++++++++ src/lib/components/CliVersion.test.ts | 134 ++++++++++++ src/lib/components/ConversationTabs.test.ts | 111 ++++++++++ src/lib/components/HighlightedText.test.ts | 195 ++++++++++++++++++ src/lib/components/StatusBar.test.ts | 89 ++++++++ src/lib/stores/character.test.ts | 13 +- src/lib/stores/costTracking.test.ts | 1 - src/lib/stores/notifications.test.ts | 1 - src/lib/stores/search.test.ts | 1 - 9 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/AchievementNotification.test.ts create mode 100644 src/lib/components/CliVersion.test.ts create mode 100644 src/lib/components/ConversationTabs.test.ts create mode 100644 src/lib/components/HighlightedText.test.ts create mode 100644 src/lib/components/StatusBar.test.ts diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts new file mode 100644 index 0000000..ed971f2 --- /dev/null +++ b/src/lib/components/AchievementNotification.test.ts @@ -0,0 +1,153 @@ +/** + * AchievementNotification Component Tests + * + * Tests the rarity classification and colour mapping logic used by the + * AchievementNotification component. + * + * What this component does: + * - Listens for "achievement:unlocked" Tauri events + * - Queues and displays achievement notifications one at a time + * - Each notification shows the achievement's name, icon, description, and rarity + * - A gradient border and badge colour correspond to the achievement's rarity + * + * Manual testing checklist: + * - [ ] Achievement notification slides in from the right + * - [ ] Notification auto-dismisses after 5 seconds + * - [ ] Dismiss button works immediately + * - [ ] Multiple achievements queue and display sequentially + * - [ ] Legendary achievements have a yellow-orange gradient + * - [ ] Epic achievements have a purple-pink gradient + * - [ ] Rare achievements have a blue-indigo gradient + * - [ ] Common achievements have a green-emerald gradient + */ + +import { describe, it, expect } from "vitest"; + +function getAchievementRarity(id: string): string { + if (id === "TokenMaster") return "legendary"; + if (["CodeMachine", "Unstoppable"].includes(id)) return "epic"; + if ( + [ + "BlossomingCoder", + "CodeWizard", + "MasterBuilder", + "EnduranceChamp", + "DeepDive", + "CreativeCoder", + ].includes(id) + ) + return "rare"; + return "common"; +} + +function getRarityColor(rarity: string): string { + switch (rarity) { + case "legendary": + return "from-yellow-400 to-orange-500"; + case "epic": + return "from-purple-400 to-pink-500"; + case "rare": + return "from-blue-400 to-indigo-500"; + default: + return "from-green-400 to-emerald-500"; + } +} + +// --- + +describe("getAchievementRarity", () => { + describe("legendary tier", () => { + it("classifies TokenMaster as legendary", () => { + expect(getAchievementRarity("TokenMaster")).toBe("legendary"); + }); + }); + + describe("epic tier", () => { + it("classifies CodeMachine as epic", () => { + expect(getAchievementRarity("CodeMachine")).toBe("epic"); + }); + + it("classifies Unstoppable as epic", () => { + expect(getAchievementRarity("Unstoppable")).toBe("epic"); + }); + }); + + describe("rare tier", () => { + it("classifies BlossomingCoder as rare", () => { + expect(getAchievementRarity("BlossomingCoder")).toBe("rare"); + }); + + it("classifies CodeWizard as rare", () => { + expect(getAchievementRarity("CodeWizard")).toBe("rare"); + }); + + it("classifies MasterBuilder as rare", () => { + expect(getAchievementRarity("MasterBuilder")).toBe("rare"); + }); + + it("classifies EnduranceChamp as rare", () => { + expect(getAchievementRarity("EnduranceChamp")).toBe("rare"); + }); + + it("classifies DeepDive as rare", () => { + expect(getAchievementRarity("DeepDive")).toBe("rare"); + }); + + it("classifies CreativeCoder as rare", () => { + expect(getAchievementRarity("CreativeCoder")).toBe("rare"); + }); + }); + + describe("common tier", () => { + it("classifies unknown IDs as common", () => { + expect(getAchievementRarity("FirstChat")).toBe("common"); + expect(getAchievementRarity("SomeNewAchievement")).toBe("common"); + expect(getAchievementRarity("")).toBe("common"); + }); + }); +}); + +describe("getRarityColor", () => { + it("returns yellow-to-orange gradient for legendary", () => { + expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500"); + }); + + it("returns purple-to-pink gradient for epic", () => { + expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500"); + }); + + it("returns blue-to-indigo gradient for rare", () => { + expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500"); + }); + + it("returns green-to-emerald gradient for common", () => { + expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500"); + }); + + it("falls back to green-to-emerald gradient for unknown rarities", () => { + expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500"); + expect(getRarityColor("")).toBe("from-green-400 to-emerald-500"); + }); + + describe("end-to-end rarity pipeline", () => { + it("produces the correct colour for a legendary achievement", () => { + const color = getRarityColor(getAchievementRarity("TokenMaster")); + expect(color).toBe("from-yellow-400 to-orange-500"); + }); + + it("produces the correct colour for an epic achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeMachine")); + expect(color).toBe("from-purple-400 to-pink-500"); + }); + + it("produces the correct colour for a rare achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeWizard")); + expect(color).toBe("from-blue-400 to-indigo-500"); + }); + + it("produces the correct colour for a common achievement", () => { + const color = getRarityColor(getAchievementRarity("FirstChat")); + expect(color).toBe("from-green-400 to-emerald-500"); + }); + }); +}); diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts new file mode 100644 index 0000000..a16516f --- /dev/null +++ b/src/lib/components/CliVersion.test.ts @@ -0,0 +1,134 @@ +/** + * CliVersion Component Tests + * + * Tests the version comparison logic used by the CliVersion component, + * which compares the installed CLI version against the supported version. + * + * What this component does: + * - Displays the installed Claude CLI version + * - Displays the highest audited/supported CLI version + * - Shows a warning when the installed version is ahead of or behind supported + * + * Manual testing checklist: + * - [ ] Installed version is fetched and displayed on mount + * - [ ] "current" badge shows in green when versions match + * - [ ] "ahead" badge shows in amber when installed is newer than supported + * - [ ] "behind" badge shows in red when installed is older than supported + * - [ ] Warning message appears for "ahead" and "behind" states + */ + +import { describe, it, expect } from "vitest"; + +const SUPPORTED_CLI_VERSION = "2.1.53"; + +function compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aVal = aParts[i] ?? 0; + const bVal = bParts[i] ?? 0; + if (aVal > bVal) return 1; + if (aVal < bVal) return -1; + } + return 0; +} + +// --- + +describe("SUPPORTED_CLI_VERSION", () => { + it("is defined and non-empty", () => { + expect(SUPPORTED_CLI_VERSION).toBeTruthy(); + }); + + it("matches the expected audited version", () => { + expect(SUPPORTED_CLI_VERSION).toBe("2.1.53"); + }); +}); + +describe("compareVersions", () => { + describe("equal versions", () => { + it("returns 0 for identical versions", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + }); + + it("returns 0 for the supported CLI version against itself", () => { + expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0); + }); + + it("returns 0 for 0.0.0 vs 0.0.0", () => { + expect(compareVersions("0.0.0", "0.0.0")).toBe(0); + }); + }); + + describe("major version differences", () => { + it("returns 1 when a has a higher major version", () => { + expect(compareVersions("2.0.0", "1.0.0")).toBe(1); + }); + + it("returns -1 when a has a lower major version", () => { + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + }); + }); + + describe("minor version differences", () => { + it("returns 1 when a has a higher minor version", () => { + expect(compareVersions("1.2.0", "1.1.0")).toBe(1); + }); + + it("returns -1 when a has a lower minor version", () => { + expect(compareVersions("1.1.0", "1.2.0")).toBe(-1); + }); + }); + + describe("patch version differences", () => { + it("returns 1 when a has a higher patch version", () => { + expect(compareVersions("1.0.2", "1.0.1")).toBe(1); + }); + + it("returns -1 when a has a lower patch version", () => { + expect(compareVersions("1.0.1", "1.0.2")).toBe(-1); + }); + }); + + describe("major version takes precedence", () => { + it("returns 1 when a has a higher major but lower minor", () => { + expect(compareVersions("2.0.0", "1.9.9")).toBe(1); + }); + + it("returns -1 when a has a lower major but higher minor", () => { + expect(compareVersions("1.9.9", "2.0.0")).toBe(-1); + }); + }); + + describe("unequal segment counts", () => { + it("treats missing segments as 0 (a shorter than b)", () => { + expect(compareVersions("1.0", "1.0.0")).toBe(0); + }); + + it("treats missing segments as 0 (a longer than b)", () => { + expect(compareVersions("1.0.0", "1.0")).toBe(0); + }); + + it("correctly compares when a has an extra non-zero segment", () => { + expect(compareVersions("1.0.1", "1.0")).toBe(1); + }); + + it("correctly compares when b has an extra non-zero segment", () => { + expect(compareVersions("1.0", "1.0.1")).toBe(-1); + }); + }); + + describe("supported CLI version comparisons", () => { + it("returns 1 for a version ahead of supported", () => { + expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1); + }); + + it("returns -1 for a version behind supported", () => { + expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1); + }); + + it("returns 0 for exactly the supported version", () => { + expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0); + }); + }); +}); diff --git a/src/lib/components/ConversationTabs.test.ts b/src/lib/components/ConversationTabs.test.ts new file mode 100644 index 0000000..d5e1d2f --- /dev/null +++ b/src/lib/components/ConversationTabs.test.ts @@ -0,0 +1,111 @@ +/** + * ConversationTabs Component Tests + * + * Tests the connection status colour mapping and unread message detection + * logic used by the ConversationTabs component. + * + * What this component does: + * - Displays one tab per conversation + * - Each tab shows a coloured dot for its connection state + * - Inactive tabs with new messages show an animated blue dot badge + * - Tabs can be renamed by double-clicking + * - Tabs can be reordered by drag-and-drop + * - New tabs created with Ctrl+T, closed with Ctrl+W + * + * Manual testing checklist: + * - [ ] Connected tabs show a green dot + * - [ ] Connecting tabs show a yellow dot + * - [ ] Disconnected tabs show a red dot + * - [ ] Active tab never shows the unread badge + * - [ ] Inactive tab shows blue pulsing dot when it receives new messages + * - [ ] Switching to a tab clears the unread indicator + * - [ ] Double-clicking a tab name enables inline editing + * - [ ] Tabs can be dragged to reorder + */ + +import { describe, it, expect } from "vitest"; + +type ConnectionStatus = "connected" | "connecting" | "disconnected"; + +function getConnectionStatusColor(status: ConnectionStatus | string): string { + switch (status) { + case "connected": + return "bg-green-500"; + case "connecting": + return "bg-yellow-500"; + case "disconnected": + return "bg-red-500"; + default: + return "bg-gray-500"; + } +} + +function hasUnreadMessages( + id: string, + conversationLineCount: number, + activeConversationId: string | null, + lastSeenMessageCount: Map +): boolean { + if (id === activeConversationId) return false; + const lastSeen = lastSeenMessageCount.get(id) ?? 0; + return conversationLineCount > lastSeen; +} + +// --- + +describe("getConnectionStatusColor", () => { + it("returns green for connected status", () => { + expect(getConnectionStatusColor("connected")).toBe("bg-green-500"); + }); + + it("returns yellow for connecting status", () => { + expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500"); + }); + + it("returns red for disconnected status", () => { + expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500"); + }); + + it("returns grey for unknown status (fallback)", () => { + expect(getConnectionStatusColor("error")).toBe("bg-gray-500"); + expect(getConnectionStatusColor("")).toBe("bg-gray-500"); + }); +}); + +describe("hasUnreadMessages", () => { + it("returns false for the active conversation regardless of message count", () => { + const lastSeen = new Map([["tab-1", 0]]); + expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false); + }); + + it("returns true when an inactive tab has more messages than last seen", () => { + const lastSeen = new Map([["tab-1", 5]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true); + }); + + it("returns false when an inactive tab has no new messages", () => { + const lastSeen = new Map([["tab-1", 10]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false); + }); + + it("returns false when an inactive tab has fewer messages than last seen", () => { + const lastSeen = new Map([["tab-1", 15]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false); + }); + + it("treats a tab with no last-seen record as having 0 messages seen", () => { + const lastSeen = new Map(); + // Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread + expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true); + }); + + it("returns false for an untracked tab with 0 messages", () => { + const lastSeen = new Map(); + expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false); + }); + + it("handles null activeConversationId (no active tab)", () => { + const lastSeen = new Map([["tab-1", 3]]); + expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true); + }); +}); diff --git a/src/lib/components/HighlightedText.test.ts b/src/lib/components/HighlightedText.test.ts new file mode 100644 index 0000000..b5ce219 --- /dev/null +++ b/src/lib/components/HighlightedText.test.ts @@ -0,0 +1,195 @@ +/** + * HighlightedText Component Tests + * + * Tests the text-splitting logic used by the HighlightedText component, + * which highlights search query matches within a string. + * + * What this component does: + * - Splits text into an array of {text, isMatch} parts + * - Matches are case-insensitive + * - Special regex characters in the query are escaped + * - Non-matching text is preserved verbatim around matches + * + * Manual testing checklist: + * - [ ] Matching text is highlighted (yellow background) in the terminal + * - [ ] Highlighting is case-insensitive + * - [ ] Multiple matches on the same line all get highlighted + * - [ ] Non-matching text renders normally alongside matches + */ + +import { describe, it, expect } from "vitest"; + +interface TextPart { + text: string; + isMatch: boolean; +} + +function getHighlightedParts(text: string, query: string): TextPart[] { + if (!query) { + return [{ text, isMatch: false }]; + } + + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escapedQuery})`, "gi"); + const parts: TextPart[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.index), + isMatch: false, + }); + } + + parts.push({ + text: match[1], + isMatch: true, + }); + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + isMatch: false, + }); + } + + return parts; +} + +// --- + +describe("getHighlightedParts", () => { + describe("empty query", () => { + it("returns the whole text as a single non-match when query is empty string", () => { + const result = getHighlightedParts("hello world", ""); + expect(result).toEqual([{ text: "hello world", isMatch: false }]); + }); + + it("returns an empty non-match part when both text and query are empty", () => { + const result = getHighlightedParts("", ""); + expect(result).toEqual([{ text: "", isMatch: false }]); + }); + }); + + describe("no match", () => { + it("returns the whole text as a single non-match when query is not found", () => { + const result = getHighlightedParts("hello world", "xyz"); + expect(result).toEqual([{ text: "hello world", isMatch: false }]); + }); + }); + + describe("single match", () => { + it("returns three parts for a match in the middle", () => { + const result = getHighlightedParts("hello world foo", "world"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + { text: " foo", isMatch: false }, + ]); + }); + + it("returns two parts for a match at the start", () => { + const result = getHighlightedParts("hello world", "hello"); + expect(result).toEqual([ + { text: "hello", isMatch: true }, + { text: " world", isMatch: false }, + ]); + }); + + it("returns two parts for a match at the end", () => { + const result = getHighlightedParts("hello world", "world"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + ]); + }); + + it("returns a single match part when the entire text matches", () => { + const result = getHighlightedParts("hello", "hello"); + expect(result).toEqual([{ text: "hello", isMatch: true }]); + }); + }); + + describe("multiple matches", () => { + it("returns interleaved match and non-match parts for multiple occurrences", () => { + const result = getHighlightedParts("foo bar foo", "foo"); + expect(result).toEqual([ + { text: "foo", isMatch: true }, + { text: " bar ", isMatch: false }, + { text: "foo", isMatch: true }, + ]); + }); + + it("handles adjacent matches correctly", () => { + const result = getHighlightedParts("aaa", "a"); + expect(result).toEqual([ + { text: "a", isMatch: true }, + { text: "a", isMatch: true }, + { text: "a", isMatch: true }, + ]); + }); + }); + + describe("case-insensitive matching", () => { + it("matches uppercase query against lowercase text", () => { + const result = getHighlightedParts("hello world", "WORLD"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + ]); + }); + + it("matches lowercase query against uppercase text", () => { + const result = getHighlightedParts("HELLO WORLD", "hello"); + expect(result).toEqual([ + { text: "HELLO", isMatch: true }, + { text: " WORLD", isMatch: false }, + ]); + }); + + it("preserves the original casing of the matched text", () => { + const result = getHighlightedParts("Hello World", "hello"); + const matchPart = result.find((p) => p.isMatch); + expect(matchPart?.text).toBe("Hello"); + }); + }); + + describe("special regex character escaping", () => { + it("treats a dot in the query as a literal dot, not a wildcard", () => { + const result = getHighlightedParts("v1.2.3 v123", "1.2"); + const matchParts = result.filter((p) => p.isMatch); + expect(matchParts).toHaveLength(1); + expect(matchParts[0].text).toBe("1.2"); + }); + + it("handles a query with parentheses", () => { + const result = getHighlightedParts("fn(args)", "(args)"); + expect(result).toEqual([ + { text: "fn", isMatch: false }, + { text: "(args)", isMatch: true }, + ]); + }); + + it("handles a query with a plus sign", () => { + const result = getHighlightedParts("a+b=c", "+"); + expect(result).toEqual([ + { text: "a", isMatch: false }, + { text: "+", isMatch: true }, + { text: "b=c", isMatch: false }, + ]); + }); + + it("handles a query with a question mark", () => { + const result = getHighlightedParts("is it true?", "?"); + expect(result).toEqual([ + { text: "is it true", isMatch: false }, + { text: "?", isMatch: true }, + ]); + }); + }); +}); diff --git a/src/lib/components/StatusBar.test.ts b/src/lib/components/StatusBar.test.ts new file mode 100644 index 0000000..c78e9b3 --- /dev/null +++ b/src/lib/components/StatusBar.test.ts @@ -0,0 +1,89 @@ +/** + * StatusBar Component Tests + * + * Tests the connection status colour and text helpers used by the + * StatusBar component to display the current Claude connection state. + * + * What this component does: + * - Shows a coloured indicator dot for the connection state + * - Shows a text label for the connection state + * - Provides connect/disconnect buttons + * - Contains the working directory input and browse button + * - Houses all toolbar action buttons (settings, stats, panels, etc.) + * + * Manual testing checklist: + * - [ ] Green dot and "Connected" label when Claude is running + * - [ ] Animated yellow dot and "Connecting..." label whilst connecting + * - [ ] Red dot and "Error" label on connection error + * - [ ] Grey dot and "Disconnected" label when not connected + * - [ ] Directory input is hidden when connected, visible when disconnected + * - [ ] Connect button transitions to Disconnect button on connection + */ + +import { describe, it, expect } from "vitest"; + +type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error"; + +function getStatusColor(connectionStatus: ConnectionStatus): string { + switch (connectionStatus) { + case "connected": + return "bg-green-500"; + case "connecting": + return "bg-yellow-500 animate-pulse"; + case "error": + return "bg-red-500"; + default: + return "bg-gray-500"; + } +} + +function getStatusText(connectionStatus: ConnectionStatus): string { + switch (connectionStatus) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting..."; + case "error": + return "Error"; + default: + return "Disconnected"; + } +} + +// --- + +describe("getStatusColor", () => { + it("returns green for connected status", () => { + expect(getStatusColor("connected")).toBe("bg-green-500"); + }); + + it("returns animated yellow for connecting status", () => { + expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse"); + }); + + it("returns red for error status", () => { + expect(getStatusColor("error")).toBe("bg-red-500"); + }); + + it("returns grey for disconnected status", () => { + expect(getStatusColor("disconnected")).toBe("bg-gray-500"); + }); +}); + +describe("getStatusText", () => { + it("returns 'Connected' for connected status", () => { + expect(getStatusText("connected")).toBe("Connected"); + }); + + it("returns 'Connecting...' for connecting status", () => { + expect(getStatusText("connecting")).toBe("Connecting..."); + }); + + it("returns 'Error' for error status", () => { + expect(getStatusText("error")).toBe("Error"); + }); + + it("returns 'Disconnected' for disconnected status", () => { + expect(getStatusText("disconnected")).toBe("Disconnected"); + }); +}); diff --git a/src/lib/stores/character.test.ts b/src/lib/stores/character.test.ts index 4d3d794..a3c4c82 100644 --- a/src/lib/stores/character.test.ts +++ b/src/lib/stores/character.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { get } from "svelte/store"; import { characterState, characterInfo } from "./character"; @@ -26,7 +25,17 @@ describe("characterState store", () => { }); it("can set any valid state", () => { - const states = ["idle", "thinking", "typing", "coding", "searching", "mcp", "permission", "success", "error"] as const; + 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); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts index b573856..a992a82 100644 --- a/src/lib/stores/costTracking.test.ts +++ b/src/lib/stores/costTracking.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect } from "vitest"; import { formatCost, diff --git a/src/lib/stores/notifications.test.ts b/src/lib/stores/notifications.test.ts index 78132dd..a02fa47 100644 --- a/src/lib/stores/notifications.test.ts +++ b/src/lib/stores/notifications.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/init-declarations -- Variables reassigned in beforeEach */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { writable } from "svelte/store"; diff --git a/src/lib/stores/search.test.ts b/src/lib/stores/search.test.ts index f46d3aa..55c6da6 100644 --- a/src/lib/stores/search.test.ts +++ b/src/lib/stores/search.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, beforeEach } from "vitest"; import { get } from "svelte/store"; import { searchState, isSearchActive, searchQuery } from "./search"; -- 2.52.0 From 7e45c685d361b471f6ad7f96121b9760e0af72e9 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 12:52:08 -0800 Subject: [PATCH 07/22] test: add clipboard and notification rules tests Adds 55 tests for clipboard store pure functions (detectLanguage for 12 languages + formatTimestamp with fake timers) and 13 tests for the notification rules state machine (reconnect transition logic and no-op handlers). Total test count now 698 across 30 files. --- src/lib/notifications/rules.test.ts | 150 ++++++++++++ src/lib/stores/clipboard.test.ts | 359 ++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 src/lib/notifications/rules.test.ts create mode 100644 src/lib/stores/clipboard.test.ts diff --git a/src/lib/notifications/rules.test.ts b/src/lib/notifications/rules.test.ts new file mode 100644 index 0000000..40b0205 --- /dev/null +++ b/src/lib/notifications/rules.test.ts @@ -0,0 +1,150 @@ +/** + * Notification Rules Tests + * + * Tests the connection status change handler, which fires a connection + * notification sound exactly once per reconnect cycle. + * + * What this module does: + * - Tracks the previous connection status in module-level state + * - Fires a notification only when transitioning from a non-connected + * state (disconnected/connecting) to "connected" + * - Ignores the initial connection (null → connected) to avoid noisy + * notifications on app start + * - Provides no-op handlers for tool execution and user messages + * (reserved for future notification rules) + * - cleanupNotificationRules() resets tracking state on teardown + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockNotifyConnection } = vi.hoisted(() => ({ + mockNotifyConnection: vi.fn(), +})); + +vi.mock("./notificationManager", () => ({ + notificationManager: { + notifyConnection: mockNotifyConnection, + }, +})); + +import { + handleConnectionStatusChange, + handleToolExecution, + handleNewUserMessage, + initializeNotificationRules, + cleanupNotificationRules, +} from "./rules"; + +// --- + +describe("handleConnectionStatusChange", () => { + beforeEach(() => { + mockNotifyConnection.mockReset(); + cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null + }); + + describe("initial connection (null → status)", () => { + it("does not notify on first connection (null → connected)", () => { + // previousConnectionStatus is null (falsy), so condition is not met + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); + + it("does not notify when disconnecting from initial state (null → disconnected)", () => { + handleConnectionStatusChange("disconnected"); + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); + + it("does not notify when entering connecting from initial state (null → connecting)", () => { + handleConnectionStatusChange("connecting"); + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); + }); + + describe("reconnection (disconnected → connected)", () => { + it("notifies when reconnecting after a disconnection", () => { + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).toHaveBeenCalledWith(); + }); + + it("notifies exactly once per reconnect", () => { + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).toHaveBeenCalledTimes(1); + }); + }); + + describe("reconnection (connecting → connected)", () => { + it("notifies when transitioning from connecting to connected", () => { + handleConnectionStatusChange("connecting"); + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).toHaveBeenCalledWith(); + }); + }); + + describe("already connected (connected → connected)", () => { + it("does not notify when already connected", () => { + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); // First connection — notifies + mockNotifyConnection.mockReset(); + + handleConnectionStatusChange("connected"); // Second — same status, no notify + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); + }); + + describe("disconnecting (connected → disconnected)", () => { + it("does not notify when disconnecting", () => { + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); + mockNotifyConnection.mockReset(); + + handleConnectionStatusChange("disconnected"); + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); + }); + + describe("multiple reconnect cycles", () => { + it("notifies once per reconnect cycle", () => { + // First cycle + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).toHaveBeenCalledTimes(1); + + mockNotifyConnection.mockReset(); + + // Second cycle + handleConnectionStatusChange("disconnected"); + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe("cleanupNotificationRules", () => { + it("resets state so the next connection is treated as the first", () => { + // Establish a known previous status + handleConnectionStatusChange("disconnected"); + // Now cleanup + cleanupNotificationRules(); + // After cleanup, previousConnectionStatus is null again + // So the next "connected" should NOT notify (treated as initial connection) + handleConnectionStatusChange("connected"); + expect(mockNotifyConnection).not.toHaveBeenCalled(); + }); +}); + +describe("no-op handlers", () => { + it("handleToolExecution does not throw", () => { + expect(() => handleToolExecution("Bash")).not.toThrow(); + }); + + it("handleNewUserMessage does not throw", () => { + expect(() => handleNewUserMessage()).not.toThrow(); + }); + + it("initializeNotificationRules does not throw", () => { + expect(() => initializeNotificationRules()).not.toThrow(); + }); +}); diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts new file mode 100644 index 0000000..eb955c1 --- /dev/null +++ b/src/lib/stores/clipboard.test.ts @@ -0,0 +1,359 @@ +/** + * Clipboard Store Tests + * + * Tests the pure helper functions from the clipboard store: + * - detectLanguage: identifies programming language from code content + * - formatTimestamp: converts an ISO timestamp to a relative time string + * + * What this store does: + * - Maintains a history of clipboard entries (code snippets) + * - Auto-detects the language of captured content + * - Supports filtering by language and search query + * - Pinned entries persist across clear operations + * + * Manual testing checklist: + * - [ ] Clipboard entries appear when code is copied during a session + * - [ ] Language is detected and labelled correctly + * - [ ] Pinned entries survive "Clear history" + * - [ ] Language filter dropdown shows only languages present in history + * - [ ] Search query filters by content, language, and source + */ + +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mirror: detectLanguage from clipboard.ts +function detectLanguage(content: string): string | null { + const patterns: [RegExp, string][] = [ + [/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"], + [/^(def|class|import|from|if __name__|async def)\s/m, "python"], + [/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"], + [/^(package|import|func|type|var|const)\s/m, "go"], + [/<\?php/m, "php"], + [/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"], + [/^ { + describe("TypeScript detection", () => { + it("detects import statements", () => { + expect(detectLanguage("import React from 'react';")).toBe("typescript"); + }); + + it("detects export statements", () => { + expect(detectLanguage("export function foo() {}")).toBe("typescript"); + }); + + it("detects const declarations", () => { + expect(detectLanguage("const x = 1;")).toBe("typescript"); + }); + + it("detects interface declarations", () => { + expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript"); + }); + + it("detects type aliases", () => { + expect(detectLanguage("type MyType = string | number;")).toBe("typescript"); + }); + }); + + describe("Python detection", () => { + it("detects def statements", () => { + expect(detectLanguage("def foo():\n pass")).toBe("python"); + }); + + it("detects async def statements", () => { + expect(detectLanguage("async def bar():\n pass")).toBe("python"); + }); + + it("detects from imports", () => { + expect(detectLanguage("from os import path")).toBe("python"); + }); + + it("detects the __name__ guard", () => { + expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python"); + }); + }); + + describe("Rust detection", () => { + it("detects fn declarations", () => { + expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust"); + }); + + it("detects impl blocks", () => { + expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust"); + }); + + it("detects struct declarations", () => { + expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust"); + }); + + it("detects enum declarations", () => { + expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust"); + }); + + it("detects mod declarations", () => { + expect(detectLanguage("mod utils;")).toBe("rust"); + }); + + it("detects pub visibility", () => { + expect(detectLanguage("pub fn exported() {}")).toBe("rust"); + }); + }); + + describe("Go detection", () => { + it("detects package declarations", () => { + expect(detectLanguage("package main")).toBe("go"); + }); + + it("detects func declarations", () => { + expect(detectLanguage("func main() {}")).toBe("go"); + }); + }); + + describe("PHP detection", () => { + it("detects the PHP open tag", () => { + expect(detectLanguage(" { + it("detects SELECT statements", () => { + expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql"); + }); + + it("detects INSERT statements", () => { + expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql"); + }); + + it("detects CREATE statements", () => { + expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql"); + }); + + it("detects SQL case-insensitively", () => { + expect(detectLanguage("select * from users")).toBe("sql"); + }); + }); + + describe("HTML detection", () => { + it("detects DOCTYPE declarations", () => { + expect(detectLanguage("")).toBe("html"); + }); + + it("detects html tags", () => { + expect(detectLanguage("")).toBe("html"); + }); + + it("detects div tags", () => { + expect(detectLanguage("
bar
")).toBe("html"); + }); + + it("detects span tags", () => { + expect(detectLanguage("text")).toBe("html"); + }); + }); + + describe("JSON detection", () => { + it("detects JSON object syntax", () => { + expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json"); + }); + + it("detects JSON with hyphenated keys", () => { + expect(detectLanguage('{"my-key": "value"}')).toBe("json"); + }); + }); + + describe("YAML detection", () => { + it("detects YAML document separator", () => { + expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml"); + }); + }); + + describe("C detection", () => { + it("detects #include directives", () => { + expect(detectLanguage("#include \nint main() {}")).toBe("c"); + }); + + it("detects #define directives", () => { + expect(detectLanguage("#define MAX 100")).toBe("c"); + }); + + it("detects #ifdef directives", () => { + expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c"); + }); + }); + + describe("Java detection", () => { + it("detects public class declarations", () => { + expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java"); + }); + + it("detects private static methods", () => { + expect(detectLanguage("private static void helper() {}")).toBe("java"); + }); + + it("detects protected interface declarations", () => { + expect(detectLanguage("protected interface Bar {}")).toBe("java"); + }); + }); + + describe("Bash detection", () => { + it("detects shell variable assignments", () => { + expect(detectLanguage("$HOME=/usr/local")).toBe("bash"); + }); + + it("detects variable assignments with underscores", () => { + expect(detectLanguage("$MY_VAR=some_value")).toBe("bash"); + }); + }); + + describe("unknown content", () => { + it("returns null for plain text", () => { + expect(detectLanguage("Hello, world!")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(detectLanguage("")).toBeNull(); + }); + + it("returns null for mathematical expressions", () => { + expect(detectLanguage("1 + 1 = 2")).toBeNull(); + }); + + it("returns null for a markdown heading", () => { + expect(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(formatTimestamp(ts)).toBe("Just now"); + }); + + it("returns 'Just now' for the current moment", () => { + const ts = NOW.toISOString(); + expect(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(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(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(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(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(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(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(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(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(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(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 = 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 = formatTimestamp(ts); + expect(result).not.toContain("ago"); + }); + }); +}); -- 2.52.0 From acebe590c3a71ce251442e4ace7b27a762a019b2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 14:24:41 -0800 Subject: [PATCH 08/22] test: add coverage for messageMode, stateMapper branches, and initStatsListener --- src/lib/stores/clipboard.test.ts | 1 - src/lib/stores/stats.test.ts | 92 +++++++++++++++++++++- src/lib/types/messageMode.test.ts | 125 ++++++++++++++++++++++++++++++ src/lib/utils/stateMapper.test.ts | 24 ++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/lib/types/messageMode.test.ts diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts index eb955c1..34d8f69 100644 --- a/src/lib/stores/clipboard.test.ts +++ b/src/lib/stores/clipboard.test.ts @@ -19,7 +19,6 @@ * - [ ] Search query filters by content, language, and source */ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mirror: detectLanguage from clipboard.ts diff --git a/src/lib/stores/stats.test.ts b/src/lib/stores/stats.test.ts index c1da40b..e2dcce2 100644 --- a/src/lib/stores/stats.test.ts +++ b/src/lib/stores/stats.test.ts @@ -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, @@ -13,6 +15,7 @@ import { getBudgetStatusMessage, getRemainingTokenBudget, getRemainingCostBudget, + initStatsListener, } from "./stats"; import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats"; @@ -34,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 @@ -801,4 +810,85 @@ describe("stats store", () => { 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(); + }); + }); }); diff --git a/src/lib/types/messageMode.test.ts b/src/lib/types/messageMode.test.ts new file mode 100644 index 0000000..bf257de --- /dev/null +++ b/src/lib/types/messageMode.test.ts @@ -0,0 +1,125 @@ +/** + * MessageMode Type Tests + * + * Tests the helper functions exported from messageMode.ts: + * - getMessageMode: looks up a MessageMode by id + * - formatMessageWithMode: prepends a mode prefix to a message + * + * What this module does: + * - Defines the six conversation modes (chat, architect, code, debug, ask, review) + * - Provides a lookup function to find a mode by its id + * - Provides a formatting function that prepends the mode prefix to messages + * + * Manual testing checklist: + * - [ ] Mode selector shows correct names and icons for all six modes + * - [ ] Selecting a mode prefixes the next message with the correct prefix + * - [ ] Switching back to Chat sends messages without any prefix + * - [ ] Sending a message that already has the prefix does not double-prefix it + */ + +import { describe, it, expect } from "vitest"; +import { MESSAGE_MODES, getMessageMode, formatMessageWithMode } from "./messageMode"; + +describe("MESSAGE_MODES", () => { + it("contains all six expected modes", () => { + const ids = MESSAGE_MODES.map((m) => m.id); + expect(ids).toEqual(["chat", "architect", "code", "debug", "ask", "review"]); + }); + + it("each mode has an id, name, description, and icon", () => { + for (const mode of MESSAGE_MODES) { + expect(mode.id).toBeTruthy(); + expect(mode.name).toBeTruthy(); + expect(mode.description).toBeTruthy(); + expect(mode.icon).toBeTruthy(); + } + }); +}); + +describe("getMessageMode", () => { + it("returns the chat mode with no prefix", () => { + const mode = getMessageMode("chat"); + expect(mode).toBeDefined(); + expect(mode?.id).toBe("chat"); + expect(mode?.prefix).toBeUndefined(); + }); + + it("returns the architect mode with its prefix", () => { + const mode = getMessageMode("architect"); + expect(mode?.id).toBe("architect"); + expect(mode?.prefix).toBe("[Architect Mode] "); + }); + + it("returns the code mode with its prefix", () => { + const mode = getMessageMode("code"); + expect(mode?.id).toBe("code"); + expect(mode?.prefix).toBe("[Code Mode] "); + }); + + it("returns the debug mode with its prefix", () => { + const mode = getMessageMode("debug"); + expect(mode?.id).toBe("debug"); + expect(mode?.prefix).toBe("[Debug Mode] "); + }); + + it("returns the ask mode with its prefix", () => { + const mode = getMessageMode("ask"); + expect(mode?.id).toBe("ask"); + expect(mode?.prefix).toBe("[Ask Mode] "); + }); + + it("returns the review mode with its prefix", () => { + const mode = getMessageMode("review"); + expect(mode?.id).toBe("review"); + expect(mode?.prefix).toBe("[Review Mode] "); + }); + + it("returns undefined for an unknown mode id", () => { + expect(getMessageMode("unknown")).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(getMessageMode("")).toBeUndefined(); + }); +}); + +describe("formatMessageWithMode", () => { + it("prepends the prefix for code mode", () => { + expect(formatMessageWithMode("hello", "code")).toBe("[Code Mode] hello"); + }); + + it("prepends the prefix for architect mode", () => { + expect(formatMessageWithMode("design this", "architect")).toBe("[Architect Mode] design this"); + }); + + it("prepends the prefix for debug mode", () => { + expect(formatMessageWithMode("fix this bug", "debug")).toBe("[Debug Mode] fix this bug"); + }); + + it("prepends the prefix for ask mode", () => { + expect(formatMessageWithMode("what is this?", "ask")).toBe("[Ask Mode] what is this?"); + }); + + it("prepends the prefix for review mode", () => { + expect(formatMessageWithMode("review my code", "review")).toBe("[Review Mode] review my code"); + }); + + it("returns the message unchanged in chat mode (no prefix)", () => { + expect(formatMessageWithMode("hello", "chat")).toBe("hello"); + }); + + it("returns the message unchanged for unknown mode ids", () => { + expect(formatMessageWithMode("hello", "unknown")).toBe("hello"); + expect(formatMessageWithMode("hello", "")).toBe("hello"); + }); + + it("does not double-prefix a message already starting with the mode prefix", () => { + expect(formatMessageWithMode("[Code Mode] already prefixed", "code")).toBe( + "[Code Mode] already prefixed" + ); + }); + + it("adds the prefix to an empty string", () => { + expect(formatMessageWithMode("", "code")).toBe("[Code Mode] "); + }); +}); diff --git a/src/lib/utils/stateMapper.test.ts b/src/lib/utils/stateMapper.test.ts index e723330..a985a74 100644 --- a/src/lib/utils/stateMapper.test.ts +++ b/src/lib/utils/stateMapper.test.ts @@ -266,6 +266,30 @@ describe("stateMapper", () => { expect(mapMessageToState(message)).toBeNull(); }); + it("returns null for content_block_start with unrecognised content block type", () => { + const message = { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "input_json_delta" }, + }, + } as unknown as ClaudeStreamMessage; + expect(mapMessageToState(message)).toBeNull(); + }); + + it("returns null for content_block_delta with unrecognised delta type", () => { + const message = { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "{}" }, + }, + } as unknown as ClaudeStreamMessage; + expect(mapMessageToState(message)).toBeNull(); + }); + it("returns null for result with unknown subtype", () => { const message = { type: "result", -- 2.52.0 From 58f53a421b67bfe75cd0a4912773c56c105fce82 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 14:45:22 -0800 Subject: [PATCH 09/22] test: add coverage for configStore methods and derived stores --- src/lib/stores/config.test.ts | 292 +++++++++++++++++++++++++++++++++- 1 file changed, 291 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index acb6904..7453b59 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -1,4 +1,5 @@ -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, @@ -7,6 +8,11 @@ import { applyTheme, applyCustomThemeColors, clearCustomThemeColors, + isDarkTheme, + isStreamerMode, + isCompactMode, + shouldHidePaths, + showThinkingBlocks, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE, @@ -843,4 +849,288 @@ 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); + }); + }); }); -- 2.52.0 From c819adc9ea9a410cafa47a87318074932d22395c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 14:58:49 -0800 Subject: [PATCH 10/22] test: add coverage for drafts, soundPlayer, wslNotificationHelper, and costTrackingStore --- src/lib/notifications/notifications.test.ts | 47 +++++ .../wslNotificationHelper.test.ts | 40 ++++ src/lib/stores/costTracking.test.ts | 197 +++++++++++++++++- src/lib/stores/drafts.test.ts | 10 + 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/lib/notifications/wslNotificationHelper.test.ts diff --git a/src/lib/notifications/notifications.test.ts b/src/lib/notifications/notifications.test.ts index 61152a5..0b5dcd3 100644 --- a/src/lib/notifications/notifications.test.ts +++ b/src/lib/notifications/notifications.test.ts @@ -232,6 +232,53 @@ describe("notifications", () => { // Should not throw await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined(); }); + + it("play warns when audio type is not in the cache", async () => { + const { soundPlayer } = await import("./soundPlayer"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + soundPlayer.setEnabled(true); + await soundPlayer.play("nonexistent" as NotificationType); + + expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent"); + warnSpy.mockRestore(); + }); + + it("play catches errors from audio playback", async () => { + vi.resetModules(); + + class FailingAudio { + volume = 1; + preload = "auto"; + + cloneNode() { + const clone = new FailingAudio(); + clone.volume = this.volume; + return clone; + } + + async play(): Promise { + throw new Error("Playback blocked by browser"); + } + } + + const originalAudio = globalThis.Audio; + globalThis.Audio = FailingAudio as unknown as typeof Audio; + + const { soundPlayer: freshPlayer } = await import("./soundPlayer"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + freshPlayer.setEnabled(true); + await freshPlayer.play(NotificationType.SUCCESS); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to play notification sound:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + globalThis.Audio = originalAudio; + }); }); describe("NotificationManager class", () => { diff --git a/src/lib/notifications/wslNotificationHelper.test.ts b/src/lib/notifications/wslNotificationHelper.test.ts new file mode 100644 index 0000000..fe872b4 --- /dev/null +++ b/src/lib/notifications/wslNotificationHelper.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { platform } from "@tauri-apps/plugin-os"; +import { invoke } from "@tauri-apps/api/core"; +import { sendWSLNotification } from "./wslNotificationHelper"; + +// platform() is mocked in vitest.setup.ts to return "linux" by default + +describe("sendWSLNotification", () => { + it("invokes send_windows_notification when platform is windows", async () => { + vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited>); + + await sendWSLNotification("Test Title", "Test body"); + + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: "Test Title", + body: "Test body", + }); + }); + + it("does not invoke on non-Windows platforms", async () => { + // Default mock returns "linux" + await sendWSLNotification("Test Title", "Test body"); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("handles invoke errors gracefully and logs them", async () => { + vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited>); + vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await sendWSLNotification("Title", "Body"); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to send Windows notification:", + expect.any(Error) + ); + errorSpy.mockRestore(); + }); +}); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts index a992a82..5ca17e7 100644 --- a/src/lib/stores/costTracking.test.ts +++ b/src/lib/stores/costTracking.test.ts @@ -1,12 +1,23 @@ -import { describe, it, expect } from "vitest"; +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"); @@ -95,3 +106,187 @@ describe("getAlertMessage", () => { 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); + }); +}); diff --git a/src/lib/stores/drafts.test.ts b/src/lib/stores/drafts.test.ts index 4771ad6..1790f1f 100644 --- a/src/lib/stores/drafts.test.ts +++ b/src/lib/stores/drafts.test.ts @@ -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(); + }); }); }); -- 2.52.0 From a5fb22bd237ea5c2bd793e7f2cd724c16f3f6a6d Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 15:17:29 -0800 Subject: [PATCH 11/22] test: add coverage for terminalNotifier, testNotifications, and claude store --- .../notifications/terminalNotifier.test.ts | 64 ++++++++ .../notifications/testNotifications.test.ts | 58 ++++++++ src/lib/stores/claude.test.ts | 139 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/lib/notifications/terminalNotifier.test.ts create mode 100644 src/lib/notifications/testNotifications.test.ts create mode 100644 src/lib/stores/claude.test.ts diff --git a/src/lib/notifications/terminalNotifier.test.ts b/src/lib/notifications/terminalNotifier.test.ts new file mode 100644 index 0000000..24a177e --- /dev/null +++ b/src/lib/notifications/terminalNotifier.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotificationType } from "./types"; +import { claudeStore } from "$lib/stores/claude"; +import { sendTerminalNotification } from "./terminalNotifier"; + +vi.mock("$lib/stores/claude", () => ({ + claudeStore: { + addLine: vi.fn(), + }, +})); + +describe("sendTerminalNotification", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds a system line for success type with sparkle emoji", () => { + sendTerminalNotification(NotificationType.SUCCESS); + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨")); + }); + + it("adds a system line for error type with cross emoji", () => { + sendTerminalNotification(NotificationType.ERROR); + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌")); + }); + + it("adds a system line for permission type with lock emoji", () => { + sendTerminalNotification(NotificationType.PERMISSION); + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐")); + }); + + it("adds a system line for connection type with link emoji", () => { + sendTerminalNotification(NotificationType.CONNECTION); + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗")); + }); + + it("adds a system line for task_start type with rocket emoji", () => { + sendTerminalNotification(NotificationType.TASK_START); + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀")); + }); + + it("includes the optional message in the notification", () => { + sendTerminalNotification(NotificationType.SUCCESS, "Custom message text"); + + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("Custom message text") + ); + }); + + it("includes the sound phrase as the notification title", () => { + sendTerminalNotification(NotificationType.SUCCESS); + + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("I'm done!") + ); + }); +}); diff --git a/src/lib/notifications/testNotifications.test.ts b/src/lib/notifications/testNotifications.test.ts new file mode 100644 index 0000000..f989df3 --- /dev/null +++ b/src/lib/notifications/testNotifications.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { testAllNotifications } from "./testNotifications"; + +vi.mock("./notificationManager", () => ({ + notificationManager: { + notifySuccess: vi.fn().mockResolvedValue(undefined), + notifyError: vi.fn().mockResolvedValue(undefined), + notifyPermission: vi.fn().mockResolvedValue(undefined), + notifyConnection: vi.fn().mockResolvedValue(undefined), + notifyTaskStart: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe("testNotifications", () => { + describe("window assignment", () => { + it("assigns testAllNotifications to window.testNotifications", async () => { + // The module-level if block runs on import — reimport to ensure it ran + await import("./testNotifications"); + + expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe( + testAllNotifications + ); + }); + }); + + describe("testAllNotifications", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("is an async function", () => { + expect(typeof testAllNotifications).toBe("function"); + }); + + it("schedules all five notification type calls", async () => { + const { notificationManager } = await import("./notificationManager"); + + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await testAllNotifications(); + await vi.runAllTimersAsync(); + + expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!"); + expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!"); + expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!"); + expect(notificationManager.notifyConnection).toHaveBeenCalledWith( + "Test connection established!" + ); + expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!"); + + consoleLogSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/stores/claude.test.ts b/src/lib/stores/claude.test.ts new file mode 100644 index 0000000..c8ece4e --- /dev/null +++ b/src/lib/stores/claude.test.ts @@ -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); + }); +}); -- 2.52.0 From ea53bf0d4d4a5127b6adaffc8c37eac0418fe38b Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 15:48:55 -0800 Subject: [PATCH 12/22] test: add coverage for debugConsole, notificationManager, and achievements --- .../notifications/notificationManager.test.ts | 188 ++++++++++++ src/lib/stores/achievements.test.ts | 185 ++++++++++++ src/lib/stores/debugConsole.test.ts | 282 ++++++++++++++++++ 3 files changed, 655 insertions(+) create mode 100644 src/lib/notifications/notificationManager.test.ts create mode 100644 src/lib/stores/achievements.test.ts create mode 100644 src/lib/stores/debugConsole.test.ts diff --git a/src/lib/notifications/notificationManager.test.ts b/src/lib/notifications/notificationManager.test.ts new file mode 100644 index 0000000..187516e --- /dev/null +++ b/src/lib/notifications/notificationManager.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { isPermissionGranted } from "@tauri-apps/plugin-notification"; +import { setMockInvokeResult } from "../../../vitest.setup"; +import { notificationManager } from "./notificationManager"; +import { sendTerminalNotification } from "./terminalNotifier"; +import { NotificationType, NOTIFICATION_SOUNDS } from "./types"; + +vi.mock("./soundPlayer", () => ({ + soundPlayer: { play: vi.fn().mockResolvedValue(undefined) }, +})); + +vi.mock("./terminalNotifier", () => ({ + sendTerminalNotification: vi.fn(), +})); + +describe("NotificationManager", () => { + let consoleSpy: ReturnType; + let consoleWarnSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe("notify()", () => { + it("calls the first notification method by default", async () => { + await notificationManager.notify(NotificationType.SUCCESS); + expect(invoke).toHaveBeenCalledWith( + "send_windows_notification", + expect.objectContaining({ + title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase, + }) + ); + }); + + it("passes a custom message to the notification", async () => { + await notificationManager.notify(NotificationType.ERROR, "Custom error message"); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase, + body: "Custom error message", + }); + }); + + it("falls back to the next method when the first fails", async () => { + setMockInvokeResult("send_windows_notification", new Error("Method 1 failed")); + await notificationManager.notify(NotificationType.SUCCESS); + expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object)); + }); + + it("falls back to terminal notification when all methods fail", async () => { + setMockInvokeResult("send_windows_notification", new Error("failed")); + setMockInvokeResult("send_windows_toast", new Error("failed")); + setMockInvokeResult("send_wsl_notification", new Error("failed")); + setMockInvokeResult("send_notify_send", new Error("failed")); + vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed")); + + await notificationManager.notify(NotificationType.SUCCESS); + + expect(sendTerminalNotification).toHaveBeenCalledWith( + NotificationType.SUCCESS, + "Task completed successfully!" + ); + }); + + it("uses the default SUCCESS message when none is provided", async () => { + await notificationManager.notify(NotificationType.SUCCESS); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Task completed successfully!", + }); + }); + + it("uses the default ERROR message", async () => { + await notificationManager.notify(NotificationType.ERROR); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Something went wrong...", + }); + }); + + it("uses the default PERMISSION message", async () => { + await notificationManager.notify(NotificationType.PERMISSION); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Permission needed to continue", + }); + }); + + it("uses the default CONNECTION message", async () => { + await notificationManager.notify(NotificationType.CONNECTION); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Successfully connected to Claude Code", + }); + }); + + it("uses the default TASK_START message", async () => { + await notificationManager.notify(NotificationType.TASK_START); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Starting task...", + }); + }); + + it("uses the default COST_ALERT message", async () => { + await notificationManager.notify(NotificationType.COST_ALERT); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "You've exceeded your cost threshold!", + }); + }); + + it("uses the fallback default message for unhandled types", async () => { + await notificationManager.notify(NotificationType.ACHIEVEMENT); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "Notification", + }); + }); + }); + + describe("helper methods", () => { + it("notifySuccess calls notify with SUCCESS type and default message", async () => { + await notificationManager.notifySuccess(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase, + body: "Task completed successfully!", + }); + }); + + it("notifySuccess passes a custom message", async () => { + await notificationManager.notifySuccess("All done!"); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: expect.any(String), + body: "All done!", + }); + }); + + it("notifyError calls notify with ERROR type", async () => { + await notificationManager.notifyError(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase, + body: "Something went wrong...", + }); + }); + + it("notifyPermission calls notify with PERMISSION type", async () => { + await notificationManager.notifyPermission(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase, + body: "Permission needed to continue", + }); + }); + + it("notifyConnection calls notify with CONNECTION type", async () => { + await notificationManager.notifyConnection(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase, + body: "Successfully connected to Claude Code", + }); + }); + + it("notifyTaskStart calls notify with TASK_START type", async () => { + await notificationManager.notifyTaskStart(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase, + body: "Starting task...", + }); + }); + + it("notifyCostAlert calls notify with COST_ALERT type", async () => { + await notificationManager.notifyCostAlert(); + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase, + body: "You've exceeded your cost threshold!", + }); + }); + }); +}); diff --git a/src/lib/stores/achievements.test.ts b/src/lib/stores/achievements.test.ts new file mode 100644 index 0000000..68ebf58 --- /dev/null +++ b/src/lib/stores/achievements.test.ts @@ -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(); + }); +}); diff --git a/src/lib/stores/debugConsole.test.ts b/src/lib/stores/debugConsole.test.ts new file mode 100644 index 0000000..dfd80d5 --- /dev/null +++ b/src/lib/stores/debugConsole.test.ts @@ -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); + }); +}); -- 2.52.0 From 55ad039451a640925c4e187c4bf43701e1022027 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 16:06:35 -0800 Subject: [PATCH 13/22] test: add coverage for slashCommands execute functions and notificationManager Method 4 --- src/lib/commands/slashCommands.test.ts | 302 +++++++++++++++++- .../notifications/notificationManager.test.ts | 54 ++++ 2 files changed, 355 insertions(+), 1 deletion(-) diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 74c7de5..f2e16b9 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -1,4 +1,9 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; +import { claudeStore } from "$lib/stores/claude"; +import { searchState } from "$lib/stores/search"; +import { characterState } from "$lib/stores/character"; import { slashCommands, parseSlashCommand, @@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({ vi.mock("$lib/tauri", () => ({ setSkipNextGreeting: vi.fn(), + updateDiscordRpc: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("$lib/stores/conversations", () => ({ + conversationsStore: { + activeConversation: { subscribe: vi.fn() }, + }, +})); + +vi.mock("$lib/stores/config", () => ({ + configStore: { + getConfig: vi.fn().mockReturnValue({ + auto_granted_tools: [], + model: "claude-sonnet", + api_key: null, + custom_instructions: null, + mcp_servers_json: null, + use_worktree: false, + disable_1m_context: false, + max_output_tokens: null, + }), + }, })); vi.mock("$lib/stores/search", () => ({ @@ -415,4 +442,277 @@ describe("slashCommands", () => { expect(result).toBeUndefined(); }); }); + + describe("command execute functions", () => { + let getMock: ReturnType; + let invokeMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + getMock = vi.mocked(get); + invokeMock = vi.mocked(invoke); + }); + + describe("/clear execute", () => { + it("clears terminal and shows confirmation message", () => { + const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!; + clearCmd.execute(""); + expect(claudeStore.clearTerminal).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared"); + }); + }); + + describe("/help execute", () => { + it("shows available commands header", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("Available commands:") + ); + }); + + it("includes all command usages in help text", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0]; + const helpText = callArgs[1] as string; + expect(helpText).toContain("/cd"); + expect(helpText).toContain("/clear"); + expect(helpText).toContain("/help"); + expect(helpText).toContain("/search"); + expect(helpText).toContain("/new"); + expect(helpText).toContain("/summarise"); + expect(helpText).toContain("/skill"); + }); + + it("includes command descriptions in help text", () => { + const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!; + helpCmd.execute(""); + const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0]; + const helpText = callArgs[1] as string; + expect(helpText).toContain("Change the working directory"); + expect(helpText).toContain("Show available slash commands"); + }); + }); + + describe("/search execute", () => { + it("clears search when called with empty args", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(""); + expect(searchState.clear).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared"); + }); + + it("clears search when called with whitespace-only args", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(" "); + expect(searchState.clear).toHaveBeenCalledWith(); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared"); + }); + + it("sets query when called with a search term", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute("hello world"); + expect(searchState.setQuery).toHaveBeenCalledWith("hello world"); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"'); + }); + + it("trims whitespace from query before setting", () => { + const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!; + searchCmd.execute(" hello "); + expect(searchState.setQuery).toHaveBeenCalledWith("hello"); + expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"'); + }); + }); + + describe("/cd execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/some/path"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("shows current directory when called with empty args", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Current directory: /home/naomi/code" + ); + }); + + it("shows current directory when called with whitespace-only args", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute(" "); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Current directory: /home/naomi/code" + ); + }); + + it("validates path and changes directory on success", async () => { + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null); + invokeMock.mockResolvedValue("/validated/path"); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/new/path"); + expect(invokeMock).toHaveBeenCalledWith( + "validate_directory", + expect.objectContaining({ path: "/new/path" }) + ); + }); + + it("shows error when directory change fails", async () => { + getMock.mockReturnValueOnce("conv-123"); + invokeMock.mockRejectedValueOnce(new Error("invalid path")); + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + await cdCmd.execute("/bad/path"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to change directory:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + + describe("/new execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + await newCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("shows error when starting new conversation fails", async () => { + getMock.mockReturnValueOnce("conv-123"); + invokeMock.mockRejectedValueOnce(new Error("invoke failed")); + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + await newCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to start new conversation:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + + describe("/summarise execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("sends a summary prompt when there is an active conversation", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Requesting conversation summary..." + ); + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ conversationId: "conv-123" }) + ); + }); + + it("shows error when send_prompt invoke fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("network error")); + const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!; + await summariseCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to request summary:") + ); + }); + }); + + describe("/skill execute", () => { + it("shows error when no active conversation", async () => { + getMock.mockReturnValue(null); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation"); + }); + + it("lists available skills when called with no name", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(invokeMock).toHaveBeenCalledWith("list_skills"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("onboard-mentee") + ); + }); + + it("shows empty message when no skills are found", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue([]); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + expect.stringContaining("No skills found") + ); + }); + + it("invokes skill when called with a name and no data", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Invoking skill: onboard-mentee" + ); + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ conversationId: "conv-123" }) + ); + }); + + it("invokes skill with additional data in the prompt", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockResolvedValue(undefined); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee some extra data"); + expect(invokeMock).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: expect.stringContaining("some extra data"), + }); + }); + + it("shows error when listing skills fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("list error")); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute(""); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to list skills:") + ); + }); + + it("shows error and resets character state when invoking skill fails", async () => { + getMock.mockReturnValue("conv-123"); + invokeMock.mockRejectedValue(new Error("invoke error")); + const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!; + await skillCmd.execute("onboard-mentee"); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "error", + expect.stringContaining("Failed to invoke skill:") + ); + expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); + }); + }); + }); }); diff --git a/src/lib/notifications/notificationManager.test.ts b/src/lib/notifications/notificationManager.test.ts index 187516e..7046235 100644 --- a/src/lib/notifications/notificationManager.test.ts +++ b/src/lib/notifications/notificationManager.test.ts @@ -185,4 +185,58 @@ describe("NotificationManager", () => { }); }); }); + + describe("Tauri plugin notification method (Method 4)", () => { + it("sends notification when permission is already granted", async () => { + const { isPermissionGranted, sendNotification } = + await import("@tauri-apps/plugin-notification"); + setMockInvokeResult("send_windows_notification", new Error("failed")); + setMockInvokeResult("send_windows_toast", new Error("failed")); + setMockInvokeResult("send_wsl_notification", new Error("failed")); + vi.mocked(isPermissionGranted).mockResolvedValueOnce(true); + + await notificationManager.notify(NotificationType.SUCCESS); + + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase, + }) + ); + }); + + it("requests permission and sends notification when not yet granted", async () => { + const { isPermissionGranted, requestPermission, sendNotification } = + await import("@tauri-apps/plugin-notification"); + setMockInvokeResult("send_windows_notification", new Error("failed")); + setMockInvokeResult("send_windows_toast", new Error("failed")); + setMockInvokeResult("send_wsl_notification", new Error("failed")); + vi.mocked(isPermissionGranted).mockResolvedValueOnce(false); + vi.mocked(requestPermission).mockResolvedValueOnce("granted"); + + await notificationManager.notify(NotificationType.SUCCESS); + + expect(requestPermission).toHaveBeenCalledWith(); + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ body: "Task completed successfully!" }) + ); + }); + + it("falls through to next method when permission is denied", async () => { + const { isPermissionGranted, requestPermission } = + await import("@tauri-apps/plugin-notification"); + setMockInvokeResult("send_windows_notification", new Error("failed")); + setMockInvokeResult("send_windows_toast", new Error("failed")); + setMockInvokeResult("send_wsl_notification", new Error("failed")); + vi.mocked(isPermissionGranted).mockResolvedValueOnce(false); + vi.mocked(requestPermission).mockResolvedValueOnce("denied"); + setMockInvokeResult("send_notify_send", new Error("failed")); + + await notificationManager.notify(NotificationType.SUCCESS); + + expect(sendTerminalNotification).toHaveBeenCalledWith( + NotificationType.SUCCESS, + "Task completed successfully!" + ); + }); + }); }); -- 2.52.0 From 3b8a0934216d0972afb5cd33762f136a93e527dd Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 16:28:34 -0800 Subject: [PATCH 14/22] test: fix clipboard coverage (real imports) and add cd/new success paths --- src/lib/commands/slashCommands.test.ts | 150 +++++++- src/lib/stores/clipboard.test.ts | 493 +++++++++++++++++++------ 2 files changed, 528 insertions(+), 115 deletions(-) diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index f2e16b9..af08a7b 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { get } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; @@ -714,5 +714,153 @@ describe("slashCommands", () => { expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000); }); }); + + describe("/cd success path", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("changes directory and shows success message", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue(""); + invokeMock + .mockResolvedValueOnce("/new/path") // validate_directory + .mockResolvedValueOnce(undefined) // stop_claude + .mockResolvedValueOnce(undefined); // start_claude + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Changed directory to: /new/path" + ); + expect(characterState.setState).toHaveBeenCalledWith("idle"); + }); + + it("sends context restoration message when conversation history exists", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue( + "previous conversation history" + ); + invokeMock + .mockResolvedValueOnce("/new/path") // validate_directory + .mockResolvedValueOnce(undefined) // stop_claude + .mockResolvedValueOnce(undefined) // start_claude + .mockResolvedValueOnce(undefined); // send_prompt + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(invokeMock).toHaveBeenCalledWith( + "send_prompt", + expect.objectContaining({ + message: expect.stringContaining("previous conversation history"), + }) + ); + expect(claudeStore.addLine).toHaveBeenCalledWith( + "system", + "Changed directory to: /new/path" + ); + }); + + it("calls updateDiscordRpc when activeConversation is available", async () => { + const activeConv = { + name: "Test Conversation", + model: "claude-sonnet", + startedAt: new Date("2026-03-03T12:00:00Z"), + grantedTools: new Set(), + }; + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory) + .mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation) + vi.mocked(claudeStore.getConversationHistory).mockReturnValue(""); + invokeMock + .mockResolvedValueOnce("/new/path") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const { updateDiscordRpc } = await import("$lib/tauri"); + + const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!; + const promise = cdCmd.execute("/new/path"); + await vi.runAllTimersAsync(); + await promise; + + expect(updateDiscordRpc).toHaveBeenCalledWith( + "Test Conversation", + expect.any(String), + expect.any(Date) + ); + }); + }); + + describe("/new success path", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts a new conversation and shows success message", async () => { + getMock + .mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId) + .mockReturnValueOnce(null); // get(conversationsStore.activeConversation) + invokeMock + .mockResolvedValueOnce("/working/dir") // get_working_directory + .mockResolvedValueOnce(undefined) // interrupt_claude + .mockResolvedValueOnce(undefined); // start_claude + + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + const promise = newCmd.execute(""); + await vi.runAllTimersAsync(); + await promise; + + expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!"); + expect(characterState.setState).toHaveBeenCalledWith("idle"); + }); + + it("calls updateDiscordRpc when activeConversation is available", async () => { + const activeConv = { + name: "My Conv", + model: "claude-sonnet", + startedAt: new Date("2026-03-03T12:00:00Z"), + grantedTools: new Set(["tool1"]), + }; + getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv); + invokeMock + .mockResolvedValueOnce("/working/dir") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const { updateDiscordRpc } = await import("$lib/tauri"); + + const newCmd = slashCommands.find((cmd) => cmd.name === "new")!; + const promise = newCmd.execute(""); + await vi.runAllTimersAsync(); + await promise; + + expect(updateDiscordRpc).toHaveBeenCalledWith( + "My Conv", + expect.any(String), + expect.any(Date) + ); + }); + }); }); }); diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts index 34d8f69..167650f 100644 --- a/src/lib/stores/clipboard.test.ts +++ b/src/lib/stores/clipboard.test.ts @@ -1,259 +1,212 @@ /** * Clipboard Store Tests * - * Tests the pure helper functions from the clipboard store: + * 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 - * - * What this store does: - * - Maintains a history of clipboard entries (code snippets) - * - Auto-detects the language of captured content - * - Supports filtering by language and search query - * - Pinned entries persist across clear operations - * - * Manual testing checklist: - * - [ ] Clipboard entries appear when code is copied during a session - * - [ ] Language is detected and labelled correctly - * - [ ] Pinned entries survive "Clear history" - * - [ ] Language filter dropdown shows only languages present in history - * - [ ] Search query filters by content, language, and source + * - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc. + * - Derived stores: filteredEntries (language + search filtering), languages */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -// Mirror: detectLanguage from clipboard.ts -function detectLanguage(content: string): string | null { - const patterns: [RegExp, string][] = [ - [/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"], - [/^(def|class|import|from|if __name__|async def)\s/m, "python"], - [/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"], - [/^(package|import|func|type|var|const)\s/m, "go"], - [/<\?php/m, "php"], - [/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"], - [/^ { describe("TypeScript detection", () => { it("detects import statements", () => { - expect(detectLanguage("import React from 'react';")).toBe("typescript"); + expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript"); }); it("detects export statements", () => { - expect(detectLanguage("export function foo() {}")).toBe("typescript"); + expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript"); }); it("detects const declarations", () => { - expect(detectLanguage("const x = 1;")).toBe("typescript"); + expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript"); }); it("detects interface declarations", () => { - expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript"); + expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe( + "typescript" + ); }); it("detects type aliases", () => { - expect(detectLanguage("type MyType = string | number;")).toBe("typescript"); + expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript"); }); }); describe("Python detection", () => { it("detects def statements", () => { - expect(detectLanguage("def foo():\n pass")).toBe("python"); + expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python"); }); it("detects async def statements", () => { - expect(detectLanguage("async def bar():\n pass")).toBe("python"); + expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python"); }); it("detects from imports", () => { - expect(detectLanguage("from os import path")).toBe("python"); + expect(clipboardStore.detectLanguage("from os import path")).toBe("python"); }); it("detects the __name__ guard", () => { - expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python"); + expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python"); }); }); describe("Rust detection", () => { it("detects fn declarations", () => { - expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust"); + expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust"); }); it("detects impl blocks", () => { - expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust"); + expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe( + "rust" + ); }); it("detects struct declarations", () => { - expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust"); + expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust"); }); it("detects enum declarations", () => { - expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust"); + expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust"); }); it("detects mod declarations", () => { - expect(detectLanguage("mod utils;")).toBe("rust"); + expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust"); }); it("detects pub visibility", () => { - expect(detectLanguage("pub fn exported() {}")).toBe("rust"); + expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust"); }); }); describe("Go detection", () => { it("detects package declarations", () => { - expect(detectLanguage("package main")).toBe("go"); + expect(clipboardStore.detectLanguage("package main")).toBe("go"); }); it("detects func declarations", () => { - expect(detectLanguage("func main() {}")).toBe("go"); + expect(clipboardStore.detectLanguage("func main() {}")).toBe("go"); }); }); describe("PHP detection", () => { it("detects the PHP open tag", () => { - expect(detectLanguage(" { it("detects SELECT statements", () => { - expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql"); + expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql"); }); it("detects INSERT statements", () => { - expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql"); + expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe( + "sql" + ); }); it("detects CREATE statements", () => { - expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql"); + expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql"); }); it("detects SQL case-insensitively", () => { - expect(detectLanguage("select * from users")).toBe("sql"); + expect(clipboardStore.detectLanguage("select * from users")).toBe("sql"); }); }); describe("HTML detection", () => { it("detects DOCTYPE declarations", () => { - expect(detectLanguage("")).toBe("html"); + expect(clipboardStore.detectLanguage("")).toBe("html"); }); it("detects html tags", () => { - expect(detectLanguage("")).toBe("html"); + expect(clipboardStore.detectLanguage("")).toBe("html"); }); it("detects div tags", () => { - expect(detectLanguage("
bar
")).toBe("html"); + expect(clipboardStore.detectLanguage("
bar
")).toBe("html"); }); it("detects span tags", () => { - expect(detectLanguage("text")).toBe("html"); + expect(clipboardStore.detectLanguage("text")).toBe("html"); }); }); describe("JSON detection", () => { it("detects JSON object syntax", () => { - expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json"); + expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json"); }); it("detects JSON with hyphenated keys", () => { - expect(detectLanguage('{"my-key": "value"}')).toBe("json"); + expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json"); }); }); describe("YAML detection", () => { it("detects YAML document separator", () => { - expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml"); + expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml"); }); }); describe("C detection", () => { it("detects #include directives", () => { - expect(detectLanguage("#include \nint main() {}")).toBe("c"); + expect(clipboardStore.detectLanguage("#include \nint main() {}")).toBe("c"); }); it("detects #define directives", () => { - expect(detectLanguage("#define MAX 100")).toBe("c"); + expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c"); }); it("detects #ifdef directives", () => { - expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c"); + expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c"); }); }); describe("Java detection", () => { it("detects public class declarations", () => { - expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java"); + expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java"); }); it("detects private static methods", () => { - expect(detectLanguage("private static void helper() {}")).toBe("java"); + expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java"); }); it("detects protected interface declarations", () => { - expect(detectLanguage("protected interface Bar {}")).toBe("java"); + expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java"); }); }); describe("Bash detection", () => { it("detects shell variable assignments", () => { - expect(detectLanguage("$HOME=/usr/local")).toBe("bash"); + expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash"); }); it("detects variable assignments with underscores", () => { - expect(detectLanguage("$MY_VAR=some_value")).toBe("bash"); + expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash"); }); }); describe("unknown content", () => { it("returns null for plain text", () => { - expect(detectLanguage("Hello, world!")).toBeNull(); + expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull(); }); it("returns null for empty string", () => { - expect(detectLanguage("")).toBeNull(); + expect(clipboardStore.detectLanguage("")).toBeNull(); }); it("returns null for mathematical expressions", () => { - expect(detectLanguage("1 + 1 = 2")).toBeNull(); + expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull(); }); it("returns null for a markdown heading", () => { - expect(detectLanguage("# My Heading\nSome text")).toBeNull(); + expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull(); }); }); }); @@ -273,75 +226,75 @@ describe("formatTimestamp", () => { 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(formatTimestamp(ts)).toBe("Just now"); + expect(clipboardStore.formatTimestamp(ts)).toBe("Just now"); }); it("returns 'Just now' for the current moment", () => { const ts = NOW.toISOString(); - expect(formatTimestamp(ts)).toBe("Just now"); + 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(formatTimestamp(ts)).toBe("Just now"); + 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(formatTimestamp(ts)).toBe("1m ago"); + 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(formatTimestamp(ts)).toBe("5m ago"); + 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(formatTimestamp(ts)).toBe("59m ago"); + 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(formatTimestamp(ts)).toBe("1h ago"); + 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(formatTimestamp(ts)).toBe("2h ago"); + 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(formatTimestamp(ts)).toBe("23h ago"); + 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(formatTimestamp(ts)).toBe("1d ago"); + 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(formatTimestamp(ts)).toBe("3d ago"); + 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(formatTimestamp(ts)).toBe("6d ago"); + 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 = formatTimestamp(ts); + const result = clipboardStore.formatTimestamp(ts); // Should not be a relative time string expect(result).not.toContain("m ago"); expect(result).not.toContain("h ago"); @@ -351,8 +304,320 @@ describe("formatTimestamp", () => { 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 = formatTimestamp(ts); + const result = clipboardStore.formatTimestamp(ts); expect(result).not.toContain("ago"); }); }); }); + +describe("clipboardStore - derived stores", () => { + const makeEntry = (overrides: Record = {}) => ({ + 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(); + }); + }); + + 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("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(); + }); + }); +}); -- 2.52.0 From 16f92e22b95903633d0a32497c21cb8210937568 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 16:41:06 -0800 Subject: [PATCH 15/22] test: add comprehensive sessions store coverage (escapeHtml, generateHtmlExport, all exports) --- src/lib/stores/sessions.test.ts | 515 ++++++++++++++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 src/lib/stores/sessions.test.ts diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts new file mode 100644 index 0000000..810c7e5 --- /dev/null +++ b/src/lib/stores/sessions.test.ts @@ -0,0 +1,515 @@ +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 => ({ + 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"); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(""); + 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: "" })); + 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(""); + }); + + 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: '', + 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("