Remove tests

This commit is contained in:
Mike Thicke 2025-08-31 11:09:57 -04:00
parent 117cbba7b1
commit c7fa2e956d
21 changed files with 0 additions and 4205 deletions

View file

@ -1,395 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ChatView, VIEW_TYPE_COI_CHAT } from "../ChatView";
import type { WorkspaceLeaf, App, TFile } from "obsidian";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
import type { ModelChatMessage, ContextItems } from "@/types";
vi.mock("obsidian", () => ({
TextFileView: class TextFileView {
leaf: any;
app: any;
constructor(leaf: any) {
this.leaf = leaf;
}
registerEvent = vi.fn();
requestSave = vi.fn();
},
Notice: vi.fn(),
TFile: class TFile {
path: string;
basename: string;
constructor(path: string, basename: string) {
this.path = path;
this.basename = basename;
}
},
}));
vi.mock("solid-js/web", () => ({
render: vi.fn(() => ({ dispose: vi.fn() })),
}));
vi.mock("@/CoiChatApp", () => ({
CoiChatApp: vi.fn(),
}));
vi.mock("@/utils/notes", () => ({
serializeCoiNote: vi.fn(),
deserializeCoiNote: vi.fn(),
renameNote: vi.fn(),
isActiveCoiNote: vi.fn(),
deserializeCoiNoteContent: vi.fn(),
serializeCoiNoteContent: vi.fn(),
}));
describe("ChatView", () => {
let app: App;
let plugin: CoIntelligencePlugin;
let leaf: WorkspaceLeaf;
let chatView: ChatView;
let mockFile: TFile;
beforeEach(() => {
vi.useFakeTimers();
mockFile = {
path: "test.md",
basename: "test",
} as TFile;
app = {
workspace: {
getActiveFile: vi.fn().mockReturnValue(mockFile),
on: vi.fn(),
},
vault: {
cachedRead: vi.fn().mockResolvedValue("# Test Content"),
process: vi.fn(),
},
fileManager: {
processFrontMatter: vi.fn(),
},
} as any;
plugin = {
app,
registry: {
availableModels: [],
},
settings: {
defaultModel: "",
systemPromptFolder: "",
defaultSystemPromptNote: "",
},
} as any;
leaf = {} as WorkspaceLeaf;
chatView = new ChatView(leaf, plugin, app);
});
afterEach(() => {
vi.useRealTimers();
});
describe("constructor", () => {
it("should initialize with correct properties", () => {
expect(chatView.plugin).toBe(plugin);
expect(chatView.app).toBe(app);
expect(chatView.file).toBe(mockFile);
expect(chatView.icon).toBe("bot-message-square");
expect(chatView.messages).toEqual([]);
expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] });
expect(chatView.sources).toEqual([]);
});
it("should register settings change event", () => {
expect(chatView.registerEvent).toHaveBeenCalled();
expect(app.workspace.on).toHaveBeenCalledWith(
"co-intelligence:settings-changed",
expect.any(Function)
);
});
});
describe("getViewType", () => {
it("should return correct view type", () => {
expect(chatView.getViewType()).toBe(VIEW_TYPE_COI_CHAT);
});
});
describe("getDisplayText", () => {
it("should return file basename when file exists", () => {
expect(chatView.getDisplayText()).toBe("test");
});
it("should return default text when no file", () => {
chatView.file = null;
expect(chatView.getDisplayText()).toBe("Co-Intelligence Chat");
});
});
describe("debounceUpdateViewData", () => {
it("should debounce multiple calls", async () => {
const updateSpy = vi.spyOn(chatView, "updateViewData");
await chatView.debounceUpdateViewData();
await chatView.debounceUpdateViewData();
await chatView.debounceUpdateViewData();
expect(updateSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(updateSpy).toHaveBeenCalledTimes(1);
});
it("should clear previous timeout", async () => {
const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
await chatView.debounceUpdateViewData();
const firstTimeout = chatView["debounceTimeout"];
await chatView.debounceUpdateViewData();
expect(clearTimeoutSpy).toHaveBeenCalledWith(firstTimeout);
});
});
describe("updateViewData", () => {
it("should update view data with serialized content", async () => {
const { serializeCoiNoteContent } = await import("@/utils/notes");
const mockSerializedContent = "Serialized content";
vi.mocked(serializeCoiNoteContent).mockResolvedValue(mockSerializedContent);
chatView.messages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
] as ModelChatMessage[];
await chatView.updateViewData();
expect(app.vault.cachedRead).toHaveBeenCalledWith(mockFile);
expect(serializeCoiNoteContent).toHaveBeenCalledWith(
"# Test Content",
app,
chatView.messages,
chatView.contextItems
);
expect(chatView.data).toBe(mockSerializedContent);
expect(chatView.requestSave).toHaveBeenCalled();
});
it("should handle no file gracefully", async () => {
chatView.file = null;
const result = await chatView.updateViewData();
expect(result).toBe("");
expect(app.vault.cachedRead).not.toHaveBeenCalled();
});
});
describe("getViewData", () => {
it("should return current data", () => {
chatView.data = "test data";
expect(chatView.getViewData()).toBe("test data");
});
});
describe("setViewData", () => {
it("should set data and deserialize content", async () => {
const { deserializeCoiNoteContent } = await import("@/utils/notes");
const mockDeserializedData = {
messages: [{ role: "user", content: "Deserialized" }],
contextItems: { notes: ["note.md"], tags: [], sources: [] },
sources: [],
};
vi.mocked(deserializeCoiNoteContent).mockResolvedValue(mockDeserializedData);
app.metadataCache = {
getFileCache: vi.fn().mockReturnValue({}),
} as any;
await chatView.setViewData("new data", false);
expect(chatView.data).toBe("new data");
expect(chatView.messages).toEqual(mockDeserializedData.messages);
expect(chatView.contextItems).toEqual(mockDeserializedData.contextItems);
});
it("should handle null file gracefully", async () => {
const { deserializeCoiNoteContent } = await import("@/utils/notes");
const Notice = vi.mocked((await import("obsidian")).Notice);
chatView.file = null;
vi.mocked(deserializeCoiNoteContent).mockResolvedValue({
messages: [],
contextItems: { notes: [], tags: [], sources: [] },
sources: []
});
await chatView.setViewData("data", false);
expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to set view data");
});
it("should clear when clear parameter is true", async () => {
const clearSpy = vi.spyOn(chatView, "clear");
app.metadataCache = {
getFileCache: vi.fn().mockReturnValue({}),
} as any;
await chatView.setViewData("data", true);
expect(clearSpy).toHaveBeenCalled();
});
});
describe("handleChatChange", () => {
it("should serialize note with new data", async () => {
const { serializeCoiNote } = await import("@/utils/notes");
const newMessages: ModelChatMessage[] = [
{ role: "user", content: "New message" },
];
const newContextItems: ContextItems = {
notes: ["note1.md"],
tags: ["#tag1"],
sources: [],
};
await chatView.handleChatChange({
newMessages,
newTitle: "test",
contextItems: newContextItems,
sources: [],
lastModelId: "gpt-4",
});
expect(serializeCoiNote).toHaveBeenCalledWith(
mockFile,
app,
newMessages,
newContextItems,
"gpt-4",
[]
);
});
it("should rename note when title changes", async () => {
const { renameNote } = await import("@/utils/notes");
await chatView.handleChatChange({
newMessages: [],
newTitle: "New Title",
contextItems: null,
lastModelId: null,
});
expect(renameNote).toHaveBeenCalledWith(mockFile, "New Title", app, plugin);
});
it("should not rename when title is same as basename", async () => {
const { renameNote } = await import("@/utils/notes");
await chatView.handleChatChange({
newMessages: [],
newTitle: "test",
contextItems: null,
lastModelId: null,
});
expect(renameNote).not.toHaveBeenCalled();
});
it("should handle errors during serialization", async () => {
const { serializeCoiNote } = await import("@/utils/notes");
const Notice = vi.mocked((await import("obsidian")).Notice);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(serializeCoiNote).mockRejectedValue(new Error("Serialization failed"));
await chatView.handleChatChange({
newMessages: [],
newTitle: "",
contextItems: null,
lastModelId: null,
});
expect(Notice).toHaveBeenCalledWith("Error serializing CoiNote: Error: Serialization failed");
expect(chatView["updating"]).toBe(false);
consoleSpy.mockRestore();
});
it("should handle null file", async () => {
const Notice = vi.mocked((await import("obsidian")).Notice);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
chatView.file = null;
await chatView.handleChatChange({
newMessages: [],
newTitle: "",
contextItems: null,
lastModelId: null,
});
expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to handle chat change");
consoleSpy.mockRestore();
});
});
describe("refresh", () => {
it("should refresh view when active", async () => {
const { isActiveCoiNote, deserializeCoiNote } = await import("@/utils/notes");
vi.mocked(isActiveCoiNote).mockReturnValue(true);
vi.mocked(deserializeCoiNote).mockResolvedValue({
messages: [],
contextItems: { notes: [], tags: [], sources: [] },
sources: []
});
app.metadataCache = {
getFileCache: vi.fn().mockReturnValue({}),
} as any;
chatView.containerEl = {
children: [null, document.createElement("div")],
} as any;
const onOpenSpy = vi.spyOn(chatView, "onOpen");
await chatView.refresh();
expect(isActiveCoiNote).toHaveBeenCalledWith(mockFile, app);
expect(onOpenSpy).toHaveBeenCalled();
});
it("should handle no file", async () => {
chatView.file = null;
await expect(chatView.refresh()).resolves.not.toThrow();
});
});
describe("clear", () => {
it("should reset all data", () => {
chatView.messages = [{ role: "user", content: "test" }] as ModelChatMessage[];
chatView.contextItems = { notes: ["note.md"], tags: [], sources: [] };
chatView.sources = [{ title: "Source", url: "https://example.com" }];
chatView.dispose = vi.fn();
chatView.clear();
expect(chatView.messages).toEqual([]);
expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] });
expect(chatView.sources).toEqual([]);
expect(chatView.dispose).toHaveBeenCalled();
});
});
});

View file

