mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
Merge pull request #6 from Epistemic-Technology/testing-improvements
Add test infrastructure and 106 unit tests
This commit is contained in:
commit
638d33dc7f
7 changed files with 1767 additions and 0 deletions
237
src/services/__tests__/model-registry.test.ts
Normal file
237
src/services/__tests__/model-registry.test.ts
Normal file
|
|
@ -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["settings"]> = {},
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
267
src/services/__tests__/model-service.test.ts
Normal file
267
src/services/__tests__/model-service.test.ts
Normal file
|
|
@ -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["settings"]> = {},
|
||||
): 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("");
|
||||
});
|
||||
});
|
||||
220
src/utils/__tests__/model-context.test.ts
Normal file
220
src/utils/__tests__/model-context.test.ts
Normal file
|
|
@ -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>): 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: "<p>Web content</p>",
|
||||
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("<p>Web content</p>");
|
||||
expect(requestUrl).toHaveBeenCalledWith({ url: "https://example.com" });
|
||||
});
|
||||
|
||||
it("uses URL as title fallback for web sources", async () => {
|
||||
vi.mocked(requestUrl).mockResolvedValue({
|
||||
text: "<p>Content</p>",
|
||||
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);
|
||||
});
|
||||
});
|
||||
594
src/utils/__tests__/notes.test.ts
Normal file
594
src/utils/__tests__/notes.test.ts
Normal file
|
|
@ -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 = "<!-- CHAT-THREAD-START -->";
|
||||
const CHAT_END = "<!-- CHAT-THREAD-END -->";
|
||||
|
||||
function createMockApp(overrides?: Partial<App>): App {
|
||||
const app = new App();
|
||||
if (overrides) {
|
||||
Object.assign(app, overrides);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
function createMockFile(props?: Partial<TFile>): 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);
|
||||
});
|
||||
});
|
||||
89
src/utils/__tests__/url.test.ts
Normal file
89
src/utils/__tests__/url.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
352
test/mocks/obsidian.ts
Normal file
352
test/mocks/obsidian.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown>) => void) => {
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
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<T> {
|
||||
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<string, unknown>;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface CachedMetadata {
|
||||
frontmatter?: Record<string, unknown>;
|
||||
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<T extends (...args: unknown[]) => 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 {}
|
||||
}
|
||||
8
test/setup.ts
Normal file
8
test/setup.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue