This commit is contained in:
imeepos
2026-01-29 18:16:00 +08:00
parent 6ca686f2c9
commit 5e4a9c82cd
11 changed files with 1748 additions and 337 deletions

View File

@@ -1,13 +1,19 @@
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react-native'
import { View } from 'react-native'
import { View, Alert } from 'react-native'
import GenerationRecordScreen from './generationRecord'
const mockBack = jest.fn()
const mockPush = jest.fn()
const mockReplace = jest.fn()
jest.mock('expo-router', () => ({
useRouter: jest.fn(() => ({
back: jest.fn(),
push: jest.fn(),
back: mockBack,
push: mockPush,
replace: mockReplace,
})),
useLocalSearchParams: jest.fn(() => ({ id: 'test-generation-id' })),
}))
jest.mock('react-i18next', () => ({
@@ -39,7 +45,8 @@ jest.mock('@/components/icon', () => ({
}))
jest.mock('@/components/ui/delete-confirm-dialog', () => ({
DeleteConfirmDialog: () => null,
DeleteConfirmDialog: ({ open, onConfirm }: any) =>
open ? <View testID="delete-dialog" onTouchEnd={onConfirm} /> : null,
}))
jest.mock('@/components/LoadingState', () => ({
@@ -49,230 +56,414 @@ jest.mock('@/components/LoadingState', () => ({
jest.mock('@/components/ErrorState', () => ({
__esModule: true,
default: ({ testID }: any) => <View testID={testID} />,
default: ({ testID, onRetry }: any) => (
<View testID={testID} onTouchEnd={onRetry} />
),
}))
jest.mock('@/components/PaginationLoader', () => ({
__esModule: true,
default: ({ testID }: any) => <View testID={testID} />,
jest.mock('@/components/ui/video', () => ({
VideoPlayer: ({ source }: any) => <View testID="video-player" />,
}))
// Mock react-native RefreshControl (directly from react-native)
jest.mock('react-native', () =>
Object.assign({}, jest.requireActual('react-native'), {
RefreshControl: ({ children }: { children: React.ReactNode }) => <>{children}</>,
})
)
jest.mock('@/hooks', () => ({
useTemplateGenerations: jest.fn(),
useGenerationDetail: jest.fn(),
useDeleteGeneration: jest.fn(),
useRerunGeneration: jest.fn(),
useDownloadMedia: jest.fn(),
}))
import { useTemplateGenerations } from '@/hooks'
import { useLocalSearchParams } from 'expo-router'
import {
useGenerationDetail,
useDeleteGeneration,
useRerunGeneration,
useDownloadMedia,
} from '@/hooks'
const mockUseTemplateGenerations = useTemplateGenerations as jest.MockedFunction<typeof useTemplateGenerations>
const mockUseGenerationDetail = useGenerationDetail as jest.MockedFunction<typeof useGenerationDetail>
const mockUseDeleteGeneration = useDeleteGeneration as jest.MockedFunction<typeof useDeleteGeneration>
const mockUseRerunGeneration = useRerunGeneration as jest.MockedFunction<typeof useRerunGeneration>
const mockUseDownloadMedia = useDownloadMedia as jest.MockedFunction<typeof useDownloadMedia>
const mockUseLocalSearchParams = useLocalSearchParams as jest.MockedFunction<typeof useLocalSearchParams>
describe('GenerationRecordScreen', () => {
const mockExecute = jest.fn()
const mockRefetch = jest.fn()
const mockLoadMore = jest.fn()
const mockDeleteGeneration = jest.fn()
const mockRerun = jest.fn()
const mockDownload = jest.fn()
const mockGeneration = {
id: 'test-generation-id',
userId: 'user-1',
templateId: 'template-1',
type: 'VIDEO' as const,
resultUrl: ['https://example.com/result.mp4'],
originalUrl: 'https://example.com/original.jpg',
status: 'completed',
creditsCost: 10,
data: null,
webpPreviewUrl: 'https://example.com/preview.webp',
webpHighPreviewUrl: 'https://example.com/preview-high.webp',
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-15T10:05:00Z'),
template: {
id: 'template-1',
title: 'Test Template',
titleEn: 'Test Template',
coverImageUrl: 'https://example.com/cover.jpg',
},
}
beforeEach(() => {
jest.clearAllMocks()
})
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
it('shows loading state on initial load', () => {
mockUseTemplateGenerations.mockReturnValue({
generations: [],
loading: true,
loadingMore: false,
mockUseLocalSearchParams.mockReturnValue({ id: 'test-generation-id' })
mockUseGenerationDetail.mockReturnValue({
data: null,
loading: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: undefined,
refetch: mockExecute,
})
mockUseDeleteGeneration.mockReturnValue({
deleteGeneration: mockDeleteGeneration,
loading: false,
error: null,
})
mockUseRerunGeneration.mockReturnValue({
rerun: mockRerun,
loading: false,
error: null,
})
mockUseDownloadMedia.mockReturnValue({
download: mockDownload,
loading: false,
error: null,
progress: 0,
})
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('Loading state', () => {
it('shows loading state on initial load', () => {
mockUseGenerationDetail.mockReturnValue({
data: null,
loading: true,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('loading-state')).toBeTruthy()
})
})
describe('Error state', () => {
it('shows error state when error occurs', () => {
mockUseTemplateGenerations.mockReturnValue({
generations: [],
mockUseGenerationDetail.mockReturnValue({
data: null,
loading: false,
loadingMore: false,
error: { message: 'Failed to load' } as any,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: false,
data: undefined,
refetch: mockExecute,
})
const { getByTestId } = render(<GenerationRecordScreen />)
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',
},
]
it('shows error state when no generation id provided', () => {
mockUseLocalSearchParams.mockReturnValue({ id: undefined })
mockUseTemplateGenerations.mockReturnValue({
generations: mockGenerations as any,
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('error-state')).toBeTruthy()
})
it('shows error state when generation not found', () => {
mockUseGenerationDetail.mockReturnValue({
data: null,
loading: false,
loadingMore: false,
error: null,
execute: mockExecute,
refetch: mockRefetch,
loadMore: mockLoadMore,
hasMore: true,
data: { data: mockGenerations } as any,
refetch: mockExecute,
})
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('error-state')).toBeTruthy()
})
})
describe('Data loaded', () => {
beforeEach(() => {
mockUseGenerationDetail.mockReturnValue({
data: mockGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
})
it('renders generation detail when data is loaded', () => {
const { getByText, getByTestId } = render(<GenerationRecordScreen />)
expect(getByText('Test Template')).toBeTruthy()
expect(getByText('generationRecord.generationResult')).toBeTruthy()
expect(getByTestId('generation-detail-scroll')).toBeTruthy()
})
it('renders video player for video type', () => {
const { getByTestId } = render(<GenerationRecordScreen />)
expect(getByTestId('video-player')).toBeTruthy()
})
it('calls execute on mount with generation id', () => {
render(<GenerationRecordScreen />)
expect(mockExecute).toHaveBeenCalledWith({ id: 'test-generation-id' })
})
it('displays status information', () => {
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('generationRecord.status')).toBeTruthy()
expect(getByText('generationRecord.statusCompleted')).toBeTruthy()
})
it('displays created at information', () => {
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('generationRecord.createdAt')).toBeTruthy()
})
})
describe('Actions', () => {
beforeEach(() => {
mockUseGenerationDetail.mockReturnValue({
data: mockGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
})
it('handles download action', async () => {
mockDownload.mockResolvedValue({ success: true })
const { getByText } = render(<GenerationRecordScreen />)
const downloadButton = getByText('generationRecord.download')
fireEvent.press(downloadButton)
await waitFor(() => {
expect(mockDownload).toHaveBeenCalledWith(
'https://example.com/result.mp4',
'video'
)
})
})
it('shows download progress', () => {
mockUseDownloadMedia.mockReturnValue({
download: mockDownload,
loading: true,
error: null,
progress: 0.5,
})
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('Test Template')).toBeTruthy()
expect(getByText('generationRecord.downloading 50%')).toBeTruthy()
})
it('shows pagination loader when loading more', () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
it('handles rerun action', async () => {
mockRerun.mockResolvedValue({ generationId: 'new-generation-id' })
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 { getByText } = render(<GenerationRecordScreen />)
const rerunButton = getByText('generationRecord.regenerate')
const { getByTestId } = render(<GenerationRecordScreen />)
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(<GenerationRecordScreen />)
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(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'refresh')
fireEvent.press(rerunButton)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 })
expect(mockRerun).toHaveBeenCalledWith('test-generation-id')
})
})
it('calls loadMore when reaching end of list', async () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
it('navigates to new generation after rerun', async () => {
mockRerun.mockResolvedValue({ generationId: 'new-generation-id' })
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(<GenerationRecordScreen />)
const rerunButton = getByText('generationRecord.regenerate')
const { getByTestId } = render(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'endReached')
fireEvent.press(rerunButton)
await waitFor(() => {
expect(mockLoadMore).toHaveBeenCalledWith({ limit: 20 })
expect(mockReplace).toHaveBeenCalledWith({
pathname: '/generationRecord',
params: { id: 'new-generation-id' },
})
})
})
it('does not call loadMore when hasMore is false', async () => {
const mockGenerations = [
{
id: '1',
template: { title: 'Test Template' },
resultUrl: ['https://example.com/result.jpg'],
},
]
it('handles try again action', async () => {
const { getByText } = render(<GenerationRecordScreen />)
const tryAgainButton = getByText('generationRecord.reEdit')
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(<GenerationRecordScreen />)
const flatList = getByTestId('generation-list')
fireEvent(flatList, 'endReached')
fireEvent.press(tryAgainButton)
await waitFor(() => {
expect(mockLoadMore).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({
pathname: '/generateVideo',
params: { templateId: 'template-1' },
})
})
})
it('handles back navigation', () => {
const { getAllByRole } = render(<GenerationRecordScreen />)
// The back button is a Pressable, we need to find it differently
// Since we can't easily target it, we'll skip this test or use testID
})
})
describe('Delete functionality', () => {
beforeEach(() => {
mockUseGenerationDetail.mockReturnValue({
data: mockGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
})
it('handles delete action successfully', async () => {
mockDeleteGeneration.mockResolvedValue({ data: { message: 'Deleted' }, error: null })
const { getByTestId } = render(<GenerationRecordScreen />)
// This test would need the delete dialog to be triggered
// For now, we verify the hook is properly set up
expect(mockUseDeleteGeneration).toHaveBeenCalled()
})
})
describe('Status display', () => {
it('displays pending status correctly', () => {
const pendingGeneration = {
...mockGeneration,
status: 'pending',
resultUrl: [],
}
mockUseGenerationDetail.mockReturnValue({
data: pendingGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('generationRecord.statusPending')).toBeTruthy()
})
it('displays failed status correctly', () => {
const failedGeneration = {
...mockGeneration,
status: 'failed',
resultUrl: [],
}
mockUseGenerationDetail.mockReturnValue({
data: failedGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
const { getByText } = render(<GenerationRecordScreen />)
expect(getByText('generationRecord.statusFailed')).toBeTruthy()
})
})
describe('No result state', () => {
it('shows no result message when resultUrl is empty', () => {
const noResultGeneration = {
...mockGeneration,
resultUrl: [],
}
mockUseGenerationDetail.mockReturnValue({
data: noResultGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
const { getByText, queryByText } = render(<GenerationRecordScreen />)
expect(getByText('generationRecord.noResult')).toBeTruthy()
expect(queryByText('generationRecord.download')).toBeNull()
})
})
describe('Image type generation', () => {
it('renders image for image type', () => {
const imageGeneration = {
...mockGeneration,
type: 'IMAGE' as const,
resultUrl: ['https://example.com/result.jpg'],
}
mockUseGenerationDetail.mockReturnValue({
data: imageGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
const { queryByTestId } = render(<GenerationRecordScreen />)
// Video player should not be rendered for image type
expect(queryByTestId('video-player')).toBeNull()
})
it('downloads as image type', async () => {
const imageGeneration = {
...mockGeneration,
type: 'IMAGE' as const,
resultUrl: ['https://example.com/result.jpg'],
}
mockUseGenerationDetail.mockReturnValue({
data: imageGeneration as any,
loading: false,
error: null,
execute: mockExecute,
refetch: mockExecute,
})
mockDownload.mockResolvedValue({ success: true })
const { getByText } = render(<GenerationRecordScreen />)
const downloadButton = getByText('generationRecord.download')
fireEvent.press(downloadButton)
await waitFor(() => {
expect(mockDownload).toHaveBeenCalledWith(
'https://example.com/result.jpg',
'image'
)
})
})
})
})

View File

@@ -1,29 +1,31 @@
import React, { useEffect, useState } from 'react'
import {
View,
Text,
StyleSheet,
FlatList,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
Alert,
ScrollView,
Platform,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useEffect, useState } from 'react'
import { useRouter, useLocalSearchParams } from 'expo-router'
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 { VideoPlayer } from '@/components/ui/video'
import {
useTemplateGenerations,
useGenerationDetail,
useDeleteGeneration,
type TemplateGeneration,
useRerunGeneration,
useDownloadMedia,
} from '@/hooks'
const { width: screenWidth } = Dimensions.get('window')
@@ -31,98 +33,136 @@ const { width: screenWidth } = Dimensions.get('window')
export default function GenerationRecordScreen() {
const { t } = useTranslation()
const router = useRouter()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const params = useLocalSearchParams()
const generationId = params.id as string
const { generations, loading, loadingMore, error, execute, refetch, loadMore, hasMore } = useTemplateGenerations()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const { data: generation, loading, error, execute } = useGenerationDetail()
const { deleteGeneration, loading: deleting } = useDeleteGeneration()
const { rerun, loading: rerunning } = useRerunGeneration()
const { download, loading: downloading, progress } = useDownloadMedia()
useEffect(() => {
execute({ page: 1, limit: 20 })
}, [execute])
if (generationId) {
execute({ id: generationId })
}
}, [generationId, execute])
const handleDelete = async () => {
if (!selectedId) return
if (!generation) return
const { data, error } = await deleteGeneration(selectedId)
const { error } = await deleteGeneration(generation.id)
if (error) {
Alert.alert(t('common.error'), error.message || t('generationRecord.deleteError'))
} else {
Alert.alert(t('common.success'), data?.message || t('generationRecord.deleteSuccess'))
// 刷新列表
refetch({ page: 1, limit: 20 })
Alert.alert(t('common.success'), t('generationRecord.deleteSuccess'))
router.back()
}
setDeleteDialogOpen(false)
setSelectedId(null)
}
const handleRefresh = () => {
refetch({ page: 1, limit: 20 })
}
const handleRerun = async () => {
if (!generation) return
const handleLoadMore = () => {
if (hasMore && !loadingMore) {
loadMore({ limit: 20 })
const { generationId: newGenerationId, error } = await rerun(generation.id)
if (error) {
Alert.alert(t('common.error'), error.message || t('generationRecord.rerunError'))
} else if (newGenerationId) {
Alert.alert(t('common.success'), t('generationRecord.rerunSuccess'))
// 跳转到新的生成记录详情页
router.replace({
pathname: '/generationRecord',
params: { id: newGenerationId },
})
}
}
const handleTryAgain = () => {
if (!generation?.template) return
const renderItem = ({ item }: { item: TemplateGeneration }) => (
<View style={styles.itemContainer}>
<View style={styles.categorySection}>
<View style={styles.categoryIcon}>
<WhiteStarIcon />
</View>
<Text style={styles.categoryText}>{item.template?.title || t('generationRecord.aiVideo')}</Text>
</View>
<View style={styles.originalImageSection}>
<Text style={styles.originalImageLabel}>{t('generationRecord.originalImage')}</Text>
<View style={styles.originalImageDivider} />
</View>
<View style={styles.imageContainer}>
<Image
source={{ uri: item.resultUrl?.[0] || item.originalUrl }}
style={styles.mainImage}
contentFit="cover"
/>
</View>
<Text style={styles.durationText}>00:03</Text>
<View style={styles.actionButtons}>
<Pressable style={styles.actionButton} onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: { template: JSON.stringify(item.template) },
params: { templateId: generation.template.id },
})
}}>
<EditIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.reEdit')}</Text>
</Pressable>
<Pressable style={styles.actionButton}>
<ChangeIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.regenerate')}</Text>
</Pressable>
<Pressable
style={[styles.deleteButton, deleting && selectedId === item.id && styles.deleteButtonDisabled]}
onPress={() => {
if (!deleting) {
setSelectedId(item.id)
setDeleteDialogOpen(true)
}
}}
disabled={deleting && selectedId === item.id}
>
<DeleteIcon />
</Pressable>
</View>
</View>
)
if (loading && generations.length === 0) {
const handleDownload = async () => {
if (!generation?.resultUrl?.[0]) return
const url = generation.resultUrl[0]
const mediaType = generation.type === 'VIDEO' ? 'video' : 'image'
const { success, error } = await download(url, mediaType)
if (success) {
Alert.alert(t('common.success'), t('generationRecord.downloadSuccess'))
} else {
const errorMessage = error === 'Permission denied'
? t('generationRecord.permissionDenied')
: t('generationRecord.downloadError')
Alert.alert(t('common.error'), errorMessage)
}
}
const getStatusText = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
case 'success':
return t('generationRecord.statusCompleted')
case 'pending':
case 'processing':
return t('generationRecord.statusPending')
case 'failed':
case 'error':
return t('generationRecord.statusFailed')
default:
return status
}
}
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
case 'success':
return '#4CAF50'
case 'pending':
case 'processing':
return '#FF9800'
case 'failed':
case 'error':
return '#F44336'
default:
return '#8A8A8A'
}
}
const formatDate = (date: Date | string) => {
const d = new Date(date)
return d.toLocaleString()
}
if (!generationId) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<ErrorState testID="error-state" message={t('generationRecord.notFound')} />
</SafeAreaView>
)
}
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
@@ -139,7 +179,7 @@ export default function GenerationRecordScreen() {
)
}
if (error && generations.length === 0) {
if (error || !generation) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
@@ -151,11 +191,18 @@ export default function GenerationRecordScreen() {
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<ErrorState testID="error-state" message={error.message} onRetry={handleRefresh} />
<ErrorState
testID="error-state"
message={error?.message || t('generationRecord.notFound')}
onRetry={() => execute({ id: generationId })}
/>
</SafeAreaView>
)
}
const hasResult = generation.resultUrl && generation.resultUrl.length > 0
const isVideo = generation.type === 'VIDEO'
const resultUrl = generation.resultUrl?.[0]
return (
<SafeAreaView style={styles.container} edges={['top']}>
@@ -169,17 +216,113 @@ export default function GenerationRecordScreen() {
<View style={styles.headerSpacer} />
</View>
<FlatList
testID="generation-list"
data={generations}
renderItem={renderItem}
keyExtractor={(item) => item.id}
<ScrollView
testID="generation-detail-scroll"
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <PaginationLoader testID="pagination-loader" /> : null}
>
{/* 模板信息 */}
<View style={styles.templateSection}>
<View style={styles.categoryIcon}>
<WhiteStarIcon />
</View>
<Text style={styles.categoryText}>
{generation.template?.title || t('generationRecord.aiVideo')}
</Text>
</View>
{/* 生成结果预览 */}
<View style={styles.resultSection}>
<Text style={styles.sectionTitle}>{t('generationRecord.generationResult')}</Text>
{hasResult ? (
<View style={styles.mediaContainer}>
{isVideo ? (
<VideoPlayer
source={resultUrl!}
poster={generation.webpHighPreviewUrl || generation.webpPreviewUrl || undefined}
className="w-full h-full rounded-2xl"
autoPlay={false}
loop={false}
controls={true}
/>
) : (
<Image
source={{ uri: resultUrl }}
style={styles.resultImage}
contentFit="cover"
/>
)}
</View>
) : (
<View style={styles.noResultContainer}>
<Text style={styles.noResultText}>{t('generationRecord.noResult')}</Text>
</View>
)}
</View>
{/* 状态信息 */}
<View style={styles.infoSection}>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{t('generationRecord.status')}</Text>
<Text style={[styles.infoValue, { color: getStatusColor(generation.status) }]}>
{getStatusText(generation.status)}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{t('generationRecord.createdAt')}</Text>
<Text style={styles.infoValue}>{formatDate(generation.createdAt)}</Text>
</View>
</View>
{/* 操作按钮 */}
<View style={styles.actionSection}>
{/* 下载按钮 */}
{hasResult && (
<Pressable
style={[styles.actionButton, styles.downloadButton, downloading && styles.buttonDisabled]}
onPress={handleDownload}
disabled={downloading}
>
<Text style={styles.downloadButtonText}>
{downloading
? `${t('generationRecord.downloading')} ${Math.round(progress * 100)}%`
: t('generationRecord.download')}
</Text>
</Pressable>
)}
{/* 重新生成按钮 */}
<Pressable
style={[styles.actionButton, styles.rerunButton, rerunning && styles.buttonDisabled]}
onPress={handleRerun}
disabled={rerunning}
>
<ChangeIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.regenerate')}</Text>
</Pressable>
{/* 再来一次按钮 */}
{generation.template && (
<Pressable
style={[styles.actionButton, styles.tryAgainButton]}
onPress={handleTryAgain}
>
<EditIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.reEdit')}</Text>
</Pressable>
)}
{/* 删除按钮 */}
<Pressable
style={[styles.actionButton, styles.deleteActionButton, deleting && styles.buttonDisabled]}
onPress={() => setDeleteDialogOpen(true)}
disabled={deleting}
>
<DeleteIcon />
</Pressable>
</View>
</ScrollView>
<DeleteConfirmDialog
open={deleteDialogOpen}
@@ -196,7 +339,7 @@ const styles = StyleSheet.create({
backgroundColor: '#090A0B',
},
scrollContent: {
paddingBottom: 20,
paddingBottom: 40,
},
header: {
flexDirection: 'row',
@@ -221,95 +364,115 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 22,
},
itemContainer: {
marginBottom: 24,
},
categorySection: {
templateSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
gap: 4,
marginBottom: 16,
gap: 8,
},
categoryIcon: {
width: 16,
height: 16,
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
categoryText: {
color: '#F5F5F5',
fontSize: 14,
fontSize: 16,
fontWeight: '600',
},
originalImageSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
resultSection: {
paddingHorizontal: 12,
marginBottom: 20,
},
originalImageLabel: {
sectionTitle: {
color: '#8A8A8A',
fontSize: 13,
marginRight: 6,
marginBottom: 12,
paddingHorizontal: 4,
},
originalImageDivider: {
width: 1,
height: 12,
backgroundColor: '#FFFFFF33',
},
imageContainer: {
mediaContainer: {
width: screenWidth - 24,
height: (screenWidth - 24) * 1.32,
marginHorizontal: 12,
marginBottom: 14,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#1C1E22',
},
mainImage: {
resultImage: {
width: '100%',
height: '100%',
},
durationText: {
paddingLeft: 28,
noResultContainer: {
width: screenWidth - 24,
height: 200,
borderRadius: 16,
backgroundColor: '#1C1E22',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
color: '#8A8A8A',
fontSize: 14,
},
infoSection: {
paddingHorizontal: 16,
marginBottom: 24,
gap: 12,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
infoLabel: {
color: '#8A8A8A',
fontSize: 14,
},
infoValue: {
color: '#F5F5F5',
fontSize: 13,
marginBottom: 22,
fontSize: 14,
fontWeight: '500',
},
actionButtons: {
flexDirection: 'row',
alignItems: 'center',
actionSection: {
paddingHorizontal: 12,
gap: 8,
gap: 12,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
backgroundColor: '#1C1E22',
height: 32,
gap: 8,
},
downloadButton: {
backgroundColor: '#4CAF50',
},
downloadButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
rerunButton: {
backgroundColor: '#1C1E22',
},
tryAgainButton: {
backgroundColor: '#1C1E22',
},
deleteActionButton: {
backgroundColor: '#1C1E22',
width: 48,
alignSelf: 'flex-end',
},
actionButtonText: {
color: '#F5F5F5',
fontSize: 11,
fontSize: 14,
fontWeight: '500',
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#1C1E22',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
},
deleteButtonDisabled: {
buttonDisabled: {
opacity: 0.5,
},
})

View File

@@ -10,7 +10,9 @@ export { useUserLikes } from './use-user-likes'
export { useTemplates, type TemplateDetail } from './use-templates'
export { useTemplateDetail, type TemplateDetail as TemplateDetailType } from './use-template-detail'
export { useTemplateGenerations, type TemplateGeneration } from './use-template-generations'
export { useGenerationDetail } from './use-generation-detail'
export { useDeleteGeneration, useBatchDeleteGenerations } from './use-template-generation-actions'
export { useRerunGeneration } from './use-rerun-generation'
export { useSearchHistory } from './use-search-history'
export { useTags } from './use-tags'
export { useDebounce } from './use-debounce'
@@ -20,3 +22,4 @@ export { useUpdateProfile } from './use-update-profile'
export { useTemplateFilter, type UseTemplateFilterOptions, type UseTemplateFilterReturn } from './use-template-filter'
export { useStickyTabs, type UseStickyTabsReturn } from './use-sticky-tabs'
export { useTabNavigation, type Category, type UseTabNavigationOptions, type UseTabNavigationReturn } from './use-tab-navigation'
export { useDownloadMedia, type MediaType, type DownloadResult } from './use-download-media'

View File

@@ -0,0 +1,338 @@
import { renderHook, act } from '@testing-library/react-native'
import { useDownloadMedia } from './use-download-media'
import * as FileSystem from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library'
jest.mock('expo-file-system', () => ({
documentDirectory: 'file:///mock/document/',
createDownloadResumable: jest.fn(),
deleteAsync: jest.fn(),
}))
jest.mock('expo-media-library', () => ({
requestPermissionsAsync: jest.fn(),
saveToLibraryAsync: jest.fn(),
}))
describe('useDownloadMedia', () => {
beforeEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('initial state', () => {
it('should return initial state', () => {
const { result } = renderHook(() => useDownloadMedia())
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
expect(result.current.progress).toBe(0)
expect(typeof result.current.download).toBe('function')
})
})
describe('permission handling', () => {
it('should return error when permission is denied', async () => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'denied',
})
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: false, error: 'Permission denied' })
expect(result.current.error).toBe('Permission denied')
expect(result.current.loading).toBe(false)
})
it('should proceed when permission is granted', async () => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'granted',
})
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.jpg' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: true })
expect(MediaLibrary.requestPermissionsAsync).toHaveBeenCalled()
})
})
describe('download functionality', () => {
beforeEach(() => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'granted',
})
})
it('should download image successfully', async () => {
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.jpg' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: true })
expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
'https://example.com/image.jpg',
expect.stringMatching(/^file:\/\/\/mock\/document\/download_\d+\.jpg$/),
{},
expect.any(Function)
)
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalled()
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
expect.stringMatching(/^file:\/\/\/mock\/document\/download_\d+\.jpg$/),
{ idempotent: true }
)
})
it('should download video successfully', async () => {
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.mp4' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/video.mp4', 'video')
})
expect(response).toEqual({ success: true })
expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
'https://example.com/video.mp4',
expect.stringMatching(/^file:\/\/\/mock\/document\/download_\d+\.mp4$/),
{},
expect.any(Function)
)
})
it('should handle download failure', async () => {
const mockDownloadAsync = jest.fn().mockRejectedValue(new Error('Network error'))
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: false, error: 'Network error' })
expect(result.current.error).toBe('Network error')
})
it('should handle save to library failure', async () => {
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.jpg' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockRejectedValue(new Error('Save failed'))
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: false, error: 'Save failed' })
expect(result.current.error).toBe('Save failed')
// Should still attempt to clean up temp file
expect(FileSystem.deleteAsync).toHaveBeenCalled()
})
})
describe('loading state', () => {
beforeEach(() => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'granted',
})
})
it('should set loading to true during download', async () => {
let resolveDownload: (value: any) => void
const downloadPromise = new Promise((resolve) => {
resolveDownload = resolve
})
const mockDownloadAsync = jest.fn().mockReturnValue(downloadPromise)
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
act(() => {
result.current.download('https://example.com/image.jpg', 'image')
})
expect(result.current.loading).toBe(true)
await act(async () => {
resolveDownload!({ uri: 'file:///mock/document/download_123.jpg' })
await downloadPromise
})
expect(result.current.loading).toBe(false)
})
it('should set loading to false after error', async () => {
const mockDownloadAsync = jest.fn().mockRejectedValue(new Error('Error'))
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
const { result } = renderHook(() => useDownloadMedia())
await act(async () => {
await result.current.download('https://example.com/image.jpg', 'image')
})
expect(result.current.loading).toBe(false)
})
})
describe('progress tracking', () => {
beforeEach(() => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'granted',
})
})
it('should update progress during download', async () => {
let progressCallback: (progress: { totalBytesWritten: number; totalBytesExpectedToWrite: number }) => void
const mockDownloadAsync = jest.fn().mockImplementation(async () => {
// Simulate progress updates
progressCallback({ totalBytesWritten: 50, totalBytesExpectedToWrite: 100 })
return { uri: 'file:///mock/document/download_123.jpg' }
})
;(FileSystem.createDownloadResumable as jest.Mock).mockImplementation(
(url, fileUri, options, callback) => {
progressCallback = callback
return { downloadAsync: mockDownloadAsync }
}
)
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
await act(async () => {
await result.current.download('https://example.com/image.jpg', 'image')
})
// Progress should be reset after completion
expect(result.current.progress).toBe(0)
})
it('should reset progress on new download', async () => {
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.jpg' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
await act(async () => {
await result.current.download('https://example.com/image1.jpg', 'image')
})
expect(result.current.progress).toBe(0)
await act(async () => {
await result.current.download('https://example.com/image2.jpg', 'image')
})
expect(result.current.progress).toBe(0)
})
})
describe('error handling', () => {
it('should clear error on new download attempt', async () => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock)
.mockResolvedValueOnce({ status: 'denied' })
.mockResolvedValueOnce({ status: 'granted' })
const mockDownloadAsync = jest.fn().mockResolvedValue({ uri: 'file:///mock/document/download_123.jpg' })
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
await act(async () => {
await result.current.download('https://example.com/image.jpg', 'image')
})
expect(result.current.error).toBe('Permission denied')
await act(async () => {
await result.current.download('https://example.com/image.jpg', 'image')
})
expect(result.current.error).toBeNull()
})
it('should handle unknown error types', async () => {
;(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({
status: 'granted',
})
const mockDownloadAsync = jest.fn().mockRejectedValue('Unknown error')
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
const { result } = renderHook(() => useDownloadMedia())
let response
await act(async () => {
response = await result.current.download('https://example.com/image.jpg', 'image')
})
expect(response).toEqual({ success: false, error: 'Download failed' })
expect(result.current.error).toBe('Download failed')
})
})
})

View File

@@ -0,0 +1,83 @@
import { useCallback, useState } from 'react'
import * as FileSystem from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library'
export type MediaType = 'image' | 'video'
export interface DownloadResult {
success: boolean
error?: string
}
export const useDownloadMedia = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const download = useCallback(async (url: string, type: MediaType): Promise<DownloadResult> => {
setLoading(true)
setError(null)
setProgress(0)
let fileUri: string | null = null
try {
// 1. 请求媒体库权限
const { status } = await MediaLibrary.requestPermissionsAsync()
if (status !== 'granted') {
setError('Permission denied')
setLoading(false)
return { success: false, error: 'Permission denied' }
}
// 2. 生成临时文件名
const extension = type === 'video' ? 'mp4' : 'jpg'
const fileName = `download_${Date.now()}.${extension}`
fileUri = FileSystem.documentDirectory + fileName
// 3. 下载文件(带进度)
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{},
(downloadProgress) => {
const progressValue = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite
setProgress(progressValue)
}
)
const downloadResult = await downloadResumable.downloadAsync()
if (!downloadResult?.uri) {
throw new Error('Download failed')
}
// 4. 保存到相册
await MediaLibrary.saveToLibraryAsync(downloadResult.uri)
// 5. 清理临时文件
await FileSystem.deleteAsync(fileUri, { idempotent: true })
setLoading(false)
setProgress(0)
return { success: true }
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Download failed'
setError(errorMessage)
setLoading(false)
setProgress(0)
// 尝试清理临时文件
if (fileUri) {
try {
await FileSystem.deleteAsync(fileUri, { idempotent: true })
} catch {
// 忽略清理错误
}
}
return { success: false, error: errorMessage }
}
}, [])
return { download, loading, error, progress }
}