@ -1,315 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CoIntelligencePlugin } from "../CoIntelligencePlugin";
import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings";
import { ModelRegistry } from "@/services/model-registry";
import { VIEW_TYPE_COI_CHAT } from "@/ChatView";
import type { App, PluginManifest, WorkspaceLeaf, TFile, Menu, ViewState } from "obsidian";
vi.mock("obsidian", () => ({
Plugin: class Plugin {
app: any;
manifest: any;
constructor(app: any, manifest: any) {
this.app = app;
this.manifest = manifest;
}
addSettingTab = vi.fn();
addCommand = vi.fn();
registerView = vi.fn();
addRibbonIcon = vi.fn();
registerEvent = vi.fn();
register = vi.fn();
loadData = vi.fn();
saveData = vi.fn();
},
TFile: class TFile {
path: string;
basename: string;
constructor(path: string, basename: string) {
this.path = path;
this.basename = basename;
}
},
around: vi.fn((target, patches) => {
return () => {};
}),
}));
vi.mock("../settings", () => ({
CoIntelligenceSettingsTab: vi.fn(),
DEFAULT_SETTINGS: {
openaiApiKey: "",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
defaultFolder: "coi",
defaultModel: "",
renamingModel: "",
systemPromptFolder: "coi/prompts",
defaultSystemPromptNote: "",
},
}));
vi.mock("@/services/model-registry", () => ({
ModelRegistry: {
getInstance: vi.fn(),
},
}));
vi.mock("@/commands/new-chat", () => ({
NewChatCommand: vi.fn(),
}));
vi.mock("@/commands/toggle-chat-view", () => ({
ToggleChatViewCommand: vi.fn(),
}));
vi.mock("@/ChatView", () => ({
ChatView: vi.fn(),
VIEW_TYPE_COI_CHAT: "co-intelligence-chat",
}));
vi.mock("@/utils/notes", () => ({
isCoiNote: vi.fn(),
isPathActiveCoiNote: vi.fn(),
isActiveCoiNote: vi.fn(),
createCOINote: vi.fn(),
waitForMetadataCache: vi.fn(),
}));
describe("CoIntelligencePlugin", () => {
let app: App;
let manifest: PluginManifest;
let plugin: CoIntelligencePlugin;
let mockRegistry: ModelRegistry;
beforeEach(() => {
vi.clearAllMocks();
app = {
workspace: {
on: vi.fn(),
onLayoutReady: vi.fn((callback) => callback()),
getActiveFile: vi.fn(),
},
vault: {
on: vi.fn(),
},
fileManager: {
processFrontMatter: vi.fn(),
},
commands: {
executeCommandById: vi.fn(),
},
} as any;
manifest = {
id: "co-intelligence",
name: "Co-Intelligence",
version: "1.0.0",
} as PluginManifest;
mockRegistry = {} as ModelRegistry;
vi.mocked(ModelRegistry.getInstance).mockReturnValue(mockRegistry);
plugin = new CoIntelligencePlugin(app, manifest);
});
describe("constructor", () => {
it("should initialize with default settings", () => {
expect(plugin.settings).toEqual(DEFAULT_SETTINGS);
expect(plugin.isPerformingAutomaticRename).toBe(false);
});
});
describe("onload", () => {
it("should initialize plugin components", async () => {
plugin.loadData = vi.fn().mockResolvedValue({});
await plugin.onload();
expect(plugin.registry).toBe(mockRegistry);
expect(plugin.addSettingTab).toHaveBeenCalled();
expect(plugin.addCommand).toHaveBeenCalledTimes(2);
expect(plugin.registerView).toHaveBeenCalledWith(
VIEW_TYPE_COI_CHAT,
expect.any(Function)
);
expect(plugin.addRibbonIcon).toHaveBeenCalledWith(
"bot-message-square",
"New COI chat",
expect.any(Function)
);
});
it("should register event handlers", async () => {
await plugin.onload();
expect(plugin.registerEvent).toHaveBeenCalledWith(
app.workspace.on("file-open", expect.any(Function))
);
expect(plugin.registerEvent).toHaveBeenCalledWith(
app.vault.on("rename", expect.any(Function))
);
expect(plugin.registerEvent).toHaveBeenCalledWith(
app.workspace.on("file-menu", expect.any(Function))
);
});
it("should call onLayoutReady", async () => {
await plugin.onload();
expect(app.workspace.onLayoutReady).toHaveBeenCalledWith(expect.any(Function));
});
});
describe("loadSettings", () => {
it("should merge loaded data with default settings", async () => {
const customSettings = {
openaiApiKey: "test-key",
defaultFolder: "custom-folder",
};
plugin.loadData = vi.fn().mockResolvedValue(customSettings);
await plugin.loadSettings();
expect(plugin.settings).toEqual({
...DEFAULT_SETTINGS,
...customSettings,
});
});
it("should use default settings when no data is loaded", async () => {
plugin.loadData = vi.fn().mockResolvedValue(null);
await plugin.loadSettings();
expect(plugin.settings).toEqual(DEFAULT_SETTINGS);
});
});
describe("saveSettings", () => {
it("should save current settings", async () => {
plugin.saveData = vi.fn();
plugin.settings = { ...DEFAULT_SETTINGS, openaiApiKey: "test-key" };
await plugin.saveSettings();
expect(plugin.saveData).toHaveBeenCalledWith(plugin.settings);
});
});
describe("handleFileRename", () => {
let mockFile: TFile;
beforeEach(async () => {
const { TFile } = await import("obsidian");
mockFile = new TFile("test.md", "test");
});
it("should mark COI notes as renamed when not automatic", async () => {
const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes");
vi.mocked(isCoiNote).mockReturnValue(true);
plugin.isPerformingAutomaticRename = false;
const processFrontMatterCallback = vi.fn();
app.fileManager.processFrontMatter = vi.fn(async (file, callback) => {
const frontmatter = {};
callback(frontmatter);
processFrontMatterCallback(frontmatter);
});
await plugin["handleFileRename"](mockFile, "old-path.md");
expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile);
expect(processFrontMatterCallback).toHaveBeenCalledWith(
expect.objectContaining({ "note-renamed": true })
);
});
it("should not mark as renamed when automatic rename", async () => {
const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes");
vi.mocked(isCoiNote).mockReturnValue(true);
plugin.isPerformingAutomaticRename = true;
await plugin["handleFileRename"](mockFile, "old-path.md");
expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile);
expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled();
expect(plugin.isPerformingAutomaticRename).toBe(false);
});
it("should ignore non-TFile objects", async () => {
const notAFile = { path: "not-a-file" } as any;
await plugin["handleFileRename"](notAFile, "old-path");
expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled();
});
it("should ignore non-COI notes", async () => {
const { isCoiNote } = await import("@/utils/notes");
vi.mocked(isCoiNote).mockReturnValue(false);
await plugin["handleFileRename"](mockFile, "old-path.md");
expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled();
});
});
describe("onFileMenuHandler", () => {
let mockMenu: Menu;
let mockFile: TFile;
let mockLeaf: WorkspaceLeaf;
beforeEach(async () => {
mockMenu = {
addItem: vi.fn(),
} as any;
const { TFile } = await import("obsidian");
mockFile = new TFile("test.md", "test");
mockLeaf = {} as WorkspaceLeaf;
});
it("should not add menu item for non-COI notes", async () => {
const { isCoiNote } = await import("@/utils/notes");
vi.mocked(isCoiNote).mockReturnValue(false);
await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf);
expect(mockMenu.addItem).not.toHaveBeenCalled();
});
it("should not add menu item for active COI notes", async () => {
const { isCoiNote, isActiveCoiNote } = await import("@/utils/notes");
vi.mocked(isCoiNote).mockReturnValue(true);
vi.mocked(isActiveCoiNote).mockReturnValue(true);
await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf);
expect(mockMenu.addItem).not.toHaveBeenCalled();
});
});
describe("ribbon icon", () => {
it("should create COI note when ribbon icon is clicked", async () => {
const { createCOINote } = await import("@/utils/notes");
let ribbonClickHandler: () => void;
plugin.addRibbonIcon = vi.fn((icon, title, callback) => {
ribbonClickHandler = callback;
});
await plugin.onload();
ribbonClickHandler!();
expect(createCOINote).toHaveBeenCalledWith(app, plugin);
});
});
});

View file

@ -1,213 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@solidjs/testing-library";
import {
CoiChatApp,
PluginContext,
AppContext,
FileContext,
} from "../CoiChatApp";
import type { App, TFile } from "obsidian";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
import type { ModelChatMessage, ContextItems, Source } from "@/types";
import { useContext } from "solid-js";
vi.mock("@/components/ChatInterface", () => ({
ChatInterface: (props: any) => {
// Test component that verifies props and context
const plugin = useContext(PluginContext);
const app = useContext(AppContext);
const file = useContext(FileContext);
return (
<div data-testid="chat-interface">
<div data-testid="initial-messages">
{JSON.stringify(props.initialMessages)}
</div>
<div data-testid="initial-context">
{JSON.stringify(props.initialContext)}
</div>
<div data-testid="initial-sources">
{JSON.stringify(props.initialSources)}
</div>
<div data-testid="has-plugin">{plugin ? "true" : "false"}</div>
<div data-testid="has-app">{app ? "true" : "false"}</div>
<div data-testid="has-file">{file ? "true" : "false"}</div>
<button
onClick={() =>
props.onChange({
newMessages: [{ role: "user", content: "test" }],
newTitle: "Test Title",
contextItems: null,
lastModelId: "test-model",
})
}
>
Trigger Change
</button>
</div>
);
},
}));
describe("CoiChatApp", () => {
let app: App;
let file: TFile;
let plugin: CoIntelligencePlugin;
let onChange: vi.Mock;
let initialMessages: ModelChatMessage[];
let initialContext: ContextItems;
let initialSources: Source[];
beforeEach(() => {
app = { name: "MockApp" } as App;
file = { path: "test.md", basename: "test" } as TFile;
plugin = { name: "MockPlugin" } as CoIntelligencePlugin;
onChange = vi.fn();
initialMessages = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
];
initialContext = {
notes: ["note1.md"],
tags: ["#tag1"],
sources: [],
};
initialSources = [{ title: "Source 1", url: "https://example.com" }];
});
it("should render ChatInterface with correct props", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
initialSources={initialSources}
/>
));
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
expect(screen.getByTestId("initial-messages").textContent).toBe(
JSON.stringify(initialMessages),
);
expect(screen.getByTestId("initial-context").textContent).toBe(
JSON.stringify(initialContext),
);
expect(screen.getByTestId("initial-sources").textContent).toBe(
JSON.stringify(initialSources),
);
});
it("should provide contexts to child components", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
expect(screen.getByTestId("has-plugin").textContent).toBe("true");
expect(screen.getByTestId("has-app").textContent).toBe("true");
expect(screen.getByTestId("has-file").textContent).toBe("true");
});
it("should pass onChange callback to ChatInterface", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
const button = screen.getByText("Trigger Change");
button.click();
expect(onChange).toHaveBeenCalledWith({
newMessages: [{ role: "user", content: "test" }],
newTitle: "Test Title",
contextItems: null,
lastModelId: "test-model",
});
});
it("should handle undefined initialSources with default value", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
expect(screen.getByTestId("initial-sources").textContent).toBe(
JSON.stringify([]),
);
});
it("should wrap content in coi-app div", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
const wrapper = screen.getByTestId("chat-interface").parentElement;
expect(wrapper).toHaveClass("coi-app");
});
it("should handle empty initial messages", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={[]}
initialContext={initialContext}
/>
));
expect(screen.getByTestId("initial-messages").textContent).toBe("[]");
});
it("should handle complex context items", () => {
const complexContext: ContextItems = {
notes: ["note1.md", "note2.md", "folder/note3.md"],
tags: ["#tag1", "#tag2", "#nested/tag"],
sources: ["https://source1.com", "https://source2.com"],
};
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={complexContext}
/>
));
expect(screen.getByTestId("initial-context").textContent).toBe(
JSON.stringify(complexContext),
);
});
});

View file

@ -1,208 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings";
import type { App, Setting } from "obsidian";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
vi.mock("obsidian", () => ({
PluginSettingTab: class PluginSettingTab {
app: any;
plugin: any;
containerEl: any;
constructor(app: any, plugin: any) {
this.app = app;
this.plugin = plugin;
this.containerEl = {
empty: vi.fn(),
createEl: vi.fn(),
createDiv: vi.fn(),
querySelectorAll: vi.fn(),
};
}
},
Setting: vi.fn().mockImplementation(() => ({
setName: vi.fn().mockReturnThis(),
setDesc: vi.fn().mockReturnThis(),
addText: vi.fn().mockReturnThis(),
addDropdown: vi.fn().mockReturnThis(),
})),
normalizePath: vi.fn((path: string) =>
path.replace(/\/+/g, "/").replace(/\/$/, ""),
),
}));
describe("settings", () => {
describe("DEFAULT_SETTINGS", () => {
it("should have all required fields with default values", () => {
expect(DEFAULT_SETTINGS).toEqual({
openaiApiKey: "",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
defaultFolder: "coi",
defaultModel: "",
renamingModel: "",
systemPromptFolder: "coi/prompts",
defaultSystemPromptNote: "",
});
});
});
describe("CoIntelligenceSettingsTab", () => {
let app: App;
let plugin: CoIntelligencePlugin;
let settingsTab: CoIntelligenceSettingsTab;
let mockElement: any;
beforeEach(() => {
vi.useFakeTimers();
mockElement = {
empty: vi.fn(),
createEl: vi.fn().mockReturnValue({
createEl: vi.fn().mockReturnValue({
createEl: vi.fn(),
appendChild: vi.fn(),
setAttribute: vi.fn(),
}),
}),
createDiv: vi.fn().mockReturnValue({
createDiv: vi.fn().mockReturnValue({
createEl: vi.fn().mockReturnValue({
createEl: vi.fn(),
}),
}),
createEl: vi.fn().mockReturnValue({
createEl: vi.fn(),
appendChild: vi.fn(),
}),
}),
querySelectorAll: vi.fn().mockReturnValue([]),
};
app = {
vault: {
getMarkdownFiles: vi.fn().mockReturnValue([
{ path: "coi/prompts/prompt1.md", basename: "prompt1" },
{ path: "coi/prompts/prompt2.md", basename: "prompt2" },
]),
},
workspace: {
trigger: vi.fn(),
},
} as any;
plugin = {
settings: { ...DEFAULT_SETTINGS },
saveSettings: vi.fn().mockResolvedValue(undefined),
registry: {
availableModels: [
{ id: "gpt-4", name: "GPT-4", renaming: true },
{ id: "claude-3", name: "Claude 3", renaming: true },
{ id: "gemini-pro", name: "Gemini Pro", renaming: false },
],
reinitialize: vi.fn(),
},
} as any;
settingsTab = new CoIntelligenceSettingsTab(app, plugin);
settingsTab.containerEl = mockElement;
});
afterEach(() => {
vi.useRealTimers();
});
describe("createDebouncedChangeHandler", () => {
it("should debounce multiple changes", async () => {
const handler = settingsTab["createDebouncedChangeHandler"](
"openaiApiKey",
true,
false,
);
handler("key1");
handler("key2");
handler("key3");
expect(plugin.saveSettings).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(plugin.saveSettings).toHaveBeenCalledTimes(1);
expect(plugin.settings.openaiApiKey).toBe("key3");
});
it("should reinitialize registry when required", async () => {
const handler = settingsTab["createDebouncedChangeHandler"](
"anthropicApiKey",
true,
false,
);
handler("new-key");
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(plugin.registry.reinitialize).toHaveBeenCalled();
});
it("should trigger settings-changed event", async () => {
const handler = settingsTab["createDebouncedChangeHandler"](
"defaultFolder",
false,
false,
);
handler("new-folder");
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(app.workspace.trigger).toHaveBeenCalledWith(
"co-intelligence:settings-changed",
);
});
it("should normalize folder paths", async () => {
const handler = settingsTab["createDebouncedChangeHandler"](
"defaultFolder",
false,
false,
);
handler("folder//with///slashes/");
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(plugin.settings.defaultFolder).toBe("folder/with/slashes");
});
it("should handle multiple pending changes for different settings", async () => {
const handler1 = settingsTab["createDebouncedChangeHandler"](
"openaiApiKey",
true,
false,
);
const handler2 = settingsTab["createDebouncedChangeHandler"](
"anthropicApiKey",
true,
false,
);
handler1("openai-key");
handler2("anthropic-key");
vi.advanceTimersByTime(500);
await vi.runAllTimersAsync();
expect(plugin.settings.openaiApiKey).toBe("openai-key");
expect(plugin.settings.anthropicApiKey).toBe("anthropic-key");
expect(plugin.saveSettings).toHaveBeenCalledTimes(1);
expect(plugin.registry.reinitialize).toHaveBeenCalledTimes(1);
});
});
});
});

