mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
Unit testing setup complete
This commit is contained in:
parent
73022a71dd
commit
31e42b6f06
13 changed files with 1409 additions and 742 deletions
|
|
@ -8,7 +8,5 @@
|
|||
"authorUrl": "https://github.com/Epistemic-Technology",
|
||||
"fundingUrl": "https://ko-fi.com/epistemictechnology",
|
||||
"helpUrl": "https://github.com/Epistemic-Technology/co-intelligence#readme",
|
||||
"main": "main.js",
|
||||
"css": "styles.css",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
395
src/__tests__/ChatView.test.tsx
Normal file
395
src/__tests__/ChatView.test.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
315
src/__tests__/CoIntelligencePlugin.test.tsx
Normal file
315
src/__tests__/CoIntelligencePlugin.test.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
213
src/__tests__/CoiChatApp.test.tsx
Normal file
213
src/__tests__/CoiChatApp.test.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
208
src/__tests__/settings.test.ts
Normal file
208
src/__tests__/settings.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/commands/__tests__/new-chat.test.ts
Normal file
40
src/commands/__tests__/new-chat.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
233
src/commands/__tests__/toggle-chat-view.test.ts
Normal file
233
src/commands/__tests__/toggle-chat-view.test.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createSignal, useContext, Show, createEffect } from "solid-js";
|
||||
import { ModelChatMessage } from "@/types";
|
||||
import { TFile, Notice } from "obsidian";
|
||||
import { TFile, Notice, debounce } from "obsidian";
|
||||
|
||||
import { ModelRegistry } from "@/services/model-registry";
|
||||
import {
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
ContextItems,
|
||||
Tag,
|
||||
} from "@/types";
|
||||
import { debounce } from "@/utils/debounce";
|
||||
import { PluginContext, AppContext } from "@/CoiChatApp";
|
||||
import { ChatHistory } from "@/components/ChatHistory";
|
||||
import { UserInput } from "@/components/UserInput";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.coi-app > * {
|
||||
|
|
@ -477,10 +478,6 @@ li button.coi-add-context-menu-option:focus-visible {
|
|||
border: 0;
|
||||
}
|
||||
|
||||
/* Enable container queries for workspace tab container */
|
||||
.workspace-tab-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Compact view for narrow containers */
|
||||
@container (max-width: 545px) {
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { debounce } from '../debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should delay function execution', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue(undefined);
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('test');
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockFn).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('should cancel previous execution when called multiple times', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue(undefined);
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('first');
|
||||
vi.advanceTimersByTime(50);
|
||||
debouncedFn('second');
|
||||
vi.advanceTimersByTime(50);
|
||||
debouncedFn('third');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
it('should handle multiple arguments', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue(undefined);
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('arg1', 'arg2', 'arg3');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
|
||||
});
|
||||
|
||||
it('should handle async function execution', async () => {
|
||||
const mockFn = vi.fn().mockImplementation(async (value: string) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return value.toUpperCase();
|
||||
});
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('test');
|
||||
vi.advanceTimersByTime(100);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith('test');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Debounces a function by delaying its execution until the specified delay
|
||||
* has elapsed since the last time it was invoked.
|
||||
*
|
||||
* @param func The function to debounce
|
||||
* @param wait The number of milliseconds to delay
|
||||
* @returns A debounced version of the function
|
||||
*/
|
||||
export function debounce<TArgs extends readonly unknown[]>(
|
||||
func: (...args: TArgs) => Promise<void>,
|
||||
wait: number,
|
||||
): (...args: TArgs) => void {
|
||||
let timeout: number | null = null;
|
||||
|
||||
return function (...args: TArgs): void {
|
||||
const later = async () => {
|
||||
timeout = null;
|
||||
await func(...args);
|
||||
};
|
||||
|
||||
if (timeout !== null) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = window.setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
TFolder,
|
||||
CachedMetadata,
|
||||
MetadataCache,
|
||||
getAllTags,
|
||||
} from "obsidian";
|
||||
import { ModelChatMessage } from "@/types";
|
||||
|
||||
|
|
@ -257,7 +258,7 @@ export async function serializeCoiNote(
|
|||
lastModelId: string | null,
|
||||
sources?: Source[],
|
||||
) {
|
||||
const currentNoteContent = await app.vault.cachedRead(note);
|
||||
const currentNoteContent = await app.vault.read(note);
|
||||
|
||||
if (
|
||||
!currentNoteContent.includes(CHAT_START) ||
|
||||
|
|
@ -409,6 +410,7 @@ export const getFilesWithTag = (tag: string, app: App): TFile[] => {
|
|||
const allFiles = app.vault.getMarkdownFiles();
|
||||
for (const file of allFiles) {
|
||||
const cache = app.metadataCache.getCache(file.path);
|
||||
if (!cache) continue;
|
||||
const tags = getAllTags(cache);
|
||||
|
||||
if (tags?.includes(tag)) {
|
||||
|
|
@ -419,27 +421,6 @@ export const getFilesWithTag = (tag: string, app: App): TFile[] => {
|
|||
return filesWithTag;
|
||||
};
|
||||
|
||||
function getAllTags(cache: CachedMetadata | null): string[] {
|
||||
if (!cache) return [];
|
||||
|
||||
const tags: Set<string> = new Set();
|
||||
|
||||
// Inline tags (e.g., #mytag)
|
||||
cache.tags?.forEach((tagObj) => tags.add(tagObj.tag));
|
||||
|
||||
// Frontmatter tags (array or string)
|
||||
if (cache.frontmatter?.tags) {
|
||||
const fmTags = cache.frontmatter.tags;
|
||||
if (typeof fmTags === "string") {
|
||||
tags.add(fmTags.startsWith("#") ? fmTags : `#${fmTags}`);
|
||||
} else if (Array.isArray(fmTags)) {
|
||||
fmTags.forEach((t) => tags.add(t.startsWith("#") ? t : `#${t}`));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the metadata cache to be available for a file.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,615 +0,0 @@
|
|||
# Testing Implementation Plan for Co-Intelligence AI
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a comprehensive testing strategy for the Co-Intelligence AI Obsidian plugin. The plugin presents unique testing challenges due to its architecture: a Solid.js UI framework, integration with Obsidian's API, AI SDK streaming capabilities, and the constraint that Obsidian plugins must compile to a single `main.js` file.
|
||||
|
||||
Our approach uses Vitest as the primary testing framework (leveraging existing Vite configuration), the AI SDK's built-in testing utilities for mocking AI responses, and minimal Obsidian API mocks to enable unit and integration testing without requiring a full Obsidian environment.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Testing Framework
|
||||
- **Vitest** - Primary test runner (chosen over Jest for seamless Vite integration)
|
||||
- **@vitest/ui** - Interactive UI for exploring test results
|
||||
- **@vitest/coverage-v8** - Code coverage reporting
|
||||
- **jsdom** - DOM environment for testing Solid.js components
|
||||
|
||||
### Solid.js Testing
|
||||
- **@solidjs/testing-library** - Solid-specific testing utilities
|
||||
- **@testing-library/user-event** - Realistic user interaction simulation
|
||||
|
||||
### AI SDK Testing
|
||||
- **Built-in test utilities from 'ai/test'**:
|
||||
- `MockLanguageModelV2` - Mock language model
|
||||
- `simulateReadableStream` - Stream simulation with realistic delays
|
||||
- No additional mocking libraries needed for AI responses
|
||||
|
||||
### Existing Dependencies (Already in project)
|
||||
- **typescript** - Type checking
|
||||
- **vite** - Build tool
|
||||
- **solid-js** - UI framework
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Step 1: Install Testing Dependencies
|
||||
|
||||
```bash
|
||||
npm install -D vitest @vitest/ui @vitest/coverage-v8 jsdom @solidjs/testing-library @testing-library/user-event
|
||||
```
|
||||
|
||||
### Step 2: Configure Vitest
|
||||
|
||||
Create `vitest.config.ts` in the project root:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { loadEnv } from 'vite';
|
||||
import solid from 'vite-plugin-solid';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@assets': path.resolve(__dirname, './assets'),
|
||||
'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'test/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/dist/**'
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Update package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Create Test Infrastructure
|
||||
|
||||
```
|
||||
test/
|
||||
├── setup.ts # Global test setup and configuration
|
||||
├── mocks/
|
||||
│ ├── obsidian.ts # Minimal Obsidian API mocks
|
||||
│ └── solid-context.tsx # Mock providers for Solid.js components
|
||||
├── fixtures/
|
||||
│ ├── chat-data.ts # Sample chat messages and threads
|
||||
│ ├── ai-responses.ts # Predefined AI response patterns
|
||||
│ └── notes.ts # Sample Obsidian notes
|
||||
└── utils/
|
||||
├── test-helpers.ts # Common test utilities
|
||||
└── render.tsx # Custom render with providers
|
||||
```
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
### Test Categories
|
||||
|
||||
#### 1. Unit Tests (Priority: High)
|
||||
- **Location**: `src/**/__tests__/*.test.ts`
|
||||
- **Targets**: Pure functions, utilities, business logic
|
||||
- **Examples**:
|
||||
- URL validation in `utils/url.ts`
|
||||
- Debounce logic in `utils/debounce.ts`
|
||||
- Note serialization in `utils/notes.ts`
|
||||
|
||||
#### 2. Service Tests (Priority: High)
|
||||
- **Location**: `src/services/__tests__/*.test.ts`
|
||||
- **Targets**: Core services with mocked dependencies
|
||||
- **Examples**:
|
||||
- Model registry singleton behavior
|
||||
- AI streaming responses with abort handling
|
||||
- Context preparation for prompts
|
||||
|
||||
#### 3. Component Tests (Priority: Medium)
|
||||
- **Location**: `src/components/__tests__/*.test.tsx`
|
||||
- **Targets**: Solid.js components with mocked contexts
|
||||
- **Examples**:
|
||||
- User input handling and keyboard shortcuts
|
||||
- Message rendering and interactions
|
||||
- Model selector behavior
|
||||
|
||||
#### 4. Integration Tests (Priority: Low)
|
||||
- **Location**: `test/integration/*.test.ts`
|
||||
- **Targets**: Multi-component workflows
|
||||
- **Examples**:
|
||||
- Complete chat creation flow
|
||||
- Settings persistence
|
||||
- View switching
|
||||
|
||||
### Mocking Strategy
|
||||
|
||||
#### Obsidian API Mocks
|
||||
|
||||
Create minimal mocks that only implement the methods actually used:
|
||||
|
||||
```typescript
|
||||
// test/mocks/obsidian.ts
|
||||
export class TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop()?.replace(/\.[^/.]+$/, '') || '';
|
||||
this.extension = path.split('.').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class Plugin {
|
||||
app: App;
|
||||
manifest: any;
|
||||
|
||||
constructor(app: App, manifest: any) {
|
||||
this.app = app;
|
||||
this.manifest = manifest;
|
||||
}
|
||||
|
||||
addCommand() { return this; }
|
||||
addSettingTab() { return this; }
|
||||
registerView() { return this; }
|
||||
saveData() { return Promise.resolve(); }
|
||||
loadData() { return Promise.resolve({}); }
|
||||
}
|
||||
|
||||
export class App {
|
||||
vault = {
|
||||
create: jest.fn(),
|
||||
modify: jest.fn(),
|
||||
read: jest.fn(),
|
||||
getMarkdownFiles: jest.fn(() => []),
|
||||
};
|
||||
|
||||
workspace = {
|
||||
getLeaf: jest.fn(),
|
||||
activeLeaf: null,
|
||||
};
|
||||
|
||||
metadataCache = {
|
||||
getFileCache: jest.fn(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### AI SDK Mock Patterns
|
||||
|
||||
```typescript
|
||||
// test/fixtures/ai-responses.ts
|
||||
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
|
||||
|
||||
export const createMockModel = (response: string) => {
|
||||
return new MockLanguageModelV2({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'text-delta', textDelta: response },
|
||||
{ type: 'finish', finishReason: 'stop' }
|
||||
]
|
||||
})
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
export const createMockStreamingModel = (chunks: string[]) => {
|
||||
return new MockLanguageModelV2({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
...chunks.map(chunk => ({ type: 'text-delta', textDelta: chunk })),
|
||||
{ type: 'finish', finishReason: 'stop' }
|
||||
]
|
||||
})
|
||||
})
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
1. **Day 1-2**: Set up Vitest configuration and test infrastructure
|
||||
2. **Day 3-4**: Create minimal Obsidian mocks
|
||||
3. **Day 5**: Write first utility function tests (debounce, url validation)
|
||||
4. **Day 6-7**: Establish testing patterns and documentation
|
||||
|
||||
**Deliverables**:
|
||||
- Working test environment
|
||||
- 5-10 utility function tests
|
||||
- Testing guidelines document
|
||||
|
||||
### Phase 2: Core Services (Week 2)
|
||||
1. **Day 1-2**: Test `model-registry.ts`
|
||||
- Singleton pattern
|
||||
- Provider initialization
|
||||
- Model availability checks
|
||||
2. **Day 3-4**: Test `model-service.ts`
|
||||
- Stream handling with AI SDK mocks
|
||||
- Abort controller behavior
|
||||
- Context injection
|
||||
3. **Day 5-7**: Test note utilities
|
||||
- COI note creation
|
||||
- Serialization/deserialization
|
||||
- Markdown parsing
|
||||
|
||||
**Deliverables**:
|
||||
- 90%+ coverage for services
|
||||
- Documentation of service testing patterns
|
||||
|
||||
### Phase 3: Component Testing (Week 3)
|
||||
1. **Day 1-2**: Set up Solid.js testing utilities
|
||||
2. **Day 3-4**: Test simple components (buttons, icons)
|
||||
3. **Day 5-7**: Test complex components
|
||||
- ChatInterface
|
||||
- UserInput
|
||||
- ModelSelector
|
||||
|
||||
**Deliverables**:
|
||||
- Component test suite
|
||||
- Custom render utilities
|
||||
- Interaction testing examples
|
||||
|
||||
### Phase 4: Integration & Polish (Week 4)
|
||||
1. **Day 1-2**: Plugin lifecycle tests
|
||||
2. **Day 3-4**: Chat workflow integration tests
|
||||
3. **Day 5-6**: CI/CD setup
|
||||
4. **Day 7**: Documentation and cleanup
|
||||
|
||||
**Deliverables**:
|
||||
- Integration test suite
|
||||
- GitHub Actions workflow
|
||||
- Final documentation
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example 1: Testing the Model Service
|
||||
|
||||
```typescript
|
||||
// src/services/__tests__/model-service.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MockLanguageModelV2, simulateReadableStream } from 'ai/test';
|
||||
import { generateChatResponse } from '@/services/model-service';
|
||||
import { ModelRegistry } from '@/services/model-registry';
|
||||
|
||||
describe('model-service', () => {
|
||||
let mockRegistry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRegistry = {
|
||||
getLanguageModel: vi.fn(),
|
||||
getModel: vi.fn(() => ({ toggleWebSearch: true }))
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('should stream a chat response', async () => {
|
||||
const mockModel = new MockLanguageModelV2({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'text-delta', textDelta: 'Hello' },
|
||||
{ type: 'text-delta', textDelta: ' world' },
|
||||
{ type: 'finish', finishReason: 'stop', usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 2,
|
||||
totalTokens: 12
|
||||
}}
|
||||
]
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
mockRegistry.getLanguageModel.mockReturnValue(mockModel);
|
||||
|
||||
const request = {
|
||||
modelId: 'test-model',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
requestID: 'test-123',
|
||||
webSearch: false
|
||||
};
|
||||
|
||||
const result = await generateChatResponse(request, mockRegistry);
|
||||
|
||||
// Collect the streamed text
|
||||
let fullText = '';
|
||||
for await (const part of result.textStream) {
|
||||
fullText += part;
|
||||
}
|
||||
|
||||
expect(fullText).toBe('Hello world');
|
||||
expect(mockRegistry.getLanguageModel).toHaveBeenCalledWith('test-model');
|
||||
});
|
||||
|
||||
it('should handle abort signals', async () => {
|
||||
// Test abort controller functionality
|
||||
});
|
||||
|
||||
it('should inject context into system prompt', async () => {
|
||||
// Test context preparation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Example 2: Testing a Solid.js Component
|
||||
|
||||
```typescript
|
||||
// src/components/__tests__/UserInput.test.tsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserInput } from '@/components/UserInput';
|
||||
|
||||
describe('UserInput', () => {
|
||||
const defaultProps = {
|
||||
onSendMessage: vi.fn(),
|
||||
isLoading: false,
|
||||
onStopGenerating: vi.fn()
|
||||
};
|
||||
|
||||
it('should send message on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(() => <UserInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Type your message...');
|
||||
await user.type(input, 'Test message{Enter}');
|
||||
|
||||
expect(defaultProps.onSendMessage).toHaveBeenCalledWith('Test message');
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should not send empty messages', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(() => <UserInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Type your message...');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(defaultProps.onSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show stop button when loading', () => {
|
||||
render(() => <UserInput {...defaultProps} isLoading={true} />);
|
||||
|
||||
const stopButton = screen.getByLabelText('Stop generating');
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Example 3: Testing Note Utilities
|
||||
|
||||
```typescript
|
||||
// src/utils/__tests__/notes.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createCOINote, deserializeChatThread } from '@/utils/notes';
|
||||
import { TFile } from 'obsidian';
|
||||
|
||||
describe('notes utilities', () => {
|
||||
describe('createCOINote', () => {
|
||||
it('should create a valid COI note structure', () => {
|
||||
const note = createCOINote('Test Chat', [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' }
|
||||
]);
|
||||
|
||||
expect(note).toContain('---');
|
||||
expect(note).toContain('is-coi-chat: true');
|
||||
expect(note).toContain('# Test Chat');
|
||||
expect(note).toContain('<!-- CHAT-THREAD-START -->');
|
||||
expect(note).toContain('<!-- CHAT-THREAD-END -->');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deserializeChatThread', () => {
|
||||
it('should parse messages from note content', () => {
|
||||
const content = `
|
||||
---
|
||||
is-coi-chat: true
|
||||
---
|
||||
# Chat
|
||||
<!-- CHAT-THREAD-START -->
|
||||
[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi!"}]
|
||||
<!-- CHAT-THREAD-END -->
|
||||
`;
|
||||
|
||||
const messages = deserializeChatThread(content);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual({ role: 'user', content: 'Hello' });
|
||||
expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi!' });
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
Create `.github/workflows/test.yml`:
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage/coverage-final.json
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### General Testing Guidelines
|
||||
|
||||
1. **Test Behavior, Not Implementation**
|
||||
- Focus on public APIs and user-facing behavior
|
||||
- Avoid testing internal implementation details
|
||||
|
||||
2. **Keep Tests Fast**
|
||||
- Mock external dependencies (Obsidian API, AI providers)
|
||||
- Use in-memory operations instead of file I/O
|
||||
|
||||
3. **Write Descriptive Test Names**
|
||||
```typescript
|
||||
// Good
|
||||
it('should display error message when model API key is missing')
|
||||
|
||||
// Bad
|
||||
it('test error')
|
||||
```
|
||||
|
||||
4. **Follow AAA Pattern**
|
||||
- **Arrange**: Set up test data and dependencies
|
||||
- **Act**: Execute the code under test
|
||||
- **Assert**: Verify the results
|
||||
|
||||
### Obsidian Plugin-Specific Considerations
|
||||
|
||||
1. **Mock Minimally**
|
||||
- Only mock the Obsidian APIs your code actually uses
|
||||
- Keep mocks simple and focused
|
||||
|
||||
2. **Handle Async Operations**
|
||||
- Most Obsidian APIs are async
|
||||
- Use `async/await` in tests consistently
|
||||
|
||||
3. **Test File Operations Carefully**
|
||||
```typescript
|
||||
// Mock file operations instead of actual I/O
|
||||
vault.create = vi.fn().mockResolvedValue(new TFile('test.md'));
|
||||
```
|
||||
|
||||
4. **Settings Management**
|
||||
- Use in-memory settings for tests
|
||||
- Reset settings between tests
|
||||
|
||||
### AI SDK Testing Patterns
|
||||
|
||||
1. **Test Different Response Scenarios**
|
||||
- Successful completion
|
||||
- Partial responses
|
||||
- Error handling
|
||||
- Abort/cancellation
|
||||
|
||||
2. **Simulate Realistic Streaming**
|
||||
```typescript
|
||||
simulateReadableStream({
|
||||
chunks: [...],
|
||||
chunkDelayInMs: 50 // Simulate typing delay
|
||||
})
|
||||
```
|
||||
|
||||
3. **Test Context Handling**
|
||||
- Empty context
|
||||
- Large context
|
||||
- Special characters in context
|
||||
|
||||
## Common Pitfalls and Solutions
|
||||
|
||||
### Pitfall 1: Over-Mocking
|
||||
**Problem**: Creating elaborate mocks that mirror entire Obsidian API
|
||||
**Solution**: Mock only what you use, add mocks as needed
|
||||
|
||||
### Pitfall 2: Testing Framework Code
|
||||
**Problem**: Testing Solid.js reactivity or Obsidian's internal behavior
|
||||
**Solution**: Focus on your business logic and user interactions
|
||||
|
||||
### Pitfall 3: Slow Tests
|
||||
**Problem**: Tests that perform real file I/O or network requests
|
||||
**Solution**: Use mocks and in-memory operations
|
||||
|
||||
### Pitfall 4: Brittle Tests
|
||||
**Problem**: Tests that break with minor implementation changes
|
||||
**Solution**: Test public APIs and user-facing behavior
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Vitest + Solid.js Issues
|
||||
|
||||
1. **Component not rendering**
|
||||
- Ensure `@solidjs/testing-library` is properly configured
|
||||
- Check that jsdom environment is set
|
||||
|
||||
2. **Reactivity not working in tests**
|
||||
- Wrap state changes in `act()` if needed
|
||||
- Use `waitFor` for async updates
|
||||
|
||||
### Obsidian Mock Issues
|
||||
|
||||
1. **Missing API methods**
|
||||
- Add methods to mocks as you discover them
|
||||
- Keep a shared mock file updated
|
||||
|
||||
2. **Type errors**
|
||||
- Use TypeScript's `Partial` types for mocks
|
||||
- Cast to `any` when necessary (document why)
|
||||
|
||||
### AI SDK Test Issues
|
||||
|
||||
1. **Stream not completing**
|
||||
- Ensure you include a finish chunk
|
||||
- Check for proper error handling
|
||||
|
||||
2. **Timing issues**
|
||||
- Use `waitFor` for async assertions
|
||||
- Adjust chunk delays if needed
|
||||
|
||||
## Conclusion
|
||||
|
||||
This testing implementation plan provides a pragmatic approach to testing the Co-Intelligence AI Obsidian plugin. By leveraging Vitest's integration with Vite, the AI SDK's built-in testing utilities, and minimal Obsidian mocks, we can achieve good test coverage without the complexity of full end-to-end testing.
|
||||
|
||||
Remember: Start small, focus on high-value tests, and progressively improve coverage as the codebase evolves. The goal is not 100% coverage but confidence that critical functionality works correctly.
|
||||
Loading…
Reference in a new issue