diff --git a/src/services/__tests__/model-registry.test.ts b/src/services/__tests__/model-registry.test.ts new file mode 100644 index 0000000..34fcb13 --- /dev/null +++ b/src/services/__tests__/model-registry.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the AI SDK providers before importing ModelRegistry +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn().mockReturnValue({ id: "openai" }), +})); +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: vi.fn().mockReturnValue({ id: "anthropic" }), +})); +vi.mock("@ai-sdk/google", () => ({ + createGoogleGenerativeAI: vi.fn().mockReturnValue({ id: "google" }), +})); +vi.mock("@ai-sdk/perplexity", () => ({ + createPerplexity: vi.fn().mockReturnValue({ id: "perplexity" }), +})); +vi.mock("ai", () => ({ + createProviderRegistry: vi.fn().mockReturnValue({ + languageModel: vi.fn().mockReturnValue({ modelId: "mock-model" }), + providers: {}, + }), +})); + +import { ModelRegistry } from "@/services/model-registry"; +import type { CoIntelligencePlugin } from "@/CoIntelligencePlugin"; +import { createProviderRegistry } from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createPerplexity } from "@ai-sdk/perplexity"; + +function createMockPlugin( + settings: Partial = {}, +): CoIntelligencePlugin { + return { + settings: { + openaiApiKey: "", + anthropicApiKey: "", + googleApiKey: "", + perplexityApiKey: "", + defaultFolder: "coi", + defaultModel: "", + renamingModel: "", + systemPromptFolder: "coi/prompts", + defaultSystemPromptNote: "", + ...settings, + }, + } as unknown as CoIntelligencePlugin; +} + +describe("ModelRegistry", () => { + beforeEach(() => { + // Reset the singleton between tests + // @ts-expect-error accessing private static for test reset + ModelRegistry.instance = null; + vi.clearAllMocks(); + }); + + afterEach(() => { + // @ts-expect-error accessing private static for test reset + ModelRegistry.instance = null; + }); + + describe("getInstance", () => { + it("creates a new instance when none exists", () => { + const plugin = createMockPlugin({ openaiApiKey: "test-key" }); + const registry = ModelRegistry.getInstance(plugin); + expect(registry).toBeInstanceOf(ModelRegistry); + }); + + it("returns same instance on subsequent calls", () => { + const plugin = createMockPlugin({ openaiApiKey: "test-key" }); + const registry1 = ModelRegistry.getInstance(plugin); + const registry2 = ModelRegistry.getInstance(); + expect(registry1).toBe(registry2); + }); + + it("throws 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("initializes OpenAI provider when API key is set", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-test" }); + ModelRegistry.getInstance(plugin); + expect(createOpenAI).toHaveBeenCalledWith({ apiKey: "sk-test" }); + }); + + it("initializes Anthropic provider when API key is set", () => { + const plugin = createMockPlugin({ anthropicApiKey: "ant-test" }); + ModelRegistry.getInstance(plugin); + expect(createAnthropic).toHaveBeenCalledWith({ apiKey: "ant-test" }); + }); + + it("initializes Google provider when API key is set", () => { + const plugin = createMockPlugin({ googleApiKey: "ggl-test" }); + ModelRegistry.getInstance(plugin); + expect(createGoogleGenerativeAI).toHaveBeenCalledWith({ + apiKey: "ggl-test", + }); + }); + + it("initializes Perplexity provider when API key is set", () => { + const plugin = createMockPlugin({ perplexityApiKey: "pplx-test" }); + ModelRegistry.getInstance(plugin); + expect(createPerplexity).toHaveBeenCalledWith({ apiKey: "pplx-test" }); + }); + + it("does not initialize providers without API keys", () => { + const plugin = createMockPlugin(); + ModelRegistry.getInstance(plugin); + expect(createOpenAI).not.toHaveBeenCalled(); + expect(createAnthropic).not.toHaveBeenCalled(); + expect(createGoogleGenerativeAI).not.toHaveBeenCalled(); + expect(createPerplexity).not.toHaveBeenCalled(); + }); + + it("initializes all providers when all keys are set", () => { + const plugin = createMockPlugin({ + openaiApiKey: "sk-1", + anthropicApiKey: "ant-1", + googleApiKey: "ggl-1", + perplexityApiKey: "pplx-1", + }); + ModelRegistry.getInstance(plugin); + expect(createOpenAI).toHaveBeenCalled(); + expect(createAnthropic).toHaveBeenCalled(); + expect(createGoogleGenerativeAI).toHaveBeenCalled(); + expect(createPerplexity).toHaveBeenCalled(); + }); + + it("passes all initialized providers to createProviderRegistry", () => { + const plugin = createMockPlugin({ + openaiApiKey: "sk-1", + anthropicApiKey: "ant-1", + }); + ModelRegistry.getInstance(plugin); + expect(createProviderRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + openai: expect.anything(), + anthropic: expect.anything(), + }), + ); + }); + }); + + describe("availableModels", () => { + it("lists only models for initialized providers", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + expect(registry.availableModels.length).toBeGreaterThan(0); + for (const model of registry.availableModels) { + expect(model.provider).toBe("openai"); + } + }); + + it("includes models from multiple providers", () => { + const plugin = createMockPlugin({ + openaiApiKey: "sk-1", + anthropicApiKey: "ant-1", + }); + const registry = ModelRegistry.getInstance(plugin); + const providers = new Set(registry.availableModels.map((m) => m.provider)); + expect(providers).toContain("openai"); + expect(providers).toContain("anthropic"); + }); + + it("returns empty list when no providers initialized", () => { + const plugin = createMockPlugin(); + const registry = ModelRegistry.getInstance(plugin); + expect(registry.availableModels).toHaveLength(0); + }); + }); + + describe("getModel", () => { + it("returns model by ID", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + const model = registry.getModel("openai:gpt-4o"); + expect(model.id).toBe("openai:gpt-4o"); + expect(model.provider).toBe("openai"); + }); + + it("throws for unknown model ID", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + expect(() => + registry.getModel("openai:nonexistent" as never), + ).toThrow("Model not found"); + }); + }); + + describe("getDefaultModel", () => { + it("returns first available model", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + const model = registry.getDefaultModel(); + expect(model).not.toBeNull(); + expect(model!.provider).toBe("openai"); + }); + + it("returns null when no models available", () => { + const plugin = createMockPlugin(); + const registry = ModelRegistry.getInstance(plugin); + expect(registry.getDefaultModel()).toBeNull(); + }); + }); + + describe("hasInitializedProviders", () => { + it("returns true when providers are initialized", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + expect(registry.hasInitializedProviders()).toBe(true); + }); + + it("returns false when no providers initialized", () => { + const plugin = createMockPlugin(); + const registry = ModelRegistry.getInstance(plugin); + expect(registry.hasInitializedProviders()).toBe(false); + }); + }); + + describe("reinitialize", () => { + it("re-initializes providers from current settings", () => { + const plugin = createMockPlugin({ openaiApiKey: "sk-1" }); + const registry = ModelRegistry.getInstance(plugin); + + vi.clearAllMocks(); + registry.reinitialize(); + + expect(createOpenAI).toHaveBeenCalledWith({ apiKey: "sk-1" }); + expect(createProviderRegistry).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/__tests__/model-service.test.ts b/src/services/__tests__/model-service.test.ts new file mode 100644 index 0000000..5ac62b3 --- /dev/null +++ b/src/services/__tests__/model-service.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("ai", () => ({ + streamText: vi.fn().mockReturnValue({ + textStream: (async function* () { + yield "Hello"; + })(), + text: Promise.resolve("Hello"), + }), + generateText: vi.fn().mockResolvedValue({ text: "Chat summary title" }), + createProviderRegistry: vi.fn().mockReturnValue({ + languageModel: vi.fn().mockReturnValue({ + modelId: "mock-model", + provider: "openai", + }), + providers: {}, + }), +})); + +vi.mock("@ai-sdk/openai", () => ({ + openai: { + tools: { + webSearchPreview: vi.fn().mockReturnValue({ type: "web_search" }), + }, + }, + createOpenAI: vi.fn().mockReturnValue({ id: "openai" }), +})); + +vi.mock("@ai-sdk/google", () => ({ + google: { + tools: { + googleSearch: vi.fn().mockReturnValue({ type: "google_search" }), + }, + }, + createGoogleGenerativeAI: vi.fn().mockReturnValue({ id: "google" }), +})); + +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: vi.fn().mockReturnValue({ id: "anthropic" }), +})); + +vi.mock("@ai-sdk/perplexity", () => ({ + createPerplexity: vi.fn().mockReturnValue({ id: "perplexity" }), +})); + +import { + generateChatResponse, + cancelChatResponse, + generateChatTitle, +} from "@/services/model-service"; +import { ModelRegistry } from "@/services/model-registry"; +import { streamText, generateText } from "ai"; +import type { ChatRequest, ModelId } from "@/types"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; + +function createMockPlugin( + settings: Partial = {}, +): CoIntelligencePlugin { + return { + settings: { + openaiApiKey: "sk-test", + anthropicApiKey: "", + googleApiKey: "", + perplexityApiKey: "", + defaultFolder: "coi", + defaultModel: "", + renamingModel: "openai:gpt-4o" as ModelId, + systemPromptFolder: "coi/prompts", + defaultSystemPromptNote: "", + ...settings, + }, + } as unknown as CoIntelligencePlugin; +} + +function createMockRegistry(): ModelRegistry { + const plugin = createMockPlugin(); + // @ts-expect-error accessing private static for test reset + ModelRegistry.instance = null; + return ModelRegistry.getInstance(plugin); +} + +describe("generateChatResponse", () => { + let registry: ModelRegistry; + + beforeEach(() => { + vi.clearAllMocks(); + registry = createMockRegistry(); + }); + + it("calls streamText with the correct model", async () => { + const request: ChatRequest = { + requestID: "test-1", + modelId: "openai:gpt-4o", + messages: [{ role: "user", content: "Hello" }], + }; + await generateChatResponse(request, registry); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: request.messages, + system: expect.stringContaining("helpful assistant"), + }), + ); + }); + + it("includes system prompt when provided", async () => { + const request: ChatRequest = { + requestID: "test-2", + modelId: "openai:gpt-4o", + messages: [{ role: "user", content: "Hello" }], + systemPrompt: "You are a pirate.", + }; + await generateChatResponse(request, registry); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: expect.stringContaining("You are a pirate."), + }), + ); + }); + + it("appends web search instructions when webSearch is true", async () => { + const request: ChatRequest = { + requestID: "test-3", + modelId: "openai:gpt-4o", + messages: [{ role: "user", content: "Latest news" }], + webSearch: true, + }; + await generateChatResponse(request, registry); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: expect.stringContaining("search the web"), + }), + ); + }); + + it("appends context to system prompt when provided", async () => { + const request: ChatRequest = { + requestID: "test-4", + modelId: "openai:gpt-4o", + messages: [{ role: "user", content: "Hello" }], + context: [{ title: "Note A", content: "Important info" }], + }; + await generateChatResponse(request, registry); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: expect.stringContaining("Important info"), + }), + ); + }); + + it("disables web search for models that don't support it", async () => { + // o1 has toggleWebSearch: false + const request: ChatRequest = { + requestID: "test-5", + modelId: "openai:o1", + messages: [{ role: "user", content: "Hello" }], + webSearch: true, + }; + await generateChatResponse(request, registry); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: expect.not.stringContaining("search the web"), + }), + ); + }); +}); + +describe("cancelChatResponse", () => { + let registry: ModelRegistry; + + beforeEach(() => { + vi.clearAllMocks(); + registry = createMockRegistry(); + }); + + it("cancels an active request", async () => { + const request: ChatRequest = { + requestID: "cancel-test", + modelId: "openai:gpt-4o", + messages: [{ role: "user", content: "Hello" }], + }; + await generateChatResponse(request, registry); + // Should not throw + cancelChatResponse(request); + }); + + it("does nothing for unknown request ID", () => { + const request: ChatRequest = { + requestID: "nonexistent", + modelId: "openai:gpt-4o", + messages: [], + }; + // Should not throw + cancelChatResponse(request); + }); +}); + +describe("generateChatTitle", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset singleton + // @ts-expect-error accessing private static for test reset + ModelRegistry.instance = null; + }); + + it("generates a title from messages", async () => { + const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId }); + ModelRegistry.getInstance(plugin); + + const messages = [ + { role: "user" as const, content: "Tell me about quantum physics" }, + { + role: "assistant" as const, + content: "Quantum physics is the study of matter at the atomic level.", + }, + ]; + const title = await generateChatTitle(messages, plugin); + expect(title).toBe("Chat summary title (Chat)"); + expect(generateText).toHaveBeenCalled(); + }); + + it("returns empty string when no renaming model configured", async () => { + const plugin = createMockPlugin({ renamingModel: "" }); + const title = await generateChatTitle([], plugin); + expect(title).toBe(""); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("strips disallowed filename characters from title", async () => { + vi.mocked(generateText).mockResolvedValueOnce({ + text: "Title/with\\bad:chars", + } as never); + + const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId }); + ModelRegistry.getInstance(plugin); + + const messages = [{ role: "user" as const, content: "Hello" }]; + const title = await generateChatTitle(messages, plugin); + expect(title).not.toContain("/"); + expect(title).not.toContain("\\"); + expect(title).not.toContain(":"); + }); + + it("truncates long titles to 50 characters plus suffix", async () => { + vi.mocked(generateText).mockResolvedValueOnce({ + text: "A".repeat(100), + } as never); + + const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId }); + ModelRegistry.getInstance(plugin); + + const messages = [{ role: "user" as const, content: "Hello" }]; + const title = await generateChatTitle(messages, plugin); + // Title is truncated to 50 chars + " (Chat)" suffix + expect(title.length).toBeLessThanOrEqual(57); + }); + + it("returns empty string on generation error", async () => { + vi.mocked(generateText).mockRejectedValueOnce(new Error("API error")); + + const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId }); + ModelRegistry.getInstance(plugin); + + const messages = [{ role: "user" as const, content: "Hello" }]; + const title = await generateChatTitle(messages, plugin); + expect(title).toBe(""); + }); +}); diff --git a/src/utils/__tests__/model-context.test.ts b/src/utils/__tests__/model-context.test.ts new file mode 100644 index 0000000..6ca2305 --- /dev/null +++ b/src/utils/__tests__/model-context.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { App, TFile, requestUrl } from "obsidian"; +import { getContext, makeContext, contextTokenEstimate } from "@/utils/model-context"; +import { ContextItems, ContextItemContent } from "@/types"; + +function createMockApp(): App { + return new App(); +} + +function createMockFile(props?: Partial): TFile { + const file = new TFile(); + if (props) { + Object.assign(file, props); + } + return file; +} + +// --- makeContext --- + +describe("makeContext", () => { + it("returns empty string for empty array", () => { + expect(makeContext([])).toBe(""); + }); + + it("formats a single context item", () => { + const items: ContextItemContent[] = [ + { title: "Note A", content: "Content of note A" }, + ]; + const result = makeContext(items); + expect(result).toBe("--- Note: Note A ---\nContent of note A\n---"); + }); + + it("formats multiple context items separated by double newlines", () => { + const items: ContextItemContent[] = [ + { title: "Note A", content: "Content A" }, + { title: "Note B", content: "Content B" }, + ]; + const result = makeContext(items); + expect(result).toContain("--- Note: Note A ---"); + expect(result).toContain("Content A"); + expect(result).toContain("--- Note: Note B ---"); + expect(result).toContain("Content B"); + expect(result).toContain("\n\n"); + }); + + it("preserves newlines within content", () => { + const items: ContextItemContent[] = [ + { title: "Note", content: "Line 1\nLine 2\nLine 3" }, + ]; + const result = makeContext(items); + expect(result).toContain("Line 1\nLine 2\nLine 3"); + }); +}); + +// --- getContext --- + +describe("getContext", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + vi.mocked(requestUrl).mockReset(); + }); + + it("returns empty array for null items", async () => { + const result = await getContext(null, app); + expect(result).toEqual([]); + }); + + it("returns empty array for empty items", async () => { + const items: ContextItems = { notes: [], tags: [], sources: [] }; + const result = await getContext(items, app); + expect(result).toEqual([]); + }); + + it("fetches content from linked notes", async () => { + const file = createMockFile({ name: "research.md", path: "research.md" }); + vi.mocked(app.vault.cachedRead).mockResolvedValue("Note content here"); + + const items: ContextItems = { notes: [file], tags: [], sources: [] }; + const result = await getContext(items, app); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("research.md"); + expect(result[0].content).toBe("Note content here"); + expect(app.vault.cachedRead).toHaveBeenCalledWith(file); + }); + + it("fetches content from tagged notes", async () => { + const file = createMockFile({ name: "tagged.md", path: "tagged.md" }); + vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([file]); + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + tags: [{ tag: "#research", position: {} }], + }); + vi.mocked(app.vault.cachedRead).mockResolvedValue("Tagged note content"); + + const items: ContextItems = { + notes: [], + tags: ["#research"], + sources: [], + }; + const result = await getContext(items, app); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("tagged.md"); + expect(result[0].content).toBe("Tagged note content"); + }); + + it("fetches content from web sources", async () => { + vi.mocked(requestUrl).mockResolvedValue({ + text: "

Web content

", + json: {}, + } as never); + + const items: ContextItems = { + notes: [], + tags: [], + sources: [{ url: "https://example.com", title: "Example" }], + }; + const result = await getContext(items, app); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Example"); + // htmlToMarkdown in mock returns the input as-is + expect(result[0].content).toBe("

Web content

"); + expect(requestUrl).toHaveBeenCalledWith({ url: "https://example.com" }); + }); + + it("uses URL as title fallback for web sources", async () => { + vi.mocked(requestUrl).mockResolvedValue({ + text: "

Content

", + json: {}, + } as never); + + const items: ContextItems = { + notes: [], + tags: [], + sources: [{ url: "https://example.com" }], + }; + const result = await getContext(items, app); + expect(result[0].title).toBe("https://example.com"); + }); + + it("combines notes, tags, and sources", async () => { + const noteFile = createMockFile({ + name: "direct.md", + path: "direct.md", + }); + const taggedFile = createMockFile({ + name: "tagged.md", + path: "tagged.md", + }); + + vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([taggedFile]); + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + tags: [{ tag: "#test", position: {} }], + }); + vi.mocked(app.vault.cachedRead).mockImplementation(async (file: TFile) => { + if (file.name === "direct.md") return "Direct content"; + if (file.name === "tagged.md") return "Tagged content"; + return ""; + }); + vi.mocked(requestUrl).mockResolvedValue({ + text: "Web content", + json: {}, + } as never); + + const items: ContextItems = { + notes: [noteFile], + tags: ["#test"], + sources: [{ url: "https://example.com", title: "Web" }], + }; + const result = await getContext(items, app); + + expect(result).toHaveLength(3); + expect(result[0].title).toBe("direct.md"); + expect(result[1].title).toBe("tagged.md"); + expect(result[2].title).toBe("Web"); + }); +}); + +// --- contextTokenEstimate --- + +describe("contextTokenEstimate", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns 0 for empty context", async () => { + const items: ContextItems = { notes: [], tags: [], sources: [] }; + const result = await contextTokenEstimate(items, app); + expect(result).toBe(0); + }); + + it("estimates tokens as content length / 4", async () => { + const file = createMockFile({ name: "note.md", path: "note.md" }); + // 40 characters -> 10 tokens + vi.mocked(app.vault.cachedRead).mockResolvedValue( + "a".repeat(40), + ); + const items: ContextItems = { notes: [file], tags: [], sources: [] }; + const result = await contextTokenEstimate(items, app); + expect(result).toBe(10); + }); + + it("sums token estimates across multiple items", async () => { + const file1 = createMockFile({ name: "a.md", path: "a.md" }); + const file2 = createMockFile({ name: "b.md", path: "b.md" }); + vi.mocked(app.vault.cachedRead).mockImplementation(async (file: TFile) => { + if (file.name === "a.md") return "a".repeat(100); // 25 tokens + if (file.name === "b.md") return "b".repeat(200); // 50 tokens + return ""; + }); + const items: ContextItems = { notes: [file1, file2], tags: [], sources: [] }; + const result = await contextTokenEstimate(items, app); + expect(result).toBe(75); + }); +}); diff --git a/src/utils/__tests__/notes.test.ts b/src/utils/__tests__/notes.test.ts new file mode 100644 index 0000000..c6df95d --- /dev/null +++ b/src/utils/__tests__/notes.test.ts @@ -0,0 +1,594 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + App, + TFile, + TFolder, + CachedMetadata, +} from "obsidian"; +import { + isCoiNote, + isPathCoiNote, + isPathActiveCoiNote, + isActiveCoiNote, + serializeCoiNoteContent, + deserializeCoiNoteContent, + sanitizeForTagName, + getFilesWithTag, +} from "@/utils/notes"; + +const CHAT_START = ""; +const CHAT_END = ""; + +function createMockApp(overrides?: Partial): App { + const app = new App(); + if (overrides) { + Object.assign(app, overrides); + } + return app; +} + +function createMockFile(props?: Partial): TFile { + const file = new TFile(); + if (props) { + Object.assign(file, props); + } + return file; +} + +// --- isCoiNote --- + +describe("isCoiNote", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns true when note has is-coi-chat frontmatter", () => { + const file = createMockFile({ path: "test.md" }); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true }, + }); + expect(isCoiNote(file, app)).toBe(true); + }); + + it("returns false when note lacks is-coi-chat frontmatter", () => { + const file = createMockFile({ path: "test.md" }); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue({ + frontmatter: {}, + }); + expect(isCoiNote(file, app)).toBe(false); + }); + + it("returns false when metadata cache returns null", () => { + const file = createMockFile({ path: "test.md" }); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue(null); + expect(isCoiNote(file, app)).toBe(false); + }); + + it("returns false when is-coi-chat is false", () => { + const file = createMockFile({ path: "test.md" }); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue({ + frontmatter: { "is-coi-chat": false }, + }); + expect(isCoiNote(file, app)).toBe(false); + }); +}); + +// --- isPathCoiNote --- + +describe("isPathCoiNote", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns true for COI note path", () => { + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true }, + }); + expect(isPathCoiNote("chat.md", app)).toBe(true); + }); + + it("returns false for empty path", () => { + expect(isPathCoiNote("", app)).toBe(false); + }); + + it("returns false for non-COI note", () => { + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + frontmatter: {}, + }); + expect(isPathCoiNote("note.md", app)).toBe(false); + }); +}); + +// --- isPathActiveCoiNote --- + +describe("isPathActiveCoiNote", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns true when both is-coi-chat and coi-chat-view are true", () => { + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true, "coi-chat-view": true }, + }); + expect(isPathActiveCoiNote("chat.md", app)).toBe(true); + }); + + it("returns false when is-coi-chat is true but coi-chat-view is false", () => { + vi.mocked(app.metadataCache.getCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true, "coi-chat-view": false }, + }); + expect(isPathActiveCoiNote("chat.md", app)).toBe(false); + }); + + it("returns false for empty path", () => { + expect(isPathActiveCoiNote("", app)).toBe(false); + }); +}); + +// --- isActiveCoiNote --- + +describe("isActiveCoiNote", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns true when note has both flags set", () => { + const file = createMockFile(); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true, "coi-chat-view": true }, + }); + expect(isActiveCoiNote(file, app)).toBe(true); + }); + + it("returns false when coi-chat-view is missing", () => { + const file = createMockFile(); + vi.mocked(app.metadataCache.getFileCache).mockReturnValue({ + frontmatter: { "is-coi-chat": true }, + }); + expect(isActiveCoiNote(file, app)).toBe(false); + }); +}); + +// --- sanitizeForTagName --- + +describe("sanitizeForTagName", () => { + it("converts to lowercase", () => { + expect(sanitizeForTagName("Hello")).toBe("hello"); + }); + + it("replaces special characters with hyphens", () => { + expect(sanitizeForTagName("openai:gpt-4")).toBe("openai-gpt-4"); + }); + + it("collapses multiple hyphens", () => { + expect(sanitizeForTagName("a---b")).toBe("a-b"); + }); + + it("strips leading and trailing hyphens/slashes", () => { + expect(sanitizeForTagName("-hello-")).toBe("hello"); + expect(sanitizeForTagName("/hello/")).toBe("hello"); + }); + + it("preserves forward slashes in the middle", () => { + expect(sanitizeForTagName("foo/bar")).toBe("foo/bar"); + }); + + it("preserves underscores", () => { + expect(sanitizeForTagName("my_tag")).toBe("my_tag"); + }); + + it("handles empty string", () => { + expect(sanitizeForTagName("")).toBe(""); + }); + + it("handles string with only special chars", () => { + expect(sanitizeForTagName(":::")).toBe(""); + }); + + it("trims whitespace before processing", () => { + expect(sanitizeForTagName(" hello ")).toBe("hello"); + }); +}); + +// --- serializeCoiNoteContent --- + +describe("serializeCoiNoteContent", () => { + const baseContent = `---\nis-coi-chat: true\n---\n${CHAT_START}\n\n${CHAT_END}`; + + it("serializes a single user message", async () => { + const messages = [{ role: "user" as const, content: "Hello" }]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + expect(result).toContain("## user:"); + expect(result).toContain("Hello"); + expect(result).toContain(CHAT_START); + expect(result).toContain(CHAT_END); + }); + + it("serializes user and assistant messages", async () => { + const messages = [ + { role: "user" as const, content: "Hello" }, + { role: "assistant" as const, content: "Hi there!" }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + expect(result).toContain("## user:"); + expect(result).toContain("Hello"); + expect(result).toContain("## assistant:"); + expect(result).toContain("Hi there!"); + }); + + it("adjusts h1 headers in content by adding ##", async () => { + const messages = [ + { + role: "assistant" as const, + content: "# Top heading\n\nSome text\n\n## Sub heading", + }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + // h1 should become h3, h2 should become h4 + expect(result).toContain("### Top heading"); + expect(result).toContain("#### Sub heading"); + }); + + it("adjusts h2 headers in content by adding #", async () => { + const messages = [ + { + role: "assistant" as const, + content: "## Sub heading\n\nSome text\n\n### Deeper", + }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + // h2 should become h3, h3 should become h4 + expect(result).toContain("### Sub heading"); + expect(result).toContain("#### Deeper"); + }); + + it("does not adjust h3+ headers", async () => { + const messages = [ + { role: "assistant" as const, content: "### Already deep\n\n#### Deeper" }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + expect(result).toContain("### Already deep"); + expect(result).toContain("#### Deeper"); + }); + + it("preserves wikilinks", async () => { + const messages = [ + { role: "user" as const, content: "See [[My Note]] for details" }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + expect(result).toContain("[[My Note]]"); + }); + + it("serializes sources", async () => { + const messages = [{ role: "user" as const, content: "Hello" }]; + const sources = [ + { url: "https://example.com", title: "Example" }, + { url: "https://other.com", title: "Other" }, + ]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + sources, + ); + expect(result).toContain("## Sources"); + expect(result).toContain("1. [Example](https://example.com)"); + expect(result).toContain("2. [Other](https://other.com)"); + }); + + it("uses URL as source link text when title is missing", async () => { + const messages = [{ role: "user" as const, content: "Hello" }]; + const sources = [{ url: "https://example.com" }]; + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + sources, + ); + expect(result).toContain("1. [https://example.com](https://example.com)"); + }); + + it("preserves content before and after chat markers", async () => { + const contentBefore = "# My Note\n\nSome intro text.\n\n"; + const contentAfter = "\n\nSome footer text."; + const fullContent = `${contentBefore}${CHAT_START}\n\n${CHAT_END}${contentAfter}`; + const messages = [{ role: "user" as const, content: "Hello" }]; + const result = await serializeCoiNoteContent( + fullContent, + createMockApp(), + messages, + null, + ); + expect(result).toContain("# My Note"); + expect(result).toContain("Some intro text."); + expect(result).toContain("Some footer text."); + }); + + it("handles empty messages array", async () => { + const result = await serializeCoiNoteContent( + baseContent, + createMockApp(), + [], + null, + ); + expect(result).toContain(CHAT_START); + expect(result).toContain(CHAT_END); + }); +}); + +// --- deserializeCoiNoteContent --- + +describe("deserializeCoiNoteContent", () => { + it("deserializes a single user message", async () => { + const content = `${CHAT_START}\n## user:\n\nHello world\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content).toBe("Hello world"); + }); + + it("deserializes user and assistant messages", async () => { + const content = `${CHAT_START}\n## user:\n\nHello\n\n## assistant:\n\nHi there!\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content).toBe("Hello"); + expect(result.messages[1].role).toBe("assistant"); + expect(result.messages[1].content).toBe("Hi there!"); + }); + + it("deserializes sources section", async () => { + const content = `${CHAT_START}\n## user:\n\nQuery\n\n## Sources\n\n1. [Example](https://example.com)\n2. [Other](https://other.com)\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.sources).toHaveLength(2); + expect(result.sources[0]).toEqual({ + title: "Example", + url: "https://example.com", + }); + expect(result.sources[1]).toEqual({ + title: "Other", + url: "https://other.com", + }); + }); + + it("returns empty arrays when no chat markers found", async () => { + const content = "# Regular note\n\nNo chat here."; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(0); + expect(result.sources).toHaveLength(0); + expect(result.contextItems).toEqual({ notes: [], tags: [], sources: [] }); + }); + + it("handles multiline message content", async () => { + const content = `${CHAT_START}\n## user:\n\nLine 1\nLine 2\n\nLine 3\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toContain("Line 1"); + expect(result.messages[0].content).toContain("Line 2"); + expect(result.messages[0].content).toContain("Line 3"); + }); + + it("restores linked notes from frontmatter", async () => { + const content = `${CHAT_START}\n## user:\n\nHello\n${CHAT_END}`; + const app = createMockApp(); + const mockFile = createMockFile({ path: "notes/linked.md" }); + vi.mocked(app.vault.getAbstractFileByPath).mockReturnValue(mockFile); + + const metadata: CachedMetadata = { + frontmatter: { + "linked-notes": ["notes/linked.md"], + }, + }; + const result = await deserializeCoiNoteContent(content, metadata, app); + expect(result.contextItems.notes).toHaveLength(1); + expect(result.contextItems.notes[0].path).toBe("notes/linked.md"); + }); + + it("restores linked tags from frontmatter", async () => { + const content = `${CHAT_START}\n## user:\n\nHello\n${CHAT_END}`; + const metadata: CachedMetadata = { + frontmatter: { + "linked-tags": ["#research", "#draft"], + }, + }; + const result = await deserializeCoiNoteContent( + content, + metadata, + createMockApp(), + ); + expect(result.contextItems.tags).toEqual(["#research", "#draft"]); + }); + + it("handles empty chat section", async () => { + const content = `${CHAT_START}\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(0); + expect(result.sources).toHaveLength(0); + }); + + it("ignores invalid mode headers", async () => { + const content = `${CHAT_START}\n## unknown:\n\nShould be ignored\n\n## user:\n\nValid\n${CHAT_END}`; + const result = await deserializeCoiNoteContent(content, null, createMockApp()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content).toBe("Valid"); + }); +}); + +// --- Round-trip serialization --- + +describe("serialize/deserialize round-trip", () => { + const baseContent = `---\nis-coi-chat: true\n---\n${CHAT_START}\n\n${CHAT_END}`; + + it("preserves messages through round-trip", async () => { + const messages = [ + { role: "user" as const, content: "What is 2+2?" }, + { role: "assistant" as const, content: "The answer is 4." }, + { role: "user" as const, content: "Thanks!" }, + ]; + + const serialized = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + const deserialized = await deserializeCoiNoteContent( + serialized, + null, + createMockApp(), + ); + + expect(deserialized.messages).toHaveLength(3); + expect(deserialized.messages[0].role).toBe("user"); + expect(deserialized.messages[0].content).toBe("What is 2+2?"); + expect(deserialized.messages[1].role).toBe("assistant"); + expect(deserialized.messages[1].content).toBe("The answer is 4."); + expect(deserialized.messages[2].role).toBe("user"); + expect(deserialized.messages[2].content).toBe("Thanks!"); + }); + + it("preserves sources through round-trip", async () => { + const messages = [{ role: "user" as const, content: "Query" }]; + const sources = [ + { url: "https://example.com", title: "Example" }, + { url: "https://other.com", title: "Other Site" }, + ]; + + const serialized = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + sources, + ); + const deserialized = await deserializeCoiNoteContent( + serialized, + null, + createMockApp(), + ); + + expect(deserialized.sources).toHaveLength(2); + expect(deserialized.sources[0]).toEqual({ + title: "Example", + url: "https://example.com", + }); + expect(deserialized.sources[1]).toEqual({ + title: "Other Site", + url: "https://other.com", + }); + }); + + it("preserves wikilinks through round-trip", async () => { + const messages = [ + { role: "user" as const, content: "See [[My Note]] and [[Other Note]]" }, + ]; + const serialized = await serializeCoiNoteContent( + baseContent, + createMockApp(), + messages, + null, + ); + const deserialized = await deserializeCoiNoteContent( + serialized, + null, + createMockApp(), + ); + expect(deserialized.messages[0].content).toContain("[[My Note]]"); + expect(deserialized.messages[0].content).toContain("[[Other Note]]"); + }); +}); + +// --- getFilesWithTag --- + +describe("getFilesWithTag", () => { + let app: App; + + beforeEach(() => { + app = createMockApp(); + }); + + it("returns files that have the specified tag", () => { + const file1 = createMockFile({ path: "note1.md" }); + const file2 = createMockFile({ path: "note2.md" }); + const file3 = createMockFile({ path: "note3.md" }); + + vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([ + file1, + file2, + file3, + ]); + + vi.mocked(app.metadataCache.getCache).mockImplementation((path: string) => { + if (path === "note1.md") { + return { tags: [{ tag: "#research", position: {} }] }; + } + if (path === "note2.md") { + return { tags: [{ tag: "#draft", position: {} }] }; + } + if (path === "note3.md") { + return { tags: [{ tag: "#research", position: {} }] }; + } + return null; + }); + + const result = getFilesWithTag("#research", app); + expect(result).toHaveLength(2); + expect(result[0].path).toBe("note1.md"); + expect(result[1].path).toBe("note3.md"); + }); + + it("returns empty array when no files have the tag", () => { + vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([]); + const result = getFilesWithTag("#nonexistent", app); + expect(result).toHaveLength(0); + }); + + it("skips files with no cache", () => { + const file = createMockFile({ path: "note.md" }); + vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([file]); + vi.mocked(app.metadataCache.getCache).mockReturnValue(null); + const result = getFilesWithTag("#test", app); + expect(result).toHaveLength(0); + }); +}); diff --git a/src/utils/__tests__/url.test.ts b/src/utils/__tests__/url.test.ts new file mode 100644 index 0000000..992cac7 --- /dev/null +++ b/src/utils/__tests__/url.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { getDomainFromUrl, ensureSourceTitle } from "@/utils/url"; + +describe("getDomainFromUrl", () => { + it("extracts domain from a standard https URL", () => { + expect(getDomainFromUrl("https://example.com/path")).toBe("example.com"); + }); + + it("extracts domain from http URL", () => { + expect(getDomainFromUrl("http://example.com")).toBe("example.com"); + }); + + it("extracts domain with subdomain", () => { + expect(getDomainFromUrl("https://www.example.com/page")).toBe( + "www.example.com", + ); + }); + + it("extracts domain without port (hostname only)", () => { + expect(getDomainFromUrl("https://example.com:8080/path")).toBe( + "example.com", + ); + }); + + it("extracts domain from URL with query params", () => { + expect(getDomainFromUrl("https://example.com/path?q=1&b=2")).toBe( + "example.com", + ); + }); + + it("falls back to regex for malformed URLs", () => { + expect(getDomainFromUrl("not-a-url.com/path")).toBe("not-a-url.com"); + }); + + it("falls back to regex for URLs without protocol (strips www.)", () => { + expect(getDomainFromUrl("www.example.com/page")).toBe("example.com"); + }); + + it("returns original string for completely invalid input", () => { + expect(getDomainFromUrl("")).toBe(""); + }); +}); + +describe("ensureSourceTitle", () => { + it("returns existing title when present", () => { + const result = ensureSourceTitle({ + url: "https://example.com", + title: "My Title", + }); + expect(result.title).toBe("My Title"); + expect(result.url).toBe("https://example.com"); + }); + + it("trims whitespace from existing title", () => { + const result = ensureSourceTitle({ + url: "https://example.com", + title: " My Title ", + }); + expect(result.title).toBe("My Title"); + }); + + it("uses domain as fallback when title is missing", () => { + const result = ensureSourceTitle({ url: "https://example.com/page" }); + expect(result.title).toBe("example.com"); + }); + + it("uses domain as fallback when title is empty string", () => { + const result = ensureSourceTitle({ + url: "https://example.com", + title: "", + }); + expect(result.title).toBe("example.com"); + }); + + it("uses domain as fallback when title is whitespace", () => { + const result = ensureSourceTitle({ + url: "https://example.com", + title: " ", + }); + expect(result.title).toBe("example.com"); + }); + + it("does not mutate the original source object", () => { + const original = { url: "https://example.com", title: "Original" }; + const result = ensureSourceTitle(original); + expect(result).not.toBe(original); + expect(original.title).toBe("Original"); + }); +}); diff --git a/test/mocks/obsidian.ts b/test/mocks/obsidian.ts new file mode 100644 index 0000000..c626cd5 --- /dev/null +++ b/test/mocks/obsidian.ts @@ -0,0 +1,352 @@ +/** + * Mock implementation of the Obsidian API for testing. + */ +import { vi } from "vitest"; + +// --- Core file types --- +// Note: vault property is typed as any to avoid circular instantiation +// (TAbstractFile -> Vault -> TFile -> TAbstractFile -> ...). + +export class TAbstractFile { + path = ""; + name = ""; + parent: TFolder | null = null; + vault: any = null; +} + +export class TFile extends TAbstractFile { + basename = ""; + extension = "md"; + stat = { ctime: 0, mtime: 0, size: 0 }; +} + +export class TFolder extends TAbstractFile { + children: TAbstractFile[] = []; + isRoot(): boolean { + return this.parent === null; + } +} + +// --- Vault --- + +export class Vault { + read = vi.fn().mockResolvedValue(""); + cachedRead = vi.fn().mockResolvedValue(""); + create = vi.fn().mockImplementation(async () => new TFile()); + modify = vi.fn().mockResolvedValue(undefined); + delete = vi.fn().mockResolvedValue(undefined); + rename = vi.fn().mockResolvedValue(undefined); + getAbstractFileByPath = vi.fn().mockReturnValue(null); + getMarkdownFiles = vi.fn().mockReturnValue([]); + createFolder = vi.fn().mockImplementation(async () => new TFolder()); +} + +// --- Workspace --- + +export class WorkspaceLeaf { + view: Record = {}; + openFile = vi.fn().mockResolvedValue(undefined); + setViewState = vi.fn().mockResolvedValue(undefined); +} + +export class Workspace { + getLeaf = vi.fn().mockReturnValue(new WorkspaceLeaf()); + getActiveViewOfType = vi.fn().mockReturnValue(null); + on = vi.fn().mockReturnValue({ id: "mock-event-ref" }); + off = vi.fn(); + getLeavesOfType = vi.fn().mockReturnValue([]); +} + +// --- MetadataCache --- + +export class MetadataCache { + getFileCache = vi.fn().mockReturnValue(null); + getCache = vi.fn().mockReturnValue(null); + on = vi.fn().mockReturnValue({ id: "mock-event-ref" }); +} + +// --- FileManager --- + +export class FileManager { + processFrontMatter = vi.fn().mockImplementation( + async (_file: TFile, fn: (frontmatter: Record) => void) => { + const frontmatter: Record = {}; + fn(frontmatter); + }, + ); + renameFile = vi.fn().mockResolvedValue(undefined); +} + +// --- App --- + +export class App { + vault = new Vault(); + workspace = new Workspace(); + metadataCache = new MetadataCache(); + fileManager = new FileManager(); +} + +// --- View classes --- + +export class TextFileView { + app: App; + leaf: WorkspaceLeaf; + file: TFile | null = null; + data = ""; + requestSave = vi.fn(); + + constructor(leaf: WorkspaceLeaf) { + this.leaf = leaf; + this.app = new App(); + } + + getViewData(): string { + return this.data; + } + + setViewData(_data: string, _clear: boolean): void {} + + clear(): void {} +} + +// --- UI classes --- + +export class Notice { + constructor(_message: string, _timeout?: number) {} +} + +export class Setting { + settingEl = document.createElement("div"); + controlEl = document.createElement("div"); + nameEl = document.createElement("div"); + descEl = document.createElement("div"); + + constructor(_containerEl: HTMLElement) {} + + setName(_name: string): this { + return this; + } + + setDesc(_desc: string): this { + return this; + } + + addText(cb: (component: TextComponent) => void): this { + cb(new TextComponent(document.createElement("input"))); + return this; + } + + addDropdown(cb: (component: DropdownComponent) => void): this { + cb(new DropdownComponent(document.createElement("select"))); + return this; + } + + addToggle(cb: (component: ToggleComponent) => void): this { + cb(new ToggleComponent(document.createElement("div"))); + return this; + } + + addButton(cb: (component: ButtonComponent) => void): this { + cb(new ButtonComponent(document.createElement("button"))); + return this; + } +} + +class TextComponent { + inputEl: HTMLInputElement; + constructor(inputEl: HTMLElement) { + this.inputEl = inputEl as HTMLInputElement; + } + setPlaceholder(_ph: string): this { + return this; + } + setValue(_val: string): this { + return this; + } + onChange(_cb: (value: string) => void): this { + return this; + } +} + +class DropdownComponent { + selectEl: HTMLSelectElement; + constructor(selectEl: HTMLElement) { + this.selectEl = selectEl as HTMLSelectElement; + } + addOption(_value: string, _display: string): this { + return this; + } + setValue(_val: string): this { + return this; + } + onChange(_cb: (value: string) => void): this { + return this; + } +} + +class ToggleComponent { + toggleEl: HTMLElement; + constructor(el: HTMLElement) { + this.toggleEl = el; + } + setValue(_val: boolean): this { + return this; + } + onChange(_cb: (value: boolean) => void): this { + return this; + } +} + +class ButtonComponent { + buttonEl: HTMLButtonElement; + constructor(el: HTMLElement) { + this.buttonEl = el as HTMLButtonElement; + } + setButtonText(_text: string): this { + return this; + } + setCta(): this { + return this; + } + onClick(_cb: () => void): this { + return this; + } +} + +export class SuggestModal { + app: App; + inputEl = document.createElement("input"); + resultContainerEl = document.createElement("div"); + + constructor(app: App) { + this.app = app; + } + + open(): void {} + close(): void {} + + getSuggestions(_query: string): T[] { + return []; + } + + renderSuggestion(_item: T, _el: HTMLElement): void {} + + onChooseSuggestion(_item: T, _evt: MouseEvent | KeyboardEvent): void {} +} + +// --- Plugin classes --- + +export class Plugin { + app: App; + manifest: PluginManifest; + + constructor(app: App, manifest: PluginManifest) { + this.app = app; + this.manifest = manifest; + } + + addCommand = vi.fn(); + addRibbonIcon = vi.fn(); + addSettingTab = vi.fn(); + registerView = vi.fn(); + registerEvent = vi.fn(); + registerExtensions = vi.fn(); + loadData = vi.fn().mockResolvedValue(null); + saveData = vi.fn().mockResolvedValue(undefined); +} + +export class PluginSettingTab { + app: App; + plugin: Plugin; + containerEl = document.createElement("div"); + + constructor(app: App, plugin: Plugin) { + this.app = app; + this.plugin = plugin; + } + + display(): void {} + hide(): void {} +} + +// --- Interfaces / types --- + +export interface PluginManifest { + id: string; + name: string; + version: string; + author: string; + description: string; +} + +export interface ViewState { + type: string; + state?: Record; + active?: boolean; +} + +export interface CachedMetadata { + frontmatter?: Record; + tags?: { tag: string; position: unknown }[]; +} + +export interface Menu { + addItem: (cb: (item: MenuItem) => void) => Menu; + showAtMouseEvent: (evt: MouseEvent) => void; +} + +export interface MenuItem { + setTitle: (title: string) => MenuItem; + setIcon: (icon: string) => MenuItem; + onClick: (cb: () => void) => MenuItem; +} + +export interface Command { + id: string; + name: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean; +} + +// --- Utility functions --- + +export function normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+/g, "/"); +} + +export function getAllTags(cache: CachedMetadata): string[] | null { + if (!cache.tags) return null; + return cache.tags.map((t) => t.tag); +} + +export const requestUrl = vi.fn().mockResolvedValue({ text: "", json: {} }); + +export function htmlToMarkdown(html: string): string { + return html; +} + +export function setIcon(_el: HTMLElement, _iconId: string): void {} + +export function getIcon(_iconId: string): SVGSVGElement | null { + return null; +} + +export function debounce unknown>( + fn: T, + _delay: number, +): T { + return fn; +} + +export class MarkdownRenderer { + static render = vi.fn().mockResolvedValue(undefined); +} + +export class MarkdownRenderChild { + containerEl: HTMLElement; + constructor(containerEl: HTMLElement) { + this.containerEl = containerEl; + } + onload(): void {} + onunload(): void {} +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..9c082ee --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest"; + +// Obsidian adds .contains() to String.prototype. Polyfill it for tests. +if (!String.prototype.contains) { + String.prototype.contains = function (searchString: string): boolean { + return this.includes(searchString); + }; +}