View file

@ -1,40 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NewChatCommand } from "../new-chat";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
import type { App } from "obsidian";
vi.mock("@/utils/notes", () => ({
createCOINote: vi.fn(),
}));
describe("NewChatCommand", () => {
let app: App;
let plugin: CoIntelligencePlugin;
let command: NewChatCommand;
beforeEach(() => {
app = {} as App;
plugin = { app } as CoIntelligencePlugin;
command = new NewChatCommand(plugin);
});
it("should have correct id and name", () => {
expect(command.id).toBe("new-chat");
expect(command.name).toBe("New chat");
});
it("should call createCOINote when callback is executed", async () => {
const { createCOINote } = await import("@/utils/notes");
const mockedCreateCOINote = vi.mocked(createCOINote);
await command.callback();
expect(mockedCreateCOINote).toHaveBeenCalledWith(app, plugin);
expect(mockedCreateCOINote).toHaveBeenCalledTimes(1);
});
it("should store app and plugin references", () => {
expect(command["app"]).toBe(app);
expect(command["plugin"]).toBe(plugin);
});
});

View file

@ -1,233 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ToggleChatViewCommand } from "../toggle-chat-view";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
import type { App, TFile, WorkspaceLeaf, View, Notice } from "obsidian";
import { VIEW_TYPE_COI_CHAT } from "@/ChatView";
vi.mock("@/utils/notes", () => ({
isCoiNote: vi.fn(),
isActiveCoiNote: vi.fn(),
}));
vi.mock("obsidian", async (importOriginal) => {
const actual = await importOriginal() as any;
return {
...actual,
Notice: vi.fn(),
SuggestModal: vi.fn().mockImplementation(() => ({
open: vi.fn(),
close: vi.fn(),
})),
};
});
describe("ToggleChatViewCommand", () => {
let app: App;
let plugin: CoIntelligencePlugin;
let command: ToggleChatViewCommand;
let mockFile: TFile;
let mockLeaf: WorkspaceLeaf;
let mockView: View;
beforeEach(() => {
vi.useFakeTimers();
mockView = {
getState: vi.fn().mockReturnValue({ mode: "source" }),
} as any;
mockLeaf = {
view: mockView,
setViewState: vi.fn().mockResolvedValue(undefined),
} as any;
mockFile = {
path: "test.md",
basename: "test",
} as TFile;
app = {
workspace: {
getActiveFile: vi.fn(),
getMostRecentLeaf: vi.fn().mockReturnValue(mockLeaf),
},
fileManager: {
processFrontMatter: vi.fn(),
},
} as any;
plugin = { app } as CoIntelligencePlugin;
command = new ToggleChatViewCommand(plugin);
});
afterEach(() => {
vi.useRealTimers();
});
it("should have correct id and name", () => {
expect(command.id).toBe("toggle-chat-view");
expect(command.name).toBe("Toggle chat view");
});
describe("checkCallback", () => {
it("should return false when checking with no active file", () => {
app.workspace.getActiveFile = vi.fn().mockReturnValue(null);
const result = command.checkCallback(true);
expect(result).toBe(false);
});
it("should return false when checking non-COI note", async () => {
const { isCoiNote } = await import("@/utils/notes");
const mockedIsCoiNote = vi.mocked(isCoiNote);
app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile);
mockedIsCoiNote.mockReturnValue(false);
const result = command.checkCallback(true);
expect(result).toBe(false);
expect(mockedIsCoiNote).toHaveBeenCalledWith(mockFile, app);
});
it("should return true when checking COI note", async () => {
const { isCoiNote } = await import("@/utils/notes");
const mockedIsCoiNote = vi.mocked(isCoiNote);
app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile);
mockedIsCoiNote.mockReturnValue(true);
const result = command.checkCallback(true);
expect(result).toBe(true);
});
it("should perform toggle when not checking and file is COI note", async () => {
const { isCoiNote } = await import("@/utils/notes");
const mockedIsCoiNote = vi.mocked(isCoiNote);
const performToggleSpy = vi.spyOn(command as any, "performToggle");
app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile);
mockedIsCoiNote.mockReturnValue(true);
command.checkCallback(false);
expect(performToggleSpy).toHaveBeenCalledWith(mockFile);
});
it("should not perform toggle when file is not COI note", async () => {
const { isCoiNote } = await import("@/utils/notes");
const mockedIsCoiNote = vi.mocked(isCoiNote);
const performToggleSpy = vi.spyOn(command as any, "performToggle");
app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile);
mockedIsCoiNote.mockReturnValue(false);
command.checkCallback(false);
expect(performToggleSpy).not.toHaveBeenCalled();
});
});
describe("performToggle", () => {
it("should toggle from active to source view", async () => {
const { isActiveCoiNote } = await import("@/utils/notes");
const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote);
mockedIsActiveCoiNote.mockReturnValue(true);
const processFrontMatterCallback = vi.fn();
app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => {
const frontmatter = { "coi-chat-view": true };
callback(frontmatter);
processFrontMatterCallback(frontmatter);
return Promise.resolve();
});
const togglePromise = command["performToggle"](mockFile);
await vi.runAllTimersAsync();
await togglePromise;
expect(processFrontMatterCallback).toHaveBeenCalledWith(
expect.objectContaining({ "coi-chat-view": false })
);
expect(mockLeaf.setViewState).toHaveBeenCalledWith(
{
type: "markdown",
state: { mode: "source" },
popstate: true,
},
{ focus: true }
);
});
it("should toggle from source to chat view", async () => {
const { isActiveCoiNote } = await import("@/utils/notes");
const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote);
mockedIsActiveCoiNote.mockReturnValue(false);
const processFrontMatterCallback = vi.fn();
app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => {
const frontmatter = { "coi-chat-view": false };
callback(frontmatter);
processFrontMatterCallback(frontmatter);
return Promise.resolve();
});
const togglePromise = command["performToggle"](mockFile);
await vi.runAllTimersAsync();
await togglePromise;
expect(processFrontMatterCallback).toHaveBeenCalledWith(
expect.objectContaining({ "coi-chat-view": true })
);
expect(mockLeaf.setViewState).toHaveBeenCalledWith(
{
type: VIEW_TYPE_COI_CHAT,
state: { mode: "source" },
popstate: true,
},
{ focus: true }
);
});
});
describe("error handling", () => {
it("should show notice when no leaf found for default editor", async () => {
const Notice = vi.mocked((await import("obsidian")).Notice);
app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null);
await command["openInDefaultEditor"](mockFile);
expect(Notice).toHaveBeenCalledWith(
"Error: no leaf found while opening chat in default editor"
);
});
it("should show notice when no leaf found for chat view", async () => {
const Notice = vi.mocked((await import("obsidian")).Notice);
app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null);
await command["openInChatView"](mockFile);
expect(Notice).toHaveBeenCalledWith(
"Error: no leaf found while opening chat in chat view"
);
});
it("should log error to console when no leaf found", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation();
app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null);
await command["openInDefaultEditor"](mockFile);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"No leaf found while opening chat in default editor"
);
consoleErrorSpy.mockRestore();
});
});
});

View file

@ -1,51 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@solidjs/testing-library';
import { LucideIcon } from '@/components/LucideIcon';
import { render } from '../../../test/utils/render';
// Mock the setIcon function from Obsidian
vi.mock('obsidian', () => ({
setIcon: vi.fn(),
}));
describe('LucideIcon', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with correct CSS class', () => {
render(() => <LucideIcon name="settings" />);
expect(document.querySelector('.coi-lucide-icon')).toBeInTheDocument();
});
it('should call setIcon with correct parameters on mount', async () => {
const { setIcon } = await import('obsidian');
render(() => <LucideIcon name="home" />);
// Wait for onMount to be called
await new Promise(resolve => setTimeout(resolve, 0));
expect(setIcon).toHaveBeenCalledTimes(1);
expect(setIcon).toHaveBeenCalledWith(
expect.any(HTMLElement),
'home'
);
});
it('should render different icons with different names', async () => {
const { setIcon } = await import('obsidian');
const { unmount } = render(() => <LucideIcon name="star" />);
await new Promise(resolve => setTimeout(resolve, 0));
expect(setIcon).toHaveBeenCalledWith(expect.any(HTMLElement), 'star');
unmount();
vi.clearAllMocks();
render(() => <LucideIcon name="heart" />);
await new Promise(resolve => setTimeout(resolve, 0));
expect(setIcon).toHaveBeenCalledWith(expect.any(HTMLElement), 'heart');
});
});

View file

@ -1,186 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { createSignal } from 'solid-js';
import { ModelSelector } from '@/components/ModelSelector';
import { Model, ModelId } from '@/types';
import { render, createMockPlugin } from '../../../test/utils/render';
// Mock ModelRegistry
vi.mock('@/services/model-registry', () => ({
ModelRegistry: {
getInstance: vi.fn(),
}
}));
// Mock LucideIcon component
vi.mock('@/components/LucideIcon', () => ({
LucideIcon: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}></span>
}));
describe('ModelSelector', () => {
const mockModels: Model[] = [
{
id: 'openai:gpt-4' as ModelId,
provider: 'openai',
name: 'GPT-4',
renaming: false,
toggleWebSearch: true,
streaming: true,
},
{
id: 'anthropic:claude-3-5-sonnet' as ModelId,
provider: 'anthropic',
name: 'Claude 3.5 Sonnet',
renaming: false,
toggleWebSearch: false,
streaming: true,
}
];
let mockRegistry: any;
beforeEach(async () => {
mockRegistry = {
availableModels: [],
getModel: vi.fn(),
};
const { ModelRegistry } = await import('@/services/model-registry');
vi.mocked(ModelRegistry.getInstance).mockReturnValue(mockRegistry);
});
it('should render with no models available', () => {
mockRegistry.availableModels = [];
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>,
{ plugin: createMockPlugin() }
);
expect(screen.getByRole('combobox')).toBeDisabled();
expect(screen.getByText('No available models')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toHaveAttribute('aria-label', 'No models available');
});
it('should render available models', () => {
mockRegistry.availableModels = mockModels;
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>,
{ plugin: createMockPlugin() }
);
expect(screen.getByRole('combobox')).not.toBeDisabled();
expect(screen.getByText('GPT-4')).toBeInTheDocument();
expect(screen.getByText('Claude 3.5 Sonnet')).toBeInTheDocument();
});
it('should call onModelChange when selection changes', async () => {
mockRegistry.availableModels = mockModels;
mockRegistry.getModel.mockImplementation((id: ModelId) =>
mockModels.find(model => model.id === id) || null
);
const user = userEvent.setup();
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>,
{ plugin: createMockPlugin() }
);
const select = screen.getByRole('combobox');
await user.selectOptions(select, 'openai:gpt-4');
expect(mockRegistry.getModel).toHaveBeenCalledWith('openai:gpt-4');
expect(onModelChange).toHaveBeenCalledWith(mockModels[0]);
});
it('should display selected model', () => {
mockRegistry.availableModels = mockModels;
const [selectedModel, setSelectedModel] = createSignal<Model | null>(mockModels[0]);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>,
{ plugin: createMockPlugin() }
);
expect(screen.getByRole('combobox')).toHaveValue('openai:gpt-4');
});
it('should show label when showLabel is true', () => {
mockRegistry.availableModels = [];
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
label="Choose Model"
showLabel={true}
/>,
{ plugin: createMockPlugin() }
);
expect(screen.getByText('Choose Model')).toBeInTheDocument();
expect(screen.getByText('Choose Model')).toHaveClass('model-selector-label');
});
it('should hide label when showLabel is false', () => {
mockRegistry.availableModels = [];
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
label="Choose Model"
showLabel={false}
/>,
{ plugin: createMockPlugin() }
);
// The label should exist but be visually hidden with sr-only class
const hiddenLabel = document.querySelector('label.sr-only');
expect(hiddenLabel).toBeInTheDocument();
expect(hiddenLabel).toHaveClass('sr-only');
expect(hiddenLabel).toHaveTextContent('Choose Model');
});
it('should render bot icon', () => {
mockRegistry.availableModels = [];
const [selectedModel, setSelectedModel] = createSignal<Model | null>(null);
const onModelChange = vi.fn();
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
/>,
{ plugin: createMockPlugin() }
);
expect(screen.getByTestId('icon-bot-message-square')).toBeInTheDocument();
});
});

