diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts index 167650f..1671a97 100644 --- a/src/lib/stores/clipboard.test.ts +++ b/src/lib/stores/clipboard.test.ts @@ -552,6 +552,48 @@ describe("clipboardStore - store actions", () => { expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error)); consoleErrorSpy.mockRestore(); }); + + it("exercises the unpinned-before-pinned sort branch", async () => { + // Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144 + const e1 = { ...mockEntry, id: "e1", is_pinned: false }; + const e2 = { ...mockEntry, id: "e2", is_pinned: false }; + const e3 = { ...mockEntry, id: "e3", is_pinned: false }; + clipboardStore.entries.set([e1, e2, e3]); + const pinned = { ...e2, is_pinned: true }; + setMockInvokeResult("toggle_pin_clipboard_entry", pinned); + await clipboardStore.togglePin("e2"); + expect(get(clipboardStore.entries)[0].id).toBe("e2"); + }); + + it("sorts same-pin-status entries by timestamp descending", async () => { + // Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp + const older = { + ...mockEntry, + id: "older", + is_pinned: false, + timestamp: "2026-01-01T00:00:00.000Z", + }; + const newer = { + ...mockEntry, + id: "newer", + is_pinned: false, + timestamp: "2026-01-02T00:00:00.000Z", + }; + const pinned = { + ...mockEntry, + id: "pinned", + is_pinned: false, + timestamp: "2026-01-03T00:00:00.000Z", + }; + clipboardStore.entries.set([older, newer, pinned]); + const pinnedResult = { ...pinned, is_pinned: true }; + setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult); + await clipboardStore.togglePin("pinned"); + const entries = get(clipboardStore.entries); + expect(entries[0].id).toBe("pinned"); + expect(entries[1].id).toBe("newer"); + expect(entries[2].id).toBe("older"); + }); }); describe("clearHistory", () => { @@ -587,6 +629,16 @@ describe("clipboardStore - store actions", () => { expect(get(clipboardStore.entries)[0].language).toBe("javascript"); }); + it("leaves non-matching entries unchanged", async () => { + const other = { ...mockEntry, id: "other", language: "python" }; + clipboardStore.entries.set([mockEntry, other]); + const updated = { ...mockEntry, language: "javascript" }; + setMockInvokeResult("update_clipboard_language", updated); + await clipboardStore.updateLanguage("entry-1", "javascript"); + const entries = get(clipboardStore.entries); + expect(entries[1].language).toBe("python"); + }); + it("handles errors gracefully", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("update_clipboard_language", new Error("Failed")); diff --git a/src/lib/stores/editor.test.ts b/src/lib/stores/editor.test.ts index 0154423..5aca84e 100644 --- a/src/lib/stores/editor.test.ts +++ b/src/lib/stores/editor.test.ts @@ -360,3 +360,280 @@ describe("editorStore - initializeFileTree", () => { consoleErrorSpy.mockRestore(); }); }); + +describe("editorStore - toggleDirectory", () => { + beforeEach(async () => { + const entries = [ + { + path: "/path/dir", + name: "dir", + isDirectory: true, + isExpanded: false, + children: undefined, + }, + ]; + setMockInvokeResult("list_directory", entries); + await editorStore.initializeFileTree("/path"); + }); + + it("does nothing when entry is not a directory", async () => { + const nonDirEntry = { + path: "/path/file.ts", + name: "file.ts", + isDirectory: false, + }; + const treeBefore = get(editorStore.fileTree); + await editorStore.toggleDirectory(nonDirEntry); + expect(get(editorStore.fileTree)).toEqual(treeBefore); + }); + + it("expands a collapsed directory and loads children asynchronously", async () => { + const children = [ + { path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", children); + + const dirEntry = get(editorStore.fileTree)[0]; + await editorStore.toggleDirectory(dirEntry); + + const updatedEntry = get(editorStore.fileTree)[0]; + expect(updatedEntry.isExpanded).toBe(true); + expect(updatedEntry.children).toEqual(children); + expect(updatedEntry.isLoading).toBe(false); + }); + + it("collapses an expanded directory", async () => { + const children = [ + { path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", children); + + // Expand first + const dirEntry = get(editorStore.fileTree)[0]; + await editorStore.toggleDirectory(dirEntry); + expect(get(editorStore.fileTree)[0].isExpanded).toBe(true); + + // Now collapse + const expandedEntry = get(editorStore.fileTree)[0]; + await editorStore.toggleDirectory(expandedEntry); + expect(get(editorStore.fileTree)[0].isExpanded).toBe(false); + }); + + it("expands a directory that already has children without reloading", async () => { + // Re-init with a directory that already has children + const entries = [ + { + path: "/path/dir", + name: "dir", + isDirectory: true, + isExpanded: false, + children: [ + { + path: "/path/dir/child.ts", + name: "child.ts", + isDirectory: false, + children: undefined, + }, + ], + }, + ]; + setMockInvokeResult("list_directory", entries); + await editorStore.initializeFileTree("/path"); + + // Toggle should expand without loading (children already present) + const dirEntry = get(editorStore.fileTree)[0]; + await editorStore.toggleDirectory(dirEntry); + + const updatedEntry = get(editorStore.fileTree)[0]; + expect(updatedEntry.isExpanded).toBe(true); + expect(updatedEntry.children).toHaveLength(1); + }); + + it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => { + // Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file + const entries = [ + { + path: "/path/dir", + name: "dir", + isDirectory: true, + isExpanded: false, + children: undefined, + }, + { path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", entries); + await editorStore.initializeFileTree("/path"); + + const children: never[] = []; + setMockInvokeResult("list_directory", children); + const dirEntry = get(editorStore.fileTree)[0]; + await editorStore.toggleDirectory(dirEntry); + + // The sibling file should remain unchanged + expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts"); + expect(get(editorStore.fileTree)[0].isExpanded).toBe(true); + }); + + it("handles nested directory toggle — exercises recursive updateTree branches", async () => { + // Tree: outer_dir → [inner_dir (no children), sibling_file] + // Toggling inner_dir covers the recursive path through outer's children + const entries = [ + { + path: "/path/outer", + name: "outer", + isDirectory: true, + isExpanded: true, + children: [ + { + path: "/path/outer/inner", + name: "inner", + isDirectory: true, + isExpanded: false, + children: undefined, + }, + { + path: "/path/outer/sibling.ts", + name: "sibling.ts", + isDirectory: false, + children: undefined, + }, + ], + }, + ]; + setMockInvokeResult("list_directory", entries); + await editorStore.initializeFileTree("/path"); + + const innerChildren = [ + { + path: "/path/outer/inner/deep.ts", + name: "deep.ts", + isDirectory: false, + children: undefined, + }, + ]; + setMockInvokeResult("list_directory", innerChildren); + + const innerEntry = get(editorStore.fileTree)[0].children![0]; + await editorStore.toggleDirectory(innerEntry); + + const outer = get(editorStore.fileTree)[0]; + expect(outer.children![0].isExpanded).toBe(true); + expect(outer.children![0].children).toEqual(innerChildren); + }); +}); + +describe("editorStore - deleteFile closes open tab", () => { + it("closes the tab when the deleted file is open", async () => { + setMockInvokeResult("read_file_content", FILE_CONTENT); + await editorStore.openFile("/path/to/file.ts"); + expect(get(editorStore.tabs)).toHaveLength(1); + + setMockInvokeResult("delete_file", undefined); + setMockInvokeResult("list_directory", []); + await editorStore.deleteFile("/path/to/file.ts"); + expect(get(editorStore.tabs)).toHaveLength(0); + }); +}); + +describe("editorStore - deleteDirectory closes open tabs", () => { + it("closes all tabs inside the deleted directory", async () => { + setMockInvokeResult("read_file_content", FILE_CONTENT); + await editorStore.openFile("/path/to/dir/file1.ts"); + await editorStore.openFile("/path/to/dir/nested/file2.ts"); + await editorStore.openFile("/path/to/other.ts"); + expect(get(editorStore.tabs)).toHaveLength(3); + + setMockInvokeResult("delete_directory", undefined); + setMockInvokeResult("list_directory", []); + await editorStore.deleteDirectory("/path/to/dir"); + + const remainingTabs = get(editorStore.tabs); + expect(remainingTabs).toHaveLength(1); + expect(remainingTabs[0].filePath).toBe("/path/to/other.ts"); + }); +}); + +describe("editorStore - renamePath updates open tabs", () => { + it("updates filePath and fileName when the renamed file is open in a tab", async () => { + setMockInvokeResult("read_file_content", FILE_CONTENT); + await editorStore.openFile("/path/to/old-name.ts"); + + setMockInvokeResult("rename_path", undefined); + setMockInvokeResult("list_directory", []); + await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts"); + + const tab = get(editorStore.activeTab); + expect(tab?.filePath).toBe("/path/to/new-name.ts"); + expect(tab?.fileName).toBe("new-name.ts"); + }); + + it("updates filePaths for tabs inside a renamed directory", async () => { + setMockInvokeResult("read_file_content", FILE_CONTENT); + await editorStore.openFile("/path/to/old-dir/file.ts"); + + setMockInvokeResult("rename_path", undefined); + setMockInvokeResult("list_directory", []); + await editorStore.renamePath("/path/to/old-dir", "new-dir"); + + const tab = get(editorStore.activeTab); + expect(tab?.filePath).toBe("/path/to/new-dir/file.ts"); + }); + + it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => { + setMockInvokeResult("read_file_content", FILE_CONTENT); + await editorStore.openFile("/path/to/unrelated.ts"); + await editorStore.openFile("/path/to/old-name.ts"); + + setMockInvokeResult("rename_path", undefined); + setMockInvokeResult("list_directory", []); + await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts"); + + const tabs = get(editorStore.tabs); + const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts"); + expect(unrelated).toBeDefined(); + expect(unrelated?.fileName).toBe("unrelated.ts"); + }); +}); + +describe("editorStore - refreshDirectory", () => { + it("reloads the entire tree when refreshing the root directory", async () => { + const initialEntries = [ + { path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", initialEntries); + await editorStore.initializeFileTree("/root"); + expect(get(editorStore.fileTree)).toHaveLength(1); + + const updatedEntries = [ + { path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined }, + { path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", updatedEntries); + await editorStore.refreshDirectory("/root"); + expect(get(editorStore.fileTree)).toHaveLength(2); + }); + + it("updates a subdirectory entry in the tree", async () => { + const initialEntries = [ + { + path: "/root/subdir", + name: "subdir", + isDirectory: true, + isExpanded: true, + children: [], + }, + ]; + setMockInvokeResult("list_directory", initialEntries); + await editorStore.initializeFileTree("/root"); + + const subChildren = [ + { path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined }, + ]; + setMockInvokeResult("list_directory", subChildren); + await editorStore.refreshDirectory("/root/subdir"); + + const tree = get(editorStore.fileTree); + expect(tree[0].children).toEqual(subChildren); + expect(tree[0].isExpanded).toBe(true); + }); +}); diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts index 810c7e5..f686f06 100644 --- a/src/lib/stores/sessions.test.ts +++ b/src/lib/stores/sessions.test.ts @@ -220,6 +220,16 @@ describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => { it("handles cancel when no auto-save is pending", () => { sessionsStore.cancelAutoSave("non-existent-id"); }); + + it("clears an existing pending auto-save when rescheduled for the same conversation", async () => { + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + const conv = makeConversation(); + sessionsStore.scheduleAutoSave(conv as never); + // Schedule again before timer fires — hits clearTimeout branch (line 487) + sessionsStore.scheduleAutoSave(conv as never); + await vi.advanceTimersByTimeAsync(2001); + }); }); describe("sessionsStore - exportSessionAsJson", () => { @@ -254,6 +264,17 @@ describe("sessionsStore - exportSessionAsJson", () => { expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false); spy.mockRestore(); }); + + it("returns false when writeTextFile throws", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session.json"); + vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); + expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false); + spy.mockRestore(); + }); }); describe("sessionsStore - exportSessionAsMarkdown", () => { @@ -318,6 +339,17 @@ describe("sessionsStore - exportSessionAsMarkdown", () => { expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false); spy.mockRestore(); }); + + it("returns false when writeTextFile throws", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session.md"); + vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); + expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false); + spy.mockRestore(); + }); }); describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => { @@ -424,6 +456,17 @@ describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlEx expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false); spy.mockRestore(); }); + + it("returns false when writeTextFile throws", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session.html"); + vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); + expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false); + spy.mockRestore(); + }); }); describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => { @@ -472,6 +515,17 @@ describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false); spy.mockRestore(); }); + + it("returns false when writeTextFile throws", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session-print.html"); + vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); + expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false); + spy.mockRestore(); + }); }); describe("sessionsStore - importSession", () => {