From c7fa2e956d77342d76ec3d7a1e1cd467738ffc9a Mon Sep 17 00:00:00 2001 From: Mike Thicke Date: Sun, 31 Aug 2025 11:09:57 -0400 Subject: [PATCH] Remove tests --- src/__tests__/ChatView.test.tsx | 395 ---------------- src/__tests__/CoIntelligencePlugin.test.tsx | 315 ------------- src/__tests__/CoiChatApp.test.tsx | 213 --------- src/__tests__/settings.test.ts | 208 --------- src/commands/__tests__/new-chat.test.ts | 40 -- .../__tests__/toggle-chat-view.test.ts | 233 ---------- src/components/__tests__/LucideIcon.test.tsx | 51 -- .../__tests__/ModelSelector.test.tsx | 186 -------- .../__tests__/ProcessingIndicator.test.tsx | 42 -- src/components/__tests__/UserInput.test.tsx | 127 ----- src/services/__tests__/model-registry.test.ts | 286 ------------ src/services/__tests__/model-service.test.ts | 437 ------------------ src/utils/__tests__/model-context.test.ts | 377 --------------- src/utils/__tests__/notes.test.ts | 268 ----------- src/utils/__tests__/url.test.ts | 113 ----- test/component-testing-guide.md | 328 ------------- test/mocks/obsidian.ts | 209 --------- test/setup.ts | 32 -- test/testing.md | 273 ----------- test/utils/render.tsx | 58 --- test/utils/test-helpers.ts | 14 - 21 files changed, 4205 deletions(-) delete mode 100644 src/__tests__/ChatView.test.tsx delete mode 100644 src/__tests__/CoIntelligencePlugin.test.tsx delete mode 100644 src/__tests__/CoiChatApp.test.tsx delete mode 100644 src/__tests__/settings.test.ts delete mode 100644 src/commands/__tests__/new-chat.test.ts delete mode 100644 src/commands/__tests__/toggle-chat-view.test.ts delete mode 100644 src/components/__tests__/LucideIcon.test.tsx delete mode 100644 src/components/__tests__/ModelSelector.test.tsx delete mode 100644 src/components/__tests__/ProcessingIndicator.test.tsx delete mode 100644 src/components/__tests__/UserInput.test.tsx delete mode 100644 src/services/__tests__/model-registry.test.ts delete mode 100644 src/services/__tests__/model-service.test.ts delete mode 100644 src/utils/__tests__/model-context.test.ts delete mode 100644 src/utils/__tests__/notes.test.ts delete mode 100644 src/utils/__tests__/url.test.ts delete mode 100644 test/component-testing-guide.md delete mode 100644 test/mocks/obsidian.ts delete mode 100644 test/setup.ts delete mode 100644 test/testing.md delete mode 100644 test/utils/render.tsx delete mode 100644 test/utils/test-helpers.ts diff --git a/src/__tests__/ChatView.test.tsx b/src/__tests__/ChatView.test.tsx deleted file mode 100644 index 4f02d98..0000000 --- a/src/__tests__/ChatView.test.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ChatView, VIEW_TYPE_COI_CHAT } from "../ChatView"; -import type { WorkspaceLeaf, App, TFile } from "obsidian"; -import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; -import type { ModelChatMessage, ContextItems } from "@/types"; - -vi.mock("obsidian", () => ({ - TextFileView: class TextFileView { - leaf: any; - app: any; - constructor(leaf: any) { - this.leaf = leaf; - } - registerEvent = vi.fn(); - requestSave = vi.fn(); - }, - Notice: vi.fn(), - TFile: class TFile { - path: string; - basename: string; - constructor(path: string, basename: string) { - this.path = path; - this.basename = basename; - } - }, -})); - -vi.mock("solid-js/web", () => ({ - render: vi.fn(() => ({ dispose: vi.fn() })), -})); - -vi.mock("@/CoiChatApp", () => ({ - CoiChatApp: vi.fn(), -})); - -vi.mock("@/utils/notes", () => ({ - serializeCoiNote: vi.fn(), - deserializeCoiNote: vi.fn(), - renameNote: vi.fn(), - isActiveCoiNote: vi.fn(), - deserializeCoiNoteContent: vi.fn(), - serializeCoiNoteContent: vi.fn(), -})); - -describe("ChatView", () => { - let app: App; - let plugin: CoIntelligencePlugin; - let leaf: WorkspaceLeaf; - let chatView: ChatView; - let mockFile: TFile; - - beforeEach(() => { - vi.useFakeTimers(); - - mockFile = { - path: "test.md", - basename: "test", - } as TFile; - - app = { - workspace: { - getActiveFile: vi.fn().mockReturnValue(mockFile), - on: vi.fn(), - }, - vault: { - cachedRead: vi.fn().mockResolvedValue("# Test Content"), - process: vi.fn(), - }, - fileManager: { - processFrontMatter: vi.fn(), - }, - } as any; - - plugin = { - app, - registry: { - availableModels: [], - }, - settings: { - defaultModel: "", - systemPromptFolder: "", - defaultSystemPromptNote: "", - }, - } as any; - - leaf = {} as WorkspaceLeaf; - - chatView = new ChatView(leaf, plugin, app); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe("constructor", () => { - it("should initialize with correct properties", () => { - expect(chatView.plugin).toBe(plugin); - expect(chatView.app).toBe(app); - expect(chatView.file).toBe(mockFile); - expect(chatView.icon).toBe("bot-message-square"); - expect(chatView.messages).toEqual([]); - expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] }); - expect(chatView.sources).toEqual([]); - }); - - it("should register settings change event", () => { - expect(chatView.registerEvent).toHaveBeenCalled(); - expect(app.workspace.on).toHaveBeenCalledWith( - "co-intelligence:settings-changed", - expect.any(Function) - ); - }); - }); - - describe("getViewType", () => { - it("should return correct view type", () => { - expect(chatView.getViewType()).toBe(VIEW_TYPE_COI_CHAT); - }); - }); - - describe("getDisplayText", () => { - it("should return file basename when file exists", () => { - expect(chatView.getDisplayText()).toBe("test"); - }); - - it("should return default text when no file", () => { - chatView.file = null; - expect(chatView.getDisplayText()).toBe("Co-Intelligence Chat"); - }); - }); - - describe("debounceUpdateViewData", () => { - it("should debounce multiple calls", async () => { - const updateSpy = vi.spyOn(chatView, "updateViewData"); - - await chatView.debounceUpdateViewData(); - await chatView.debounceUpdateViewData(); - await chatView.debounceUpdateViewData(); - - expect(updateSpy).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(updateSpy).toHaveBeenCalledTimes(1); - }); - - it("should clear previous timeout", async () => { - const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); - - await chatView.debounceUpdateViewData(); - const firstTimeout = chatView["debounceTimeout"]; - - await chatView.debounceUpdateViewData(); - - expect(clearTimeoutSpy).toHaveBeenCalledWith(firstTimeout); - }); - }); - - describe("updateViewData", () => { - it("should update view data with serialized content", async () => { - const { serializeCoiNoteContent } = await import("@/utils/notes"); - const mockSerializedContent = "Serialized content"; - vi.mocked(serializeCoiNoteContent).mockResolvedValue(mockSerializedContent); - - chatView.messages = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] as ModelChatMessage[]; - - await chatView.updateViewData(); - - expect(app.vault.cachedRead).toHaveBeenCalledWith(mockFile); - expect(serializeCoiNoteContent).toHaveBeenCalledWith( - "# Test Content", - app, - chatView.messages, - chatView.contextItems - ); - expect(chatView.data).toBe(mockSerializedContent); - expect(chatView.requestSave).toHaveBeenCalled(); - }); - - it("should handle no file gracefully", async () => { - chatView.file = null; - - const result = await chatView.updateViewData(); - - expect(result).toBe(""); - expect(app.vault.cachedRead).not.toHaveBeenCalled(); - }); - - }); - - describe("getViewData", () => { - it("should return current data", () => { - chatView.data = "test data"; - expect(chatView.getViewData()).toBe("test data"); - }); - }); - - describe("setViewData", () => { - it("should set data and deserialize content", async () => { - const { deserializeCoiNoteContent } = await import("@/utils/notes"); - const mockDeserializedData = { - messages: [{ role: "user", content: "Deserialized" }], - contextItems: { notes: ["note.md"], tags: [], sources: [] }, - sources: [], - }; - vi.mocked(deserializeCoiNoteContent).mockResolvedValue(mockDeserializedData); - - app.metadataCache = { - getFileCache: vi.fn().mockReturnValue({}), - } as any; - - await chatView.setViewData("new data", false); - - expect(chatView.data).toBe("new data"); - expect(chatView.messages).toEqual(mockDeserializedData.messages); - expect(chatView.contextItems).toEqual(mockDeserializedData.contextItems); - }); - - it("should handle null file gracefully", async () => { - const { deserializeCoiNoteContent } = await import("@/utils/notes"); - const Notice = vi.mocked((await import("obsidian")).Notice); - chatView.file = null; - - vi.mocked(deserializeCoiNoteContent).mockResolvedValue({ - messages: [], - contextItems: { notes: [], tags: [], sources: [] }, - sources: [] - }); - - await chatView.setViewData("data", false); - - expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to set view data"); - }); - - it("should clear when clear parameter is true", async () => { - const clearSpy = vi.spyOn(chatView, "clear"); - app.metadataCache = { - getFileCache: vi.fn().mockReturnValue({}), - } as any; - - await chatView.setViewData("data", true); - - expect(clearSpy).toHaveBeenCalled(); - }); - }); - - describe("handleChatChange", () => { - it("should serialize note with new data", async () => { - const { serializeCoiNote } = await import("@/utils/notes"); - const newMessages: ModelChatMessage[] = [ - { role: "user", content: "New message" }, - ]; - const newContextItems: ContextItems = { - notes: ["note1.md"], - tags: ["#tag1"], - sources: [], - }; - - await chatView.handleChatChange({ - newMessages, - newTitle: "test", - contextItems: newContextItems, - sources: [], - lastModelId: "gpt-4", - }); - - expect(serializeCoiNote).toHaveBeenCalledWith( - mockFile, - app, - newMessages, - newContextItems, - "gpt-4", - [] - ); - }); - - it("should rename note when title changes", async () => { - const { renameNote } = await import("@/utils/notes"); - - await chatView.handleChatChange({ - newMessages: [], - newTitle: "New Title", - contextItems: null, - lastModelId: null, - }); - - expect(renameNote).toHaveBeenCalledWith(mockFile, "New Title", app, plugin); - }); - - it("should not rename when title is same as basename", async () => { - const { renameNote } = await import("@/utils/notes"); - - await chatView.handleChatChange({ - newMessages: [], - newTitle: "test", - contextItems: null, - lastModelId: null, - }); - - expect(renameNote).not.toHaveBeenCalled(); - }); - - it("should handle errors during serialization", async () => { - const { serializeCoiNote } = await import("@/utils/notes"); - const Notice = vi.mocked((await import("obsidian")).Notice); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(serializeCoiNote).mockRejectedValue(new Error("Serialization failed")); - - await chatView.handleChatChange({ - newMessages: [], - newTitle: "", - contextItems: null, - lastModelId: null, - }); - - expect(Notice).toHaveBeenCalledWith("Error serializing CoiNote: Error: Serialization failed"); - expect(chatView["updating"]).toBe(false); - - consoleSpy.mockRestore(); - }); - - it("should handle null file", async () => { - const Notice = vi.mocked((await import("obsidian")).Notice); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - chatView.file = null; - - await chatView.handleChatChange({ - newMessages: [], - newTitle: "", - contextItems: null, - lastModelId: null, - }); - - expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to handle chat change"); - - consoleSpy.mockRestore(); - }); - }); - - describe("refresh", () => { - it("should refresh view when active", async () => { - const { isActiveCoiNote, deserializeCoiNote } = await import("@/utils/notes"); - vi.mocked(isActiveCoiNote).mockReturnValue(true); - vi.mocked(deserializeCoiNote).mockResolvedValue({ - messages: [], - contextItems: { notes: [], tags: [], sources: [] }, - sources: [] - }); - - app.metadataCache = { - getFileCache: vi.fn().mockReturnValue({}), - } as any; - - chatView.containerEl = { - children: [null, document.createElement("div")], - } as any; - - const onOpenSpy = vi.spyOn(chatView, "onOpen"); - - await chatView.refresh(); - - expect(isActiveCoiNote).toHaveBeenCalledWith(mockFile, app); - expect(onOpenSpy).toHaveBeenCalled(); - }); - - - it("should handle no file", async () => { - chatView.file = null; - - await expect(chatView.refresh()).resolves.not.toThrow(); - }); - }); - - describe("clear", () => { - it("should reset all data", () => { - chatView.messages = [{ role: "user", content: "test" }] as ModelChatMessage[]; - chatView.contextItems = { notes: ["note.md"], tags: [], sources: [] }; - chatView.sources = [{ title: "Source", url: "https://example.com" }]; - chatView.dispose = vi.fn(); - - chatView.clear(); - - expect(chatView.messages).toEqual([]); - expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] }); - expect(chatView.sources).toEqual([]); - expect(chatView.dispose).toHaveBeenCalled(); - }); - }); - - -}); \ No newline at end of file diff --git a/src/__tests__/CoIntelligencePlugin.test.tsx b/src/__tests__/CoIntelligencePlugin.test.tsx deleted file mode 100644 index 441dfe5..0000000 --- a/src/__tests__/CoIntelligencePlugin.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { CoIntelligencePlugin } from "../CoIntelligencePlugin"; -import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings"; -import { ModelRegistry } from "@/services/model-registry"; -import { VIEW_TYPE_COI_CHAT } from "@/ChatView"; -import type { App, PluginManifest, WorkspaceLeaf, TFile, Menu, ViewState } from "obsidian"; - -vi.mock("obsidian", () => ({ - Plugin: class Plugin { - app: any; - manifest: any; - constructor(app: any, manifest: any) { - this.app = app; - this.manifest = manifest; - } - addSettingTab = vi.fn(); - addCommand = vi.fn(); - registerView = vi.fn(); - addRibbonIcon = vi.fn(); - registerEvent = vi.fn(); - register = vi.fn(); - loadData = vi.fn(); - saveData = vi.fn(); - }, - TFile: class TFile { - path: string; - basename: string; - constructor(path: string, basename: string) { - this.path = path; - this.basename = basename; - } - }, - around: vi.fn((target, patches) => { - return () => {}; - }), -})); - -vi.mock("../settings", () => ({ - CoIntelligenceSettingsTab: vi.fn(), - DEFAULT_SETTINGS: { - openaiApiKey: "", - anthropicApiKey: "", - googleApiKey: "", - perplexityApiKey: "", - defaultFolder: "coi", - defaultModel: "", - renamingModel: "", - systemPromptFolder: "coi/prompts", - defaultSystemPromptNote: "", - }, -})); - -vi.mock("@/services/model-registry", () => ({ - ModelRegistry: { - getInstance: vi.fn(), - }, -})); - -vi.mock("@/commands/new-chat", () => ({ - NewChatCommand: vi.fn(), -})); - -vi.mock("@/commands/toggle-chat-view", () => ({ - ToggleChatViewCommand: vi.fn(), -})); - -vi.mock("@/ChatView", () => ({ - ChatView: vi.fn(), - VIEW_TYPE_COI_CHAT: "co-intelligence-chat", -})); - -vi.mock("@/utils/notes", () => ({ - isCoiNote: vi.fn(), - isPathActiveCoiNote: vi.fn(), - isActiveCoiNote: vi.fn(), - createCOINote: vi.fn(), - waitForMetadataCache: vi.fn(), -})); - -describe("CoIntelligencePlugin", () => { - let app: App; - let manifest: PluginManifest; - let plugin: CoIntelligencePlugin; - let mockRegistry: ModelRegistry; - - beforeEach(() => { - vi.clearAllMocks(); - - app = { - workspace: { - on: vi.fn(), - onLayoutReady: vi.fn((callback) => callback()), - getActiveFile: vi.fn(), - }, - vault: { - on: vi.fn(), - }, - fileManager: { - processFrontMatter: vi.fn(), - }, - commands: { - executeCommandById: vi.fn(), - }, - } as any; - - manifest = { - id: "co-intelligence", - name: "Co-Intelligence", - version: "1.0.0", - } as PluginManifest; - - mockRegistry = {} as ModelRegistry; - vi.mocked(ModelRegistry.getInstance).mockReturnValue(mockRegistry); - - plugin = new CoIntelligencePlugin(app, manifest); - }); - - describe("constructor", () => { - it("should initialize with default settings", () => { - expect(plugin.settings).toEqual(DEFAULT_SETTINGS); - expect(plugin.isPerformingAutomaticRename).toBe(false); - }); - }); - - describe("onload", () => { - it("should initialize plugin components", async () => { - plugin.loadData = vi.fn().mockResolvedValue({}); - await plugin.onload(); - - expect(plugin.registry).toBe(mockRegistry); - expect(plugin.addSettingTab).toHaveBeenCalled(); - expect(plugin.addCommand).toHaveBeenCalledTimes(2); - expect(plugin.registerView).toHaveBeenCalledWith( - VIEW_TYPE_COI_CHAT, - expect.any(Function) - ); - expect(plugin.addRibbonIcon).toHaveBeenCalledWith( - "bot-message-square", - "New COI chat", - expect.any(Function) - ); - }); - - it("should register event handlers", async () => { - await plugin.onload(); - - expect(plugin.registerEvent).toHaveBeenCalledWith( - app.workspace.on("file-open", expect.any(Function)) - ); - expect(plugin.registerEvent).toHaveBeenCalledWith( - app.vault.on("rename", expect.any(Function)) - ); - expect(plugin.registerEvent).toHaveBeenCalledWith( - app.workspace.on("file-menu", expect.any(Function)) - ); - }); - - it("should call onLayoutReady", async () => { - await plugin.onload(); - - expect(app.workspace.onLayoutReady).toHaveBeenCalledWith(expect.any(Function)); - }); - }); - - describe("loadSettings", () => { - it("should merge loaded data with default settings", async () => { - const customSettings = { - openaiApiKey: "test-key", - defaultFolder: "custom-folder", - }; - plugin.loadData = vi.fn().mockResolvedValue(customSettings); - - await plugin.loadSettings(); - - expect(plugin.settings).toEqual({ - ...DEFAULT_SETTINGS, - ...customSettings, - }); - }); - - it("should use default settings when no data is loaded", async () => { - plugin.loadData = vi.fn().mockResolvedValue(null); - - await plugin.loadSettings(); - - expect(plugin.settings).toEqual(DEFAULT_SETTINGS); - }); - }); - - describe("saveSettings", () => { - it("should save current settings", async () => { - plugin.saveData = vi.fn(); - plugin.settings = { ...DEFAULT_SETTINGS, openaiApiKey: "test-key" }; - - await plugin.saveSettings(); - - expect(plugin.saveData).toHaveBeenCalledWith(plugin.settings); - }); - }); - - describe("handleFileRename", () => { - let mockFile: TFile; - - beforeEach(async () => { - const { TFile } = await import("obsidian"); - mockFile = new TFile("test.md", "test"); - }); - - it("should mark COI notes as renamed when not automatic", async () => { - const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes"); - vi.mocked(isCoiNote).mockReturnValue(true); - - plugin.isPerformingAutomaticRename = false; - const processFrontMatterCallback = vi.fn(); - - app.fileManager.processFrontMatter = vi.fn(async (file, callback) => { - const frontmatter = {}; - callback(frontmatter); - processFrontMatterCallback(frontmatter); - }); - - await plugin["handleFileRename"](mockFile, "old-path.md"); - - expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile); - expect(processFrontMatterCallback).toHaveBeenCalledWith( - expect.objectContaining({ "note-renamed": true }) - ); - }); - - it("should not mark as renamed when automatic rename", async () => { - const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes"); - vi.mocked(isCoiNote).mockReturnValue(true); - - plugin.isPerformingAutomaticRename = true; - - await plugin["handleFileRename"](mockFile, "old-path.md"); - - expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile); - expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); - expect(plugin.isPerformingAutomaticRename).toBe(false); - }); - - it("should ignore non-TFile objects", async () => { - const notAFile = { path: "not-a-file" } as any; - - await plugin["handleFileRename"](notAFile, "old-path"); - - expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); - }); - - it("should ignore non-COI notes", async () => { - const { isCoiNote } = await import("@/utils/notes"); - vi.mocked(isCoiNote).mockReturnValue(false); - - await plugin["handleFileRename"](mockFile, "old-path.md"); - - expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); - }); - }); - - describe("onFileMenuHandler", () => { - let mockMenu: Menu; - let mockFile: TFile; - let mockLeaf: WorkspaceLeaf; - - beforeEach(async () => { - mockMenu = { - addItem: vi.fn(), - } as any; - - const { TFile } = await import("obsidian"); - mockFile = new TFile("test.md", "test"); - - mockLeaf = {} as WorkspaceLeaf; - }); - - - it("should not add menu item for non-COI notes", async () => { - const { isCoiNote } = await import("@/utils/notes"); - vi.mocked(isCoiNote).mockReturnValue(false); - - await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf); - - expect(mockMenu.addItem).not.toHaveBeenCalled(); - }); - - it("should not add menu item for active COI notes", async () => { - const { isCoiNote, isActiveCoiNote } = await import("@/utils/notes"); - vi.mocked(isCoiNote).mockReturnValue(true); - vi.mocked(isActiveCoiNote).mockReturnValue(true); - - await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf); - - expect(mockMenu.addItem).not.toHaveBeenCalled(); - }); - }); - - - describe("ribbon icon", () => { - it("should create COI note when ribbon icon is clicked", async () => { - const { createCOINote } = await import("@/utils/notes"); - let ribbonClickHandler: () => void; - - plugin.addRibbonIcon = vi.fn((icon, title, callback) => { - ribbonClickHandler = callback; - }); - - await plugin.onload(); - - ribbonClickHandler!(); - - expect(createCOINote).toHaveBeenCalledWith(app, plugin); - }); - }); -}); \ No newline at end of file diff --git a/src/__tests__/CoiChatApp.test.tsx b/src/__tests__/CoiChatApp.test.tsx deleted file mode 100644 index 77880ac..0000000 --- a/src/__tests__/CoiChatApp.test.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; -import { - CoiChatApp, - PluginContext, - AppContext, - FileContext, -} from "../CoiChatApp"; -import type { App, TFile } from "obsidian"; -import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; -import type { ModelChatMessage, ContextItems, Source } from "@/types"; -import { useContext } from "solid-js"; - -vi.mock("@/components/ChatInterface", () => ({ - ChatInterface: (props: any) => { - // Test component that verifies props and context - const plugin = useContext(PluginContext); - const app = useContext(AppContext); - const file = useContext(FileContext); - - return ( -
-
- {JSON.stringify(props.initialMessages)} -
-
- {JSON.stringify(props.initialContext)} -
-
- {JSON.stringify(props.initialSources)} -
-
{plugin ? "true" : "false"}
-
{app ? "true" : "false"}
-
{file ? "true" : "false"}
- -
- ); - }, -})); - -describe("CoiChatApp", () => { - let app: App; - let file: TFile; - let plugin: CoIntelligencePlugin; - let onChange: vi.Mock; - let initialMessages: ModelChatMessage[]; - let initialContext: ContextItems; - let initialSources: Source[]; - - beforeEach(() => { - app = { name: "MockApp" } as App; - file = { path: "test.md", basename: "test" } as TFile; - plugin = { name: "MockPlugin" } as CoIntelligencePlugin; - onChange = vi.fn(); - initialMessages = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ]; - initialContext = { - notes: ["note1.md"], - tags: ["#tag1"], - sources: [], - }; - initialSources = [{ title: "Source 1", url: "https://example.com" }]; - }); - - it("should render ChatInterface with correct props", () => { - render(() => ( - - )); - - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - expect(screen.getByTestId("initial-messages").textContent).toBe( - JSON.stringify(initialMessages), - ); - expect(screen.getByTestId("initial-context").textContent).toBe( - JSON.stringify(initialContext), - ); - expect(screen.getByTestId("initial-sources").textContent).toBe( - JSON.stringify(initialSources), - ); - }); - - it("should provide contexts to child components", () => { - render(() => ( - - )); - - expect(screen.getByTestId("has-plugin").textContent).toBe("true"); - expect(screen.getByTestId("has-app").textContent).toBe("true"); - expect(screen.getByTestId("has-file").textContent).toBe("true"); - }); - - it("should pass onChange callback to ChatInterface", () => { - render(() => ( - - )); - - const button = screen.getByText("Trigger Change"); - button.click(); - - expect(onChange).toHaveBeenCalledWith({ - newMessages: [{ role: "user", content: "test" }], - newTitle: "Test Title", - contextItems: null, - lastModelId: "test-model", - }); - }); - - it("should handle undefined initialSources with default value", () => { - render(() => ( - - )); - - expect(screen.getByTestId("initial-sources").textContent).toBe( - JSON.stringify([]), - ); - }); - - it("should wrap content in coi-app div", () => { - render(() => ( - - )); - - const wrapper = screen.getByTestId("chat-interface").parentElement; - expect(wrapper).toHaveClass("coi-app"); - }); - - it("should handle empty initial messages", () => { - render(() => ( - - )); - - expect(screen.getByTestId("initial-messages").textContent).toBe("[]"); - }); - - it("should handle complex context items", () => { - const complexContext: ContextItems = { - notes: ["note1.md", "note2.md", "folder/note3.md"], - tags: ["#tag1", "#tag2", "#nested/tag"], - sources: ["https://source1.com", "https://source2.com"], - }; - - render(() => ( - - )); - - expect(screen.getByTestId("initial-context").textContent).toBe( - JSON.stringify(complexContext), - ); - }); -}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts deleted file mode 100644 index 0a7bf88..0000000 --- a/src/__tests__/settings.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings"; -import type { App, Setting } from "obsidian"; -import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; - -vi.mock("obsidian", () => ({ - PluginSettingTab: class PluginSettingTab { - app: any; - plugin: any; - containerEl: any; - constructor(app: any, plugin: any) { - this.app = app; - this.plugin = plugin; - this.containerEl = { - empty: vi.fn(), - createEl: vi.fn(), - createDiv: vi.fn(), - querySelectorAll: vi.fn(), - }; - } - }, - Setting: vi.fn().mockImplementation(() => ({ - setName: vi.fn().mockReturnThis(), - setDesc: vi.fn().mockReturnThis(), - addText: vi.fn().mockReturnThis(), - addDropdown: vi.fn().mockReturnThis(), - })), - normalizePath: vi.fn((path: string) => - path.replace(/\/+/g, "/").replace(/\/$/, ""), - ), -})); - -describe("settings", () => { - describe("DEFAULT_SETTINGS", () => { - it("should have all required fields with default values", () => { - expect(DEFAULT_SETTINGS).toEqual({ - openaiApiKey: "", - anthropicApiKey: "", - googleApiKey: "", - perplexityApiKey: "", - defaultFolder: "coi", - defaultModel: "", - renamingModel: "", - systemPromptFolder: "coi/prompts", - defaultSystemPromptNote: "", - }); - }); - }); - - describe("CoIntelligenceSettingsTab", () => { - let app: App; - let plugin: CoIntelligencePlugin; - let settingsTab: CoIntelligenceSettingsTab; - let mockElement: any; - - beforeEach(() => { - vi.useFakeTimers(); - - mockElement = { - empty: vi.fn(), - createEl: vi.fn().mockReturnValue({ - createEl: vi.fn().mockReturnValue({ - createEl: vi.fn(), - appendChild: vi.fn(), - setAttribute: vi.fn(), - }), - }), - createDiv: vi.fn().mockReturnValue({ - createDiv: vi.fn().mockReturnValue({ - createEl: vi.fn().mockReturnValue({ - createEl: vi.fn(), - }), - }), - createEl: vi.fn().mockReturnValue({ - createEl: vi.fn(), - appendChild: vi.fn(), - }), - }), - querySelectorAll: vi.fn().mockReturnValue([]), - }; - - app = { - vault: { - getMarkdownFiles: vi.fn().mockReturnValue([ - { path: "coi/prompts/prompt1.md", basename: "prompt1" }, - { path: "coi/prompts/prompt2.md", basename: "prompt2" }, - ]), - }, - workspace: { - trigger: vi.fn(), - }, - } as any; - - plugin = { - settings: { ...DEFAULT_SETTINGS }, - saveSettings: vi.fn().mockResolvedValue(undefined), - registry: { - availableModels: [ - { id: "gpt-4", name: "GPT-4", renaming: true }, - { id: "claude-3", name: "Claude 3", renaming: true }, - { id: "gemini-pro", name: "Gemini Pro", renaming: false }, - ], - reinitialize: vi.fn(), - }, - } as any; - - settingsTab = new CoIntelligenceSettingsTab(app, plugin); - settingsTab.containerEl = mockElement; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe("createDebouncedChangeHandler", () => { - it("should debounce multiple changes", async () => { - const handler = settingsTab["createDebouncedChangeHandler"]( - "openaiApiKey", - true, - false, - ); - - handler("key1"); - handler("key2"); - handler("key3"); - - expect(plugin.saveSettings).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(plugin.saveSettings).toHaveBeenCalledTimes(1); - expect(plugin.settings.openaiApiKey).toBe("key3"); - }); - - it("should reinitialize registry when required", async () => { - const handler = settingsTab["createDebouncedChangeHandler"]( - "anthropicApiKey", - true, - false, - ); - - handler("new-key"); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(plugin.registry.reinitialize).toHaveBeenCalled(); - }); - - it("should trigger settings-changed event", async () => { - const handler = settingsTab["createDebouncedChangeHandler"]( - "defaultFolder", - false, - false, - ); - - handler("new-folder"); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(app.workspace.trigger).toHaveBeenCalledWith( - "co-intelligence:settings-changed", - ); - }); - - it("should normalize folder paths", async () => { - const handler = settingsTab["createDebouncedChangeHandler"]( - "defaultFolder", - false, - false, - ); - - handler("folder//with///slashes/"); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(plugin.settings.defaultFolder).toBe("folder/with/slashes"); - }); - - it("should handle multiple pending changes for different settings", async () => { - const handler1 = settingsTab["createDebouncedChangeHandler"]( - "openaiApiKey", - true, - false, - ); - const handler2 = settingsTab["createDebouncedChangeHandler"]( - "anthropicApiKey", - true, - false, - ); - - handler1("openai-key"); - handler2("anthropic-key"); - - vi.advanceTimersByTime(500); - await vi.runAllTimersAsync(); - - expect(plugin.settings.openaiApiKey).toBe("openai-key"); - expect(plugin.settings.anthropicApiKey).toBe("anthropic-key"); - expect(plugin.saveSettings).toHaveBeenCalledTimes(1); - expect(plugin.registry.reinitialize).toHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/src/commands/__tests__/new-chat.test.ts b/src/commands/__tests__/new-chat.test.ts deleted file mode 100644 index 3d882a9..0000000 --- a/src/commands/__tests__/new-chat.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NewChatCommand } from "../new-chat"; -import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; -import type { App } from "obsidian"; - -vi.mock("@/utils/notes", () => ({ - createCOINote: vi.fn(), -})); - -describe("NewChatCommand", () => { - let app: App; - let plugin: CoIntelligencePlugin; - let command: NewChatCommand; - - beforeEach(() => { - app = {} as App; - plugin = { app } as CoIntelligencePlugin; - command = new NewChatCommand(plugin); - }); - - it("should have correct id and name", () => { - expect(command.id).toBe("new-chat"); - expect(command.name).toBe("New chat"); - }); - - it("should call createCOINote when callback is executed", async () => { - const { createCOINote } = await import("@/utils/notes"); - const mockedCreateCOINote = vi.mocked(createCOINote); - - await command.callback(); - - expect(mockedCreateCOINote).toHaveBeenCalledWith(app, plugin); - expect(mockedCreateCOINote).toHaveBeenCalledTimes(1); - }); - - it("should store app and plugin references", () => { - expect(command["app"]).toBe(app); - expect(command["plugin"]).toBe(plugin); - }); -}); \ No newline at end of file diff --git a/src/commands/__tests__/toggle-chat-view.test.ts b/src/commands/__tests__/toggle-chat-view.test.ts deleted file mode 100644 index 16c14ee..0000000 --- a/src/commands/__tests__/toggle-chat-view.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ToggleChatViewCommand } from "../toggle-chat-view"; -import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; -import type { App, TFile, WorkspaceLeaf, View, Notice } from "obsidian"; -import { VIEW_TYPE_COI_CHAT } from "@/ChatView"; - -vi.mock("@/utils/notes", () => ({ - isCoiNote: vi.fn(), - isActiveCoiNote: vi.fn(), -})); - -vi.mock("obsidian", async (importOriginal) => { - const actual = await importOriginal() as any; - return { - ...actual, - Notice: vi.fn(), - SuggestModal: vi.fn().mockImplementation(() => ({ - open: vi.fn(), - close: vi.fn(), - })), - }; -}); - -describe("ToggleChatViewCommand", () => { - let app: App; - let plugin: CoIntelligencePlugin; - let command: ToggleChatViewCommand; - let mockFile: TFile; - let mockLeaf: WorkspaceLeaf; - let mockView: View; - - beforeEach(() => { - vi.useFakeTimers(); - - mockView = { - getState: vi.fn().mockReturnValue({ mode: "source" }), - } as any; - - mockLeaf = { - view: mockView, - setViewState: vi.fn().mockResolvedValue(undefined), - } as any; - - mockFile = { - path: "test.md", - basename: "test", - } as TFile; - - app = { - workspace: { - getActiveFile: vi.fn(), - getMostRecentLeaf: vi.fn().mockReturnValue(mockLeaf), - }, - fileManager: { - processFrontMatter: vi.fn(), - }, - } as any; - - plugin = { app } as CoIntelligencePlugin; - command = new ToggleChatViewCommand(plugin); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should have correct id and name", () => { - expect(command.id).toBe("toggle-chat-view"); - expect(command.name).toBe("Toggle chat view"); - }); - - describe("checkCallback", () => { - it("should return false when checking with no active file", () => { - app.workspace.getActiveFile = vi.fn().mockReturnValue(null); - - const result = command.checkCallback(true); - - expect(result).toBe(false); - }); - - it("should return false when checking non-COI note", async () => { - const { isCoiNote } = await import("@/utils/notes"); - const mockedIsCoiNote = vi.mocked(isCoiNote); - - app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); - mockedIsCoiNote.mockReturnValue(false); - - const result = command.checkCallback(true); - - expect(result).toBe(false); - expect(mockedIsCoiNote).toHaveBeenCalledWith(mockFile, app); - }); - - it("should return true when checking COI note", async () => { - const { isCoiNote } = await import("@/utils/notes"); - const mockedIsCoiNote = vi.mocked(isCoiNote); - - app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); - mockedIsCoiNote.mockReturnValue(true); - - const result = command.checkCallback(true); - - expect(result).toBe(true); - }); - - it("should perform toggle when not checking and file is COI note", async () => { - const { isCoiNote } = await import("@/utils/notes"); - const mockedIsCoiNote = vi.mocked(isCoiNote); - const performToggleSpy = vi.spyOn(command as any, "performToggle"); - - app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); - mockedIsCoiNote.mockReturnValue(true); - - command.checkCallback(false); - - expect(performToggleSpy).toHaveBeenCalledWith(mockFile); - }); - - it("should not perform toggle when file is not COI note", async () => { - const { isCoiNote } = await import("@/utils/notes"); - const mockedIsCoiNote = vi.mocked(isCoiNote); - const performToggleSpy = vi.spyOn(command as any, "performToggle"); - - app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); - mockedIsCoiNote.mockReturnValue(false); - - command.checkCallback(false); - - expect(performToggleSpy).not.toHaveBeenCalled(); - }); - }); - - describe("performToggle", () => { - it("should toggle from active to source view", async () => { - const { isActiveCoiNote } = await import("@/utils/notes"); - const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote); - mockedIsActiveCoiNote.mockReturnValue(true); - - const processFrontMatterCallback = vi.fn(); - app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => { - const frontmatter = { "coi-chat-view": true }; - callback(frontmatter); - processFrontMatterCallback(frontmatter); - return Promise.resolve(); - }); - - const togglePromise = command["performToggle"](mockFile); - await vi.runAllTimersAsync(); - await togglePromise; - - expect(processFrontMatterCallback).toHaveBeenCalledWith( - expect.objectContaining({ "coi-chat-view": false }) - ); - - expect(mockLeaf.setViewState).toHaveBeenCalledWith( - { - type: "markdown", - state: { mode: "source" }, - popstate: true, - }, - { focus: true } - ); - }); - - it("should toggle from source to chat view", async () => { - const { isActiveCoiNote } = await import("@/utils/notes"); - const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote); - mockedIsActiveCoiNote.mockReturnValue(false); - - const processFrontMatterCallback = vi.fn(); - app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => { - const frontmatter = { "coi-chat-view": false }; - callback(frontmatter); - processFrontMatterCallback(frontmatter); - return Promise.resolve(); - }); - - const togglePromise = command["performToggle"](mockFile); - await vi.runAllTimersAsync(); - await togglePromise; - - expect(processFrontMatterCallback).toHaveBeenCalledWith( - expect.objectContaining({ "coi-chat-view": true }) - ); - - expect(mockLeaf.setViewState).toHaveBeenCalledWith( - { - type: VIEW_TYPE_COI_CHAT, - state: { mode: "source" }, - popstate: true, - }, - { focus: true } - ); - }); - }); - - describe("error handling", () => { - it("should show notice when no leaf found for default editor", async () => { - const Notice = vi.mocked((await import("obsidian")).Notice); - app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); - - await command["openInDefaultEditor"](mockFile); - - expect(Notice).toHaveBeenCalledWith( - "Error: no leaf found while opening chat in default editor" - ); - }); - - it("should show notice when no leaf found for chat view", async () => { - const Notice = vi.mocked((await import("obsidian")).Notice); - app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); - - await command["openInChatView"](mockFile); - - expect(Notice).toHaveBeenCalledWith( - "Error: no leaf found while opening chat in chat view" - ); - }); - - it("should log error to console when no leaf found", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(); - app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); - - await command["openInDefaultEditor"](mockFile); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - "No leaf found while opening chat in default editor" - ); - - consoleErrorSpy.mockRestore(); - }); - }); -}); \ No newline at end of file diff --git a/src/components/__tests__/LucideIcon.test.tsx b/src/components/__tests__/LucideIcon.test.tsx deleted file mode 100644 index 18e43e9..0000000 --- a/src/components/__tests__/LucideIcon.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen } from '@solidjs/testing-library'; -import { LucideIcon } from '@/components/LucideIcon'; -import { render } from '../../../test/utils/render'; - -// Mock the setIcon function from Obsidian -vi.mock('obsidian', () => ({ - setIcon: vi.fn(), -})); - -describe('LucideIcon', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should render with correct CSS class', () => { - render(() => ); - - expect(document.querySelector('.coi-lucide-icon')).toBeInTheDocument(); - }); - - it('should call setIcon with correct parameters on mount', async () => { - const { setIcon } = await import('obsidian'); - render(() => ); - - // Wait for onMount to be called - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(setIcon).toHaveBeenCalledTimes(1); - expect(setIcon).toHaveBeenCalledWith( - expect.any(HTMLElement), - 'home' - ); - }); - - it('should render different icons with different names', async () => { - const { setIcon } = await import('obsidian'); - const { unmount } = render(() => ); - - await new Promise(resolve => setTimeout(resolve, 0)); - expect(setIcon).toHaveBeenCalledWith(expect.any(HTMLElement), 'star'); - - unmount(); - vi.clearAllMocks(); - - render(() => ); - - await new Promise(resolve => setTimeout(resolve, 0)); - expect(setIcon).toHaveBeenCalledWith(expect.any(HTMLElement), 'heart'); - }); -}); \ No newline at end of file diff --git a/src/components/__tests__/ModelSelector.test.tsx b/src/components/__tests__/ModelSelector.test.tsx deleted file mode 100644 index 342bf1d..0000000 --- a/src/components/__tests__/ModelSelector.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen } from '@solidjs/testing-library'; -import userEvent from '@testing-library/user-event'; -import { createSignal } from 'solid-js'; -import { ModelSelector } from '@/components/ModelSelector'; -import { Model, ModelId } from '@/types'; -import { render, createMockPlugin } from '../../../test/utils/render'; - -// Mock ModelRegistry -vi.mock('@/services/model-registry', () => ({ - ModelRegistry: { - getInstance: vi.fn(), - } -})); - -// Mock LucideIcon component -vi.mock('@/components/LucideIcon', () => ({ - LucideIcon: ({ name }: { name: string }) => -})); - -describe('ModelSelector', () => { - const mockModels: Model[] = [ - { - id: 'openai:gpt-4' as ModelId, - provider: 'openai', - name: 'GPT-4', - renaming: false, - toggleWebSearch: true, - streaming: true, - }, - { - id: 'anthropic:claude-3-5-sonnet' as ModelId, - provider: 'anthropic', - name: 'Claude 3.5 Sonnet', - renaming: false, - toggleWebSearch: false, - streaming: true, - } - ]; - - let mockRegistry: any; - - beforeEach(async () => { - mockRegistry = { - availableModels: [], - getModel: vi.fn(), - }; - - const { ModelRegistry } = await import('@/services/model-registry'); - vi.mocked(ModelRegistry.getInstance).mockReturnValue(mockRegistry); - }); - - it('should render with no models available', () => { - mockRegistry.availableModels = []; - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - expect(screen.getByRole('combobox')).toBeDisabled(); - expect(screen.getByText('No available models')).toBeInTheDocument(); - expect(screen.getByRole('combobox')).toHaveAttribute('aria-label', 'No models available'); - }); - - it('should render available models', () => { - mockRegistry.availableModels = mockModels; - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - expect(screen.getByRole('combobox')).not.toBeDisabled(); - expect(screen.getByText('GPT-4')).toBeInTheDocument(); - expect(screen.getByText('Claude 3.5 Sonnet')).toBeInTheDocument(); - }); - - it('should call onModelChange when selection changes', async () => { - mockRegistry.availableModels = mockModels; - mockRegistry.getModel.mockImplementation((id: ModelId) => - mockModels.find(model => model.id === id) || null - ); - - const user = userEvent.setup(); - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - const select = screen.getByRole('combobox'); - await user.selectOptions(select, 'openai:gpt-4'); - - expect(mockRegistry.getModel).toHaveBeenCalledWith('openai:gpt-4'); - expect(onModelChange).toHaveBeenCalledWith(mockModels[0]); - }); - - it('should display selected model', () => { - mockRegistry.availableModels = mockModels; - const [selectedModel, setSelectedModel] = createSignal(mockModels[0]); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - expect(screen.getByRole('combobox')).toHaveValue('openai:gpt-4'); - }); - - it('should show label when showLabel is true', () => { - mockRegistry.availableModels = []; - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - expect(screen.getByText('Choose Model')).toBeInTheDocument(); - expect(screen.getByText('Choose Model')).toHaveClass('model-selector-label'); - }); - - it('should hide label when showLabel is false', () => { - mockRegistry.availableModels = []; - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - // The label should exist but be visually hidden with sr-only class - const hiddenLabel = document.querySelector('label.sr-only'); - expect(hiddenLabel).toBeInTheDocument(); - expect(hiddenLabel).toHaveClass('sr-only'); - expect(hiddenLabel).toHaveTextContent('Choose Model'); - }); - - it('should render bot icon', () => { - mockRegistry.availableModels = []; - const [selectedModel, setSelectedModel] = createSignal(null); - const onModelChange = vi.fn(); - - render(() => - , - { plugin: createMockPlugin() } - ); - - expect(screen.getByTestId('icon-bot-message-square')).toBeInTheDocument(); - }); -}); \ No newline at end of file diff --git a/src/components/__tests__/ProcessingIndicator.test.tsx b/src/components/__tests__/ProcessingIndicator.test.tsx deleted file mode 100644 index f60ee01..0000000 --- a/src/components/__tests__/ProcessingIndicator.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { screen } from '@solidjs/testing-library'; -import userEvent from '@testing-library/user-event'; -import { ProcessingIndicator } from '@/components/ProcessingIndicator'; -import { render } from '../../../test/utils/render'; - -describe('ProcessingIndicator', () => { - it('should render processing indicator with text', () => { - render(() => ); - - expect(screen.getByText('Generating response...')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - }); - - it('should call onCancel when cancel button is clicked', async () => { - const user = userEvent.setup(); - const onCancel = vi.fn(); - - render(() => ); - - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(cancelButton); - - expect(onCancel).toHaveBeenCalledTimes(1); - }); - - it('should render without onCancel prop', () => { - render(() => ); - - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - expect(cancelButton).toBeInTheDocument(); - }); - - it('should have correct CSS classes', () => { - render(() => ); - - expect(document.querySelector('.coi-processing-indicator')).toBeInTheDocument(); - expect(document.querySelector('.coi-processing-spinner')).toBeInTheDocument(); - expect(document.querySelector('.coi-processing-text')).toBeInTheDocument(); - expect(document.querySelector('.coi-cancel-button')).toBeInTheDocument(); - }); -}); \ No newline at end of file diff --git a/src/components/__tests__/UserInput.test.tsx b/src/components/__tests__/UserInput.test.tsx deleted file mode 100644 index bfa42e7..0000000 --- a/src/components/__tests__/UserInput.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen } from '@solidjs/testing-library'; -import userEvent from '@testing-library/user-event'; -import { createSignal } from 'solid-js'; -import { UserInput } from '@/components/UserInput'; -import { Model, ModelId } from '@/types'; -import { render, createMockPlugin, createMockApp } from '../../../test/utils/render'; - -// Mock ModelRegistry -vi.mock('@/services/model-registry', () => ({ - ModelRegistry: { - getInstance: vi.fn(() => ({ - availableModels: [mockModel], - })), - } -})); - -// Mock child components to isolate UserInput behavior -vi.mock('@/components/ModelSelector', () => ({ - ModelSelector: () =>
Model Selector
-})); - -vi.mock('@/components/SystemPromptSelector', () => ({ - SystemPromptSelector: () =>
System Prompt Selector
-})); - -vi.mock('@/components/NoteLinkSuggestionModal', () => ({ - NoteLinkSuggestionModal: class { - constructor() {} - open() {} - onClose = vi.fn(); - } -})); - -vi.mock('@/components/TagSuggestionModal', () => ({ - TagSuggestionModal: class { - constructor() {} - open() {} - onClose = vi.fn(); - } -})); - -const mockModel: Model = { - id: 'openai:gpt-4' as ModelId, - provider: 'openai', - name: 'GPT-4', - renaming: false, - toggleWebSearch: true, - streaming: true, -}; - -describe('UserInput', () => { - let mockProps: any; - - beforeEach(() => { - const [currentModel, setCurrentModel] = createSignal(mockModel); - - mockProps = { - triggerChange: vi.fn(), - onSubmit: vi.fn(), - currentModel, - updateModel: vi.fn(), - onLinkNote: vi.fn(), - onAddTag: vi.fn(), - initialSystemPrompt: '', - }; - }); - - it('should render textarea with correct placeholder', () => { - render(() => , { - app: createMockApp(), - plugin: createMockPlugin() - }); - - expect(screen.getByPlaceholderText('Type your message...')).toBeInTheDocument(); - expect(screen.getByRole('textbox')).toBeInTheDocument(); - }); - - it('should render child components', () => { - render(() => , { - app: createMockApp(), - plugin: createMockPlugin() - }); - - expect(screen.getByTestId('model-selector')).toBeInTheDocument(); - expect(screen.getByTestId('system-prompt-selector')).toBeInTheDocument(); - }); - - it('should submit message on Enter key', async () => { - const user = userEvent.setup(); - - render(() => , { - app: createMockApp(), - plugin: createMockPlugin() - }); - - const textarea = screen.getByRole('textbox'); - await user.type(textarea, 'Test message{Enter}'); - - expect(mockProps.onSubmit).toHaveBeenCalled(); - }); - - it('should show web search checkbox when model supports it', () => { - render(() => , { - app: createMockApp(), - plugin: createMockPlugin() - }); - - expect(screen.getByRole('checkbox')).toBeInTheDocument(); - expect(screen.getByText('Web search')).toBeInTheDocument(); - }); - - it('should toggle web search checkbox', async () => { - const user = userEvent.setup(); - - render(() => , { - app: createMockApp(), - plugin: createMockPlugin() - }); - - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).not.toBeChecked(); - - await user.click(checkbox); - expect(checkbox).toBeChecked(); - }); -}); \ No newline at end of file diff --git a/src/services/__tests__/model-registry.test.ts b/src/services/__tests__/model-registry.test.ts deleted file mode 100644 index f32766a..0000000 --- a/src/services/__tests__/model-registry.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ModelRegistry } from "@/services/model-registry"; -import type { CoIntelligencePlugin } from "@/CoIntelligencePlugin"; - -// Mock the AI SDK providers -vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: vi.fn(() => "mocked-openai-provider"), -})); - -vi.mock("@ai-sdk/anthropic", () => ({ - createAnthropic: vi.fn(() => "mocked-anthropic-provider"), -})); - -vi.mock("@ai-sdk/google", () => ({ - createGoogleGenerativeAI: vi.fn(() => "mocked-google-provider"), -})); - -vi.mock("@ai-sdk/perplexity", () => ({ - createPerplexity: vi.fn(() => "mocked-perplexity-provider"), -})); - -vi.mock("ai", () => ({ - createProviderRegistry: vi.fn((providers) => ({ - providers, - languageModel: vi.fn((modelId: string) => { - if (modelId.startsWith("openai:")) return { provider: "openai" }; - if (modelId.startsWith("anthropic:")) return { provider: "anthropic" }; - if (modelId.startsWith("google:")) return { provider: "google" }; - if (modelId.startsWith("perplexity:")) return { provider: "perplexity" }; - return null; - }), - })), -})); - -vi.mock("obsidian", () => ({ - Notice: vi.fn(), -})); - -describe("ModelRegistry", () => { - let mockPlugin: CoIntelligencePlugin; - - beforeEach(() => { - // Reset the singleton instance before each test - (ModelRegistry as any).instance = null; - - mockPlugin = { - settings: { - openaiApiKey: "", - anthropicApiKey: "", - googleApiKey: "", - perplexityApiKey: "", - }, - } as CoIntelligencePlugin; - }); - - afterEach(() => { - vi.clearAllMocks(); - // Clean up singleton instance - (ModelRegistry as any).instance = null; - }); - - describe("singleton pattern", () => { - it("should create a new instance when none exists and plugin provided", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - expect(registry).toBeInstanceOf(ModelRegistry); - }); - - it("should return the same instance on subsequent calls", () => { - const registry1 = ModelRegistry.getInstance(mockPlugin); - const registry2 = ModelRegistry.getInstance(); - - expect(registry1).toBe(registry2); - }); - - it("should throw error when no instance exists and no plugin provided", () => { - expect(() => { - ModelRegistry.getInstance(); - }).toThrow( - "No existing registry instance and no plugin instance provided.", - ); - }); - }); - - describe("provider initialization", () => { - it("should initialize no providers when no API keys provided", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - - expect(registry.availableModels).toHaveLength(0); - expect(registry.hasInitializedProviders()).toBe(false); - }); - - it("should initialize OpenAI provider when API key provided", () => { - mockPlugin.settings.openaiApiKey = "sk-test-key"; - const registry = ModelRegistry.getInstance(mockPlugin); - - const openaiModels = registry.availableModels.filter( - (m) => m.provider === "openai", - ); - expect(openaiModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - - it("should initialize Anthropic provider when API key provided", () => { - mockPlugin.settings.anthropicApiKey = "sk-ant-test-key"; - const registry = ModelRegistry.getInstance(mockPlugin); - - const anthropicModels = registry.availableModels.filter( - (m) => m.provider === "anthropic", - ); - expect(anthropicModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - - it("should initialize Google provider when API key provided", () => { - mockPlugin.settings.googleApiKey = "google-test-key"; - const registry = ModelRegistry.getInstance(mockPlugin); - - const googleModels = registry.availableModels.filter( - (m) => m.provider === "google", - ); - expect(googleModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - - it("should initialize Perplexity provider when API key provided", () => { - mockPlugin.settings.perplexityApiKey = "pplx-test-key"; - const registry = ModelRegistry.getInstance(mockPlugin); - - const perplexityModels = registry.availableModels.filter( - (m) => m.provider === "perplexity", - ); - expect(perplexityModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - - it("should initialize multiple providers when multiple API keys provided", () => { - mockPlugin.settings.openaiApiKey = "sk-test-key"; - mockPlugin.settings.anthropicApiKey = "sk-ant-test-key"; - - const registry = ModelRegistry.getInstance(mockPlugin); - - const openaiModels = registry.availableModels.filter( - (m) => m.provider === "openai", - ); - const anthropicModels = registry.availableModels.filter( - (m) => m.provider === "anthropic", - ); - - expect(openaiModels.length).toBeGreaterThan(0); - expect(anthropicModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - }); - - describe("model retrieval", () => { - beforeEach(() => { - mockPlugin.settings.openaiApiKey = "sk-test-key"; - mockPlugin.settings.anthropicApiKey = "sk-ant-test-key"; - }); - - it("should retrieve a model by ID", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const model = registry.getModel("openai:gpt-4o"); - - expect(model).toBeDefined(); - expect(model.id).toBe("openai:gpt-4o"); - expect(model.provider).toBe("openai"); - expect(model.name).toBe("OpenAI GPT-4o"); - }); - - it("should throw error for non-existent model", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - - expect(() => { - registry.getModel("nonexistent:model" as any); - }).toThrow("Model not found: nonexistent:model"); - }); - - it("should get language model from provider registry", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const languageModel = registry.getLanguageModel("openai:gpt-4o"); - - expect(languageModel).toBeDefined(); - expect(languageModel.provider).toBe("openai"); - }); - - it("should return default model when available", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const defaultModel = registry.getDefaultModel(); - - expect(defaultModel).toBeDefined(); - expect(registry.availableModels).toContain(defaultModel); - }); - - it("should return null as default model when no providers initialized", () => { - // Create registry without API keys - mockPlugin.settings = { - openaiApiKey: "", - anthropicApiKey: "", - googleApiKey: "", - perplexityApiKey: "", - defaultFolder: "", - defaultModel: "" as any, - renamingModel: "" as any, - systemPromptFolder: "", - defaultSystemPromptNote: "" - }; - - const registry = ModelRegistry.getInstance(mockPlugin); - const defaultModel = registry.getDefaultModel(); - - expect(defaultModel).toBeNull(); - }); - }); - - describe("reinitialization", () => { - it("should reinitialize providers when reinitialize is called", () => { - // Start with no API keys - const registry = ModelRegistry.getInstance(mockPlugin); - expect(registry.availableModels).toHaveLength(0); - - // Add API key and reinitialize - mockPlugin.settings.openaiApiKey = "sk-test-key"; - registry.reinitialize(); - - expect(registry.availableModels.length).toBeGreaterThan(0); - expect(registry.hasInitializedProviders()).toBe(true); - }); - - it("should update available models after reinitialization", () => { - // Start with OpenAI only - mockPlugin.settings.openaiApiKey = "sk-test-key"; - const registry = ModelRegistry.getInstance(mockPlugin); - - const initialModelCount = registry.availableModels.length; - expect( - registry.availableModels.every((m) => m.provider === "openai"), - ).toBe(true); - - // Add Anthropic and reinitialize - mockPlugin.settings.anthropicApiKey = "sk-ant-test-key"; - registry.reinitialize(); - - expect(registry.availableModels.length).toBeGreaterThan( - initialModelCount, - ); - expect( - registry.availableModels.some((m) => m.provider === "anthropic"), - ).toBe(true); - }); - }); - - describe("model properties", () => { - beforeEach(() => { - mockPlugin.settings.openaiApiKey = "sk-test-key"; - mockPlugin.settings.anthropicApiKey = "sk-ant-test-key"; - }); - - it("should have correct model properties for OpenAI models", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const gpt4o = registry.getModel("openai:gpt-4o"); - - expect(gpt4o.renaming).toBe(true); - expect(gpt4o.toggleWebSearch).toBe(true); - expect(gpt4o.streaming).toBe(true); - }); - - it("should have correct model properties for O1 models (no web search)", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const o1 = registry.getModel("openai:o1"); - - expect(o1.renaming).toBe(false); - expect(o1.toggleWebSearch).toBe(false); - expect(o1.streaming).toBe(true); - }); - - it("should have correct model properties for Claude models", () => { - const registry = ModelRegistry.getInstance(mockPlugin); - const claude = registry.getModel("anthropic:claude-4-sonnet-20250514"); - - expect(claude.renaming).toBe(true); - expect(claude.toggleWebSearch).toBe(false); - expect(claude.streaming).toBe(true); - }); - }); -}); diff --git a/src/services/__tests__/model-service.test.ts b/src/services/__tests__/model-service.test.ts deleted file mode 100644 index 4bd3feb..0000000 --- a/src/services/__tests__/model-service.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - generateChatResponse, - cancelChatResponse, - deleteAbortControllerForRequest, - generateChatTitle -} from '@/services/model-service'; -import { ModelRegistry } from '@/services/model-registry'; -import { ChatRequest, ModelChatMessage, ContextItemContent } from '@/types'; -import type { CoIntelligencePlugin } from '@/CoIntelligencePlugin'; - -// Mock the AI SDK -vi.mock('ai', async () => { - const actual = await vi.importActual('ai'); - return { - ...actual, - streamText: vi.fn(), - generateText: vi.fn() - }; -}); - -// Mock the openai SDK -vi.mock('@ai-sdk/openai', () => ({ - openai: { - tools: { - webSearchPreview: vi.fn(() => ({ type: 'web_search_preview' })) - } - } -})); - -// Mock the model context utility -vi.mock('@/utils/model-context', () => ({ - makeContext: vi.fn((contexts: ContextItemContent[]) => - contexts.map(c => `--- Note: ${c.title} ---\n${c.content}\n---`).join('\n\n') - ) -})); - -// Mock ModelRegistry to avoid singleton instance issues in tests -let mockRegistryInstance: any; -vi.mock('@/services/model-registry', () => ({ - ModelRegistry: { - getInstance: vi.fn(() => mockRegistryInstance) - } -})); - -import { streamText, generateText } from 'ai'; - -describe('model-service', () => { - let mockRegistry: ModelRegistry; - let mockPlugin: CoIntelligencePlugin; - let mockModel: any; - let baseRequest: ChatRequest; - - beforeEach(() => { - // Reset mocks - vi.clearAllMocks(); - - // Create mock model - mockModel = { - provider: 'openai', - doStream: vi.fn().mockResolvedValue({ - stream: async function* () { - yield { type: 'text-delta', textDelta: 'Hello' }; - yield { type: 'text-delta', textDelta: ' world' }; - yield { type: 'finish', finishReason: 'stop', usage: { - promptTokens: 10, - completionTokens: 2, - totalTokens: 12 - }}; - } - }) - }; - - // Create mock registry - mockRegistry = { - getLanguageModel: vi.fn().mockReturnValue(mockModel), - getModel: vi.fn().mockReturnValue({ - id: 'openai:gpt-4o', - provider: 'openai', - name: 'OpenAI GPT-4o', - renaming: true, - toggleWebSearch: true, - streaming: true - }) - } as any; - - // Set the mock registry instance for the mocked ModelRegistry.getInstance - mockRegistryInstance = mockRegistry; - - // Create mock plugin - mockPlugin = { - settings: { - renamingModel: 'openai:gpt-4o' - } - } as any; - - // Base request object - baseRequest = { - requestID: 'test-request-123', - modelId: 'openai:gpt-4o' as any, - messages: [{ role: 'user', content: 'Hello' }], - webSearch: false - }; - - // Mock streamText to return a mock result - const mockStreamResult = { - textStream: (async function* () { - yield 'Hello'; - yield ' world'; - })(), - text: 'Hello world', - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 2, totalTokens: 12 } - }; - - (streamText as any).mockResolvedValue(mockStreamResult); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('generateChatResponse', () => { - it('should generate a basic chat response', async () => { - const result = await generateChatResponse(baseRequest, mockRegistry); - - expect(mockRegistry.getLanguageModel).toHaveBeenCalledWith('openai:gpt-4o'); - expect(mockRegistry.getModel).toHaveBeenCalledWith('openai:gpt-4o'); - expect(streamText).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - - it('should use default system prompt when none provided', async () => { - await generateChatResponse(baseRequest, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.system).toBe('You are a helpful assistant.'); - }); - - it('should use custom system prompt when provided', async () => { - const requestWithSystem = { - ...baseRequest, - systemPrompt: 'You are a helpful coding assistant.' - }; - - await generateChatResponse(requestWithSystem, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.system).toBe('You are a helpful coding assistant.'); - }); - - it('should disable web search for models that do not support it', async () => { - mockRegistry.getModel = vi.fn().mockReturnValue({ - id: 'openai:o1', - provider: 'openai', - name: 'OpenAI O1', - renaming: false, - toggleWebSearch: false, - streaming: true - }); - - const requestWithWebSearch = { - ...baseRequest, - modelId: 'openai:o1' as any, - webSearch: true - }; - - await generateChatResponse(requestWithWebSearch, mockRegistry); - - // The webSearch should be set to false internally - expect(requestWithWebSearch.webSearch).toBe(false); - }); - - it('should add web search system prompt when web search enabled', async () => { - const requestWithWebSearch = { - ...baseRequest, - webSearch: true, - systemPrompt: 'Custom prompt.' - }; - - await generateChatResponse(requestWithWebSearch, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.system).toContain('Custom prompt.'); - expect(streamTextCall.system).toContain('You should search the web for current information'); - }); - - it('should add OpenAI web search tool when web search enabled for OpenAI models', async () => { - const requestWithWebSearch = { - ...baseRequest, - webSearch: true - }; - - await generateChatResponse(requestWithWebSearch, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.tools).toBeDefined(); - expect(streamTextCall.tools.web_search_preview).toEqual({ type: 'web_search_preview' }); - }); - - it('should add reasoning summary for O3 models', async () => { - const o3Request = { - ...baseRequest, - modelId: 'openai:o3' as any - }; - - await generateChatResponse(o3Request, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.providerOptions?.openai?.reasoningSummary).toBe('detailed'); - }); - - it('should add anthropic headers for anthropic models', async () => { - const anthropicModel = { - provider: 'anthropic' - }; - - mockRegistry.getLanguageModel = vi.fn().mockReturnValue(anthropicModel); - mockRegistry.getModel = vi.fn().mockReturnValue({ - id: 'anthropic:claude-4-sonnet-20250514', - provider: 'anthropic', - name: 'Anthropic Claude 4 Sonnet', - renaming: true, - toggleWebSearch: false, - streaming: true - }); - - const anthropicRequest = { - ...baseRequest, - modelId: 'anthropic:claude-4-sonnet-20250514' as any - }; - - await generateChatResponse(anthropicRequest, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.headers).toEqual({ - 'anthropic-dangerous-direct-browser-access': 'true' - }); - }); - - it('should configure Google models for web search', async () => { - const mockGoogleModel = { - provider: 'google', - settings: {} as any - }; - - mockRegistry.getLanguageModel = vi.fn().mockReturnValue(mockGoogleModel); - mockRegistry.getModel = vi.fn().mockReturnValue({ - id: 'google:gemini-2.0-flash', - provider: 'google', - name: 'Google Gemini 2.0 Flash', - renaming: false, - toggleWebSearch: true, - streaming: true - }); - - const googleRequest = { - ...baseRequest, - modelId: 'google:gemini-2.0-flash' as any, - webSearch: true - }; - - await generateChatResponse(googleRequest, mockRegistry); - - expect(mockGoogleModel.settings.useSearchGrounding).toBe(true); - }); - - it('should handle context injection', async () => { - const contextItems: ContextItemContent[] = [ - { title: 'Note 1', content: 'Content of note 1' }, - { title: 'Note 2', content: 'Content of note 2' } - ]; - - const requestWithContext = { - ...baseRequest, - context: contextItems, - systemPrompt: 'Base prompt.' - }; - - await generateChatResponse(requestWithContext, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.system).toContain('Base prompt.'); - expect(streamTextCall.system).toContain('The following documents provide additional context'); - expect(streamTextCall.system).toContain('--- Note: Note 1 ---'); - expect(streamTextCall.system).toContain('Content of note 1'); - }); - - it('should handle errors in streaming', async () => { - const errorMessage = 'Streaming error'; - (streamText as any).mockRejectedValue(new Error(errorMessage)); - - await expect(generateChatResponse(baseRequest, mockRegistry)).rejects.toThrow(errorMessage); - }); - - it('should create and store abort controller', async () => { - await generateChatResponse(baseRequest, mockRegistry); - - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.abortSignal).toBeInstanceOf(AbortSignal); - }); - }); - - describe('abort controller functionality', () => { - it('should cancel chat response', async () => { - // Start a request - const responsePromise = generateChatResponse(baseRequest, mockRegistry); - - // Cancel it - cancelChatResponse(baseRequest); - - // The abort signal should be triggered - const streamTextCall = (streamText as any).mock.calls[0][0]; - expect(streamTextCall.abortSignal).toBeInstanceOf(AbortSignal); - - await responsePromise; - }); - - it('should delete abort controller for request', async () => { - await generateChatResponse(baseRequest, mockRegistry); - - // Should not throw - deleteAbortControllerForRequest(baseRequest); - }); - - it('should handle canceling non-existent request', () => { - const nonExistentRequest = { - ...baseRequest, - requestID: 'non-existent-request' - }; - - // Should not throw - expect(() => cancelChatResponse(nonExistentRequest)).not.toThrow(); - }); - - it('should handle deleting non-existent abort controller', () => { - const nonExistentRequest = { - ...baseRequest, - requestID: 'non-existent-request' - }; - - // Should not throw - expect(() => deleteAbortControllerForRequest(nonExistentRequest)).not.toThrow(); - }); - }); - - describe('generateChatTitle', () => { - const testMessages: ModelChatMessage[] = [ - { role: 'user', content: 'How do I implement a binary search algorithm?' }, - { role: 'assistant', content: 'A binary search algorithm works by...' } - ]; - - - it('should generate a chat title', async () => { - const mockGenerateResult = { - text: 'Binary Search Implementation Guide' - }; - (generateText as any).mockResolvedValue(mockGenerateResult); - - const title = await generateChatTitle(testMessages, mockPlugin); - - expect(title).toBe('Binary Search Implementation Guide (Chat)'); - expect(generateText).toHaveBeenCalled(); - }); - - it('should return empty string when no renaming model configured', async () => { - mockPlugin.settings.renamingModel = "" as any; - - const title = await generateChatTitle(testMessages, mockPlugin); - - expect(title).toBe(''); - expect(generateText).not.toHaveBeenCalled(); - }); - - it('should sanitize forbidden characters in title', async () => { - const mockGenerateResult = { - text: 'How to use / \\ and : characters' - }; - (generateText as any).mockResolvedValue(mockGenerateResult); - - const title = await generateChatTitle(testMessages, mockPlugin); - - expect(title).toBe('How to use - - and - characters (Chat)'); - }); - - it('should truncate long titles', async () => { - const mockGenerateResult = { - text: 'This is a very long title that exceeds the fifty character limit and should be truncated' - }; - (generateText as any).mockResolvedValue(mockGenerateResult); - - const title = await generateChatTitle(testMessages, mockPlugin); - - expect(title.length).toBeLessThanOrEqual(57); // 50 chars + ' (Chat)' = 57 - }); - - it('should handle anthropic models with special headers', async () => { - mockRegistry.getLanguageModel = vi.fn().mockReturnValue({ - provider: 'anthropic.claude-4-sonnet-20250514' - }); - - const mockGenerateResult = { - text: 'Anthropic Chat Title' - }; - (generateText as any).mockResolvedValue(mockGenerateResult); - - await generateChatTitle(testMessages, mockPlugin); - - const generateTextCall = (generateText as any).mock.calls[0][0]; - expect(generateTextCall.headers).toEqual({ - 'anthropic-dangerous-direct-browser-access': 'true' - }); - }); - - it('should handle errors gracefully', async () => { - (generateText as any).mockRejectedValue(new Error('Title generation failed')); - - const title = await generateChatTitle(testMessages, mockPlugin); - - expect(title).toBe(''); - }); - - it('should use correct system prompt for title generation', async () => { - const mockGenerateResult = { - text: 'Generated Title' - }; - (generateText as any).mockResolvedValue(mockGenerateResult); - - await generateChatTitle(testMessages, mockPlugin); - - const generateTextCall = (generateText as any).mock.calls[0][0]; - expect(generateTextCall.system).toContain('Summarize the following conversation'); - expect(generateTextCall.system).toContain('six words or less'); - expect(generateTextCall.system).toContain('must not contain the characters /, \\, or :'); - }); - }); -}); \ No newline at end of file diff --git a/src/utils/__tests__/model-context.test.ts b/src/utils/__tests__/model-context.test.ts deleted file mode 100644 index b7d69bc..0000000 --- a/src/utils/__tests__/model-context.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { TFile, App } from 'obsidian'; -import { - getContext, - makeContext, - contextTokenEstimate -} from '@/utils/model-context'; -import { ContextItems, ContextItemContent, Source } from '@/types'; - -// Mock Obsidian functions -vi.mock('obsidian', async () => { - const actual = await vi.importActual('obsidian'); - return { - ...actual, - requestUrl: vi.fn(), - htmlToMarkdown: vi.fn(), - TFile: class MockTFile { - name: string; - path: string; - basename: string; - extension: string; - - constructor(path: string) { - this.name = path.split('/').pop() || ''; - this.path = path; - this.basename = this.name.replace(/\.[^/.]+$/, ''); - this.extension = path.split('.').pop() || ''; - } - } - }; -}); - -// Mock the notes utility -vi.mock('@/utils/notes', () => ({ - getFilesWithTag: vi.fn() -})); - -import { requestUrl, htmlToMarkdown } from 'obsidian'; -import { getFilesWithTag } from '@/utils/notes'; - -describe('model-context utilities', () => { - let mockApp: App; - let mockFile1: TFile; - let mockFile2: TFile; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFile1 = { - name: 'note1.md', - path: 'notes/note1.md', - basename: 'note1', - extension: 'md', - vault: null, - parent: null, - stat: { ctime: 0, mtime: 0, size: 0 } - } as any; - - mockFile2 = { - name: 'note2.md', - path: 'notes/note2.md', - basename: 'note2', - extension: 'md', - vault: null, - parent: null, - stat: { ctime: 0, mtime: 0, size: 0 } - } as any; - - mockApp = { - vault: { - cachedRead: vi.fn() - }, - metadataCache: { - getFileCache: vi.fn(), - getCache: vi.fn() - } - } as any; - }); - - describe('makeContext', () => { - it('should create context string from array of context items', () => { - const contextItems: ContextItemContent[] = [ - { title: 'Note 1', content: 'Content of first note' }, - { title: 'Note 2', content: 'Content of second note' } - ]; - - const result = makeContext(contextItems); - - expect(result).toBe( - '--- Note: Note 1 ---\nContent of first note\n---\n\n--- Note: Note 2 ---\nContent of second note\n---' - ); - }); - - it('should return empty string for empty array', () => { - const result = makeContext([]); - expect(result).toBe(''); - }); - - it('should handle single context item', () => { - const contextItems: ContextItemContent[] = [ - { title: 'Single Note', content: 'Single note content' } - ]; - - const result = makeContext(contextItems); - - expect(result).toBe('--- Note: Single Note ---\nSingle note content\n---'); - }); - - it('should handle context items with special characters', () => { - const contextItems: ContextItemContent[] = [ - { title: 'Note with: Special & Characters', content: 'Content with\n\nmultiple\nlines' } - ]; - - const result = makeContext(contextItems); - - expect(result).toContain('--- Note: Note with: Special & Characters ---'); - expect(result).toContain('Content with\n\nmultiple\nlines'); - }); - - it('should handle empty content', () => { - const contextItems: ContextItemContent[] = [ - { title: 'Empty Note', content: '' } - ]; - - const result = makeContext(contextItems); - - expect(result).toBe('--- Note: Empty Note ---\n\n---'); - }); - }); - - describe('getContext', () => { - it('should return empty array when items is null', async () => { - const result = await getContext(null, mockApp); - expect(result).toEqual([]); - }); - - it('should process notes from ContextItems', async () => { - const contextItems: ContextItems = { - notes: [mockFile1, mockFile2], - tags: [], - sources: [] - }; - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('Content of note 1') - .mockResolvedValueOnce('Content of note 2'); - - const result = await getContext(contextItems, mockApp); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - title: 'note1.md', - content: 'Content of note 1' - }); - expect(result[1]).toEqual({ - title: 'note2.md', - content: 'Content of note 2' - }); - }); - - it('should process tags and get associated files', async () => { - const contextItems: ContextItems = { - notes: [], - tags: ['#important', '#work'], - sources: [] - }; - - const tagFile1 = { ...mockFile1, name: 'tagged1.md' }; - const tagFile2 = { ...mockFile2, name: 'tagged2.md' }; - - (getFilesWithTag as any) - .mockReturnValueOnce([tagFile1]) - .mockReturnValueOnce([tagFile2]); - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('Content of tagged note 1') - .mockResolvedValueOnce('Content of tagged note 2'); - - const result = await getContext(contextItems, mockApp); - - expect(getFilesWithTag).toHaveBeenCalledWith('#important', mockApp); - expect(getFilesWithTag).toHaveBeenCalledWith('#work', mockApp); - expect(result).toHaveLength(2); - }); - - it('should process sources and fetch web content', async () => { - const contextItems: ContextItems = { - notes: [], - tags: [], - sources: [ - { url: 'https://example.com', title: 'Example Site' }, - { url: 'https://test.com' } // No title provided - ] - }; - - (requestUrl as any) - .mockResolvedValueOnce({ text: 'Example content' }) - .mockResolvedValueOnce({ text: 'Test content' }); - - (htmlToMarkdown as any) - .mockReturnValueOnce('Example content') - .mockReturnValueOnce('Test content'); - - const result = await getContext(contextItems, mockApp); - - expect(requestUrl).toHaveBeenCalledWith({ url: 'https://example.com' }); - expect(requestUrl).toHaveBeenCalledWith({ url: 'https://test.com' }); - expect(htmlToMarkdown).toHaveBeenCalledTimes(2); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - title: 'Example Site', - content: 'Example content' - }); - expect(result[1]).toEqual({ - title: 'https://test.com', - content: 'Test content' - }); - }); - - it('should handle mixed context items (notes, tags, sources)', async () => { - const taggedFile = { ...mockFile1, name: 'tagged.md' }; - - const contextItems: ContextItems = { - notes: [mockFile1], - tags: ['#important'], - sources: [{ url: 'https://example.com', title: 'Example' }] - }; - - (getFilesWithTag as any).mockReturnValueOnce([taggedFile]); - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('Direct note content') - .mockResolvedValueOnce('Tagged note content'); - - (requestUrl as any).mockResolvedValueOnce({ text: 'Web content' }); - (htmlToMarkdown as any).mockReturnValueOnce('Web content'); - - const result = await getContext(contextItems, mockApp); - - expect(result).toHaveLength(3); - expect(result[0].title).toBe('note1.md'); - expect(result[1].title).toBe('tagged.md'); - expect(result[2].title).toBe('Example'); - }); - - it('should handle duplicate files from notes and tags', async () => { - const contextItems: ContextItems = { - notes: [mockFile1], - tags: ['#important'], - sources: [] - }; - - // Tag search returns the same file that's already in notes - (getFilesWithTag as any).mockReturnValueOnce([mockFile1]); - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('Note content first time') - .mockResolvedValueOnce('Note content second time'); - - const result = await getContext(contextItems, mockApp); - - // Should include the file twice since it's in both notes and found via tags - expect(result).toHaveLength(2); - expect(result[0].title).toBe('note1.md'); - expect(result[1].title).toBe('note1.md'); - }); - - it('should handle empty file content', async () => { - const contextItems: ContextItems = { - notes: [mockFile1], - tags: [], - sources: [] - }; - - mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(''); - - const result = await getContext(contextItems, mockApp); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - title: 'note1.md', - content: '' - }); - }); - - it('should handle vault read failures gracefully', async () => { - const contextItems: ContextItems = { - notes: [mockFile1], - tags: [], - sources: [] - }; - - mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(null); - - const result = await getContext(contextItems, mockApp); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - title: 'note1.md', - content: '' - }); - }); - }); - - describe('contextTokenEstimate', () => { - it('should estimate tokens for context items', async () => { - const contextItems: ContextItems = { - notes: [mockFile1], - tags: [], - sources: [] - }; - - // Mock content that's 100 characters long - const content = 'a'.repeat(100); - mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(content); - - const result = await contextTokenEstimate(contextItems, mockApp); - - // Should be approximately content.length / 4 = 100 / 4 = 25 - expect(result).toBe(25); - }); - - it('should sum tokens from multiple context items', async () => { - const contextItems: ContextItems = { - notes: [mockFile1, mockFile2], - tags: [], - sources: [] - }; - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('a'.repeat(80)) // 20 tokens - .mockResolvedValueOnce('b'.repeat(120)); // 30 tokens - - const result = await contextTokenEstimate(contextItems, mockApp); - - expect(result).toBe(50); // 20 + 30 - }); - - it('should handle empty content', async () => { - const contextItems: ContextItems = { - notes: [mockFile1], - tags: [], - sources: [] - }; - - mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(''); - - const result = await contextTokenEstimate(contextItems, mockApp); - - expect(result).toBe(0); - }); - - it('should include tokens from all context types', async () => { - const taggedFile = { ...mockFile1, name: 'tagged.md' }; - - const contextItems: ContextItems = { - notes: [mockFile1], - tags: ['#test'], - sources: [{ url: 'https://example.com', title: 'Example' }] - }; - - (getFilesWithTag as any).mockReturnValueOnce([taggedFile]); - - mockApp.vault.cachedRead = vi.fn() - .mockResolvedValueOnce('a'.repeat(40)) // 10 tokens - .mockResolvedValueOnce('b'.repeat(80)); // 20 tokens - - (requestUrl as any).mockResolvedValueOnce({ text: 'content' }); - (htmlToMarkdown as any).mockReturnValueOnce('c'.repeat(120)); // 30 tokens - - const result = await contextTokenEstimate(contextItems, mockApp); - - expect(result).toBe(60); // 10 + 20 + 30 - }); - }); -}); \ No newline at end of file diff --git a/src/utils/__tests__/notes.test.ts b/src/utils/__tests__/notes.test.ts deleted file mode 100644 index 89270e8..0000000 --- a/src/utils/__tests__/notes.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - isCoiNote, - isPathCoiNote, - isActiveCoiNote, - isPathActiveCoiNote, - sanitizeForTagName, - serializeCoiNoteContent, - deserializeCoiNoteContent -} from '../notes'; -import { TFile, App } from 'obsidian'; - -describe('notes utilities', () => { - let mockApp: App; - let mockFile: TFile; - - beforeEach(() => { - mockApp = new App(); - mockFile = new TFile('test.md'); - }); - - describe('isCoiNote', () => { - it('should return true for COI notes', () => { - mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({ - frontmatter: { 'is-coi-chat': true } - }); - - const result = isCoiNote(mockFile, mockApp); - expect(result).toBe(true); - }); - - it('should return false for non-COI notes', () => { - mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({ - frontmatter: { 'is-coi-chat': false } - }); - - const result = isCoiNote(mockFile, mockApp); - expect(result).toBe(false); - }); - - it('should return false when frontmatter is missing', () => { - mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue(null); - - const result = isCoiNote(mockFile, mockApp); - expect(result).toBe(false); - }); - }); - - describe('isPathCoiNote', () => { - it('should return true for COI note paths', () => { - mockApp.metadataCache.getCache = vi.fn().mockReturnValue({ - frontmatter: { 'is-coi-chat': true } - }); - - const result = isPathCoiNote('test.md', mockApp); - expect(result).toBe(true); - }); - - it('should return false for empty path', () => { - const result = isPathCoiNote('', mockApp); - expect(result).toBe(false); - }); - }); - - describe('isActiveCoiNote', () => { - it('should return true for active COI notes', () => { - mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({ - frontmatter: { - 'is-coi-chat': true, - 'coi-chat-view': true - } - }); - - const result = isActiveCoiNote(mockFile, mockApp); - expect(result).toBe(true); - }); - - it('should return false for inactive COI notes', () => { - mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({ - frontmatter: { - 'is-coi-chat': true, - 'coi-chat-view': false - } - }); - - const result = isActiveCoiNote(mockFile, mockApp); - expect(result).toBe(false); - }); - }); - - describe('isPathActiveCoiNote', () => { - it('should return true for active COI note paths', () => { - mockApp.metadataCache.getCache = vi.fn().mockReturnValue({ - frontmatter: { - 'is-coi-chat': true, - 'coi-chat-view': true - } - }); - - const result = isPathActiveCoiNote('test.md', mockApp); - expect(result).toBe(true); - }); - - it('should return false for empty path', () => { - const result = isPathActiveCoiNote('', mockApp); - expect(result).toBe(false); - }); - }); - - describe('sanitizeForTagName', () => { - it('should sanitize special characters', () => { - const result = sanitizeForTagName('Hello World!@#$%'); - expect(result).toBe('hello-world'); - }); - - it('should preserve alphanumeric and allowed characters', () => { - const result = sanitizeForTagName('test_tag-123'); - expect(result).toBe('test_tag-123'); - }); - - it('should remove leading and trailing dashes', () => { - const result = sanitizeForTagName('--test-tag--'); - expect(result).toBe('test-tag'); - }); - - it('should collapse multiple dashes', () => { - const result = sanitizeForTagName('test---multiple--dashes'); - expect(result).toBe('test-multiple-dashes'); - }); - - it('should handle empty string', () => { - const result = sanitizeForTagName(''); - expect(result).toBe(''); - }); - - it('should trim whitespace', () => { - const result = sanitizeForTagName(' test tag '); - expect(result).toBe('test-tag'); - }); - }); - - describe('serializeCoiNoteContent', () => { - it('should serialize basic chat messages', async () => { - const currentContent = '\n\n'; - const messages = [ - { role: 'user' as const, content: 'Hello' }, - { role: 'assistant' as const, content: 'Hi there!' } - ]; - - const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null); - - expect(result).toContain('## user:\n\nHello'); - expect(result).toContain('## assistant:\n\nHi there!'); - }); - - it('should include sources when provided', async () => { - const currentContent = '\n\n'; - const messages = [ - { role: 'user' as const, content: 'Question' } - ]; - const sources = [ - { title: 'Test Source', url: 'https://example.com' } - ]; - - const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null, sources); - - expect(result).toContain('## Sources'); - expect(result).toContain('1. [Test Source](https://example.com)'); - }); - - it('should preserve content before and after chat section', async () => { - const currentContent = 'Before\n\n\n\nAfter'; - const messages = [ - { role: 'user' as const, content: 'Test' } - ]; - - const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null); - - expect(result.startsWith('Before')).toBe(true); - expect(result.endsWith('After')).toBe(true); - }); - - it('should adjust header levels in content', async () => { - const currentContent = '\n\n'; - const messages = [ - { role: 'user' as const, content: '# Main Header\n## Sub Header' } - ]; - - const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null); - - expect(result).toContain('### Main Header'); - expect(result).toContain('#### Sub Header'); - }); - }); - - describe('deserializeCoiNoteContent', () => { - it('should deserialize basic chat messages', async () => { - const content = ` - -## user: - -Hello - -## assistant: - -Hi there! - - `.trim(); - - const result = await deserializeCoiNoteContent(content, null, mockApp); - - expect(result.messages).toHaveLength(2); - expect(result.messages[0]).toEqual({ role: 'user', content: 'Hello' }); - expect(result.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' }); - }); - - it('should deserialize sources', async () => { - const content = ` - -## user: - -Question - -## Sources - -1. [Test Source](https://example.com) -2. [Another Source](https://test.com) - - `.trim(); - - const result = await deserializeCoiNoteContent(content, null, mockApp); - - expect(result.sources).toHaveLength(2); - expect(result.sources[0]).toEqual({ title: 'Test Source', url: 'https://example.com' }); - expect(result.sources[1]).toEqual({ title: 'Another Source', url: 'https://test.com' }); - }); - - it('should return empty result when no chat section found', async () => { - const content = 'Regular note content without chat section'; - - const result = await deserializeCoiNoteContent(content, null, mockApp); - - expect(result.messages).toHaveLength(0); - expect(result.sources).toHaveLength(0); - expect(result.contextItems).toEqual({ notes: [], tags: [], sources: [] }); - }); - - it('should handle context items from frontmatter', async () => { - const content = '\n'; - const metadata = { - frontmatter: { - 'linked-notes': ['note1.md', 'note2.md'], - 'linked-tags': ['#tag1', '#tag2'] - } - }; - - // Mock files for linked notes - mockApp.vault.getAbstractFileByPath = vi.fn() - .mockReturnValueOnce(new TFile('note1.md')) - .mockReturnValueOnce(new TFile('note2.md')); - - const result = await deserializeCoiNoteContent(content, metadata, mockApp); - - expect(result.contextItems.notes).toHaveLength(2); - expect(result.contextItems.tags).toEqual(['#tag1', '#tag2']); - }); - }); -}); \ No newline at end of file diff --git a/src/utils/__tests__/url.test.ts b/src/utils/__tests__/url.test.ts deleted file mode 100644 index 3dc0e2f..0000000 --- a/src/utils/__tests__/url.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getDomainFromUrl, ensureSourceTitle } from '../url'; - -describe('url utilities', () => { - describe('getDomainFromUrl', () => { - it('should extract domain from valid HTTPS URL', () => { - const result = getDomainFromUrl('https://www.example.com/path/to/page'); - expect(result).toBe('www.example.com'); - }); - - it('should extract domain from valid HTTP URL', () => { - const result = getDomainFromUrl('http://example.org/some/path'); - expect(result).toBe('example.org'); - }); - - it('should extract domain without protocol', () => { - const result = getDomainFromUrl('example.com/path'); - expect(result).toBe('example.com'); - }); - - it('should handle URLs with www prefix', () => { - const result = getDomainFromUrl('www.test-site.co.uk'); - expect(result).toBe('test-site.co.uk'); - }); - - it('should handle URLs with ports', () => { - const result = getDomainFromUrl('https://localhost:3000/api/endpoint'); - expect(result).toBe('localhost'); - }); - - it('should fallback to manual parsing for invalid URLs', () => { - const result = getDomainFromUrl('not-a-valid-url'); - expect(result).toBe('not-a-valid-url'); - }); - - it('should handle empty string', () => { - const result = getDomainFromUrl(''); - expect(result).toBe(''); - }); - - it('should handle complex URLs with subdomains', () => { - const result = getDomainFromUrl('https://api.subdomain.example.com/v1/data'); - expect(result).toBe('api.subdomain.example.com'); - }); - }); - - describe('ensureSourceTitle', () => { - it('should keep existing title when provided', () => { - const source = { url: 'https://example.com', title: 'My Title' }; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://example.com', - title: 'My Title' - }); - }); - - it('should trim whitespace from existing title', () => { - const source = { url: 'https://example.com', title: ' My Title ' }; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://example.com', - title: 'My Title' - }); - }); - - it('should use domain as fallback when title is empty', () => { - const source = { url: 'https://www.example.com/page', title: '' }; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://www.example.com/page', - title: 'www.example.com' - }); - }); - - it('should use domain as fallback when title is only whitespace', () => { - const source = { url: 'https://example.org/path', title: ' ' }; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://example.org/path', - title: 'example.org' - }); - }); - - it('should use domain as fallback when title is missing', () => { - const source = { url: 'https://test.com/api/data' }; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://test.com/api/data', - title: 'test.com' - }); - }); - - it('should preserve other properties from source', () => { - const source = { - url: 'https://example.com', - title: 'Test', - customProp: 'value' - } as any; - const result = ensureSourceTitle(source); - - expect(result).toEqual({ - url: 'https://example.com', - title: 'Test', - customProp: 'value' - }); - }); - }); -}); \ No newline at end of file diff --git a/test/component-testing-guide.md b/test/component-testing-guide.md deleted file mode 100644 index 598eb4e..0000000 --- a/test/component-testing-guide.md +++ /dev/null @@ -1,328 +0,0 @@ -# Component Testing Guide - -This guide provides patterns and best practices for testing Solid.js components in the Co-Intelligence AI plugin. - -## Overview - -Component testing in this project uses: -- **@solidjs/testing-library** - Solid-specific testing utilities -- **@testing-library/user-event** - User interaction simulation -- **Custom render function** - Provides necessary contexts and mocks - -## Testing Patterns - -### 1. Simple Components - -For components with minimal dependencies and straightforward behavior: - -```typescript -// Example: ProcessingIndicator.test.tsx -import { render } from '../../../test/utils/render'; - -describe('ProcessingIndicator', () => { - it('should render processing indicator with text', () => { - render(() => ); - - expect(screen.getByText('Generating response...')).toBeInTheDocument(); - }); -}); -``` - -### 2. Components with Obsidian Dependencies - -For components that use Obsidian APIs (like `setIcon`): - -```typescript -// Mock Obsidian functions -const mockSetIcon = vi.fn(); -vi.mock('obsidian', async () => { - const actual = await vi.importActual('obsidian'); - return { - ...actual, - setIcon: mockSetIcon, - }; -}); - -describe('LucideIcon', () => { - it('should call setIcon with correct parameters', async () => { - render(() => ); - - await new Promise(resolve => setTimeout(resolve, 0)); // Wait for onMount - - expect(mockSetIcon).toHaveBeenCalledWith( - expect.any(HTMLElement), - 'home' - ); - }); -}); -``` - -### 3. Components with Context Dependencies - -For components that use context (App, Plugin): - -```typescript -import { render, createMockPlugin, createMockApp } from '../../../test/utils/render'; - -describe('ModelSelector', () => { - it('should render with plugin context', () => { - render(() => - , - { - plugin: createMockPlugin(), - app: createMockApp() - } - ); - }); -}); -``` - -### 4. Complex Components with Services - -For components that interact with services: - -```typescript -// Mock the service -const mockModelRegistry = { - availableModels: [], - getInstance: vi.fn(), -}; - -vi.mock('@/services/model-registry', () => ({ - ModelRegistry: mockModelRegistry -})); - -describe('UserInput', () => { - beforeEach(() => { - mockModelRegistry.getInstance.mockReturnValue(mockModelRegistry); - }); -}); -``` - -### 5. Testing User Interactions - -Use `@testing-library/user-event` for realistic interactions: - -```typescript -import userEvent from '@testing-library/user-event'; - -it('should submit on Enter key', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - - render(() => ); - - const textarea = screen.getByRole('textbox'); - await user.type(textarea, 'Test message{Enter}'); - - expect(onSubmit).toHaveBeenCalledWith('Test message', false, ''); -}); -``` - -### 6. Testing Reactive State - -For components with Solid.js signals: - -```typescript -import { createSignal } from 'solid-js'; - -it('should display selected model', () => { - const [selectedModel, setSelectedModel] = createSignal(mockModel); - - render(() => - - ); - - expect(screen.getByRole('combobox')).toHaveValue('openai:gpt-4'); -}); -``` - -## Mock Strategies - -### 1. Service Mocking - -Always mock external services to isolate component behavior: - -```typescript -vi.mock('@/services/model-registry', () => ({ - ModelRegistry: { - getInstance: () => mockRegistry, - } -})); -``` - -### 2. Child Component Mocking - -Mock complex child components to focus on the component under test: - -```typescript -vi.mock('@/components/ModelSelector', () => ({ - ModelSelector: ({ onModelChange }: any) => ( - - ) -})); -``` - -### 3. Obsidian API Mocking - -Mock only the Obsidian APIs your component actually uses: - -```typescript -class MockNoteLinkSuggestionModal { - constructor(public app: any, public query: string, public onSelect: Function) {} - open() { this.onSelect(new TFile('/test.md')); } - onClose = vi.fn(); -} - -vi.mock('@/components/NoteLinkSuggestionModal', () => ({ - NoteLinkSuggestionModal: MockNoteLinkSuggestionModal -})); -``` - -## Best Practices - -### 1. Test User-Facing Behavior - -Focus on what users see and interact with, not implementation details: - -```typescript -// Good - tests user-facing behavior -expect(screen.getByRole('button')).toBeInTheDocument(); - -// Bad - tests implementation details -expect(wrapper.find('.internal-class')).toExist(); -``` - -### 2. Use Meaningful Test Names - -Test names should describe the expected behavior: - -```typescript -// Good -it('should disable textarea when no models are available') - -// Bad -it('should work correctly') -``` - -### 3. Arrange-Act-Assert Pattern - -Structure tests clearly: - -```typescript -it('should toggle web search checkbox', async () => { - // Arrange - const user = userEvent.setup(); - render(() => ); - - // Act - const checkbox = screen.getByRole('checkbox'); - await user.click(checkbox); - - // Assert - expect(checkbox).toBeChecked(); -}); -``` - -### 4. Clean Up Between Tests - -Use `beforeEach` to reset mocks and state: - -```typescript -beforeEach(() => { - mockService.method.mockClear(); - mockService.availableItems = []; -}); -``` - -### 5. Test Error Conditions - -Don't forget to test error states and edge cases: - -```typescript -it('should throw error when plugin context is not available', () => { - expect(() => - render(() => ), - { plugin: null } - ).toThrow('Plugin Context is not available'); -}); -``` - -### 6. Use Accessibility-Friendly Queries - -Prefer queries that match how users interact with your app: - -```typescript -// Good - uses semantic role -screen.getByRole('textbox') -screen.getByRole('button', { name: 'Submit' }) - -// Good - uses label text -screen.getByLabelText('Model selector') - -// Okay - uses visible text -screen.getByText('Cancel') - -// Avoid - uses test IDs (only when necessary) -screen.getByTestId('complex-component') -``` - -## Common Pitfalls - -### 1. Over-Mocking -Don't mock everything - focus on external dependencies and complex children. - -### 2. Testing Implementation Details -Test what users see, not how the code works internally. - -### 3. Ignoring Async Operations -Always await user interactions and effects: - -```typescript -await user.click(button); -await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument()); -``` - -### 4. Not Cleaning Up -Reset mocks and clear state between tests to avoid test interdependence. - -### 5. Overly Complex Tests -Keep tests focused on one behavior at a time. Split complex scenarios into multiple tests. - -## File Organization - -``` -src/components/ -├── ComponentName.tsx -└── __tests__/ - └── ComponentName.test.tsx -``` - -Each component should have its test file in a `__tests__` directory within the same folder as the component. - -## Running Tests - -```bash -# Run all component tests -npm run test src/components - -# Run tests with UI -npm run test:ui - -# Run tests with coverage -npm run test:coverage -``` - -## Examples - -See the following test files for complete examples: -- `src/components/__tests__/ProcessingIndicator.test.tsx` - Simple component -- `src/components/__tests__/LucideIcon.test.tsx` - Component with Obsidian dependency -- `src/components/__tests__/ModelSelector.test.tsx` - Component with service dependency -- `src/components/__tests__/UserInput.test.tsx` - Complex component with multiple interactions -- `src/components/__tests__/ChatInterface.test.tsx` - Integration-style component test \ No newline at end of file diff --git a/test/mocks/obsidian.ts b/test/mocks/obsidian.ts deleted file mode 100644 index 8660903..0000000 --- a/test/mocks/obsidian.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { vi } from 'vitest'; - -// Minimal Obsidian API mocks - only implement what we actually use - -export class TFile { - path: string; - basename: string; - extension: string; - name: string; - parent: TFolder | null = null; - - constructor(path: string) { - this.path = path; - this.name = path.split('/').pop() || ''; - this.basename = this.name.replace(/\.[^/.]+$/, ''); - this.extension = path.split('.').pop() || ''; - - // Set parent if path has directories - const parentPath = path.split('/').slice(0, -1).join('/'); - if (parentPath) { - this.parent = new TFolder(parentPath); - } - } -} - -export class TFolder { - path: string; - name: string; - - constructor(path: string) { - this.path = path; - this.name = path.split('/').pop() || ''; - } -} - -export class Plugin { - app: App; - manifest: any; - - constructor(app: App, manifest: any) { - this.app = app; - this.manifest = manifest; - } - - addCommand = vi.fn().mockReturnValue(this); - addSettingTab = vi.fn().mockReturnValue(this); - registerView = vi.fn().mockReturnValue(this); - saveData = vi.fn().mockResolvedValue(undefined); - loadData = vi.fn().mockResolvedValue({}); - onload = vi.fn(); - onunload = vi.fn(); -} - -export class App { - vault = { - create: vi.fn().mockResolvedValue(new TFile('test.md')), - modify: vi.fn().mockResolvedValue(undefined), - read: vi.fn().mockResolvedValue(''), - cachedRead: vi.fn().mockResolvedValue(''), - getMarkdownFiles: vi.fn().mockReturnValue([]), - delete: vi.fn().mockResolvedValue(undefined), - exists: vi.fn().mockReturnValue(true), - getAbstractFileByPath: vi.fn(), - createFolder: vi.fn().mockResolvedValue(new TFolder('test')), - }; - - workspace = { - getLeaf: vi.fn().mockReturnValue({ - view: null, - setViewState: vi.fn().mockResolvedValue(undefined), - openFile: vi.fn().mockResolvedValue(undefined), - }), - activeLeaf: null, - on: vi.fn(), - off: vi.fn(), - trigger: vi.fn(), - getLeavesOfType: vi.fn().mockReturnValue([]), - detachLeavesOfType: vi.fn(), - }; - - metadataCache = { - getFileCache: vi.fn().mockReturnValue(null), - getCache: vi.fn().mockReturnValue(null), - on: vi.fn(), - off: vi.fn(), - }; - - fileManager = { - processFrontMatter: vi.fn(), - generateMarkdownLink: vi.fn(), - renameFile: vi.fn().mockResolvedValue(undefined), - }; -} - -export class WorkspaceLeaf { - view: any = null; - - setViewState = vi.fn().mockResolvedValue(undefined); - detach = vi.fn(); - getViewState = vi.fn().mockReturnValue({}); -} - -export class ItemView { - app: App; - leaf: WorkspaceLeaf; - - constructor(leaf: WorkspaceLeaf) { - this.leaf = leaf; - this.app = leaf.view?.app || new App(); - } - - onOpen = vi.fn().mockResolvedValue(undefined); - onClose = vi.fn().mockResolvedValue(undefined); - getDisplayText = vi.fn().mockReturnValue('Test View'); - getViewType = vi.fn().mockReturnValue('test-view'); -} - -export class TextFileView extends ItemView { - file: TFile | null = null; - - constructor(leaf: WorkspaceLeaf) { - super(leaf); - } - - onLoadFile = vi.fn().mockResolvedValue(undefined); - onUnloadFile = vi.fn().mockResolvedValue(undefined); - getViewData = vi.fn().mockReturnValue(''); - setViewData = vi.fn(); - clear = vi.fn(); -} - -export class PluginSettingTab { - app: App; - plugin: Plugin; - - constructor(app: App, plugin: Plugin) { - this.app = app; - this.plugin = plugin; - } - - display = vi.fn(); - hide = vi.fn(); -} - -export class Setting { - constructor(containerEl: HTMLElement) {} - - setName = vi.fn().mockReturnThis(); - setDesc = vi.fn().mockReturnThis(); - addText = vi.fn().mockReturnThis(); - addToggle = vi.fn().mockReturnThis(); - addDropdown = vi.fn().mockReturnThis(); -} - -export class SuggestModal { - app: App; - - constructor(app: App) { - this.app = app; - } - - open = vi.fn(); - close = vi.fn(); - getSuggestions = vi.fn().mockReturnValue([]); - renderSuggestion = vi.fn(); - onChooseSuggestion = vi.fn(); -} - -export class Modal { - app: App; - - constructor(app: App) { - this.app = app; - } - - open = vi.fn(); - close = vi.fn(); - onOpen = vi.fn(); - onClose = vi.fn(); -} - -// Mock global functions -export const addIcon = vi.fn(); -export const debounce = vi.fn((fn: Function, delay: number) => fn); -export const normalizePath = vi.fn((path: string) => path); -export const parseFrontMatterEntry = vi.fn(); -export const parseFrontMatterStringArray = vi.fn(); -export const parseFrontMatterTags = vi.fn(); - -// Default export for compatibility -export default { - TFile, - TFolder, - Plugin, - App, - WorkspaceLeaf, - ItemView, - TextFileView, - PluginSettingTab, - Setting, - SuggestModal, - Modal, - addIcon, - debounce, - normalizePath, - parseFrontMatterEntry, - parseFrontMatterStringArray, - parseFrontMatterTags, -}; \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts deleted file mode 100644 index d17a2af..0000000 --- a/test/setup.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { vi, afterEach, expect } from "vitest"; -import '@testing-library/jest-dom'; - -// Global test setup and configuration -global.console = { - ...console, - // Suppress console.log in tests unless needed for debugging - log: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: console.error, // Keep error logging for debugging -}; - -// Add Obsidian extensions to built-in types -declare global { - interface String { - contains(searchString: string): boolean; - } -} - -// Polyfill for Obsidian's String.contains method -String.prototype.contains = function (searchString: string): boolean { - return this.includes(searchString); -}; - -// Clean up after each test -afterEach(() => { - vi.clearAllMocks(); - vi.clearAllTimers(); - vi.useRealTimers(); -}); diff --git a/test/testing.md b/test/testing.md deleted file mode 100644 index 3c35966..0000000 --- a/test/testing.md +++ /dev/null @@ -1,273 +0,0 @@ -# Testing Guide for Co-Intelligence AI - -This document provides instructions for running tests and understanding the testing infrastructure for the Co-Intelligence AI Obsidian plugin. - -## Quick Start - -```bash -# Run all tests -npm test - -# Run tests in watch mode (interactive) -npm test - -# Run tests once and exit -npm run test:run - -# Run tests with coverage report -npm run test:coverage - -# Open interactive test UI in browser -npm run test:ui -``` - -## Testing Commands - -### Basic Testing -- `npm test` - Run tests in watch mode (recommended for development) -- `npm run test:run` - Run all tests once and exit (used in CI) - -### Coverage & Analysis -- `npm run test:coverage` - Generate detailed coverage report (HTML + terminal) -- `npm run test:ui` - Open Vitest UI for interactive test exploration - -### Development Tips -- Use `npm test` during development - it watches for file changes and re-runs tests automatically -- Use `npm run test:ui` for debugging complex test scenarios with the visual interface -- Check `coverage/index.html` after running coverage command for detailed HTML report - -## Directory Structure - -``` -test/ -├── testing.md # This guide -├── setup.ts # Global test setup and configuration -├── mocks/ # Mock implementations -│ └── obsidian.ts # Obsidian API mocks -├── fixtures/ # Test data (planned) -│ ├── chat-data.ts # Sample chat messages -│ ├── ai-responses.ts # Mock AI responses -│ └── notes.ts # Sample Obsidian notes -└── utils/ # Test utilities - ├── test-helpers.ts # Common helper functions - └── render.tsx # Custom Solid.js render utilities - -src/ -├── **/__tests__/ # Unit tests (co-located with source) -│ ├── *.test.ts # TypeScript/utility tests -│ └── *.test.tsx # Component tests -└── ... -``` - -## Testing Approach - -### 1. Unit Tests (Current Focus) -**Location**: `src/**/__tests__/*.test.ts` -**Purpose**: Test individual functions and utilities in isolation - -Examples: -- `src/utils/__tests__/debounce.test.ts` - Debounce function logic -- `src/utils/__tests__/url.test.ts` - URL parsing and validation -- `src/utils/__tests__/notes.test.ts` - Note serialization/deserialization - -### 2. Service Tests (Phase 2) -**Location**: `src/services/__tests__/*.test.ts` -**Purpose**: Test core business logic with mocked dependencies - -Planned tests: -- Model registry behavior -- AI streaming with abort handling -- Context preparation for prompts - -### 3. Component Tests (Phase 3) -**Location**: `src/components/__tests__/*.test.tsx` -**Purpose**: Test Solid.js components with user interactions - -Planned tests: -- User input handling -- Message rendering -- Model selector behavior - -## Mock Strategy - -### Obsidian API Mocks -We use minimal mocks in `test/mocks/obsidian.ts` that only implement methods actually used by our code: - -```typescript -// Example: Mock only what you need -export class App { - vault = { - create: vi.fn().mockResolvedValue(new TFile('test.md')), - read: vi.fn().mockResolvedValue(''), - // ... only mocked methods we use - }; -} -``` - -### AI SDK Testing -The AI SDK provides built-in testing utilities: -```typescript -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test'; -``` - -### Obsidian Extensions -We polyfill Obsidian-specific extensions in `test/setup.ts`: -```typescript -// Adds String.contains() method used by Obsidian -String.prototype.contains = function(searchString: string): boolean { - return this.includes(searchString); -}; -``` - -## Writing Tests - -### Basic Test Structure -```typescript -import { describe, it, expect, vi } from 'vitest'; -import { functionToTest } from '../source-file'; - -describe('functionToTest', () => { - it('should do something specific', () => { - const result = functionToTest('input'); - expect(result).toBe('expected'); - }); -}); -``` - -### Testing Async Functions -```typescript -it('should handle async operations', async () => { - const result = await asyncFunction(); - expect(result).toBeDefined(); -}); -``` - -### Mocking Dependencies -```typescript -import { vi } from 'vitest'; - -const mockFn = vi.fn().mockReturnValue('mocked result'); -const mockAsync = vi.fn().mockResolvedValue('async result'); -``` - -### Testing Solid.js Components (Phase 3) -```typescript -import { render, screen } from '@solidjs/testing-library'; -import { MyComponent } from '../MyComponent'; - -it('should render correctly', () => { - render(() => ); - expect(screen.getByText('Expected Text')).toBeInTheDocument(); -}); -``` - -## Configuration Files - -### vitest.config.ts -Main test configuration with: -- Solid.js plugin integration -- Path aliases matching main Vite config -- Obsidian mock aliasing -- Coverage settings -- jsdom environment for DOM testing - -### test/setup.ts -Global test setup that runs before all tests: -- Console suppression for cleaner test output -- Obsidian API extensions (String.contains, etc.) -- Mock cleanup between tests - -## Coverage Goals - -### Current Coverage (Phase 1) -- ✅ **debounce.ts**: 100% coverage -- ✅ **url.ts**: 100% coverage -- ✅ **notes.ts**: 46% coverage (core functions tested) - -### Target Coverage (Future Phases) -- **Services**: 90%+ coverage -- **Components**: 70%+ coverage -- **Overall**: 60%+ coverage - -Coverage reports help identify untested code paths and ensure quality. - -## Best Practices - -### DO: -- ✅ Test behavior, not implementation details -- ✅ Use descriptive test names: `should handle empty input gracefully` -- ✅ Mock external dependencies (Obsidian API, network calls) -- ✅ Test edge cases and error conditions -- ✅ Keep tests fast and independent - -### DON'T: -- ❌ Test framework internals (Solid.js reactivity, Obsidian's behavior) -- ❌ Create elaborate mocks that mirror entire APIs -- ❌ Write slow tests with real file I/O or network requests -- ❌ Make tests depend on each other's state - -## Troubleshooting - -### Common Issues - -#### Tests Not Finding Obsidian Imports -**Solution**: Check that `vitest.config.ts` has the obsidian alias: -```typescript -resolve: { - alias: { - 'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts') - } -} -``` - -#### Solid.js Components Not Rendering -**Solution**: Ensure jsdom environment is configured: -```typescript -test: { - environment: 'jsdom' -} -``` - -#### Missing Mock Methods -**Solution**: Add methods to `test/mocks/obsidian.ts` as you discover them: -```typescript -// Add missing methods as needed -someObsidianClass = { - newMethod: vi.fn().mockReturnValue('default'), - // ... -} -``` - -#### String.contains() Not Found -This is handled in `test/setup.ts` but if you see this error, ensure setup files are properly configured in `vitest.config.ts`. - -## Future Phases - -### Phase 2: Core Services Testing -- Model registry singleton behavior -- AI streaming with real response simulation -- Context injection and preparation -- Error handling and abort scenarios - -### Phase 3: Component Testing -- User interaction simulation -- State management testing -- Integration between components -- Accessibility testing - -### Phase 4: Integration & E2E -- Complete workflow testing -- Plugin lifecycle testing -- Settings persistence testing -- CI/CD pipeline integration - -## Resources - -- [Vitest Documentation](https://vitest.dev/) -- [Solid.js Testing Library](https://github.com/solidjs/solid-testing-library) -- [AI SDK Testing Guide](https://sdk.vercel.ai/docs) -- [Testing Implementation Plan](../testing-implementation.md) - Full implementation strategy - ---- - -For questions or issues with testing, refer to the main implementation plan or create an issue in the repository. \ No newline at end of file diff --git a/test/utils/render.tsx b/test/utils/render.tsx deleted file mode 100644 index 5258348..0000000 --- a/test/utils/render.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render as solidRender } from '@solidjs/testing-library'; -import { JSX, createContext } from 'solid-js'; -import { App } from 'obsidian'; -import { vi } from 'vitest'; - -// Mock contexts that components might need -export const MockAppContext = createContext(); -export const MockPluginContext = createContext(); - -// Mock App instance for testing -export const createMockApp = (): App => ({ - vault: { - create: vi.fn(), - modify: vi.fn(), - read: vi.fn(), - getMarkdownFiles: vi.fn(() => []), - }, - workspace: { - getLeaf: vi.fn(), - activeLeaf: null, - }, - metadataCache: { - getFileCache: vi.fn(), - }, -} as any); - -// Mock Plugin instance for testing -export const createMockPlugin = () => ({ - app: createMockApp(), - manifest: {}, - addCommand: vi.fn(), - addSettingTab: vi.fn(), - registerView: vi.fn(), - saveData: vi.fn(() => Promise.resolve()), - loadData: vi.fn(() => Promise.resolve({})), -}); - -// Custom render function with providers -export const render = ( - component: () => JSX.Element, - options?: { - app?: App; - plugin?: any; - } -) => { - const mockApp = options?.app || createMockApp(); - const mockPlugin = options?.plugin || createMockPlugin(); - - const WrappedComponent = () => ( - - - {component()} - - - ); - - return solidRender(WrappedComponent); -}; \ No newline at end of file diff --git a/test/utils/test-helpers.ts b/test/utils/test-helpers.ts deleted file mode 100644 index db2da01..0000000 --- a/test/utils/test-helpers.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { vi } from 'vitest'; - -// Common test utilities - -export const mockFn = any>() => vi.fn, ReturnType>(); - -export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -export const createMockFile = (path: string) => ({ - path, - basename: path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '', - extension: path.split('.').pop() || '', - name: path.split('/').pop() || '', -}); \ No newline at end of file