View file

@ -1,42 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { ProcessingIndicator } from '@/components/ProcessingIndicator';
import { render } from '../../../test/utils/render';
describe('ProcessingIndicator', () => {
it('should render processing indicator with text', () => {
render(() => <ProcessingIndicator />);
expect(screen.getByText('Generating response...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(() => <ProcessingIndicator onCancel={onCancel} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('should render without onCancel prop', () => {
render(() => <ProcessingIndicator />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toBeInTheDocument();
});
it('should have correct CSS classes', () => {
render(() => <ProcessingIndicator />);
expect(document.querySelector('.coi-processing-indicator')).toBeInTheDocument();
expect(document.querySelector('.coi-processing-spinner')).toBeInTheDocument();
expect(document.querySelector('.coi-processing-text')).toBeInTheDocument();
expect(document.querySelector('.coi-cancel-button')).toBeInTheDocument();
});
});

View file

@ -1,127 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { createSignal } from 'solid-js';
import { UserInput } from '@/components/UserInput';
import { Model, ModelId } from '@/types';
import { render, createMockPlugin, createMockApp } from '../../../test/utils/render';
// Mock ModelRegistry
vi.mock('@/services/model-registry', () => ({
ModelRegistry: {
getInstance: vi.fn(() => ({
availableModels: [mockModel],
})),
}
}));
// Mock child components to isolate UserInput behavior
vi.mock('@/components/ModelSelector', () => ({
ModelSelector: () => <div data-testid="model-selector">Model Selector</div>
}));
vi.mock('@/components/SystemPromptSelector', () => ({
SystemPromptSelector: () => <div data-testid="system-prompt-selector">System Prompt Selector</div>
}));
vi.mock('@/components/NoteLinkSuggestionModal', () => ({
NoteLinkSuggestionModal: class {
constructor() {}
open() {}
onClose = vi.fn();
}
}));
vi.mock('@/components/TagSuggestionModal', () => ({
TagSuggestionModal: class {
constructor() {}
open() {}
onClose = vi.fn();
}
}));
const mockModel: Model = {
id: 'openai:gpt-4' as ModelId,
provider: 'openai',
name: 'GPT-4',
renaming: false,
toggleWebSearch: true,
streaming: true,
};
describe('UserInput', () => {
let mockProps: any;
beforeEach(() => {
const [currentModel, setCurrentModel] = createSignal<Model | null>(mockModel);
mockProps = {
triggerChange: vi.fn(),
onSubmit: vi.fn(),
currentModel,
updateModel: vi.fn(),
onLinkNote: vi.fn(),
onAddTag: vi.fn(),
initialSystemPrompt: '',
};
});
it('should render textarea with correct placeholder', () => {
render(() => <UserInput {...mockProps} />, {
app: createMockApp(),
plugin: createMockPlugin()
});
expect(screen.getByPlaceholderText('Type your message...')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('should render child components', () => {
render(() => <UserInput {...mockProps} />, {
app: createMockApp(),
plugin: createMockPlugin()
});
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
expect(screen.getByTestId('system-prompt-selector')).toBeInTheDocument();
});
it('should submit message on Enter key', async () => {
const user = userEvent.setup();
render(() => <UserInput {...mockProps} />, {
app: createMockApp(),
plugin: createMockPlugin()
});
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Test message{Enter}');
expect(mockProps.onSubmit).toHaveBeenCalled();
});
it('should show web search checkbox when model supports it', () => {
render(() => <UserInput {...mockProps} />, {
app: createMockApp(),
plugin: createMockPlugin()
});
expect(screen.getByRole('checkbox')).toBeInTheDocument();
expect(screen.getByText('Web search')).toBeInTheDocument();
});
it('should toggle web search checkbox', async () => {
const user = userEvent.setup();
render(() => <UserInput {...mockProps} />, {
app: createMockApp(),
plugin: createMockPlugin()
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
});

View file

@ -1,286 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ModelRegistry } from "@/services/model-registry";
import type { CoIntelligencePlugin } from "@/CoIntelligencePlugin";
// Mock the AI SDK providers
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => "mocked-openai-provider"),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn(() => "mocked-anthropic-provider"),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn(() => "mocked-google-provider"),
}));
vi.mock("@ai-sdk/perplexity", () => ({
createPerplexity: vi.fn(() => "mocked-perplexity-provider"),
}));
vi.mock("ai", () => ({
createProviderRegistry: vi.fn((providers) => ({
providers,
languageModel: vi.fn((modelId: string) => {
if (modelId.startsWith("openai:")) return { provider: "openai" };
if (modelId.startsWith("anthropic:")) return { provider: "anthropic" };
if (modelId.startsWith("google:")) return { provider: "google" };
if (modelId.startsWith("perplexity:")) return { provider: "perplexity" };
return null;
}),
})),
}));
vi.mock("obsidian", () => ({
Notice: vi.fn(),
}));
describe("ModelRegistry", () => {
let mockPlugin: CoIntelligencePlugin;
beforeEach(() => {
// Reset the singleton instance before each test
(ModelRegistry as any).instance = null;
mockPlugin = {
settings: {
openaiApiKey: "",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
},
} as CoIntelligencePlugin;
});
afterEach(() => {
vi.clearAllMocks();
// Clean up singleton instance
(ModelRegistry as any).instance = null;
});
describe("singleton pattern", () => {
it("should create a new instance when none exists and plugin provided", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
expect(registry).toBeInstanceOf(ModelRegistry);
});
it("should return the same instance on subsequent calls", () => {
const registry1 = ModelRegistry.getInstance(mockPlugin);
const registry2 = ModelRegistry.getInstance();
expect(registry1).toBe(registry2);
});
it("should throw error when no instance exists and no plugin provided", () => {
expect(() => {
ModelRegistry.getInstance();
}).toThrow(
"No existing registry instance and no plugin instance provided.",
);
});
});
describe("provider initialization", () => {
it("should initialize no providers when no API keys provided", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
expect(registry.availableModels).toHaveLength(0);
expect(registry.hasInitializedProviders()).toBe(false);
});
it("should initialize OpenAI provider when API key provided", () => {
mockPlugin.settings.openaiApiKey = "sk-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const openaiModels = registry.availableModels.filter(
(m) => m.provider === "openai",
);
expect(openaiModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("should initialize Anthropic provider when API key provided", () => {
mockPlugin.settings.anthropicApiKey = "sk-ant-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const anthropicModels = registry.availableModels.filter(
(m) => m.provider === "anthropic",
);
expect(anthropicModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("should initialize Google provider when API key provided", () => {
mockPlugin.settings.googleApiKey = "google-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const googleModels = registry.availableModels.filter(
(m) => m.provider === "google",
);
expect(googleModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("should initialize Perplexity provider when API key provided", () => {
mockPlugin.settings.perplexityApiKey = "pplx-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const perplexityModels = registry.availableModels.filter(
(m) => m.provider === "perplexity",
);
expect(perplexityModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("should initialize multiple providers when multiple API keys provided", () => {
mockPlugin.settings.openaiApiKey = "sk-test-key";
mockPlugin.settings.anthropicApiKey = "sk-ant-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const openaiModels = registry.availableModels.filter(
(m) => m.provider === "openai",
);
const anthropicModels = registry.availableModels.filter(
(m) => m.provider === "anthropic",
);
expect(openaiModels.length).toBeGreaterThan(0);
expect(anthropicModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
});
describe("model retrieval", () => {
beforeEach(() => {
mockPlugin.settings.openaiApiKey = "sk-test-key";
mockPlugin.settings.anthropicApiKey = "sk-ant-test-key";
});
it("should retrieve a model by ID", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const model = registry.getModel("openai:gpt-4o");
expect(model).toBeDefined();
expect(model.id).toBe("openai:gpt-4o");
expect(model.provider).toBe("openai");
expect(model.name).toBe("OpenAI GPT-4o");
});
it("should throw error for non-existent model", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
expect(() => {
registry.getModel("nonexistent:model" as any);
}).toThrow("Model not found: nonexistent:model");
});
it("should get language model from provider registry", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const languageModel = registry.getLanguageModel("openai:gpt-4o");
expect(languageModel).toBeDefined();
expect(languageModel.provider).toBe("openai");
});
it("should return default model when available", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const defaultModel = registry.getDefaultModel();
expect(defaultModel).toBeDefined();
expect(registry.availableModels).toContain(defaultModel);
});
it("should return null as default model when no providers initialized", () => {
// Create registry without API keys
mockPlugin.settings = {
openaiApiKey: "",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
defaultFolder: "",
defaultModel: "" as any,
renamingModel: "" as any,
systemPromptFolder: "",
defaultSystemPromptNote: ""
};
const registry = ModelRegistry.getInstance(mockPlugin);
const defaultModel = registry.getDefaultModel();
expect(defaultModel).toBeNull();
});
});
describe("reinitialization", () => {
it("should reinitialize providers when reinitialize is called", () => {
// Start with no API keys
const registry = ModelRegistry.getInstance(mockPlugin);
expect(registry.availableModels).toHaveLength(0);
// Add API key and reinitialize
mockPlugin.settings.openaiApiKey = "sk-test-key";
registry.reinitialize();
expect(registry.availableModels.length).toBeGreaterThan(0);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("should update available models after reinitialization", () => {
// Start with OpenAI only
mockPlugin.settings.openaiApiKey = "sk-test-key";
const registry = ModelRegistry.getInstance(mockPlugin);
const initialModelCount = registry.availableModels.length;
expect(
registry.availableModels.every((m) => m.provider === "openai"),
).toBe(true);
// Add Anthropic and reinitialize
mockPlugin.settings.anthropicApiKey = "sk-ant-test-key";
registry.reinitialize();
expect(registry.availableModels.length).toBeGreaterThan(
initialModelCount,
);
expect(
registry.availableModels.some((m) => m.provider === "anthropic"),
).toBe(true);
});
});
describe("model properties", () => {
beforeEach(() => {
mockPlugin.settings.openaiApiKey = "sk-test-key";
mockPlugin.settings.anthropicApiKey = "sk-ant-test-key";
});
it("should have correct model properties for OpenAI models", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const gpt4o = registry.getModel("openai:gpt-4o");
expect(gpt4o.renaming).toBe(true);
expect(gpt4o.toggleWebSearch).toBe(true);
expect(gpt4o.streaming).toBe(true);
});
it("should have correct model properties for O1 models (no web search)", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const o1 = registry.getModel("openai:o1");
expect(o1.renaming).toBe(false);
expect(o1.toggleWebSearch).toBe(false);
expect(o1.streaming).toBe(true);
});
it("should have correct model properties for Claude models", () => {
const registry = ModelRegistry.getInstance(mockPlugin);
const claude = registry.getModel("anthropic:claude-4-sonnet-20250514");
expect(claude.renaming).toBe(true);
expect(claude.toggleWebSearch).toBe(false);
expect(claude.streaming).toBe(true);
});
});
});

View file

@ -1,437 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
generateChatResponse,
cancelChatResponse,
deleteAbortControllerForRequest,
generateChatTitle
} from '@/services/model-service';
import { ModelRegistry } from '@/services/model-registry';
import { ChatRequest, ModelChatMessage, ContextItemContent } from '@/types';
import type { CoIntelligencePlugin } from '@/CoIntelligencePlugin';
// Mock the AI SDK
vi.mock('ai', async () => {
const actual = await vi.importActual('ai');
return {
...actual,
streamText: vi.fn(),
generateText: vi.fn()
};
});
// Mock the openai SDK
vi.mock('@ai-sdk/openai', () => ({
openai: {
tools: {
webSearchPreview: vi.fn(() => ({ type: 'web_search_preview' }))
}
}
}));
// Mock the model context utility
vi.mock('@/utils/model-context', () => ({
makeContext: vi.fn((contexts: ContextItemContent[]) =>
contexts.map(c => `--- Note: ${c.title} ---\n${c.content}\n---`).join('\n\n')
)
}));
// Mock ModelRegistry to avoid singleton instance issues in tests
let mockRegistryInstance: any;
vi.mock('@/services/model-registry', () => ({
ModelRegistry: {
getInstance: vi.fn(() => mockRegistryInstance)
}
}));
import { streamText, generateText } from 'ai';
describe('model-service', () => {
let mockRegistry: ModelRegistry;
let mockPlugin: CoIntelligencePlugin;
let mockModel: any;
let baseRequest: ChatRequest;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Create mock model
mockModel = {
provider: 'openai',
doStream: vi.fn().mockResolvedValue({
stream: async function* () {
yield { type: 'text-delta', textDelta: 'Hello' };
yield { type: 'text-delta', textDelta: ' world' };
yield { type: 'finish', finishReason: 'stop', usage: {
promptTokens: 10,
completionTokens: 2,
totalTokens: 12
}};
}
})
};
// Create mock registry
mockRegistry = {
getLanguageModel: vi.fn().mockReturnValue(mockModel),
getModel: vi.fn().mockReturnValue({
id: 'openai:gpt-4o',
provider: 'openai',
name: 'OpenAI GPT-4o',
renaming: true,
toggleWebSearch: true,
streaming: true
})
} as any;
// Set the mock registry instance for the mocked ModelRegistry.getInstance
mockRegistryInstance = mockRegistry;
// Create mock plugin
mockPlugin = {
settings: {
renamingModel: 'openai:gpt-4o'
}
} as any;
// Base request object
baseRequest = {
requestID: 'test-request-123',
modelId: 'openai:gpt-4o' as any,
messages: [{ role: 'user', content: 'Hello' }],
webSearch: false
};
// Mock streamText to return a mock result
const mockStreamResult = {
textStream: (async function* () {
yield 'Hello';
yield ' world';
})(),
text: 'Hello world',
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 2, totalTokens: 12 }
};
(streamText as any).mockResolvedValue(mockStreamResult);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('generateChatResponse', () => {
it('should generate a basic chat response', async () => {
const result = await generateChatResponse(baseRequest, mockRegistry);
expect(mockRegistry.getLanguageModel).toHaveBeenCalledWith('openai:gpt-4o');
expect(mockRegistry.getModel).toHaveBeenCalledWith('openai:gpt-4o');
expect(streamText).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should use default system prompt when none provided', async () => {
await generateChatResponse(baseRequest, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.system).toBe('You are a helpful assistant.');
});
it('should use custom system prompt when provided', async () => {
const requestWithSystem = {
...baseRequest,
systemPrompt: 'You are a helpful coding assistant.'
};
await generateChatResponse(requestWithSystem, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.system).toBe('You are a helpful coding assistant.');
});
it('should disable web search for models that do not support it', async () => {
mockRegistry.getModel = vi.fn().mockReturnValue({
id: 'openai:o1',
provider: 'openai',
name: 'OpenAI O1',
renaming: false,
toggleWebSearch: false,
streaming: true
});
const requestWithWebSearch = {
...baseRequest,
modelId: 'openai:o1' as any,
webSearch: true
};
await generateChatResponse(requestWithWebSearch, mockRegistry);
// The webSearch should be set to false internally
expect(requestWithWebSearch.webSearch).toBe(false);
});
it('should add web search system prompt when web search enabled', async () => {
const requestWithWebSearch = {
...baseRequest,
webSearch: true,
systemPrompt: 'Custom prompt.'
};
await generateChatResponse(requestWithWebSearch, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.system).toContain('Custom prompt.');
expect(streamTextCall.system).toContain('You should search the web for current information');
});
it('should add OpenAI web search tool when web search enabled for OpenAI models', async () => {
const requestWithWebSearch = {
...baseRequest,
webSearch: true
};
await generateChatResponse(requestWithWebSearch, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.tools).toBeDefined();
expect(streamTextCall.tools.web_search_preview).toEqual({ type: 'web_search_preview' });
});
it('should add reasoning summary for O3 models', async () => {
const o3Request = {
...baseRequest,
modelId: 'openai:o3' as any
};
await generateChatResponse(o3Request, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.providerOptions?.openai?.reasoningSummary).toBe('detailed');
});
it('should add anthropic headers for anthropic models', async () => {
const anthropicModel = {
provider: 'anthropic'
};
mockRegistry.getLanguageModel = vi.fn().mockReturnValue(anthropicModel);
mockRegistry.getModel = vi.fn().mockReturnValue({
id: 'anthropic:claude-4-sonnet-20250514',
provider: 'anthropic',
name: 'Anthropic Claude 4 Sonnet',
renaming: true,
toggleWebSearch: false,
streaming: true
});
const anthropicRequest = {
...baseRequest,
modelId: 'anthropic:claude-4-sonnet-20250514' as any
};
await generateChatResponse(anthropicRequest, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.headers).toEqual({
'anthropic-dangerous-direct-browser-access': 'true'
});
});
it('should configure Google models for web search', async () => {
const mockGoogleModel = {
provider: 'google',
settings: {} as any
};
mockRegistry.getLanguageModel = vi.fn().mockReturnValue(mockGoogleModel);
mockRegistry.getModel = vi.fn().mockReturnValue({
id: 'google:gemini-2.0-flash',
provider: 'google',
name: 'Google Gemini 2.0 Flash',
renaming: false,
toggleWebSearch: true,
streaming: true
});
const googleRequest = {
...baseRequest,
modelId: 'google:gemini-2.0-flash' as any,
webSearch: true
};
await generateChatResponse(googleRequest, mockRegistry);
expect(mockGoogleModel.settings.useSearchGrounding).toBe(true);
});
it('should handle context injection', async () => {
const contextItems: ContextItemContent[] = [
{ title: 'Note 1', content: 'Content of note 1' },
{ title: 'Note 2', content: 'Content of note 2' }
];
const requestWithContext = {
...baseRequest,
context: contextItems,
systemPrompt: 'Base prompt.'
};
await generateChatResponse(requestWithContext, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.system).toContain('Base prompt.');
expect(streamTextCall.system).toContain('The following documents provide additional context');
expect(streamTextCall.system).toContain('--- Note: Note 1 ---');
expect(streamTextCall.system).toContain('Content of note 1');
});
it('should handle errors in streaming', async () => {
const errorMessage = 'Streaming error';
(streamText as any).mockRejectedValue(new Error(errorMessage));
await expect(generateChatResponse(baseRequest, mockRegistry)).rejects.toThrow(errorMessage);
});
it('should create and store abort controller', async () => {
await generateChatResponse(baseRequest, mockRegistry);
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.abortSignal).toBeInstanceOf(AbortSignal);
});
});
describe('abort controller functionality', () => {
it('should cancel chat response', async () => {
// Start a request
const responsePromise = generateChatResponse(baseRequest, mockRegistry);
// Cancel it
cancelChatResponse(baseRequest);
// The abort signal should be triggered
const streamTextCall = (streamText as any).mock.calls[0][0];
expect(streamTextCall.abortSignal).toBeInstanceOf(AbortSignal);
await responsePromise;
});
it('should delete abort controller for request', async () => {
await generateChatResponse(baseRequest, mockRegistry);
// Should not throw
deleteAbortControllerForRequest(baseRequest);
});
it('should handle canceling non-existent request', () => {
const nonExistentRequest = {
...baseRequest,
requestID: 'non-existent-request'
};
// Should not throw
expect(() => cancelChatResponse(nonExistentRequest)).not.toThrow();
});
it('should handle deleting non-existent abort controller', () => {
const nonExistentRequest = {
...baseRequest,
requestID: 'non-existent-request'
};
// Should not throw
expect(() => deleteAbortControllerForRequest(nonExistentRequest)).not.toThrow();
});
});
describe('generateChatTitle', () => {
const testMessages: ModelChatMessage[] = [
{ role: 'user', content: 'How do I implement a binary search algorithm?' },
{ role: 'assistant', content: 'A binary search algorithm works by...' }
];
it('should generate a chat title', async () => {
const mockGenerateResult = {
text: 'Binary Search Implementation Guide'
};
(generateText as any).mockResolvedValue(mockGenerateResult);
const title = await generateChatTitle(testMessages, mockPlugin);
expect(title).toBe('Binary Search Implementation Guide (Chat)');
expect(generateText).toHaveBeenCalled();
});
it('should return empty string when no renaming model configured', async () => {
mockPlugin.settings.renamingModel = "" as any;
const title = await generateChatTitle(testMessages, mockPlugin);
expect(title).toBe('');
expect(generateText).not.toHaveBeenCalled();
});
it('should sanitize forbidden characters in title', async () => {
const mockGenerateResult = {
text: 'How to use / \\ and : characters'
};
(generateText as any).mockResolvedValue(mockGenerateResult);
const title = await generateChatTitle(testMessages, mockPlugin);
expect(title).toBe('How to use - - and - characters (Chat)');
});
it('should truncate long titles', async () => {
const mockGenerateResult = {
text: 'This is a very long title that exceeds the fifty character limit and should be truncated'
};
(generateText as any).mockResolvedValue(mockGenerateResult);
const title = await generateChatTitle(testMessages, mockPlugin);
expect(title.length).toBeLessThanOrEqual(57); // 50 chars + ' (Chat)' = 57
});
it('should handle anthropic models with special headers', async () => {
mockRegistry.getLanguageModel = vi.fn().mockReturnValue({
provider: 'anthropic.claude-4-sonnet-20250514'
});
const mockGenerateResult = {
text: 'Anthropic Chat Title'
};
(generateText as any).mockResolvedValue(mockGenerateResult);
await generateChatTitle(testMessages, mockPlugin);
const generateTextCall = (generateText as any).mock.calls[0][0];
expect(generateTextCall.headers).toEqual({
'anthropic-dangerous-direct-browser-access': 'true'
});
});
it('should handle errors gracefully', async () => {
(generateText as any).mockRejectedValue(new Error('Title generation failed'));
const title = await generateChatTitle(testMessages, mockPlugin);
expect(title).toBe('');
});
it('should use correct system prompt for title generation', async () => {
const mockGenerateResult = {
text: 'Generated Title'
};
(generateText as any).mockResolvedValue(mockGenerateResult);
await generateChatTitle(testMessages, mockPlugin);
const generateTextCall = (generateText as any).mock.calls[0][0];
expect(generateTextCall.system).toContain('Summarize the following conversation');
expect(generateTextCall.system).toContain('six words or less');
expect(generateTextCall.system).toContain('must not contain the characters /, \\, or :');
});
});
});

View file

@ -1,377 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TFile, App } from 'obsidian';
import {
getContext,
makeContext,
contextTokenEstimate
} from '@/utils/model-context';
import { ContextItems, ContextItemContent, Source } from '@/types';
// Mock Obsidian functions
vi.mock('obsidian', async () => {
const actual = await vi.importActual('obsidian');
return {
...actual,
requestUrl: vi.fn(),
htmlToMarkdown: vi.fn(),
TFile: class MockTFile {
name: string;
path: string;
basename: string;
extension: string;
constructor(path: string) {
this.name = path.split('/').pop() || '';
this.path = path;
this.basename = this.name.replace(/\.[^/.]+$/, '');
this.extension = path.split('.').pop() || '';
}
}
};
});
// Mock the notes utility
vi.mock('@/utils/notes', () => ({
getFilesWithTag: vi.fn()
}));
import { requestUrl, htmlToMarkdown } from 'obsidian';
import { getFilesWithTag } from '@/utils/notes';
describe('model-context utilities', () => {
let mockApp: App;
let mockFile1: TFile;
let mockFile2: TFile;
beforeEach(() => {
vi.clearAllMocks();
mockFile1 = {
name: 'note1.md',
path: 'notes/note1.md',
basename: 'note1',
extension: 'md',
vault: null,
parent: null,
stat: { ctime: 0, mtime: 0, size: 0 }
} as any;
mockFile2 = {
name: 'note2.md',
path: 'notes/note2.md',
basename: 'note2',
extension: 'md',
vault: null,
parent: null,
stat: { ctime: 0, mtime: 0, size: 0 }
} as any;
mockApp = {
vault: {
cachedRead: vi.fn()
},
metadataCache: {
getFileCache: vi.fn(),
getCache: vi.fn()
}
} as any;
});
describe('makeContext', () => {
it('should create context string from array of context items', () => {
const contextItems: ContextItemContent[] = [
{ title: 'Note 1', content: 'Content of first note' },
{ title: 'Note 2', content: 'Content of second note' }
];
const result = makeContext(contextItems);
expect(result).toBe(
'--- Note: Note 1 ---\nContent of first note\n---\n\n--- Note: Note 2 ---\nContent of second note\n---'
);
});
it('should return empty string for empty array', () => {
const result = makeContext([]);
expect(result).toBe('');
});
it('should handle single context item', () => {
const contextItems: ContextItemContent[] = [
{ title: 'Single Note', content: 'Single note content' }
];
const result = makeContext(contextItems);
expect(result).toBe('--- Note: Single Note ---\nSingle note content\n---');
});
it('should handle context items with special characters', () => {
const contextItems: ContextItemContent[] = [
{ title: 'Note with: Special & Characters', content: 'Content with\n\nmultiple\nlines' }
];
const result = makeContext(contextItems);
expect(result).toContain('--- Note: Note with: Special & Characters ---');
expect(result).toContain('Content with\n\nmultiple\nlines');
});
it('should handle empty content', () => {
const contextItems: ContextItemContent[] = [
{ title: 'Empty Note', content: '' }
];
const result = makeContext(contextItems);
expect(result).toBe('--- Note: Empty Note ---\n\n---');
});
});
describe('getContext', () => {
it('should return empty array when items is null', async () => {
const result = await getContext(null, mockApp);
expect(result).toEqual([]);
});
it('should process notes from ContextItems', async () => {
const contextItems: ContextItems = {
notes: [mockFile1, mockFile2],
tags: [],
sources: []
};
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('Content of note 1')
.mockResolvedValueOnce('Content of note 2');
const result = await getContext(contextItems, mockApp);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
title: 'note1.md',
content: 'Content of note 1'
});
expect(result[1]).toEqual({
title: 'note2.md',
content: 'Content of note 2'
});
});
it('should process tags and get associated files', async () => {
const contextItems: ContextItems = {
notes: [],
tags: ['#important', '#work'],
sources: []
};
const tagFile1 = { ...mockFile1, name: 'tagged1.md' };
const tagFile2 = { ...mockFile2, name: 'tagged2.md' };
(getFilesWithTag as any)
.mockReturnValueOnce([tagFile1])
.mockReturnValueOnce([tagFile2]);
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('Content of tagged note 1')
.mockResolvedValueOnce('Content of tagged note 2');
const result = await getContext(contextItems, mockApp);
expect(getFilesWithTag).toHaveBeenCalledWith('#important', mockApp);
expect(getFilesWithTag).toHaveBeenCalledWith('#work', mockApp);
expect(result).toHaveLength(2);
});
it('should process sources and fetch web content', async () => {
const contextItems: ContextItems = {
notes: [],
tags: [],
sources: [
{ url: 'https://example.com', title: 'Example Site' },
{ url: 'https://test.com' } // No title provided
]
};
(requestUrl as any)
.mockResolvedValueOnce({ text: '<html><body>Example content</body></html>' })
.mockResolvedValueOnce({ text: '<html><body>Test content</body></html>' });
(htmlToMarkdown as any)
.mockReturnValueOnce('Example content')
.mockReturnValueOnce('Test content');
const result = await getContext(contextItems, mockApp);
expect(requestUrl).toHaveBeenCalledWith({ url: 'https://example.com' });
expect(requestUrl).toHaveBeenCalledWith({ url: 'https://test.com' });
expect(htmlToMarkdown).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
title: 'Example Site',
content: 'Example content'
});
expect(result[1]).toEqual({
title: 'https://test.com',
content: 'Test content'
});
});
it('should handle mixed context items (notes, tags, sources)', async () => {
const taggedFile = { ...mockFile1, name: 'tagged.md' };
const contextItems: ContextItems = {
notes: [mockFile1],
tags: ['#important'],
sources: [{ url: 'https://example.com', title: 'Example' }]
};
(getFilesWithTag as any).mockReturnValueOnce([taggedFile]);
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('Direct note content')
.mockResolvedValueOnce('Tagged note content');
(requestUrl as any).mockResolvedValueOnce({ text: '<html>Web content</html>' });
(htmlToMarkdown as any).mockReturnValueOnce('Web content');
const result = await getContext(contextItems, mockApp);
expect(result).toHaveLength(3);
expect(result[0].title).toBe('note1.md');
expect(result[1].title).toBe('tagged.md');
expect(result[2].title).toBe('Example');
});
it('should handle duplicate files from notes and tags', async () => {
const contextItems: ContextItems = {
notes: [mockFile1],
tags: ['#important'],
sources: []
};
// Tag search returns the same file that's already in notes
(getFilesWithTag as any).mockReturnValueOnce([mockFile1]);
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('Note content first time')
.mockResolvedValueOnce('Note content second time');
const result = await getContext(contextItems, mockApp);
// Should include the file twice since it's in both notes and found via tags
expect(result).toHaveLength(2);
expect(result[0].title).toBe('note1.md');
expect(result[1].title).toBe('note1.md');
});
it('should handle empty file content', async () => {
const contextItems: ContextItems = {
notes: [mockFile1],
tags: [],
sources: []
};
mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce('');
const result = await getContext(contextItems, mockApp);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
title: 'note1.md',
content: ''
});
});
it('should handle vault read failures gracefully', async () => {
const contextItems: ContextItems = {
notes: [mockFile1],
tags: [],
sources: []
};
mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(null);
const result = await getContext(contextItems, mockApp);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
title: 'note1.md',
content: ''
});
});
});
describe('contextTokenEstimate', () => {
it('should estimate tokens for context items', async () => {
const contextItems: ContextItems = {
notes: [mockFile1],
tags: [],
sources: []
};
// Mock content that's 100 characters long
const content = 'a'.repeat(100);
mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce(content);
const result = await contextTokenEstimate(contextItems, mockApp);
// Should be approximately content.length / 4 = 100 / 4 = 25
expect(result).toBe(25);
});
it('should sum tokens from multiple context items', async () => {
const contextItems: ContextItems = {
notes: [mockFile1, mockFile2],
tags: [],
sources: []
};
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('a'.repeat(80)) // 20 tokens
.mockResolvedValueOnce('b'.repeat(120)); // 30 tokens
const result = await contextTokenEstimate(contextItems, mockApp);
expect(result).toBe(50); // 20 + 30
});
it('should handle empty content', async () => {
const contextItems: ContextItems = {
notes: [mockFile1],
tags: [],
sources: []
};
mockApp.vault.cachedRead = vi.fn().mockResolvedValueOnce('');
const result = await contextTokenEstimate(contextItems, mockApp);
expect(result).toBe(0);
});
it('should include tokens from all context types', async () => {
const taggedFile = { ...mockFile1, name: 'tagged.md' };
const contextItems: ContextItems = {
notes: [mockFile1],
tags: ['#test'],
sources: [{ url: 'https://example.com', title: 'Example' }]
};
(getFilesWithTag as any).mockReturnValueOnce([taggedFile]);
mockApp.vault.cachedRead = vi.fn()
.mockResolvedValueOnce('a'.repeat(40)) // 10 tokens
.mockResolvedValueOnce('b'.repeat(80)); // 20 tokens
(requestUrl as any).mockResolvedValueOnce({ text: '<html>content</html>' });
(htmlToMarkdown as any).mockReturnValueOnce('c'.repeat(120)); // 30 tokens
const result = await contextTokenEstimate(contextItems, mockApp);
expect(result).toBe(60); // 10 + 20 + 30
});
});
});

