import { ChatView } from '../src/chat-view'; import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types'; // Mock Obsidian types interface MockVault { getMarkdownFiles: () => any[]; read: () => Promise; create: () => Promise; } interface MockWorkspace { getLeaf: () => any; revealLeaf: () => void; } interface MockApp { vault: MockVault; workspace: MockWorkspace; } // Mock Obsidian module - ItemView must set this.app from the leaf jest.mock('obsidian', () => ({ ItemView: jest.fn().mockImplementation(function (this: any, leaf: any) { this.app = leaf.app; }), WorkspaceLeaf: jest.fn(), Notice: jest.fn(), })); const mockSettings: PluginSettings = { ollamaUrl: 'http://localhost:11434', model: 'llama3', lastIndexTime: 0, }; describe('ChatView', () => { let view: ChatView; let mockLeaf: any; let mockApp: MockApp; beforeEach(() => { mockApp = { vault: { getMarkdownFiles: jest.fn().mockReturnValue([]), read: jest.fn().mockResolvedValue(''), create: jest.fn().mockResolvedValue(null), }, workspace: { getLeaf: jest.fn(), revealLeaf: jest.fn(), }, }; mockLeaf = { view: null, setViewState: jest.fn(), app: mockApp, }; view = new ChatView(mockLeaf as unknown as any, mockSettings); // Obsidian's contentEl has a createEl helper that standard DOM lacks // Unlike standard DOM, Obsidian elements can create nested elements with createEl const contentDiv = document.createElement('div') as any; // Create a factory function that captures the parent element const createElementWithCreateEl = function (parent: any) { return function (tag: string, options?: { cls?: string }) { const el = document.createElement(tag); if (options?.cls) { el.classList.add(...options.cls.split(' ')); } parent.appendChild(el); // Add createEl to the new element so it can create nested elements (el as any).createEl = createElementWithCreateEl(el); return el; }; }; contentDiv.createEl = createElementWithCreateEl(contentDiv); view.contentEl = contentDiv; }); describe('getViewType', () => { it('should return the correct view type', () => { expect(view.getViewType()).toBe('ollama-chat-view'); }); }); describe('getDisplayText', () => { it('should return the correct display text', () => { expect(view.getDisplayText()).toBe('Ollama Chat'); }); }); describe('onOpen', () => { it('should call render and setup event listeners', async () => { const renderSpy = jest.spyOn(view, 'render'); const setupSpy = jest.spyOn(view, 'setupEventListeners' as any); await view.onOpen(); expect(renderSpy).toHaveBeenCalled(); expect(setupSpy).toHaveBeenCalled(); }); }); describe('onClose', () => { it('should clean up resources and remove event listeners', async () => { view['lastMessageEl'] = document.createElement('div'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); const cancelSpy = jest.spyOn(view['ollamaClient'], 'cancelStream'); const removeSpy = jest.spyOn(view, 'removeEventListeners' as any); await view.onClose(); expect(view['lastMessageEl']).toBeNull(); expect(view['sendButton']).toBeNull(); expect(view['inputEl']).toBeNull(); expect(cancelSpy).toHaveBeenCalled(); expect(removeSpy).toHaveBeenCalled(); }); }); describe('render', () => { it('should render the chat interface', async () => { await view.render(); expect(view.contentEl.querySelector('.ollama-chat-container')).not.toBeNull(); expect(view.contentEl.querySelector('.ollama-input-container')).not.toBeNull(); }); it('should not duplicate elements on re-render', async () => { // First render await view.render(); const firstRenderCount = view.contentEl.querySelectorAll('.ollama-message').length; // Second render with no changes await view.render(); const secondRenderCount = view.contentEl.querySelectorAll('.ollama-message').length; expect(secondRenderCount).toBe(firstRenderCount); }); it('should only render non-streaming messages', async () => { view['messages'] = [ { id: '1', role: 'user', content: 'test', timestamp: Date.now() }, { id: '2', role: 'assistant', content: 'response', timestamp: Date.now(), isStreaming: true, }, ]; await view.render(); const messages = view.contentEl.querySelectorAll('.ollama-message'); expect(messages.length).toBe(1); }); }); describe('handleUserInput', () => { it('should handle user input and call ollamaClient', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({ [Symbol.asyncIterator]: async function* () { yield { content: 'test' }; }, } as any); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); // Verify that messages were added to conversation history expect((view as any).messages.length).toBeGreaterThan(0); }); it('should process tool calls with follow-up context', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({ [Symbol.asyncIterator]: async function* () { yield { content: 'test', tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }], }; }, } as any); const followUpSpy = jest .spyOn(view['ollamaClient'], 'chat') .mockResolvedValue({ content: ' follow-up' }); await (view as any).handleUserInput('test'); expect(followUpSpy).toHaveBeenCalled(); // Verify that tool calls resulted in follow-up messages expect((view as any).messages.length).toBeGreaterThan(1); }); }); describe('event listeners', () => { it('should setup event listeners on open', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); await view.onOpen(); expect(view['sendButtonClickHandler']).not.toBeNull(); expect(view['inputKeyDownHandler']).not.toBeNull(); }); it('should remove event listeners on close', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); await view.onOpen(); const removeSpy = jest.spyOn(view, 'removeEventListeners' as any); await view.onClose(); expect(removeSpy).toHaveBeenCalled(); }); }); });