diff --git a/app/generationRecord.test.tsx b/app/generationRecord.test.tsx new file mode 100644 index 0000000..528bc46 --- /dev/null +++ b/app/generationRecord.test.tsx @@ -0,0 +1,276 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react-native' +import { View } from 'react-native' +import GenerationRecordScreen from './generationRecord' + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(() => ({ + back: jest.fn(), + push: jest.fn(), + })), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: jest.fn(() => ({ + t: (key: string) => key, + })), +})) + +jest.mock('expo-status-bar', () => ({ + StatusBar: 'StatusBar', +})) + +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})) + +jest.mock('expo-image', () => ({ + Image: 'Image', +})) + +jest.mock('@/components/icon', () => ({ + LeftArrowIcon: () => null, + DeleteIcon: () => null, + EditIcon: () => null, + ChangeIcon: () => null, + WhiteStarIcon: () => null, +})) + +jest.mock('@/components/ui/delete-confirm-dialog', () => ({ + DeleteConfirmDialog: () => null, +})) + +jest.mock('@/components/LoadingState', () => ({ + __esModule: true, + default: ({ testID }: any) => , +})) + +jest.mock('@/components/ErrorState', () => ({ + __esModule: true, + default: ({ testID }: any) => , +})) + +jest.mock('@/components/PaginationLoader', () => ({ + __esModule: true, + default: ({ testID }: any) => , +})) + +jest.mock('@/components/RefreshControl', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/hooks', () => ({ + useTemplateGenerations: jest.fn(), +})) + +import { useTemplateGenerations } from '@/hooks' + +const mockUseTemplateGenerations = useTemplateGenerations as jest.MockedFunction + +describe('GenerationRecordScreen', () => { + const mockExecute = jest.fn() + const mockRefetch = jest.fn() + const mockLoadMore = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('shows loading state on initial load', () => { + mockUseTemplateGenerations.mockReturnValue({ + generations: [], + loading: true, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + data: undefined, + }) + + const { getByTestId } = render() + expect(getByTestId('loading-state')).toBeTruthy() + }) + + it('shows error state when error occurs', () => { + mockUseTemplateGenerations.mockReturnValue({ + generations: [], + loading: false, + loadingMore: false, + error: { message: 'Failed to load' } as any, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + data: undefined, + }) + + const { getByTestId } = render() + expect(getByTestId('error-state')).toBeTruthy() + }) + + it('renders generation list when data is loaded', () => { + const mockGenerations = [ + { + id: '1', + template: { title: 'Test Template' }, + resultUrl: ['https://example.com/result.jpg'], + originalUrl: 'https://example.com/original.jpg', + }, + ] + + mockUseTemplateGenerations.mockReturnValue({ + generations: mockGenerations as any, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + data: { data: mockGenerations } as any, + }) + + const { getByText } = render() + expect(getByText('Test Template')).toBeTruthy() + }) + + it('shows pagination loader when loading more', () => { + const mockGenerations = [ + { + id: '1', + template: { title: 'Test Template' }, + resultUrl: ['https://example.com/result.jpg'], + }, + ] + + mockUseTemplateGenerations.mockReturnValue({ + generations: mockGenerations as any, + loading: false, + loadingMore: true, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + data: { data: mockGenerations } as any, + }) + + const { getByTestId } = render() + expect(getByTestId('pagination-loader')).toBeTruthy() + }) + + it('calls execute on mount', () => { + mockUseTemplateGenerations.mockReturnValue({ + generations: [], + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + data: undefined, + }) + + render() + expect(mockExecute).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + + it('calls refetch on pull to refresh', async () => { + const mockGenerations = [ + { + id: '1', + template: { title: 'Test Template' }, + resultUrl: ['https://example.com/result.jpg'], + }, + ] + + mockUseTemplateGenerations.mockReturnValue({ + generations: mockGenerations as any, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + data: { data: mockGenerations } as any, + }) + + const { getByTestId } = render() + const flatList = getByTestId('generation-list') + + fireEvent(flatList, 'refresh') + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('calls loadMore when reaching end of list', async () => { + const mockGenerations = [ + { + id: '1', + template: { title: 'Test Template' }, + resultUrl: ['https://example.com/result.jpg'], + }, + ] + + mockUseTemplateGenerations.mockReturnValue({ + generations: mockGenerations as any, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + data: { data: mockGenerations } as any, + }) + + const { getByTestId } = render() + const flatList = getByTestId('generation-list') + + fireEvent(flatList, 'endReached') + + await waitFor(() => { + expect(mockLoadMore).toHaveBeenCalledWith({ limit: 20 }) + }) + }) + + it('does not call loadMore when hasMore is false', async () => { + const mockGenerations = [ + { + id: '1', + template: { title: 'Test Template' }, + resultUrl: ['https://example.com/result.jpg'], + }, + ] + + mockUseTemplateGenerations.mockReturnValue({ + generations: mockGenerations as any, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + data: { data: mockGenerations } as any, + }) + + const { getByTestId } = render() + const flatList = getByTestId('generation-list') + + fireEvent(flatList, 'endReached') + + await waitFor(() => { + expect(mockLoadMore).not.toHaveBeenCalled() + }) + }) +}) diff --git a/app/generationRecord.tsx b/app/generationRecord.tsx index 2128e65..75ef02d 100644 --- a/app/generationRecord.tsx +++ b/app/generationRecord.tsx @@ -2,7 +2,7 @@ import { View, Text, StyleSheet, - ScrollView, + FlatList, Dimensions, Pressable, StatusBar as RNStatusBar, @@ -10,107 +10,156 @@ import { import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' import { Image } from 'expo-image' -import { useRouter, useLocalSearchParams } from 'expo-router' -import { useState } from 'react' +import { useRouter } from 'expo-router' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { LeftArrowIcon, DeleteIcon, EditIcon, ChangeIcon, WhiteStarIcon } from '@/components/icon' import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog' +import LoadingState from '@/components/LoadingState' +import ErrorState from '@/components/ErrorState' +import PaginationLoader from '@/components/PaginationLoader' +import RefreshControl from '@/components/RefreshControl' +import { useTemplateGenerations, type TemplateGeneration } from '@/hooks' const { width: screenWidth } = Dimensions.get('window') export default function GenerationRecordScreen() { const { t } = useTranslation() const router = useRouter() - const params = useLocalSearchParams() - const workId = params.id as string const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [selectedId, setSelectedId] = useState(null) + + const { generations, loading, loadingMore, error, execute, refetch, loadMore, hasMore } = useTemplateGenerations() + + useEffect(() => { + execute({ page: 1, limit: 20 }) + }, [execute]) const handleDelete = () => { - // TODO: 实现删除逻辑 - console.log('删除记录:', workId) + console.log('删除记录:', selectedId) + setDeleteDialogOpen(false) } - return ( - - - - - {/* 顶部导航栏 */} + const handleRefresh = () => { + refetch({ page: 1, limit: 20 }) + } + + const handleLoadMore = () => { + if (hasMore && !loadingMore) { + loadMore({ limit: 20 }) + } + } + + if (loading && generations.length === 0) { + return ( + + + - router.back()} - > + router.back()}> {t('generationRecord.title')} + + + ) + } - {/* AI 视频标签 */} - - - - - {t('generationRecord.aiVideo')} - - - {/* 原图标签 */} - - {t('generationRecord.originalImage')} - - - - {/* 主图片/视频预览区域 */} - - - {/* 视频时长显示 */} - - - - 00:03 - {/* 底部操作按钮 */} - - { - router.push({ - pathname: '/generateVideo' as any, - params: { - template: JSON.stringify({ - id: 1, - title: 'AI 视频', - image: require('@/assets/images/android-icon-background.png'), - users: 6349, - height: 214, - }), - }, - }) - }}> - - {t('generationRecord.reEdit')} - - - - {t('generationRecord.regenerate')} - - setDeleteDialogOpen(true)} - > - + if (error && generations.length === 0) { + return ( + + + + + router.back()}> + + {t('generationRecord.title')} + - + + + ) + } + + const renderItem = ({ item }: { item: TemplateGeneration }) => ( + + + + + + {item.template?.title || t('generationRecord.aiVideo')} + + + + {t('generationRecord.originalImage')} + + + + + + + 00:03 + + + { + router.push({ + pathname: '/generateVideo' as any, + params: { template: JSON.stringify(item.template) }, + }) + }}> + + {t('generationRecord.reEdit')} + + + + {t('generationRecord.regenerate')} + + { + setSelectedId(item.id) + setDeleteDialogOpen(true) + }} + > + + + + + ) + + return ( + + + + + router.back()}> + + + {t('generationRecord.title')} + + + + item.id} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator={false} + refreshControl={} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={loadingMore ? : null} + /> - {/* 删除确认弹窗 */}