View file

@ -1,268 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
isCoiNote,
isPathCoiNote,
isActiveCoiNote,
isPathActiveCoiNote,
sanitizeForTagName,
serializeCoiNoteContent,
deserializeCoiNoteContent
} from '../notes';
import { TFile, App } from 'obsidian';
describe('notes utilities', () => {
let mockApp: App;
let mockFile: TFile;
beforeEach(() => {
mockApp = new App();
mockFile = new TFile('test.md');
});
describe('isCoiNote', () => {
it('should return true for COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': true }
});
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(true);
});
it('should return false for non-COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': false }
});
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
it('should return false when frontmatter is missing', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue(null);
const result = isCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
});
describe('isPathCoiNote', () => {
it('should return true for COI note paths', () => {
mockApp.metadataCache.getCache = vi.fn().mockReturnValue({
frontmatter: { 'is-coi-chat': true }
});
const result = isPathCoiNote('test.md', mockApp);
expect(result).toBe(true);
});
it('should return false for empty path', () => {
const result = isPathCoiNote('', mockApp);
expect(result).toBe(false);
});
});
describe('isActiveCoiNote', () => {
it('should return true for active COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': true
}
});
const result = isActiveCoiNote(mockFile, mockApp);
expect(result).toBe(true);
});
it('should return false for inactive COI notes', () => {
mockApp.metadataCache.getFileCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': false
}
});
const result = isActiveCoiNote(mockFile, mockApp);
expect(result).toBe(false);
});
});
describe('isPathActiveCoiNote', () => {
it('should return true for active COI note paths', () => {
mockApp.metadataCache.getCache = vi.fn().mockReturnValue({
frontmatter: {
'is-coi-chat': true,
'coi-chat-view': true
}
});
const result = isPathActiveCoiNote('test.md', mockApp);
expect(result).toBe(true);
});
it('should return false for empty path', () => {
const result = isPathActiveCoiNote('', mockApp);
expect(result).toBe(false);
});
});
describe('sanitizeForTagName', () => {
it('should sanitize special characters', () => {
const result = sanitizeForTagName('Hello World!@#$%');
expect(result).toBe('hello-world');
});
it('should preserve alphanumeric and allowed characters', () => {
const result = sanitizeForTagName('test_tag-123');
expect(result).toBe('test_tag-123');
});
it('should remove leading and trailing dashes', () => {
const result = sanitizeForTagName('--test-tag--');
expect(result).toBe('test-tag');
});
it('should collapse multiple dashes', () => {
const result = sanitizeForTagName('test---multiple--dashes');
expect(result).toBe('test-multiple-dashes');
});
it('should handle empty string', () => {
const result = sanitizeForTagName('');
expect(result).toBe('');
});
it('should trim whitespace', () => {
const result = sanitizeForTagName(' test tag ');
expect(result).toBe('test-tag');
});
});
describe('serializeCoiNoteContent', () => {
it('should serialize basic chat messages', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there!' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result).toContain('## user:\n\nHello');
expect(result).toContain('## assistant:\n\nHi there!');
});
it('should include sources when provided', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: 'Question' }
];
const sources = [
{ title: 'Test Source', url: 'https://example.com' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null, sources);
expect(result).toContain('## Sources');
expect(result).toContain('1. [Test Source](https://example.com)');
});
it('should preserve content before and after chat section', async () => {
const currentContent = 'Before\n<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->\nAfter';
const messages = [
{ role: 'user' as const, content: 'Test' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result.startsWith('Before')).toBe(true);
expect(result.endsWith('After')).toBe(true);
});
it('should adjust header levels in content', async () => {
const currentContent = '<!-- CHAT-THREAD-START -->\n\n<!-- CHAT-THREAD-END -->';
const messages = [
{ role: 'user' as const, content: '# Main Header\n## Sub Header' }
];
const result = await serializeCoiNoteContent(currentContent, mockApp, messages, null);
expect(result).toContain('### Main Header');
expect(result).toContain('#### Sub Header');
});
});
describe('deserializeCoiNoteContent', () => {
it('should deserialize basic chat messages', async () => {
const content = `
<!-- CHAT-THREAD-START -->
## user:
Hello
## assistant:
Hi there!
<!-- CHAT-THREAD-END -->
`.trim();
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]).toEqual({ role: 'user', content: 'Hello' });
expect(result.messages[1]).toEqual({ role: 'assistant', content: 'Hi there!' });
});
it('should deserialize sources', async () => {
const content = `
<!-- CHAT-THREAD-START -->
## user:
Question
## Sources
1. [Test Source](https://example.com)
2. [Another Source](https://test.com)
<!-- CHAT-THREAD-END -->
`.trim();
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.sources).toHaveLength(2);
expect(result.sources[0]).toEqual({ title: 'Test Source', url: 'https://example.com' });
expect(result.sources[1]).toEqual({ title: 'Another Source', url: 'https://test.com' });
});
it('should return empty result when no chat section found', async () => {
const content = 'Regular note content without chat section';
const result = await deserializeCoiNoteContent(content, null, mockApp);
expect(result.messages).toHaveLength(0);
expect(result.sources).toHaveLength(0);
expect(result.contextItems).toEqual({ notes: [], tags: [], sources: [] });
});
it('should handle context items from frontmatter', async () => {
const content = '<!-- CHAT-THREAD-START -->\n<!-- CHAT-THREAD-END -->';
const metadata = {
frontmatter: {
'linked-notes': ['note1.md', 'note2.md'],
'linked-tags': ['#tag1', '#tag2']
}
};
// Mock files for linked notes
mockApp.vault.getAbstractFileByPath = vi.fn()
.mockReturnValueOnce(new TFile('note1.md'))
.mockReturnValueOnce(new TFile('note2.md'));
const result = await deserializeCoiNoteContent(content, metadata, mockApp);
expect(result.contextItems.notes).toHaveLength(2);
expect(result.contextItems.tags).toEqual(['#tag1', '#tag2']);
});
});
});

