import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import userEvent from '@testing-library/user-event' import { RouterProvider, createRouter, createRootRoute, createMemoryHistory } from '@tanstack/react-router' import ChatInput from '../ChatInput' import { usePrompt } from '@/hooks/usePrompt' import { useThreads } from '@/hooks/useThreads' import { useAppState } from '@/hooks/useAppState' import { useGeneralSetting } from '@/hooks/useGeneralSetting' import { useModelProvider } from '@/hooks/useModelProvider' import { useChat } from '@/hooks/useChat' // Mock dependencies vi.mock('@/hooks/usePrompt', () => ({ usePrompt: vi.fn(() => ({ prompt: '', setPrompt: vi.fn(), })), })) vi.mock('@/hooks/useThreads', () => ({ useThreads: vi.fn(() => ({ currentThreadId: null, getCurrentThread: vi.fn(), })), })) vi.mock('@/hooks/useAppState', () => ({ useAppState: vi.fn(() => ({ streamingContent: '', abortController: null, })), })) vi.mock('@/hooks/useGeneralSetting', () => ({ useGeneralSetting: vi.fn(() => ({ allowSendWhenUnloaded: false, })), })) vi.mock('@/hooks/useModelProvider', () => ({ useModelProvider: vi.fn(() => ({ selectedModel: null, providers: [], getModelBy: vi.fn(), selectModelProvider: vi.fn(), selectedProvider: 'llamacpp', setProviders: vi.fn(), getProviderByName: vi.fn(), updateProvider: vi.fn(), addProvider: vi.fn(), deleteProvider: vi.fn(), deleteModel: vi.fn(), deletedModels: [], })), })) vi.mock('@/hooks/useChat', () => ({ useChat: vi.fn(() => ({ sendMessage: vi.fn(), })), })) vi.mock('@/i18n/react-i18next-compat', () => ({ useTranslation: () => ({ t: (key: string) => key, }), })) vi.mock('@/services/mcp', () => ({ getConnectedServers: vi.fn(() => Promise.resolve([])), })) vi.mock('@/services/models', () => ({ stopAllModels: vi.fn(), })) describe('ChatInput', () => { const mockSendMessage = vi.fn() const mockSetPrompt = vi.fn() const createTestRouter = () => { const MockComponent = () => const rootRoute = createRootRoute({ component: MockComponent, }) return createRouter({ routeTree: rootRoute, history: createMemoryHistory({ initialEntries: ['/'], }), }) } const renderWithRouter = (component = ) => { const router = createTestRouter() return render() } beforeEach(() => { vi.clearAllMocks() // Set up default mock returns vi.mocked(usePrompt).mockReturnValue({ prompt: '', setPrompt: mockSetPrompt, }) vi.mocked(useThreads).mockReturnValue({ currentThreadId: 'test-thread-id', getCurrentThread: vi.fn(), setCurrentThreadId: vi.fn(), }) vi.mocked(useAppState).mockReturnValue({ streamingContent: null, abortControllers: {}, loadingModel: false, tools: [], }) vi.mocked(useGeneralSetting).mockReturnValue({ spellCheckChatInput: true, allowSendWhenUnloaded: false, experimentalFeatures: true, }) vi.mocked(useModelProvider).mockReturnValue({ selectedModel: { id: 'test-model', capabilities: ['tools', 'vision'], }, providers: [ { provider: 'llamacpp', models: [ { id: 'test-model', capabilities: ['tools', 'vision'], } ] } ], getModelBy: vi.fn(() => ({ id: 'test-model', capabilities: ['tools', 'vision'], })), selectModelProvider: vi.fn(), selectedProvider: 'llamacpp', setProviders: vi.fn(), getProviderByName: vi.fn(), updateProvider: vi.fn(), addProvider: vi.fn(), deleteProvider: vi.fn(), deleteModel: vi.fn(), deletedModels: [], }) vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage, }) }) it('renders chat input textarea', () => { act(() => { renderWithRouter() }) const textarea = screen.getByRole('textbox') expect(textarea).toBeInTheDocument() expect(textarea).toHaveAttribute('placeholder', 'common:placeholder.chatInput') }) it('renders send button', () => { act(() => { renderWithRouter() }) const sendButton = document.querySelector('[data-test-id="send-message-button"]') expect(sendButton).toBeInTheDocument() }) it('disables send button when prompt is empty', () => { act(() => { renderWithRouter() }) const sendButton = document.querySelector('[data-test-id="send-message-button"]') expect(sendButton).toBeDisabled() }) it('enables send button when prompt has content', () => { // Mock prompt with content vi.mocked(usePrompt).mockReturnValue({ prompt: 'Hello world', setPrompt: mockSetPrompt, }) act(() => { renderWithRouter() }) const sendButton = document.querySelector('[data-test-id="send-message-button"]') expect(sendButton).not.toBeDisabled() }) it('calls setPrompt when typing in textarea', async () => { const user = userEvent.setup() renderWithRouter() const textarea = screen.getByRole('textbox') await user.type(textarea, 'Hello') // setPrompt is called for each character typed expect(mockSetPrompt).toHaveBeenCalledTimes(5) expect(mockSetPrompt).toHaveBeenLastCalledWith('o') }) it('calls sendMessage when send button is clicked', async () => { const user = userEvent.setup() // Mock prompt with content vi.mocked(usePrompt).mockReturnValue({ prompt: 'Hello world', setPrompt: mockSetPrompt, }) renderWithRouter() const sendButton = document.querySelector('[data-test-id="send-message-button"]') await user.click(sendButton) expect(mockSendMessage).toHaveBeenCalledWith('Hello world', true, undefined) }) it('sends message when Enter key is pressed', async () => { const user = userEvent.setup() // Mock prompt with content vi.mocked(usePrompt).mockReturnValue({ prompt: 'Hello world', setPrompt: mockSetPrompt, }) renderWithRouter() const textarea = screen.getByRole('textbox') await user.type(textarea, '{Enter}') expect(mockSendMessage).toHaveBeenCalledWith('Hello world', true, undefined) }) it('does not send message when Shift+Enter is pressed', async () => { const user = userEvent.setup() // Mock prompt with content vi.mocked(usePrompt).mockReturnValue({ prompt: 'Hello world', setPrompt: mockSetPrompt, }) renderWithRouter() const textarea = screen.getByRole('textbox') await user.type(textarea, '{Shift>}{Enter}{/Shift}') expect(mockSendMessage).not.toHaveBeenCalled() }) it('shows stop button when streaming', () => { // Mock streaming state vi.mocked(useAppState).mockReturnValue({ streamingContent: { thread_id: 'test-thread' }, abortControllers: {}, loadingModel: false, tools: [], }) act(() => { renderWithRouter() }) // Stop button should be rendered (as SVG with tabler-icon-player-stop-filled class) const stopButton = document.querySelector('.tabler-icon-player-stop-filled') expect(stopButton).toBeInTheDocument() }) it('shows capability icons when model supports them', () => { act(() => { renderWithRouter() }) // Should show vision icon (rendered as SVG with tabler-icon-eye class) const visionIcon = document.querySelector('.tabler-icon-eye') expect(visionIcon).toBeInTheDocument() }) it('shows model selection dropdown', () => { act(() => { renderWithRouter() }) // Model selection dropdown should be rendered (look for popover trigger) const modelDropdown = document.querySelector('[data-slot="popover-trigger"]') expect(modelDropdown).toBeInTheDocument() }) it('shows error message when no model is selected', async () => { const user = userEvent.setup() // Mock no selected model and prompt with content vi.mocked(useModelProvider).mockReturnValue({ selectedModel: null, providers: [], getModelBy: vi.fn(), selectModelProvider: vi.fn(), selectedProvider: 'llamacpp', setProviders: vi.fn(), getProviderByName: vi.fn(), updateProvider: vi.fn(), addProvider: vi.fn(), deleteProvider: vi.fn(), deleteModel: vi.fn(), deletedModels: [], }) vi.mocked(usePrompt).mockReturnValue({ prompt: 'Hello world', setPrompt: mockSetPrompt, }) renderWithRouter() const sendButton = document.querySelector('[data-test-id="send-message-button"]') await user.click(sendButton) // The component should still render without crashing when no model is selected expect(sendButton).toBeInTheDocument() }) it('handles file upload', async () => { const user = userEvent.setup() renderWithRouter() // File upload is rendered as hidden input element const fileInput = document.querySelector('input[type="file"]') expect(fileInput).toBeInTheDocument() }) it('disables input when streaming', () => { // Mock streaming state vi.mocked(useAppState).mockReturnValue({ streamingContent: { thread_id: 'test-thread' }, abortControllers: {}, loadingModel: false, tools: [], }) act(() => { renderWithRouter() }) const textarea = screen.getByRole('textbox') expect(textarea).toBeDisabled() }) it('shows tools dropdown when model supports tools and MCP servers are connected', async () => { // Mock connected servers const { getConnectedServers } = await import('@/services/mcp') vi.mocked(getConnectedServers).mockResolvedValue(['server1']) renderWithRouter() await waitFor(() => { // Tools dropdown should be rendered (as SVG icon with tabler-icon-tool class) const toolsIcon = document.querySelector('.tabler-icon-tool') expect(toolsIcon).toBeInTheDocument() }) }) })