import { ChatView } from '../src/chat-view'; import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types'; import { ErrorHandler } from '../src/error-handler'; import { ChatHistoryManager } from '../src/chat-history'; // Mock Obsidian types interface MockVault { getMarkdownFiles: () => any[]; read: () => Promise; cachedRead: () => Promise; getAbstractFileByPath: () => any; create: () => Promise; modify: () => Promise; rename: () => Promise; delete: () => 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', chatModel: 'llama3', agentModel: 'llama3', model: 'llama3', vaultSearchLimit: 3, maxMessageHistory: 50, maxContextLength: 8000, lastIndexTime: 0, agentMode: 'ask', cacheConfig: { enabled: false, similarityThreshold: 0.9, collectionName: 'test-cache', embeddingModel: 'nomic-embed-text', chromaURL: 'http://localhost:8000', }, vaultIndexConfig: { enabled: false, similarityThreshold: 0.75, collectionName: 'test-vault-index', embeddingModel: 'nomic-embed-text', chromaURL: 'http://localhost:8000', }, autoTagConfig: { enabled: false, maxTagsPerNote: 5, minNoteLength: 50, maxNoteLength: 8000, tagPromptTemplate: 'Tags: {{content}}', dryRun: false, targetFolder: '', normalizeTags: true, }, autoLinkConfig: { enabled: false, maxLinksPerNote: 3, similarityThreshold: 0.6, targetFolder: '', dryRun: false, }, structuredMemoryConfig: { enabled: true, maxSummaries: 10, maxPreferences: 20, maxFacts: 50, }, toolTelemetryConfig: { enabled: true, maxEntries: 100, }, }; describe('ChatView', () => { let view: ChatView; let mockLeaf: any; let mockApp: MockApp; beforeEach(() => { jest.clearAllMocks(); mockApp = { vault: { getMarkdownFiles: jest.fn().mockReturnValue([]), read: jest.fn().mockResolvedValue(''), cachedRead: jest.fn().mockResolvedValue(''), getAbstractFileByPath: jest.fn().mockReturnValue(null), create: jest.fn().mockResolvedValue(null), modify: jest.fn().mockResolvedValue(undefined), rename: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), }, workspace: { getLeaf: jest.fn(), revealLeaf: jest.fn(), }, }; mockLeaf = { view: null, setViewState: jest.fn(), app: mockApp, }; view = new ChatView( mockLeaf as unknown as any, mockSettings, undefined, undefined, undefined, new ChatHistoryManager() ); // 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; // Mock NoteContextBuilder to avoid needing full Obsidian API mocks jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [], }); jest.spyOn(view['noteContextBuilder'], 'formatContext').mockReturnValue(''); }); 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('getIcon', () => { it('should return the bot icon', () => { expect(view.getIcon()).toBe('bot'); }); }); 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 removeSpy = jest.spyOn(view, 'removeEventListeners' as any); await view.onClose(); expect(view['lastMessageEl']).toBeNull(); expect(view['sendButton']).toBeNull(); expect(view['inputEl']).toBeNull(); 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').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'test' }; })() ); 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 handle empty user input gracefully', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = ' '; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat'); await (view as any).handleUserInput(' '); expect(chatSpy).not.toHaveBeenCalled(); }); it('should handle streaming responses and update UI', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'First ' }; yield { role: 'assistant', content: 'chunk ' }; yield { role: 'assistant', content: 'of response' }; })() ); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); // Should have processed all chunks const messages = (view as any).messages; const lastMessage = messages[messages.length - 1]; expect(lastMessage.isStreaming).toBe(false); }); it('should handle streaming re-attach when existing streaming element is found', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).lastMessageEl = document.createElement('div'); (view as any).lastMessageEl.setAttribute('data-msg-id', 'streaming-123'); // Mock querySelector to return existing element const container = document.createElement('div'); const existingEl = document.createElement('div'); existingEl.setAttribute('data-msg-id', 'streaming-123'); container.appendChild(existingEl); // Mock the contentEl to return our container const originalContentEl = (view as any).contentEl; (view as any).contentEl = container; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(container.contains((view as any).lastMessageEl)).toBe(true); // Restore original contentEl (view as any).contentEl = originalContentEl; }); it('should append streaming element to container when no existing element found', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; // Mock querySelector to return null (no existing element) const container = document.createElement('div'); const originalQuerySelector = container.querySelector; container.querySelector = jest.fn().mockReturnValue(null); // Mock the contentEl to return our container const originalContentEl = (view as any).contentEl; (view as any).contentEl = container; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(container.childElementCount).toBeGreaterThan(0); // Restore original contentEl and querySelector (view as any).contentEl = originalContentEl; container.querySelector = originalQuerySelector; }); it('should limit conversation history to maxMessageHistory', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; // Mock a stream that returns quickly const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); // Add enough messages to exceed maxMessageHistory for (let i = 0; i < 60; i++) { (view as any).messages.push({ id: `msg-${i}`, role: 'user', content: `message ${i}`, timestamp: Date.now(), }); } await (view as any).handleUserInput('test'); // Should be limited to maxMessageHistory expect((view as any).messages.length).toBeLessThanOrEqual(50); }); it('should call noteContextBuilder with user input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'search query'; const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [], }); const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); await (view as any).handleUserInput('search query'); expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT expect(chatSpy).toHaveBeenCalled(); }); it('should auto-run read tools when organize mode returns a non-action response', async () => { view.setAgentMode('organize'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'please implement the suggested structure by creating folders and moving notes'; const streamSpy = jest.spyOn(view['ollamaClient'], 'streamChat'); streamSpy .mockReturnValueOnce( (async function* () { yield { role: 'assistant', content: 'Let me look at the current note and previous conversation to understand what structure was suggested.', }; })() ) .mockReturnValueOnce( (async function* () { yield { role: 'assistant', content: 'Let me read the current Prompts note and look at the Vault Index for more context.', }; })() ); const handleToolSpy = jest .spyOn(view['toolExecutor'], 'handleToolCall') .mockResolvedValue({ success: true, message: 'Found vault context', data: [{ path: 'Prompts.md', title: 'Prompts' }], }); jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({ role: 'assistant', content: 'I found vault context and can now suggest the next organization step.', }); await (view as any).handleUserInput( 'please implement the suggested structure by creating folders and moving notes' ); expect(handleToolSpy).toHaveBeenCalled(); expect( handleToolSpy.mock.calls.some( ([toolCall]) => toolCall.function.name === 'get_vault_stats' ) ).toBe(true); expect( handleToolSpy.mock.calls.some( ([toolCall]) => toolCall.function.name === 'list_vault_tags' ) ).toBe(true); const messages = (view as any).messages; const lastMessage = messages[messages.length - 1]; expect(lastMessage.content).toBe( 'I found vault context and can now suggest the next organization step.' ); }); it('should handle errors during user input gracefully', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; // Mock a stream that throws an error const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { throw new Error('Network error'); })() ); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith( 'Ollama Plugin Error [ChatView.handleUserInput]: Network error' ); }); it('should handle empty user input gracefully', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = ' '; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat'); await (view as any).handleUserInput(' '); expect(chatSpy).not.toHaveBeenCalled(); }); it('should handle streaming responses and update UI', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'First ' }; yield { role: 'assistant', content: 'chunk ' }; yield { role: 'assistant', content: 'of response' }; })() ); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); // Should have processed all chunks const messages = (view as any).messages; const lastMessage = messages[messages.length - 1]; expect(lastMessage.isStreaming).toBe(false); }); it('should show preview for write tool calls and defer follow-up', async () => { view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'test', tool_calls: [ { id: 'tool_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test/file.md","content":"Test content"}', }, }, ], }; })() ); const followUpSpy = jest .spyOn(view['ollamaClient'], 'chat') .mockResolvedValue({ role: 'assistant', content: ' follow-up' }); // Mock preview builder jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({ id: 'tool_1', toolCall: { id: 'tool_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test/file.md","content":"Test content"}', }, } as unknown as any, operation: 'create', path: 'test/file.md', description: 'Create note: test/file.md', preview: { after: 'Test content' }, status: 'pending', }); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); // With write tools, follow-up should be deferred until apply expect(followUpSpy).not.toHaveBeenCalled(); expect((view as any).pendingActions.length).toBe(1); expect((view as any).messages.length).toBeGreaterThan(1); }); it('should apply pending actions and trigger follow-up', async () => { view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const assistantMessageId = 'test-assistant-id'; view['messages'] = [ { id: 'user-id', role: 'user', content: 'test', timestamp: Date.now(), }, { id: assistantMessageId, role: 'assistant', content: 'test', timestamp: Date.now(), isStreaming: false, }, ]; view['pendingActions'] = [ { id: 'tool_1', toolCall: { id: 'tool_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test/file.md","content":"Test content"}', }, } as unknown as any, operation: 'create', path: 'test/file.md', description: 'Create note: test/file.md', status: 'pending', }, ]; view['pendingReadResults'] = []; view['pendingFollowUpContext'] = { messages: [], tools: [], assistantMessageId, }; const followUpSpy = jest .spyOn(view['ollamaClient'], 'chat') .mockResolvedValue({ role: 'assistant', content: ' follow-up' }); const toolExecutor = view['toolExecutor']; jest.spyOn(toolExecutor, 'handleToolCall').mockResolvedValue({ success: true, message: 'Note created successfully', }); await view.applyPendingActions(); expect(followUpSpy).toHaveBeenCalled(); expect((view as any).pendingActions.length).toBe(0); }); it('should cancel pending actions', async () => { view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const assistantMessageId = 'test-assistant-id'; view['messages'] = [ { id: assistantMessageId, role: 'assistant', content: 'Proposed actions...', timestamp: Date.now(), isStreaming: false, }, ]; view['pendingActions'] = [ { id: 'tool_1', toolCall: { id: 'tool_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test.md","content":"test"}', }, } as unknown as any, operation: 'create', path: 'test.md', description: 'Create note: test.md', status: 'pending', }, ]; view['pendingFollowUpContext'] = { messages: [], tools: [], assistantMessageId, }; view.cancelPendingActions(); const msg = (view as any).messages.find((m: any) => m.id === assistantMessageId); expect(msg.content).toContain('cancelled'); expect((view as any).pendingActions.length).toBe(0); }); it('should handle tool call errors gracefully and continue with partial results', async () => { view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'Response with tools', tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test.md","content":"test"}' }, }, { id: 'call_2', type: 'function', function: { name: 'nonexistent_tool', arguments: '{}' }, }, ], }; })() ); const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({ role: 'assistant', content: 'Follow-up response', tool_calls: [], }); // Mock preview builder for write tool jest.spyOn(view['actionPreviewBuilder'], 'buildPreview').mockResolvedValue({ id: 'call_1', toolCall: { id: 'call_1', type: 'function', function: { name: 'create_file', arguments: '{"path":"test.md","content":"test"}', }, } as unknown as any, operation: 'create', path: 'test.md', description: 'Create note: test.md', preview: { after: 'test' }, status: 'pending', }); // Mock tool executor — read tool fails const toolExecutor = view['toolExecutor']; jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => { if (call.function.name === 'nonexistent_tool') { throw new Error('Tool not found'); } return { success: true, message: 'Done' }; }); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {}); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); // Write tools trigger preview, not immediate follow-up expect(followUpSpy).not.toHaveBeenCalled(); expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput'); expect((view as any).messages.length).toBeGreaterThan(1); consoleSpy.mockRestore(); errorHandlerSpy.mockRestore(); }); it('should continue processing even when all tool calls fail', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'Response with tools', tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'nonexistent_tool_1', arguments: '{}' }, }, { id: 'call_2', type: 'function', function: { name: 'nonexistent_tool_2', arguments: '{}' }, }, ], }; })() ); const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({ role: 'assistant', content: 'Final response despite tool failures', tool_calls: [], }); // Mock tool executor to fail for all calls const toolExecutor = view['toolExecutor']; jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async () => { throw new Error('Tool not found'); }); const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {}); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(followUpSpy).not.toHaveBeenCalled(); // Should skip follow-up when no tool results expect(errorHandlerSpy).toHaveBeenCalledTimes(2); // Should be called for each failed tool call expect((view as any).messages.length).toBeGreaterThan(1); errorHandlerSpy.mockRestore(); }); it('should skip follow-up when tool calls result in no successful results', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'Response with tools', tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'nonexistent_tool', arguments: '{}' }, }, ], }; })() ); const followUpSpy = jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({ role: 'assistant', content: 'Final response', tool_calls: [], }); // Mock tool executor to fail for the call const toolExecutor = view['toolExecutor']; jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async () => { throw new Error('Tool not found'); }); const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {}); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(followUpSpy).not.toHaveBeenCalled(); // Should skip follow-up when no tool results expect(errorHandlerSpy).toHaveBeenCalledTimes(1); // Should be called for each failed tool call errorHandlerSpy.mockRestore(); }); it('should handle /workflow command', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = '/workflow find notes and summarize'; const workflowSpy = jest .spyOn(view['workflowEngine'], 'executeWorkflowFromQuery') .mockResolvedValue({ workflowId: 'wf-1', workflowName: 'Test Workflow', success: true, stepResults: [ { stepId: 's1', stepName: 'Search', success: true, data: ['note1'], timestamp: Date.now(), }, ], finalOutput: 'Summary result', }); await (view as any).handleUserInput('/workflow find notes and summarize'); expect(workflowSpy).toHaveBeenCalledWith('find notes and summarize', expect.any(Array)); const assistantMsg = (view as any).messages.find((m: any) => m.role === 'assistant'); expect(assistantMsg.content).toContain('Test Workflow'); expect(assistantMsg.content).toContain('Summary result'); }); it('should limit conversation history to maxMessageHistory', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; // Mock a stream that returns quickly const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); // Add enough messages to exceed maxMessageHistory for (let i = 0; i < 60; i++) { (view as any).messages.push({ id: `msg-${i}`, role: 'user', content: `message ${i}`, timestamp: Date.now(), }); } await (view as any).handleUserInput('test'); // Should be limited to maxMessageHistory expect((view as any).messages.length).toBeLessThanOrEqual(50); }); it('should call noteContextBuilder with user input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'search query'; const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [], }); const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { yield { role: 'assistant', content: 'response' }; })() ); await (view as any).handleUserInput('search query'); expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT expect(chatSpy).toHaveBeenCalled(); }); it('should handle errors during user input gracefully', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; // Mock a stream that throws an error const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue( (async function* () { throw new Error('Network error'); })() ); const errorHandlerSpy = jest.spyOn(ErrorHandler, 'handleError').mockImplementation(() => {}); await (view as any).handleUserInput('test'); expect(chatSpy).toHaveBeenCalled(); expect(errorHandlerSpy).toHaveBeenCalledWith( expect.objectContaining({ message: 'Network error' }), 'ChatView.handleUserInput' ); errorHandlerSpy.mockRestore(); }); describe('event handlers', () => { it('should handle send button click', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const handler = view.getSendButtonClickHandler(); if (!handler) throw new Error('Handler not available'); await handler(); expect(handleSpy).toHaveBeenCalledWith('test'); expect((view['inputEl'] as HTMLTextAreaElement).value).toBe(''); }); it('should handle Enter key press in input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any; const handler = view.getInputKeyDownHandler(); if (!handler) throw new Error('Handler not available'); await handler(event); expect(handleSpy).toHaveBeenCalledWith('test'); expect((view['inputEl'] as HTMLTextAreaElement).value).toBe(''); }); it('should not handle Shift+Enter in input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any; const handler = view.getInputKeyDownHandler(); if (!handler) throw new Error('Handler not available'); await handler(event); expect(handleSpy).not.toHaveBeenCalled(); }); it('should handle new chat button click', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); view['newChatButton'] = document.createElement('button'); (view as any).setupEventListeners(); const clearSpy = jest.spyOn(view as any, 'clearConversation'); const handler = view.getNewChatButtonClickHandler(); if (!handler) throw new Error('Handler not available'); handler(); expect(clearSpy).toHaveBeenCalled(); }); }); 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(); }); }); }); describe('event handlers', () => { it('should handle send button click', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const handler = view.getSendButtonClickHandler(); if (!handler) throw new Error('Handler not available'); await handler(); expect(handleSpy).toHaveBeenCalledWith('test'); expect((view['inputEl'] as HTMLTextAreaElement).value).toBe(''); }); it('should handle Enter key press in input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const event = new KeyboardEvent('keydown', { key: 'Enter' }) as any; const handler = view.getInputKeyDownHandler(); if (!handler) throw new Error('Handler not available'); await handler(event); expect(handleSpy).toHaveBeenCalledWith('test'); expect((view['inputEl'] as HTMLTextAreaElement).value).toBe(''); }); it('should not handle Shift+Enter in input', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; (view as any).setupEventListeners(); const handleSpy = jest.spyOn(view as any, 'handleUserInput'); const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }) as any; const handler = view.getInputKeyDownHandler(); if (!handler) throw new Error('Handler not available'); await handler(event); expect(handleSpy).not.toHaveBeenCalled(); }); it('should handle new chat button click', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); view['newChatButton'] = document.createElement('button'); (view as any).setupEventListeners(); const clearSpy = jest.spyOn(view as any, 'clearConversation'); const handler = view.getNewChatButtonClickHandler(); if (!handler) throw new Error('Handler not available'); handler(); expect(clearSpy).toHaveBeenCalled(); }); }); 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(); }); }); });