View file

@ -1,113 +0,0 @@
import { describe, it, expect } from 'vitest';
import { getDomainFromUrl, ensureSourceTitle } from '../url';
describe('url utilities', () => {
describe('getDomainFromUrl', () => {
it('should extract domain from valid HTTPS URL', () => {
const result = getDomainFromUrl('https://www.example.com/path/to/page');
expect(result).toBe('www.example.com');
});
it('should extract domain from valid HTTP URL', () => {
const result = getDomainFromUrl('http://example.org/some/path');
expect(result).toBe('example.org');
});
it('should extract domain without protocol', () => {
const result = getDomainFromUrl('example.com/path');
expect(result).toBe('example.com');
});
it('should handle URLs with www prefix', () => {
const result = getDomainFromUrl('www.test-site.co.uk');
expect(result).toBe('test-site.co.uk');
});
it('should handle URLs with ports', () => {
const result = getDomainFromUrl('https://localhost:3000/api/endpoint');
expect(result).toBe('localhost');
});
it('should fallback to manual parsing for invalid URLs', () => {
const result = getDomainFromUrl('not-a-valid-url');
expect(result).toBe('not-a-valid-url');
});
it('should handle empty string', () => {
const result = getDomainFromUrl('');
expect(result).toBe('');
});
it('should handle complex URLs with subdomains', () => {
const result = getDomainFromUrl('https://api.subdomain.example.com/v1/data');
expect(result).toBe('api.subdomain.example.com');
});
});
describe('ensureSourceTitle', () => {
it('should keep existing title when provided', () => {
const source = { url: 'https://example.com', title: 'My Title' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'My Title'
});
});
it('should trim whitespace from existing title', () => {
const source = { url: 'https://example.com', title: ' My Title ' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'My Title'
});
});
it('should use domain as fallback when title is empty', () => {
const source = { url: 'https://www.example.com/page', title: '' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://www.example.com/page',
title: 'www.example.com'
});
});
it('should use domain as fallback when title is only whitespace', () => {
const source = { url: 'https://example.org/path', title: ' ' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.org/path',
title: 'example.org'
});
});
it('should use domain as fallback when title is missing', () => {
const source = { url: 'https://test.com/api/data' };
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://test.com/api/data',
title: 'test.com'
});
});
it('should preserve other properties from source', () => {
const source = {
url: 'https://example.com',
title: 'Test',
customProp: 'value'
} as any;
const result = ensureSourceTitle(source);
expect(result).toEqual({
url: 'https://example.com',
title: 'Test',
customProp: 'value'
});
});
});
});

