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(); + }); + }); +});