import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useAiActivity } from './useAiActivity' let lastWsInstance: MockWebSocket | null = null class MockWebSocket { onmessage: ((event: MessageEvent) => void) | null = null onerror: (() => void) | null = null onclose: (() => void) | null = null close = vi.fn() url: string constructor(url: string) { this.url = url lastWsInstance = this // eslint-disable-line @typescript-eslint/no-this-alias } } beforeEach(() => { lastWsInstance = null vi.stubGlobal('WebSocket', MockWebSocket) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() }) function sendWsMessage(data: Record) { lastWsInstance?.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) })) } describe('useAiActivity', () => { it('initializes with null highlight', () => { const { result } = renderHook(() => useAiActivity()) expect(result.current.highlightElement).toBeNull() expect(result.current.highlightPath).toBeNull() }) it('connects to ws://localhost:9711', () => { renderHook(() => useAiActivity()) expect(lastWsInstance).not.toBeNull() expect(lastWsInstance!.url).toBe('ws://localhost:9711') }) it('sets highlight on ui_action highlight message', () => { const { result } = renderHook(() => useAiActivity()) act(() => { sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor', path: '/vault/test.md' }) }) expect(result.current.highlightElement).toBe('editor') expect(result.current.highlightPath).toBe('/vault/test.md') }) it('auto-clears highlight after 800ms', () => { const { result } = renderHook(() => useAiActivity()) act(() => { sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'tab', path: '/vault/note.md' }) }) expect(result.current.highlightElement).toBe('tab') act(() => { vi.advanceTimersByTime(800) }) expect(result.current.highlightElement).toBeNull() expect(result.current.highlightPath).toBeNull() }) it('resets timer on repeated highlight messages', () => { const { result } = renderHook(() => useAiActivity()) act(() => { sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor' }) }) act(() => { vi.advanceTimersByTime(500) }) // Second message resets the timer act(() => { sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'notelist' }) }) expect(result.current.highlightElement).toBe('notelist') act(() => { vi.advanceTimersByTime(500) }) // Still active — only 500ms since the second message expect(result.current.highlightElement).toBe('notelist') act(() => { vi.advanceTimersByTime(300) }) expect(result.current.highlightElement).toBeNull() }) it('ignores non-ui_action messages', () => { const { result } = renderHook(() => useAiActivity()) act(() => { sendWsMessage({ type: 'other', action: 'highlight', element: 'editor' }) }) expect(result.current.highlightElement).toBeNull() }) it('ignores malformed JSON', () => { const { result } = renderHook(() => useAiActivity()) act(() => { lastWsInstance?.onmessage?.(new MessageEvent('message', { data: 'not json' })) }) expect(result.current.highlightElement).toBeNull() }) it('closes WebSocket on unmount', () => { const { unmount } = renderHook(() => useAiActivity()) unmount() expect(lastWsInstance!.close).toHaveBeenCalled() }) it('handles highlight with no path', () => { const { result } = renderHook(() => useAiActivity()) act(() => { sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'properties' }) }) expect(result.current.highlightElement).toBe('properties') expect(result.current.highlightPath).toBeNull() }) it('calls onOpenNote callback on open_note action', () => { const onOpenNote = vi.fn() renderHook(() => useAiActivity({ onOpenNote })) act(() => { sendWsMessage({ type: 'ui_action', action: 'open_note', path: 'project/foo.md' }) }) expect(onOpenNote).toHaveBeenCalledWith('project/foo.md') }) it('calls onOpenTab callback on open_tab action', () => { const onOpenTab = vi.fn() renderHook(() => useAiActivity({ onOpenTab })) act(() => { sendWsMessage({ type: 'ui_action', action: 'open_tab', path: 'note/bar.md' }) }) expect(onOpenTab).toHaveBeenCalledWith('note/bar.md') }) it('calls onSetFilter callback on set_filter action', () => { const onSetFilter = vi.fn() renderHook(() => useAiActivity({ onSetFilter })) act(() => { sendWsMessage({ type: 'ui_action', action: 'set_filter', filterType: 'Project' }) }) expect(onSetFilter).toHaveBeenCalledWith('Project') }) it('calls onVaultChanged callback on vault_changed action', () => { const onVaultChanged = vi.fn() renderHook(() => useAiActivity({ onVaultChanged })) act(() => { sendWsMessage({ type: 'ui_action', action: 'vault_changed', path: 'note/new.md' }) }) expect(onVaultChanged).toHaveBeenCalledWith('note/new.md') }) it('does not call onOpenNote when path is missing', () => { const onOpenNote = vi.fn() renderHook(() => useAiActivity({ onOpenNote })) act(() => { sendWsMessage({ type: 'ui_action', action: 'open_note' }) }) expect(onOpenNote).not.toHaveBeenCalled() }) it('reconnects on close after delay', () => { renderHook(() => useAiActivity()) const firstWs = lastWsInstance act(() => { firstWs?.onclose?.() }) act(() => { vi.advanceTimersByTime(3000) }) expect(lastWsInstance).not.toBe(firstWs) }) })