View file

@ -1,328 +0,0 @@
# Component Testing Guide
This guide provides patterns and best practices for testing Solid.js components in the Co-Intelligence AI plugin.
## Overview
Component testing in this project uses:
- **@solidjs/testing-library** - Solid-specific testing utilities
- **@testing-library/user-event** - User interaction simulation
- **Custom render function** - Provides necessary contexts and mocks
## Testing Patterns
### 1. Simple Components
For components with minimal dependencies and straightforward behavior:
```typescript
// Example: ProcessingIndicator.test.tsx
import { render } from '../../../test/utils/render';
describe('ProcessingIndicator', () => {
it('should render processing indicator with text', () => {
render(() => <ProcessingIndicator />);
expect(screen.getByText('Generating response...')).toBeInTheDocument();
});
});
```
### 2. Components with Obsidian Dependencies
For components that use Obsidian APIs (like `setIcon`):
```typescript
// Mock Obsidian functions
const mockSetIcon = vi.fn();
vi.mock('obsidian', async () => {
const actual = await vi.importActual('obsidian');
return {
...actual,
setIcon: mockSetIcon,
};
});
describe('LucideIcon', () => {
it('should call setIcon with correct parameters', async () => {
render(() => <LucideIcon name="home" />);
await new Promise(resolve => setTimeout(resolve, 0)); // Wait for onMount
expect(mockSetIcon).toHaveBeenCalledWith(
expect.any(HTMLElement),
'home'
);
});
});
```
### 3. Components with Context Dependencies
For components that use context (App, Plugin):
```typescript
import { render, createMockPlugin, createMockApp } from '../../../test/utils/render';
describe('ModelSelector', () => {
it('should render with plugin context', () => {
render(() =>
<ModelSelector {...props} />,
{
plugin: createMockPlugin(),
app: createMockApp()
}
);
});
});
```
### 4. Complex Components with Services
For components that interact with services:
```typescript
// Mock the service
const mockModelRegistry = {
availableModels: [],
getInstance: vi.fn(),
};
vi.mock('@/services/model-registry', () => ({
ModelRegistry: mockModelRegistry
}));
describe('UserInput', () => {
beforeEach(() => {
mockModelRegistry.getInstance.mockReturnValue(mockModelRegistry);
});
});
```
### 5. Testing User Interactions
Use `@testing-library/user-event` for realistic interactions:
```typescript
import userEvent from '@testing-library/user-event';
it('should submit on Enter key', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(() => <UserInput onSubmit={onSubmit} {...otherProps} />);
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Test message{Enter}');
expect(onSubmit).toHaveBeenCalledWith('Test message', false, '');
});
```
### 6. Testing Reactive State
For components with Solid.js signals:
```typescript
import { createSignal } from 'solid-js';
it('should display selected model', () => {
const [selectedModel, setSelectedModel] = createSignal(mockModel);
render(() =>
<ModelSelector
selectedModel={selectedModel}
onModelChange={vi.fn()}
/>
);
expect(screen.getByRole('combobox')).toHaveValue('openai:gpt-4');
});
```
## Mock Strategies
### 1. Service Mocking
Always mock external services to isolate component behavior:
```typescript
vi.mock('@/services/model-registry', () => ({
ModelRegistry: {
getInstance: () => mockRegistry,
}
}));
```
### 2. Child Component Mocking
Mock complex child components to focus on the component under test:
```typescript
vi.mock('@/components/ModelSelector', () => ({
ModelSelector: ({ onModelChange }: any) => (
<select data-testid="model-selector" onChange={() => onModelChange(null)}>
<option>Mock Model</option>
</select>
)
}));
```
### 3. Obsidian API Mocking
Mock only the Obsidian APIs your component actually uses:
```typescript
class MockNoteLinkSuggestionModal {
constructor(public app: any, public query: string, public onSelect: Function) {}
open() { this.onSelect(new TFile('/test.md')); }
onClose = vi.fn();
}
vi.mock('@/components/NoteLinkSuggestionModal', () => ({
NoteLinkSuggestionModal: MockNoteLinkSuggestionModal
}));
```
## Best Practices
### 1. Test User-Facing Behavior
Focus on what users see and interact with, not implementation details:
```typescript
// Good - tests user-facing behavior
expect(screen.getByRole('button')).toBeInTheDocument();
// Bad - tests implementation details
expect(wrapper.find('.internal-class')).toExist();
```
### 2. Use Meaningful Test Names
Test names should describe the expected behavior:
```typescript
// Good
it('should disable textarea when no models are available')
// Bad
it('should work correctly')
```
### 3. Arrange-Act-Assert Pattern
Structure tests clearly:
```typescript
it('should toggle web search checkbox', async () => {
// Arrange
const user = userEvent.setup();
render(() => <UserInput {...mockProps} />);
// Act
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
// Assert
expect(checkbox).toBeChecked();
});
```
### 4. Clean Up Between Tests
Use `beforeEach` to reset mocks and state:
```typescript
beforeEach(() => {
mockService.method.mockClear();
mockService.availableItems = [];
});
```
### 5. Test Error Conditions
Don't forget to test error states and edge cases:
```typescript
it('should throw error when plugin context is not available', () => {
expect(() =>
render(() => <Component />),
{ plugin: null }
).toThrow('Plugin Context is not available');
});
```
### 6. Use Accessibility-Friendly Queries
Prefer queries that match how users interact with your app:
```typescript
// Good - uses semantic role
screen.getByRole('textbox')
screen.getByRole('button', { name: 'Submit' })
// Good - uses label text
screen.getByLabelText('Model selector')
// Okay - uses visible text
screen.getByText('Cancel')
// Avoid - uses test IDs (only when necessary)
screen.getByTestId('complex-component')
```
## Common Pitfalls
### 1. Over-Mocking
Don't mock everything - focus on external dependencies and complex children.
### 2. Testing Implementation Details
Test what users see, not how the code works internally.
### 3. Ignoring Async Operations
Always await user interactions and effects:
```typescript
await user.click(button);
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument());
```
### 4. Not Cleaning Up
Reset mocks and clear state between tests to avoid test interdependence.
### 5. Overly Complex Tests
Keep tests focused on one behavior at a time. Split complex scenarios into multiple tests.
## File Organization
```
src/components/
├── ComponentName.tsx
└── __tests__/
└── ComponentName.test.tsx
```
Each component should have its test file in a `__tests__` directory within the same folder as the component.
## Running Tests
```bash
# Run all component tests
npm run test src/components
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverage
```
## Examples
See the following test files for complete examples:
- `src/components/__tests__/ProcessingIndicator.test.tsx` - Simple component
- `src/components/__tests__/LucideIcon.test.tsx` - Component with Obsidian dependency
- `src/components/__tests__/ModelSelector.test.tsx` - Component with service dependency
- `src/components/__tests__/UserInput.test.tsx` - Complex component with multiple interactions
- `src/components/__tests__/ChatInterface.test.tsx` - Integration-style component test

View file

