From 95dd556ded2b1c2c7f55caca74dd4c582d448c35 Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 2 Feb 2026 15:21:59 +0800 Subject: [PATCH] fix: search bug --- app/searchWorksResults.tsx | 60 ++++++- tests/app/searchWorksResults.test.tsx | 239 ++++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 tests/app/searchWorksResults.test.tsx diff --git a/app/searchWorksResults.tsx b/app/searchWorksResults.tsx index f6d3af1..5d3cb78 100644 --- a/app/searchWorksResults.tsx +++ b/app/searchWorksResults.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { StyleSheet, StatusBar as RNStatusBar, @@ -53,6 +53,9 @@ export default function SearchWorksResultsScreen() { const router = useRouter() const params = useLocalSearchParams() const [searchText, setSearchText] = useState((params.q as string) || '') + const [page, setPage] = useState(1) + const [allWorks, setAllWorks] = useState([]) + const [isLoadingMore, setIsLoadingMore] = useState(false) const categories: Category[] = [ t('worksList.all') as Category, @@ -67,15 +70,44 @@ export default function SearchWorksResultsScreen() { const { data, works, isLoading, error } = useWorksSearch({ keyword: searchText, category: selectedCategory, - page: 1, + page, limit: 20, }) + // 当搜索关键词或分类变化时,重置页码和累积数据 + useEffect(() => { + setPage(1) + setAllWorks([]) + }, [searchText, selectedCategory]) + + // 累积新数据 + useEffect(() => { + if (works && works.length > 0) { + if (page === 1) { + setAllWorks(works) + } else { + setAllWorks((prev) => [...prev, ...works]) + } + setIsLoadingMore(false) + } + }, [works, page]) + + // 加载更多函数 + const handleLoadMore = () => { + if (isLoading || isLoadingMore || !data) return + + // 检查是否还有更多数据 + if (page >= data.totalPages) return + + setIsLoadingMore(true) + setPage((prev) => prev + 1) + } + // 根据分类过滤结果(如果 API 不支持分类筛选,则在前端过滤) const filteredWorks = selectedCategory === categories[0] - ? works - : works.filter((work) => { + ? allWorks + : allWorks.filter((work) => { const convertedWork = convertToWorkItem(work) return convertedWork.category === selectedCategory }) @@ -190,6 +222,15 @@ export default function SearchWorksResultsScreen() { params: { id: id.toString() }, }) }} + onEndReached={handleLoadMore} + ListFooterComponent={ + isLoadingMore ? ( + + + {t('search.loadingMore')} + + ) : undefined + } /> )} @@ -234,4 +275,15 @@ const styles = StyleSheet.create({ color: '#8A8A8A', fontSize: 14, }, + loadingMoreContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 20, + gap: 8, + }, + loadingMoreText: { + color: '#F5F5F5', + fontSize: 12, + }, }) diff --git a/tests/app/searchWorksResults.test.tsx b/tests/app/searchWorksResults.test.tsx new file mode 100644 index 0000000..8a271aa --- /dev/null +++ b/tests/app/searchWorksResults.test.tsx @@ -0,0 +1,239 @@ +/** + * TDD Phase 1: RED - Write failing tests first + * + * 测试搜索结果页面的分页功能 + */ + +import React from 'react' +import { render, waitFor, fireEvent } from '@testing-library/react-native' +import { useRouter, useLocalSearchParams } from 'expo-router' +import SearchWorksResultsScreen from '@/app/searchWorksResults' +import { useWorksSearch } from '@/hooks/use-works-search' + +// Mock dependencies +jest.mock('expo-router', () => ({ + useRouter: jest.fn(), + useLocalSearchParams: jest.fn(), +})) + +jest.mock('@/hooks/use-works-search', () => ({ + useWorksSearch: jest.fn(), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN' }, + }), +})) + +jest.mock('@/components/SearchBar', () => 'SearchBar') +jest.mock('@/components/WorksGallery', () => 'WorksGallery') + +describe('SearchWorksResultsScreen - Pagination', () => { + const mockRouter = { + push: jest.fn(), + back: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue(mockRouter) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ q: '测试' }) + }) + + describe('Load More Functionality', () => { + it('should load first page on initial render', () => { + const mockUseWorksSearch = useWorksSearch as jest.Mock + mockUseWorksSearch.mockReturnValue({ + data: { + data: [ + { + id: '1', + createdAt: new Date('2025-01-15'), + template: { id: 't1', name: 'Template 1' }, + status: 'completed', + duration: 5, + }, + ], + total: 50, + page: 1, + limit: 20, + totalPages: 3, + }, + works: [ + { + id: '1', + createdAt: new Date('2025-01-15'), + template: { id: 't1', name: 'Template 1' }, + status: 'completed', + duration: 5, + }, + ], + isLoading: false, + error: null, + }) + + render() + + // 验证初始调用使用 page: 1 + expect(mockUseWorksSearch).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + }) + ) + }) + + it('should accumulate results when loading more pages', async () => { + const mockUseWorksSearch = useWorksSearch as jest.Mock + + // 第一页数据 + const page1Data = [ + { + id: '1', + createdAt: new Date('2025-01-15'), + template: { id: 't1', name: 'Template 1' }, + status: 'completed', + duration: 5, + }, + ] + + // 第二页数据 + const page2Data = [ + { + id: '2', + createdAt: new Date('2025-01-14'), + template: { id: 't2', name: 'Template 2' }, + status: 'completed', + duration: 10, + }, + ] + + mockUseWorksSearch.mockReturnValue({ + data: { + data: page1Data, + total: 50, + page: 1, + limit: 20, + totalPages: 3, + }, + works: page1Data, + isLoading: false, + error: null, + loadMore: jest.fn(), + }) + + const { rerender } = render() + + // 模拟加载第二页 + mockUseWorksSearch.mockReturnValue({ + data: { + data: [...page1Data, ...page2Data], + total: 50, + page: 2, + limit: 20, + totalPages: 3, + }, + works: [...page1Data, ...page2Data], + isLoading: false, + error: null, + loadMore: jest.fn(), + }) + + rerender() + + // 验证结果累积 + expect(mockUseWorksSearch).toHaveBeenCalled() + }) + + it('should not load more when already loading', () => { + const mockLoadMore = jest.fn() + const mockUseWorksSearch = useWorksSearch as jest.Mock + + mockUseWorksSearch.mockReturnValue({ + data: { + data: [], + total: 50, + page: 1, + limit: 20, + totalPages: 3, + }, + works: [], + isLoading: true, + error: null, + loadMore: mockLoadMore, + }) + + render() + + // 验证加载中时不应触发新的加载 + expect(mockLoadMore).not.toHaveBeenCalled() + }) + + it('should not load more when all pages loaded', () => { + const mockLoadMore = jest.fn() + const mockUseWorksSearch = useWorksSearch as jest.Mock + + mockUseWorksSearch.mockReturnValue({ + data: { + data: [ + { + id: '1', + createdAt: new Date('2025-01-15'), + template: { id: 't1', name: 'Template 1' }, + status: 'completed', + duration: 5, + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }, + works: [ + { + id: '1', + createdAt: new Date('2025-01-15'), + template: { id: 't1', name: 'Template 1' }, + status: 'completed', + duration: 5, + }, + ], + isLoading: false, + error: null, + loadMore: mockLoadMore, + hasMore: false, + }) + + render() + + // 验证没有更多数据时不应触发加载 + expect(mockLoadMore).not.toHaveBeenCalled() + }) + }) + + describe('Loading State', () => { + it('should show loading indicator when loading more', () => { + const mockUseWorksSearch = useWorksSearch as jest.Mock + + mockUseWorksSearch.mockReturnValue({ + data: { + data: [], + total: 50, + page: 2, + limit: 20, + totalPages: 3, + }, + works: [], + isLoading: true, + error: null, + isLoadingMore: true, + }) + + const { getByTestId } = render() + + // 验证显示加载指示器 + expect(() => getByTestId('loading-more-indicator')).not.toThrow() + }) + }) +})