diff --git a/__mocks__/stores/chatSessionStore.ts b/__mocks__/stores/chatSessionStore.ts index f1f1bd9..f7b424d 100644 --- a/__mocks__/stores/chatSessionStore.ts +++ b/__mocks__/stores/chatSessionStore.ts @@ -19,6 +19,8 @@ export const mockChatSessionStore = { updateMessage: jest.fn(), updateMessageToken: jest.fn(), exitEditMode: jest.fn(), + enterEditMode: jest.fn(), + removeMessagesFromId: jest.fn(), }; Object.defineProperty(mockChatSessionStore, 'currentSessionMessages', { diff --git a/src/api/__tests__/hf.test.ts b/src/api/__tests__/hf.test.ts new file mode 100644 index 0000000..e37d09f --- /dev/null +++ b/src/api/__tests__/hf.test.ts @@ -0,0 +1,77 @@ +import axios from 'axios'; +import {fetchGGUFSpecs, fetchModelFilesDetails, fetchModels} from '../hf'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('fetchModels', () => { + it('should fetch models with basic parameters', async () => { + const mockResponse = { + data: [{id: 'model1'}], + headers: {link: 'next-page-link'}, + }; + mockedAxios.get.mockResolvedValueOnce(mockResponse); + + const result = await fetchModels({search: 'test'}); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({search: 'test'}), + }), + ); + expect(result).toEqual({ + models: [{id: 'model1'}], + nextLink: 'next-page-link', + }); + }); + + it('should handle missing pagination link', async () => { + const mockResponse = { + data: [{id: 'model1'}], + headers: {}, + }; + mockedAxios.get.mockResolvedValueOnce(mockResponse); + + const result = await fetchModels({}); + expect(result.nextLink).toBeNull(); + }); +}); + +describe('API error handling', () => { + it('should handle network errors in fetchModels', async () => { + const error = new Error('Network error'); + mockedAxios.get.mockRejectedValueOnce(error); + + await expect(fetchModels({})).rejects.toThrow('Network error'); + }); + + it('should handle non-ok responses in fetchModelFilesDetails', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); + + await expect(fetchModelFilesDetails('model1')).rejects.toThrow( + 'Error fetching model files: Not Found', + ); + }); +}); + +describe('fetchGGUFSpecs', () => { + it('should parse GGUF specs correctly', async () => { + const mockSpecs = { + gguf: { + params: 7, + type: 'f16', + }, + }; + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpecs), + }); + + const result = await fetchGGUFSpecs('model1'); + expect(result).toEqual(mockSpecs); + }); +}); diff --git a/src/components/BottomSheetSearchbar/__tests__/BottomSheetSearchbar.test.tsx b/src/components/BottomSheetSearchbar/__tests__/BottomSheetSearchbar.test.tsx new file mode 100644 index 0000000..8c9e23e --- /dev/null +++ b/src/components/BottomSheetSearchbar/__tests__/BottomSheetSearchbar.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import {render, fireEvent} from '@testing-library/react-native'; +import {BottomSheetSearchbar} from '../BottomSheetSearchbar'; +import {useBottomSheetInternal} from '@gorhom/bottom-sheet'; + +jest.mock('@gorhom/bottom-sheet', () => ({ + useBottomSheetInternal: jest.fn(), +})); + +describe('BottomSheetSearchbar', () => { + const mockShouldHandleKeyboardEvents = {value: false}; + + beforeEach(() => { + (useBottomSheetInternal as jest.Mock).mockReturnValue({ + shouldHandleKeyboardEvents: mockShouldHandleKeyboardEvents, + }); + }); + + it('should handle focus event correctly', () => { + const onFocus = jest.fn(); + const {getByTestId} = render( + , + ); + + fireEvent(getByTestId('searchbar'), 'focus'); + + expect(mockShouldHandleKeyboardEvents.value).toBe(true); + expect(onFocus).toHaveBeenCalled(); + }); + + it('should handle blur event correctly', () => { + const onBlur = jest.fn(); + const {getByTestId} = render( + , + ); + + fireEvent(getByTestId('searchbar'), 'blur'); + + expect(mockShouldHandleKeyboardEvents.value).toBe(false); + expect(onBlur).toHaveBeenCalled(); + }); + + it('should reset keyboard events flag on unmount', () => { + const {unmount} = render(); + + unmount(); + + expect(mockShouldHandleKeyboardEvents.value).toBe(false); + }); + + it('should forward props to Searchbar component', () => { + const placeholder = 'Search...'; + const value = 'test'; + const onChangeText = jest.fn(); + + const {getByPlaceholderText} = render( + , + ); + + const searchbar = getByPlaceholderText(placeholder); + expect(searchbar.props.value).toBe(value); + + fireEvent.changeText(searchbar, 'new value'); + expect(onChangeText).toHaveBeenCalledWith('new value'); + }); +}); diff --git a/src/components/Menu/MenuItem/MenuItem.tsx b/src/components/Menu/MenuItem/MenuItem.tsx index d2f6a1c..27c5363 100644 --- a/src/components/Menu/MenuItem/MenuItem.tsx +++ b/src/components/Menu/MenuItem/MenuItem.tsx @@ -71,7 +71,7 @@ export const MenuItem: React.FC = ({ styles.leadingContainer, menuItemProps.disabled && styles.itemDisabled, ]}> - {selected && } + {selected && } {leadingIcon && (typeof leadingIcon === 'function' ? ( leadingIcon({...props, size: 18}) diff --git a/src/components/Menu/MenuItem/__tests__/MenuItem.test.tsx b/src/components/Menu/MenuItem/__tests__/MenuItem.test.tsx new file mode 100644 index 0000000..e135bfa --- /dev/null +++ b/src/components/Menu/MenuItem/__tests__/MenuItem.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {MenuItem} from '../MenuItem'; +import {useTheme} from '../../../../hooks'; +import {fireEvent, render} from '../../../../../jest/test-utils'; + +describe('MenuItem', () => { + beforeEach(() => { + (useTheme as jest.Mock).mockReturnValue({ + colors: { + menuText: '#000000', + menuDangerText: '#FF0000', + menuBackgroundActive: '#E0E0E0', + }, + fonts: { + bodySmall: {}, + }, + }); + }); + + it('renders basic menu item correctly', () => { + const onPress = jest.fn(); + const {getByText} = render( + , + ); + + expect(getByText('Test Item')).toBeTruthy(); + }); + + it('handles press events', () => { + const onPress = jest.fn(); + const {getByText} = render( + , + ); + + fireEvent.press(getByText('Test Item')); + expect(onPress).toHaveBeenCalled(); + }); + + it('renders leading icon when provided', () => { + const {UNSAFE_getByProps} = render( + {}} />, + ); + + expect(UNSAFE_getByProps({source: 'check'})).toBeTruthy(); + }); + + it('renders trailing icon when provided', () => { + const {UNSAFE_getByProps} = render( + {}} />, + ); + + expect(UNSAFE_getByProps({source: 'close'})).toBeTruthy(); + }); + + it('handles disabled state correctly', () => { + const onPress = jest.fn(); + const {getByText} = render( + , + ); + + fireEvent.press(getByText('Test Item')); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Menu/SubMenu/__tests__/SubMenu.test.tsx b/src/components/Menu/SubMenu/__tests__/SubMenu.test.tsx new file mode 100644 index 0000000..8368d7e --- /dev/null +++ b/src/components/Menu/SubMenu/__tests__/SubMenu.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {render} from '../../../../../jest/test-utils'; +import {SubMenu} from '../SubMenu'; +import {MenuItem} from '../../MenuItem'; + +describe('SubMenu', () => { + it('renders when visible', () => { + const {getByText} = render( + {}} + anchorPosition={{x: 100, y: 100}}> + {}} /> + , + ); + + expect(getByText('SubMenu Item')).toBeTruthy(); + }); + + it('does not render when not visible', () => { + const {queryByText} = render( + {}} + anchorPosition={{x: 100, y: 100}}> + {}} /> + , + ); + + expect(queryByText('SubMenu Item')).toBeNull(); + }); + + it('handles multiple menu items', () => { + const {getByText} = render( + {}} + anchorPosition={{x: 100, y: 100}}> + {}} /> + {}} /> + {}} /> + , + ); + + expect(getByText('Item 1')).toBeTruthy(); + expect(getByText('Item 2')).toBeTruthy(); + expect(getByText('Item 3')).toBeTruthy(); + }); +}); diff --git a/src/components/Menu/__tests__/Menu.test.tsx b/src/components/Menu/__tests__/Menu.test.tsx new file mode 100644 index 0000000..427d65a --- /dev/null +++ b/src/components/Menu/__tests__/Menu.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import {render} from '../../../../jest/test-utils'; +import {Menu} from '../Menu'; + +describe('Menu', () => { + it('renders menu items correctly', () => { + const {getByText} = render( + {}} anchor={undefined}> + {}} /> + {}} /> + , + ); + + expect(getByText('Item 1')).toBeTruthy(); + expect(getByText('Item 2')).toBeTruthy(); + }); + + it('renders separators correctly', () => { + const {UNSAFE_getAllByType} = render( + {}} anchor={undefined}> + {}} /> + + {}} /> + + {}} /> + , + ); + + const separators = UNSAFE_getAllByType(Menu.Separator); + const groupSeparators = UNSAFE_getAllByType(Menu.GroupSeparator); + + expect(separators).toHaveLength(1); + expect(groupSeparators).toHaveLength(1); + }); +}); diff --git a/src/hooks/__tests__/useMessageActions.test.ts b/src/hooks/__tests__/useMessageActions.test.ts new file mode 100644 index 0000000..f6c17b9 --- /dev/null +++ b/src/hooks/__tests__/useMessageActions.test.ts @@ -0,0 +1,227 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import {renderHook, act} from '@testing-library/react-hooks'; + +import {textMessage, user} from '../../../jest/fixtures'; +import {createModel} from '../../../jest/fixtures/models'; + +import {useMessageActions} from '../useMessageActions'; + +import {chatSessionStore, modelStore} from '../../store'; + +jest.mock('@react-native-clipboard/clipboard', () => ({ + setString: jest.fn(), +})); + +describe('useMessageActions', () => { + const mockSetInputText = jest.fn(); + const mockHandleSendPress = jest.fn(); + const messages = [ + { + ...textMessage, + id: '1', + text: 'Hello', + author: user, + }, + { + ...textMessage, + id: '2', + text: 'Hi there', + author: {id: 'assistant'}, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('copies message text to clipboard', () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + act(() => { + result.current.handleCopy({ + ...textMessage, + text: 'Copy this text', + type: 'text', + }); + }); + + expect(Clipboard.setString).toHaveBeenCalledWith('Copy this text'); + }); + + it('enters edit mode for user message', () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + const userMessage = { + ...textMessage, + id: 'test-id', + text: 'Edit this message', + author: user, + type: 'text' as const, + }; + + act(() => { + result.current.handleEdit(userMessage); + }); + + expect(chatSessionStore.enterEditMode).toHaveBeenCalledWith('test-id'); + expect(mockSetInputText).toHaveBeenCalledWith('Edit this message'); + }); + + it('does not enter edit mode for assistant message', () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + const assistantMessage = { + ...textMessage, + author: {id: 'assistant'}, + type: 'text' as const, + }; + + act(() => { + result.current.handleEdit(assistantMessage); + }); + + expect(chatSessionStore.enterEditMode).not.toHaveBeenCalled(); + expect(mockSetInputText).not.toHaveBeenCalled(); + }); + + describe('handleTryAgain', () => { + it('resubmits user message', async () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + const userMessage = { + ...textMessage, + id: '1', + text: 'Try again with this', + author: user, + type: 'text' as const, + }; + + await act(async () => { + await result.current.handleTryAgain(userMessage); + }); + + expect(chatSessionStore.removeMessagesFromId).toHaveBeenCalledWith( + '1', + true, + ); + expect(mockHandleSendPress).toHaveBeenCalledWith({ + text: 'Try again with this', + type: 'text', + }); + }); + + it('resubmits last user message when retrying assistant message', async () => { + const _messages = [ + { + ...textMessage, + id: '2', + text: 'Assistant response', + author: {id: 'assistant'}, + type: 'text' as const, + }, + { + ...textMessage, + id: '1', + text: 'User message', + author: user, + type: 'text' as const, + }, + ]; + + const {result} = renderHook(() => + useMessageActions({ + user, + messages: _messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + await act(async () => { + await result.current.handleTryAgain(_messages[1]); + }); + + expect(chatSessionStore.removeMessagesFromId).toHaveBeenCalledWith( + '1', + true, + ); + expect(mockHandleSendPress).toHaveBeenCalledWith({ + text: 'User message', + type: 'text', + }); + }); + }); + + describe('handleTryAgainWith', () => { + it('uses current model if model ID matches', async () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + modelStore.activeModelId = 'model-1'; + + await act(async () => { + await result.current.handleTryAgainWith('model-1', messages[0]); + }); + + expect(modelStore.initContext).not.toHaveBeenCalled(); + expect(chatSessionStore.removeMessagesFromId).toHaveBeenCalled(); + expect(mockHandleSendPress).toHaveBeenCalled(); + }); + + it('initializes new model if model ID differs', async () => { + const {result} = renderHook(() => + useMessageActions({ + user, + messages, + handleSendPress: mockHandleSendPress, + setInputText: mockSetInputText, + }), + ); + + modelStore.activeModelId = 'model-1'; + modelStore.models = [createModel({id: 'model-2', name: 'Model 2'})]; + + await act(async () => { + await result.current.handleTryAgainWith('model-2', messages[0]); + }); + + expect(modelStore.initContext).toHaveBeenCalled(); + expect(chatSessionStore.removeMessagesFromId).toHaveBeenCalled(); + expect(mockHandleSendPress).toHaveBeenCalled(); + }); + }); +});