@ -1,209 +0,0 @@
import { vi } from 'vitest';
// Minimal Obsidian API mocks - only implement what we actually use
export class TFile {
path: string;
basename: string;
extension: string;
name: string;
parent: TFolder | null = null;
constructor(path: string) {
this.path = path;
this.name = path.split('/').pop() || '';
this.basename = this.name.replace(/\.[^/.]+$/, '');
this.extension = path.split('.').pop() || '';
// Set parent if path has directories
const parentPath = path.split('/').slice(0, -1).join('/');
if (parentPath) {
this.parent = new TFolder(parentPath);
}
}
}
export class TFolder {
path: string;
name: string;
constructor(path: string) {
this.path = path;
this.name = path.split('/').pop() || '';
}
}
export class Plugin {
app: App;
manifest: any;
constructor(app: App, manifest: any) {
this.app = app;
this.manifest = manifest;
}
addCommand = vi.fn().mockReturnValue(this);
addSettingTab = vi.fn().mockReturnValue(this);
registerView = vi.fn().mockReturnValue(this);
saveData = vi.fn().mockResolvedValue(undefined);
loadData = vi.fn().mockResolvedValue({});
onload = vi.fn();
onunload = vi.fn();
}
export class App {
vault = {
create: vi.fn().mockResolvedValue(new TFile('test.md')),
modify: vi.fn().mockResolvedValue(undefined),
read: vi.fn().mockResolvedValue(''),
cachedRead: vi.fn().mockResolvedValue(''),
getMarkdownFiles: vi.fn().mockReturnValue([]),
delete: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockReturnValue(true),
getAbstractFileByPath: vi.fn(),
createFolder: vi.fn().mockResolvedValue(new TFolder('test')),
};
workspace = {
getLeaf: vi.fn().mockReturnValue({
view: null,
setViewState: vi.fn().mockResolvedValue(undefined),
openFile: vi.fn().mockResolvedValue(undefined),
}),
activeLeaf: null,
on: vi.fn(),
off: vi.fn(),
trigger: vi.fn(),
getLeavesOfType: vi.fn().mockReturnValue([]),
detachLeavesOfType: vi.fn(),
};
metadataCache = {
getFileCache: vi.fn().mockReturnValue(null),
getCache: vi.fn().mockReturnValue(null),
on: vi.fn(),
off: vi.fn(),
};
fileManager = {
processFrontMatter: vi.fn(),
generateMarkdownLink: vi.fn(),
renameFile: vi.fn().mockResolvedValue(undefined),
};
}
export class WorkspaceLeaf {
view: any = null;
setViewState = vi.fn().mockResolvedValue(undefined);
detach = vi.fn();
getViewState = vi.fn().mockReturnValue({});
}
export class ItemView {
app: App;
leaf: WorkspaceLeaf;
constructor(leaf: WorkspaceLeaf) {
this.leaf = leaf;
this.app = leaf.view?.app || new App();
}
onOpen = vi.fn().mockResolvedValue(undefined);
onClose = vi.fn().mockResolvedValue(undefined);
getDisplayText = vi.fn().mockReturnValue('Test View');
getViewType = vi.fn().mockReturnValue('test-view');
}
export class TextFileView extends ItemView {
file: TFile | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
onLoadFile = vi.fn().mockResolvedValue(undefined);
onUnloadFile = vi.fn().mockResolvedValue(undefined);
getViewData = vi.fn().mockReturnValue('');
setViewData = vi.fn();
clear = vi.fn();
}
export class PluginSettingTab {
app: App;
plugin: Plugin;
constructor(app: App, plugin: Plugin) {
this.app = app;
this.plugin = plugin;
}
display = vi.fn();
hide = vi.fn();
}
export class Setting {
constructor(containerEl: HTMLElement) {}
setName = vi.fn().mockReturnThis();
setDesc = vi.fn().mockReturnThis();
addText = vi.fn().mockReturnThis();
addToggle = vi.fn().mockReturnThis();
addDropdown = vi.fn().mockReturnThis();
}
export class SuggestModal<T> {
app: App;
constructor(app: App) {
this.app = app;
}
open = vi.fn();
close = vi.fn();
getSuggestions = vi.fn().mockReturnValue([]);
renderSuggestion = vi.fn();
onChooseSuggestion = vi.fn();
}
export class Modal {
app: App;
constructor(app: App) {
this.app = app;
}
open = vi.fn();
close = vi.fn();
onOpen = vi.fn();
onClose = vi.fn();
}
// Mock global functions
export const addIcon = vi.fn();
export const debounce = vi.fn((fn: Function, delay: number) => fn);
export const normalizePath = vi.fn((path: string) => path);
export const parseFrontMatterEntry = vi.fn();
export const parseFrontMatterStringArray = vi.fn();
export const parseFrontMatterTags = vi.fn();
// Default export for compatibility
export default {
TFile,
TFolder,
Plugin,
App,
WorkspaceLeaf,
ItemView,
TextFileView,
PluginSettingTab,
Setting,
SuggestModal,
Modal,
addIcon,
debounce,
normalizePath,
parseFrontMatterEntry,
parseFrontMatterStringArray,
parseFrontMatterTags,
};

View file

@ -1,32 +0,0 @@
import { vi, afterEach, expect } from "vitest";
import '@testing-library/jest-dom';
// Global test setup and configuration
global.console = {
...console,
// Suppress console.log in tests unless needed for debugging
log: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: console.error, // Keep error logging for debugging
};
// Add Obsidian extensions to built-in types
declare global {
interface String {
contains(searchString: string): boolean;
}
}
// Polyfill for Obsidian's String.contains method
String.prototype.contains = function (searchString: string): boolean {
return this.includes(searchString);
};
// Clean up after each test
afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});

View file

@ -1,273 +0,0 @@
# Testing Guide for Co-Intelligence AI
This document provides instructions for running tests and understanding the testing infrastructure for the Co-Intelligence AI Obsidian plugin.
## Quick Start
```bash
# Run all tests
npm test
# Run tests in watch mode (interactive)
npm test
# Run tests once and exit
npm run test:run
# Run tests with coverage report
npm run test:coverage
# Open interactive test UI in browser
npm run test:ui
```
## Testing Commands
### Basic Testing
- `npm test` - Run tests in watch mode (recommended for development)
- `npm run test:run` - Run all tests once and exit (used in CI)
### Coverage & Analysis
- `npm run test:coverage` - Generate detailed coverage report (HTML + terminal)
- `npm run test:ui` - Open Vitest UI for interactive test exploration
### Development Tips
- Use `npm test` during development - it watches for file changes and re-runs tests automatically
- Use `npm run test:ui` for debugging complex test scenarios with the visual interface
- Check `coverage/index.html` after running coverage command for detailed HTML report
## Directory Structure
```
test/
├── testing.md # This guide
├── setup.ts # Global test setup and configuration
├── mocks/ # Mock implementations
│ └── obsidian.ts # Obsidian API mocks
├── fixtures/ # Test data (planned)
│ ├── chat-data.ts # Sample chat messages
│ ├── ai-responses.ts # Mock AI responses
│ └── notes.ts # Sample Obsidian notes
└── utils/ # Test utilities
├── test-helpers.ts # Common helper functions
└── render.tsx # Custom Solid.js render utilities
src/
├── **/__tests__/ # Unit tests (co-located with source)
│ ├── *.test.ts # TypeScript/utility tests
│ └── *.test.tsx # Component tests
└── ...
```
## Testing Approach
### 1. Unit Tests (Current Focus)
**Location**: `src/**/__tests__/*.test.ts`
**Purpose**: Test individual functions and utilities in isolation
Examples:
- `src/utils/__tests__/debounce.test.ts` - Debounce function logic
- `src/utils/__tests__/url.test.ts` - URL parsing and validation
- `src/utils/__tests__/notes.test.ts` - Note serialization/deserialization
### 2. Service Tests (Phase 2)
**Location**: `src/services/__tests__/*.test.ts`
**Purpose**: Test core business logic with mocked dependencies
Planned tests:
- Model registry behavior
- AI streaming with abort handling
- Context preparation for prompts
### 3. Component Tests (Phase 3)
**Location**: `src/components/__tests__/*.test.tsx`
**Purpose**: Test Solid.js components with user interactions
Planned tests:
- User input handling
- Message rendering
- Model selector behavior
## Mock Strategy
### Obsidian API Mocks
We use minimal mocks in `test/mocks/obsidian.ts` that only implement methods actually used by our code:
```typescript
// Example: Mock only what you need
export class App {
vault = {
create: vi.fn().mockResolvedValue(new TFile('test.md')),
read: vi.fn().mockResolvedValue(''),
// ... only mocked methods we use
};
}
```
### AI SDK Testing
The AI SDK provides built-in testing utilities:
```typescript
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
```
### Obsidian Extensions
We polyfill Obsidian-specific extensions in `test/setup.ts`:
```typescript
// Adds String.contains() method used by Obsidian
String.prototype.contains = function(searchString: string): boolean {
return this.includes(searchString);
};
```
## Writing Tests
### Basic Test Structure
```typescript
import { describe, it, expect, vi } from 'vitest';
import { functionToTest } from '../source-file';
describe('functionToTest', () => {
it('should do something specific', () => {
const result = functionToTest('input');
expect(result).toBe('expected');
});
});
```
### Testing Async Functions
```typescript
it('should handle async operations', async () => {
const result = await asyncFunction();
expect(result).toBeDefined();
});
```
### Mocking Dependencies
```typescript
import { vi } from 'vitest';
const mockFn = vi.fn().mockReturnValue('mocked result');
const mockAsync = vi.fn().mockResolvedValue('async result');
```
### Testing Solid.js Components (Phase 3)
```typescript
import { render, screen } from '@solidjs/testing-library';
import { MyComponent } from '../MyComponent';
it('should render correctly', () => {
render(() => <MyComponent prop="value" />);
expect(screen.getByText('Expected Text')).toBeInTheDocument();
});
```
## Configuration Files
### vitest.config.ts
Main test configuration with:
- Solid.js plugin integration
- Path aliases matching main Vite config
- Obsidian mock aliasing
- Coverage settings
- jsdom environment for DOM testing
### test/setup.ts
Global test setup that runs before all tests:
- Console suppression for cleaner test output
- Obsidian API extensions (String.contains, etc.)
- Mock cleanup between tests
## Coverage Goals
### Current Coverage (Phase 1)
- ✅ **debounce.ts**: 100% coverage
- ✅ **url.ts**: 100% coverage
- ✅ **notes.ts**: 46% coverage (core functions tested)
### Target Coverage (Future Phases)
- **Services**: 90%+ coverage
- **Components**: 70%+ coverage
- **Overall**: 60%+ coverage
Coverage reports help identify untested code paths and ensure quality.
## Best Practices
### DO:
- ✅ Test behavior, not implementation details
- ✅ Use descriptive test names: `should handle empty input gracefully`
- ✅ Mock external dependencies (Obsidian API, network calls)
- ✅ Test edge cases and error conditions
- ✅ Keep tests fast and independent
### DON'T:
- ❌ Test framework internals (Solid.js reactivity, Obsidian's behavior)
- ❌ Create elaborate mocks that mirror entire APIs
- ❌ Write slow tests with real file I/O or network requests
- ❌ Make tests depend on each other's state
## Troubleshooting
### Common Issues
#### Tests Not Finding Obsidian Imports
**Solution**: Check that `vitest.config.ts` has the obsidian alias:
```typescript
resolve: {
alias: {
'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts')
}
}
```
#### Solid.js Components Not Rendering
**Solution**: Ensure jsdom environment is configured:
```typescript
test: {
environment: 'jsdom'
}
```
#### Missing Mock Methods
**Solution**: Add methods to `test/mocks/obsidian.ts` as you discover them:
```typescript
// Add missing methods as needed
someObsidianClass = {
newMethod: vi.fn().mockReturnValue('default'),
// ...
}
```
#### String.contains() Not Found
This is handled in `test/setup.ts` but if you see this error, ensure setup files are properly configured in `vitest.config.ts`.
## Future Phases
### Phase 2: Core Services Testing
- Model registry singleton behavior
- AI streaming with real response simulation
- Context injection and preparation
- Error handling and abort scenarios
### Phase 3: Component Testing
- User interaction simulation
- State management testing
- Integration between components
- Accessibility testing
### Phase 4: Integration & E2E
- Complete workflow testing
- Plugin lifecycle testing
- Settings persistence testing
- CI/CD pipeline integration
## Resources
- [Vitest Documentation](https://vitest.dev/)
- [Solid.js Testing Library](https://github.com/solidjs/solid-testing-library)
- [AI SDK Testing Guide](https://sdk.vercel.ai/docs)
- [Testing Implementation Plan](../testing-implementation.md) - Full implementation strategy
---
For questions or issues with testing, refer to the main implementation plan or create an issue in the repository.

View file

@ -1,58 +0,0 @@
import { render as solidRender } from '@solidjs/testing-library';
import { JSX, createContext } from 'solid-js';
import { App } from 'obsidian';
import { vi } from 'vitest';
// Mock contexts that components might need
export const MockAppContext = createContext<App>();
export const MockPluginContext = createContext<any>();
// Mock App instance for testing
export const createMockApp = (): App => ({
vault: {
create: vi.fn(),
modify: vi.fn(),
read: vi.fn(),
getMarkdownFiles: vi.fn(() => []),
},
workspace: {
getLeaf: vi.fn(),
activeLeaf: null,
},
metadataCache: {
getFileCache: vi.fn(),
},
} as any);
// Mock Plugin instance for testing
export const createMockPlugin = () => ({
app: createMockApp(),
manifest: {},
addCommand: vi.fn(),
addSettingTab: vi.fn(),
registerView: vi.fn(),
saveData: vi.fn(() => Promise.resolve()),
loadData: vi.fn(() => Promise.resolve({})),
});
// Custom render function with providers
export const render = (
component: () => JSX.Element,
options?: {
app?: App;
plugin?: any;
}
) => {
const mockApp = options?.app || createMockApp();
const mockPlugin = options?.plugin || createMockPlugin();
const WrappedComponent = () => (
<MockAppContext.Provider value={mockApp}>
<MockPluginContext.Provider value={mockPlugin}>
{component()}
</MockPluginContext.Provider>
</MockAppContext.Provider>
);
return solidRender(WrappedComponent);
};

View file

@ -1,14 +0,0 @@
import { vi } from 'vitest';
// Common test utilities
export const mockFn = <T extends (...args: any[]) => any>() => vi.fn<Parameters<T>, ReturnType<T>>();
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const createMockFile = (path: string) => ({
path,
basename: path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '',
extension: path.split('.').pop() || '',
name: path.split('/').pop() || '',
});