mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
Remove tests
This commit is contained in:
parent
117cbba7b1
commit
c7fa2e956d
21 changed files with 0 additions and 4205 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 :');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
273
test/testing.md
273
test/testing.md
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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() || '',
|
||||
});
|
||||
Loading…
Reference in a new issue