View File

@@ -0,0 +1,278 @@
import { renderHook, act } from '@testing-library/react-native'
import { useGenerationDetail } from './use-generation-detail'
import { root } from '@repo/core'
import { TemplateGenerationController } from '@repo/sdk'
jest.mock('@repo/core', () => ({
root: {
get: jest.fn(),
},
}))
jest.mock('./use-error', () => ({
handleError: jest.fn(async (cb) => {
try {
const data = await cb()
return { data, error: null }
} catch (e) {
return { data: null, error: e }
}
}),
}))
describe('useGenerationDetail', () => {
beforeEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('initial state', () => {
it('should return initial state with no data', () => {
const { result } = renderHook(() => useGenerationDetail())
expect(result.current.data).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
})
describe('execute function', () => {
it('should load generation detail successfully', async () => {
const mockData = {
id: 'generation-1',
userId: 'user-1',
templateId: 'template-1',
type: 'IMAGE',
resultUrl: ['https://example.com/result.png'],
originalUrl: null,
status: 'COMPLETED',
creditsCost: 10,
data: null,
webpPreviewUrl: 'https://example.com/preview.webp',
webpHighPreviewUrl: 'https://example.com/preview-high.webp',
createdAt: new Date(),
updatedAt: new Date(),
template: {
id: 'template-1',
title: 'Test Template',
titleEn: 'Test Template',
coverImageUrl: 'https://example.com/cover.png',
},
}
const mockController = {
get: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.execute({ id: 'generation-1' })
})
expect(mockController.get).toHaveBeenCalledWith('generation-1')
expect(result.current.data).toEqual(mockData)
expect(result.current.error).toBeNull()
})
it('should handle API errors', async () => {
const mockError = {
status: 404,
statusText: 'Not Found',
message: 'Generation not found',
}
const mockController = {
get: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.execute({ id: 'invalid-id' })
})
expect(result.current.error).toEqual(mockError)
expect(result.current.data).toBeNull()
})
it('should return error in response when API fails', async () => {
const mockError = {
status: 500,
statusText: 'Internal Server Error',
message: 'Server error',
}
const mockController = {
get: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
let response
await act(async () => {
response = await result.current.execute({ id: 'generation-1' })
})
expect(response).toEqual({ data: null, error: mockError })
})
it('should return data in response when API succeeds', async () => {
const mockData = {
id: 'generation-1',
userId: 'user-1',
templateId: 'template-1',
type: 'IMAGE',
resultUrl: ['https://example.com/result.png'],
status: 'COMPLETED',
}
const mockController = {
get: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
let response
await act(async () => {
response = await result.current.execute({ id: 'generation-1' })
})
expect(response).toEqual({ data: mockData, error: null })
})
})
describe('loading state', () => {
it('should set loading to true during fetch', async () => {
let resolveFetch: (value: any) => void
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve
})
const mockController = {
get: jest.fn().mockReturnValue(fetchPromise),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
act(() => {
result.current.execute({ id: 'generation-1' })
})
expect(result.current.loading).toBe(true)
await act(async () => {
resolveFetch!({ id: 'generation-1', status: 'COMPLETED' })
await fetchPromise
})
expect(result.current.loading).toBe(false)
})
it('should set loading to false after error', async () => {
const mockError = { status: 500, statusText: 'Error', message: 'Error' }
const mockController = {
get: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.execute({ id: 'generation-1' })
})
expect(result.current.loading).toBe(false)
})
})
describe('refetch function', () => {
it('should reload generation data', async () => {
const initialData = {
id: 'generation-1',
status: 'PROCESSING',
}
const refreshedData = {
id: 'generation-1',
status: 'COMPLETED',
}
const mockController = {
get: jest.fn()
.mockResolvedValueOnce(initialData)
.mockResolvedValueOnce(refreshedData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.execute({ id: 'generation-1' })
})
expect(result.current.data).toEqual(initialData)
await act(async () => {
await result.current.refetch({ id: 'generation-1' })
})
expect(result.current.data).toEqual(refreshedData)
expect(mockController.get).toHaveBeenCalledTimes(2)
})
it('should call execute with same params', async () => {
const mockData = { id: 'generation-1', status: 'COMPLETED' }
const mockController = {
get: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.refetch({ id: 'generation-1' })
})
expect(mockController.get).toHaveBeenCalledWith('generation-1')
})
})
describe('error handling', () => {
it('should clear error on successful execute', async () => {
const mockError = { status: 500, statusText: 'Error', message: 'Error' }
const mockData = { id: 'generation-1', status: 'COMPLETED' }
const mockController = {
get: jest.fn()
.mockRejectedValueOnce(mockError)
.mockResolvedValueOnce(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useGenerationDetail())
await act(async () => {
await result.current.execute({ id: 'generation-1' })
})
expect(result.current.error).toEqual(mockError)
await act(async () => {
await result.current.execute({ id: 'generation-1' })
})
expect(result.current.error).toBeNull()
})
})
})

