import { act, fireEvent, render } from '@testing-library/react-native' import React from 'react' import { Keyboard, ScrollView } from 'react-native' import type { useSearchResults } from '../../../hooks/use_search_results' import type { ConversationSearchResultResource, MessageSearchResultResource, } from '../../../types/resources/search_result' import { SearchScreen } from '../search_screen' const mockNavigate = jest.fn() jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate }), })) const mockUseSearchResults = jest.fn, [string]>() jest.mock('../../../hooks/use_search_results', () => ({ useSearchResults: (term: string) => mockUseSearchResults(term), })) const mockPublishEvent = jest.fn() jest.mock('../../../hooks/use_product_analytics', () => ({ ...jest.requireActual('../../../hooks/use_product_analytics'), useProductAnalytics: () => ({ publishEvent: mockPublishEvent }), })) jest.mock('../../../components/display/keyboard_view', () => ({ ...jest.requireActual('../../../components/display/keyboard_view'), KeyboardView: ({ children }: { children: React.ReactNode }) => children, })) const inputLabel = 'Search titles and messages' const conversationResult: ConversationSearchResultResource = { type: 'conversation', id: '42', title: 'Budget Team', url: '/conversations/42', extra: { avatar: { previewAvatarUrls: [] } }, } const messageResult: MessageSearchResultResource = { type: 'message', id: '01JMESSAGEULID', title: 'Finance', url: '/conversations/7/messages/01JMESSAGEULID', extra: { conversationId: 7, authorName: 'Ann', snippet: [{ text: 'budget', match: true }], createdAt: '2026-01-01T00:00:00Z', avatar: { previewAvatarUrls: [] }, }, } const secondMessageResult: MessageSearchResultResource = { ...messageResult, id: '01JSECONDULID', title: 'Youth', url: '/conversations/9/messages/01JSECONDULID', extra: { ...messageResult.extra, conversationId: 9, authorName: 'Bob' }, } const renderWithResults = () => { mockUseSearchResults.mockReturnValue({ results: [conversationResult, messageResult, secondMessageResult], failedTypes: [], isPending: false, isError: false, isFetchNextPageError: false, loadMore: jest.fn(), hasNextPage: false, isFetchingNextPage: false, }) const view = render() const input = view.getByLabelText(inputLabel) fireEvent.changeText(input, 'bud') fireEvent.changeText(input, 'budget') act(() => jest.advanceTimersByTime(200)) return view } beforeEach(() => { jest.useFakeTimers() mockNavigate.mockReset() mockPublishEvent.mockReset() mockUseSearchResults.mockReset().mockReturnValue({ results: [], failedTypes: [], isPending: true, isError: false, isFetchNextPageError: false, loadMore: jest.fn(), hasNextPage: false, isFetchingNextPage: false, }) }) afterEach(() => jest.useRealTimers()) describe('SearchScreen', () => { it('searches once with the trimmed term after typing settles', () => { const view = render() const input = view.getByLabelText(inputLabel) fireEvent.changeText(input, 'bud') fireEvent.changeText(input, ' budget ') expect(view.getByText('Search chat')).toBeTruthy() expect(mockUseSearchResults).not.toHaveBeenCalled() act(() => jest.advanceTimersByTime(200)) const searchedTerms = new Set(mockUseSearchResults.mock.calls.map(([term]) => term)) expect([...searchedTerms]).toEqual(['budget']) expect(view.queryByText('Search chat')).toBeNull() expect(view.getByLabelText('Loading results')).toBeTruthy() }) it('lets the prompt scroll while taps still reach the field', () => { const view = render() const scrollView = view.UNSAFE_getByType(ScrollView) expect(scrollView.props.keyboardShouldPersistTaps).toBe('handled') expect(scrollView.props.contentContainerStyle).toMatchObject({ flexGrow: 1, justifyContent: 'center', }) expect(view.getByText('Search chat')).toBeTruthy() }) it('returns to the prompt as soon as the term is cleared', () => { const view = render() const input = view.getByLabelText(inputLabel) fireEvent.changeText(input, 'budget') act(() => jest.advanceTimersByTime(200)) expect(view.queryByText('Search chat')).toBeNull() fireEvent.changeText(input, ' ') expect(view.getByText('Search chat')).toBeTruthy() }) it('opens a conversation result at the latest message', () => { const dismiss = jest.spyOn(Keyboard, 'dismiss') const view = renderWithResults() fireEvent.press(view.getByLabelText('Budget Team')) expect(dismiss).toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith('Conversation', { conversation_id: 42, title: 'Budget Team', }) }) it('opens a message result anchored at that message', () => { const view = renderWithResults() fireEvent.press(view.getByLabelText(/^Finance, Ann: budget/)) expect(mockNavigate).toHaveBeenCalledWith('Conversation', { conversation_id: 7, initial_message_id: '01JMESSAGEULID', title: 'Finance', }) }) it('publishes one search event per settled term with result counts, never the term', () => { renderWithResults() expect(mockPublishEvent).toHaveBeenCalledTimes(1) expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.performed', { term_length: 6, conversation_result_count: 1, message_result_count: 2, }) expect(JSON.stringify(mockPublishEvent.mock.calls)).not.toContain('budget') }) it('does not publish a retyped term whose results are already cached', () => { const view = renderWithResults() const input = view.getByLabelText(inputLabel) fireEvent.changeText(input, 'budge') act(() => jest.advanceTimersByTime(200)) fireEvent.changeText(input, 'budget') act(() => jest.advanceTimersByTime(200)) const performed = mockPublishEvent.mock.calls.filter( ([name]) => name === 'chat.mobile.search.performed' ) expect(performed.map(([, meta]) => meta.term_length)).toEqual([6, 5]) }) it('publishes the opened result type and its position within its section', () => { const view = renderWithResults() fireEvent.press(view.getByLabelText(/^Youth, Bob: budget/)) expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.result.opened', { result_type: 'message', position: 1, }) }) })