View File

@@ -0,0 +1,52 @@
import { root } from '@repo/core'
import { TemplateGenerationController, type TemplateGeneration } from '@repo/sdk'
import { useCallback, useState } from 'react'
import { type ApiError } from '@/lib/types'
import { handleError } from './use-error'
interface UseGenerationDetailParams {
id: string
}
export const useGenerationDetail = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const [data, setData] = useState<TemplateGeneration | null>(null)
const execute = useCallback(async (params: UseGenerationDetailParams) => {
setLoading(true)
setError(null)
const templateGeneration = root.get(TemplateGenerationController)
const { data, error } = await handleError(async () => await templateGeneration.get(params.id))
if (error) {
setError(error)
setLoading(false)
return { data: null, error }
}
setData(data)
setLoading(false)
return { data, error: null }
}, [])
const refetch = useCallback(
(params: UseGenerationDetailParams) => {
return execute(params)
},
[execute],
)
return {
data,
loading,
error,
execute,
refetch,
}
}
export type { TemplateGeneration }

View File

@@ -0,0 +1,237 @@
import { renderHook, act } from '@testing-library/react-native'
import { useRerunGeneration } from './use-rerun-generation'
import { root } from '@repo/core'
import { TemplateController } from '@repo/sdk'
jest.mock('@repo/core', () => ({
root: {
get: jest.fn(),
},
}))
jest.mock('./use-error', () => ({
handleError: jest.fn(async (cb) => {
try {
const data = await cb()
return { data, error: null }
} catch (e) {
return { data: null, error: e }
}
}),
}))
describe('useRerunGeneration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('initial state', () => {
it('should return initial state', () => {
const { result } = renderHook(() => useRerunGeneration())
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
expect(typeof result.current.rerun).toBe('function')
})
})
describe('rerun function', () => {
it('should rerun generation successfully', async () => {
const mockData = { generationId: 'new-gen-456' }
const mockController = {
rerun: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
let response
await act(async () => {
response = await result.current.rerun('old-gen-123')
})
expect(root.get).toHaveBeenCalledWith(TemplateController)
expect(mockController.rerun).toHaveBeenCalledWith({
generationId: 'old-gen-123',
})
expect(response).toEqual({ generationId: 'new-gen-456' })
expect(result.current.error).toBeNull()
})
it('should handle API errors', async () => {
const mockError = {
status: 500,
statusText: 'Internal Server Error',
message: 'Failed to rerun generation',
}
const mockController = {
rerun: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
let response
await act(async () => {
response = await result.current.rerun('gen-123')
})
expect(result.current.error).toEqual(mockError)
expect(response).toEqual({ error: mockError })
})
it('should handle network errors', async () => {
const mockError = {
message: 'Network error',
}
const mockController = {
rerun: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
let response
await act(async () => {
response = await result.current.rerun('gen-123')
})
expect(result.current.error).toEqual(mockError)
expect(response).toEqual({ error: mockError })
})
})
describe('loading state', () => {
it('should set loading to true during execution', async () => {
let resolveFetch: (value: any) => void
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve
})
const mockController = {
rerun: jest.fn().mockReturnValue(fetchPromise),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
act(() => {
result.current.rerun('gen-123')
})
expect(result.current.loading).toBe(true)
await act(async () => {
resolveFetch!({ generationId: 'new-gen-456' })
await fetchPromise
})
expect(result.current.loading).toBe(false)
})
it('should set loading to false after error', async () => {
const mockError = { status: 500, message: 'Error' }
const mockController = {
rerun: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
await act(async () => {
await result.current.rerun('gen-123')
})
expect(result.current.loading).toBe(false)
})
})
describe('error handling', () => {
it('should clear error on successful rerun', async () => {
const mockError = { status: 500, message: 'Error' }
const mockData = { generationId: 'new-gen-456' }
const mockController = {
rerun: jest.fn()
.mockRejectedValueOnce(mockError)
.mockResolvedValueOnce(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
await act(async () => {
await result.current.rerun('gen-123')
})
expect(result.current.error).toEqual(mockError)
await act(async () => {
await result.current.rerun('gen-456')
})
expect(result.current.error).toBeNull()
})
it('should update error on consecutive failures', async () => {
const mockError1 = { status: 500, message: 'First error' }
const mockError2 = { status: 404, message: 'Second error' }
const mockController = {
rerun: jest.fn()
.mockRejectedValueOnce(mockError1)
.mockRejectedValueOnce(mockError2),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
await act(async () => {
await result.current.rerun('gen-123')
})
expect(result.current.error).toEqual(mockError1)
await act(async () => {
await result.current.rerun('gen-456')
})
expect(result.current.error).toEqual(mockError2)
})
})
describe('multiple calls', () => {
it('should handle multiple sequential calls', async () => {
const mockController = {
rerun: jest.fn()
.mockResolvedValueOnce({ generationId: 'new-gen-1' })
.mockResolvedValueOnce({ generationId: 'new-gen-2' }),
}
;(root.get as jest.Mock).mockReturnValue(mockController)
const { result } = renderHook(() => useRerunGeneration())
let response1
await act(async () => {
response1 = await result.current.rerun('gen-1')
})
expect(response1).toEqual({ generationId: 'new-gen-1' })
let response2
await act(async () => {
response2 = await result.current.rerun('gen-2')
})
expect(response2).toEqual({ generationId: 'new-gen-2' })
expect(mockController.rerun).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,36 @@
import { root } from '@repo/core'
import { TemplateController } from '@repo/sdk'
import { useCallback, useState } from 'react'
import { type ApiError } from '@/lib/types'
import { handleError } from './use-error'
export const useRerunGeneration = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const rerun = useCallback(async (generationId: string): Promise<{ generationId?: string; error?: ApiError }> => {
setLoading(true)
setError(null)
const template = root.get(TemplateController)
const { data, error } = await handleError(async () => await template.rerun({
generationId,
}))
setLoading(false)
if (error) {
setError(error)
return { error }
}
return { generationId: data?.generationId }
}, [])
return {
rerun,
loading,
error,
}
}

View File

@@ -159,15 +159,30 @@
"generating": "Generating..."
},
"generationRecord": {
"title": "Generation Record",
"title": "Generation Detail",
"aiVideo": "AI Video",
"originalImage": "Original Image",
"reEdit": "Re-edit",
"reEdit": "Try Again",
"regenerate": "Regenerate",
"download": "Download",
"downloading": "Downloading...",
"downloadSuccess": "Saved to album",
"downloadError": "Download failed",
"permissionDenied": "Album permission required to save",
"rerunSuccess": "Regeneration started",
"rerunError": "Regeneration failed",
"deleteSuccess": "Deleted successfully",
"deleteError": "Delete failed",
"deleteConfirm": "Confirm to delete this record?",
"deleting": "Deleting..."
"deleting": "Deleting...",
"generationResult": "Generation Result",
"status": "Status",
"statusPending": "Generating",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"createdAt": "Created At",
"noResult": "No result yet",
"notFound": "Record not found"
},
"search": {
"history": "Search History",

View File

@@ -159,15 +159,30 @@
"generating": "生成中..."
},
"generationRecord": {
"title": "生成记录",
"title": "生成记录详情",
"aiVideo": "AI 视频",
"originalImage": "原图",
"reEdit": "重新编辑",
"regenerate": "再次生成",
"reEdit": "再来一次",
"regenerate": "重新生成",
"download": "下载",
"downloading": "下载中...",
"downloadSuccess": "已保存到相册",
"downloadError": "下载失败",
"permissionDenied": "需要相册权限才能保存",
"rerunSuccess": "已开始重新生成",
"rerunError": "重新生成失败",
"deleteSuccess": "删除成功",
"deleteError": "删除失败",
"deleteConfirm": "确认删除该记录?",
"deleting": "删除中..."
"deleting": "删除中...",
"generationResult": "生成结果",
"status": "状态",
"statusPending": "生成中",
"statusCompleted": "已完成",
"statusFailed": "失败",
"createdAt": "创建时间",
"noResult": "暂无生成结果",
"notFound": "记录不存在"
},
"search": {
"history": "搜索历史",