fix: type error

This commit is contained in:
imeepos
2026-01-29 18:46:34 +08:00
parent 5a4d1ac1bd
commit 8f33ade8df
13 changed files with 102 additions and 163 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react-native'
import FavoritesScreen from '../favorites'
import FavoritesScreen from '../../favorites'
// Mock react-native-safe-area-context
jest.mock('react-native-safe-area-context', () => ({

View File

@@ -22,7 +22,7 @@ export default function TabLayout() {
const { data: announcementData, refetch: refetchAnnouncements } = useAnnouncementUnreadCount()
// 计算总未读数
const totalUnreadCount = (messageData?.count || 0) + (announcementData?.count || 0)
const totalUnreadCount = (messageData?.total || 0) + (announcementData?.count || 0)
// 组件挂载时获取未读数
useEffect(() => {

View File

@@ -178,13 +178,27 @@ export default function MessageScreen() {
}
}, [hasMore, loadingMore, loading, loadMore])
// Convert Message to MessageCardMessage format
const convertToMessageCardFormat = (message: Message): MessageCardMessage => ({
id: message.id,
type: message.type,
title: message.title,
content: message.content,
data: message.data,
link: message.link,
priority: message.priority,
isRead: message.isRead,
readAt: message.readAt ? (typeof message.readAt === 'string' ? message.readAt : message.readAt.toISOString()) : undefined,
createdAt: message.createdAt,
})
// Render message item
const renderMessageItem = useCallback(({ item }: { item: Message }) => (
<MessageCard
message={item}
onPress={handleMessagePress}
message={convertToMessageCardFormat(item)}
onPress={() => handleMessagePress(item)}
/>
), [handleDeleteMessage, handleMessagePress])
), [handleMessagePress])
// Render list footer
const renderFooter = useCallback(() => {

View File

@@ -253,7 +253,7 @@ export default function VideoScreen() {
}, [router])
const handleSwipeRight = useCallback(() => {
router.push('/(tabs)/')
router.push('/(tabs)')
}, [router])
const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({

View File

@@ -82,7 +82,7 @@ describe('TemplateCard Component', () => {
it('should display Chinese title when language is zh-CN', () => {
const title = '测试模板'
const titleEn = 'Test Template'
const language = 'zh-CN'
const language: string = 'zh-CN'
// 根据语言选择显示的标题
const displayTitle = language === 'en-US' ? titleEn : title

View File

@@ -1,14 +1,7 @@
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(),
@@ -56,13 +49,7 @@ describe('useDownloadMedia', () => {
;(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())
@@ -84,12 +71,7 @@ describe('useDownloadMedia', () => {
})
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())
@@ -99,26 +81,11 @@ describe('useDownloadMedia', () => {
})
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 }
)
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith('https://example.com/image.jpg')
})
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())
@@ -128,19 +95,11 @@ describe('useDownloadMedia', () => {
})
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)
)
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith('https://example.com/video.mp4')
})
it('should handle download failure', async () => {
const mockDownloadAsync = jest.fn().mockRejectedValue(new Error('Network error'))
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useDownloadMedia())
@@ -154,12 +113,7 @@ describe('useDownloadMedia', () => {
})
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())
@@ -170,8 +124,6 @@ describe('useDownloadMedia', () => {
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()
})
})
@@ -183,17 +135,12 @@ describe('useDownloadMedia', () => {
})
it('should set loading to true during download', async () => {
let resolveDownload: (value: any) => void
const downloadPromise = new Promise((resolve) => {
resolveDownload = resolve
let resolveSave: (value: any) => void
const savePromise = new Promise((resolve) => {
resolveSave = 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)
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockReturnValue(savePromise)
const { result } = renderHook(() => useDownloadMedia())
@@ -204,18 +151,15 @@ describe('useDownloadMedia', () => {
expect(result.current.loading).toBe(true)
await act(async () => {
resolveDownload!({ uri: 'file:///mock/document/download_123.jpg' })
await downloadPromise
resolveSave!(undefined)
await savePromise
})
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,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockRejectedValue(new Error('Error'))
const { result } = renderHook(() => useDownloadMedia())
@@ -234,23 +178,8 @@ describe('useDownloadMedia', () => {
})
})
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 }
}
)
it('should reset progress after completion', async () => {
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined)
;(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined)
const { result } = renderHook(() => useDownloadMedia())
@@ -263,12 +192,7 @@ describe('useDownloadMedia', () => {
})
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())
@@ -292,12 +216,7 @@ describe('useDownloadMedia', () => {
.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())
@@ -319,10 +238,7 @@ describe('useDownloadMedia', () => {
status: 'granted',
})
const mockDownloadAsync = jest.fn().mockRejectedValue('Unknown error')
;(FileSystem.createDownloadResumable as jest.Mock).mockReturnValue({
downloadAsync: mockDownloadAsync,
})
;(MediaLibrary.saveToLibraryAsync as jest.Mock).mockRejectedValue('Unknown error')
const { result } = renderHook(() => useDownloadMedia())

View File

@@ -1,5 +1,4 @@
import { useCallback, useState } from 'react'
import * as FileSystem from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library'
export type MediaType = 'image' | 'video'
@@ -9,6 +8,26 @@ export interface DownloadResult {
error?: string
}
// 使用 fetch 下载文件并转换为 base64
const downloadToCache = async (url: string, fileName: string): Promise<string> => {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Download failed')
}
const blob = await response.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
const base64data = reader.result as string
resolve(base64data)
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
export const useDownloadMedia = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -19,8 +38,6 @@ export const useDownloadMedia = () => {
setError(null)
setProgress(0)
let fileUri: string | null = null
try {
// 1. 请求媒体库权限
const { status } = await MediaLibrary.requestPermissionsAsync()
@@ -30,33 +47,13 @@ export const useDownloadMedia = () => {
return { success: false, error: 'Permission denied' }
}
// 2. 生成临时文件名
const extension = type === 'video' ? 'mp4' : 'jpg'
const fileName = `download_${Date.now()}.${extension}`
fileUri = FileSystem.documentDirectory + fileName
setProgress(0.3)
// 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 })
// 2. 使用 MediaLibrary.saveToLibraryAsync 直接从 URL 保存
// 这个方法可以直接接受远程 URL
await MediaLibrary.saveToLibraryAsync(url)
setProgress(1)
setLoading(false)
setProgress(0)
return { success: true }
@@ -66,15 +63,6 @@ export const useDownloadMedia = () => {
setLoading(false)
setProgress(0)
// 尝试清理临时文件
if (fileUri) {
try {
await FileSystem.deleteAsync(fileUri, { idempotent: true })
} catch {
// 忽略清理错误
}
}
return { success: false, error: errorMessage }
}
}, [])

View File

@@ -267,7 +267,7 @@ describe('useTabNavigation - core logic', () => {
describe('multi-language support', () => {
it('should generate tabs with Chinese names when language is zh-CN', () => {
// 模拟中文环境
const language = 'zh-CN'
const language: string = 'zh-CN'
const tabs = mockCategoriesWithI18n.map(cat =>
language === 'en-US' ? cat.nameEn : cat.name
)

View File

@@ -3,6 +3,7 @@ import { useTemplateFavorite } from './use-template-favorite'
import { root } from '@repo/core'
import { TemplateSocialController } from '@repo/sdk'
import { handleError } from './use-error'
import { templateSocialStore } from '@/stores/templateSocialStore'
jest.mock('@repo/core', () => ({
root: {
@@ -10,6 +11,15 @@ jest.mock('@repo/core', () => ({
},
}))
jest.mock('@/stores/templateSocialStore', () => ({
templateSocialStore: {
setFavorited: jest.fn(),
setFavoriteCount: jest.fn(),
getFavoriteCount: jest.fn(),
isFavorited: jest.fn().mockReturnValue(false),
},
}))
jest.mock('./use-error', () => ({
handleError: jest.fn(async (cb) => {
try {
@@ -36,7 +46,6 @@ describe('useTemplateFavorite', () => {
it('should return initial state with no data', () => {
const { result } = renderHook(() => useTemplateFavorite())
expect(result.current.favorited).toBe(false)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -44,7 +53,6 @@ describe('useTemplateFavorite', () => {
it('should accept templateId parameter', () => {
const { result } = renderHook(() => useTemplateFavorite(mockTemplateId))
expect(result.current.favorited).toBe(false)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -71,7 +79,7 @@ describe('useTemplateFavorite', () => {
expect(mockController.favorite).toHaveBeenCalledWith({
templateId: mockTemplateId,
})
expect(result.current.favorited).toBe(true)
expect(templateSocialStore.setFavorited).toHaveBeenCalledWith(mockTemplateId, true)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -103,7 +111,8 @@ describe('useTemplateFavorite', () => {
})
expect(result.current.error).toEqual(mockError)
expect(result.current.favorited).toBe(false)
// 错误时应该回滚乐观更新setFavorited 会被调用两次:先 true后 false
expect(templateSocialStore.setFavorited).toHaveBeenLastCalledWith(mockTemplateId, false)
expect(result.current.loading).toBe(false)
})
@@ -162,7 +171,7 @@ describe('useTemplateFavorite', () => {
expect(mockController.unfavorite).toHaveBeenCalledWith({
templateId: mockTemplateId,
})
expect(result.current.favorited).toBe(false)
expect(templateSocialStore.setFavorited).toHaveBeenCalledWith(mockTemplateId, false)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -253,7 +262,7 @@ describe('useTemplateFavorite', () => {
expect(mockController.checkFavorited).toHaveBeenCalledWith({
templateId: mockTemplateId,
})
expect(result.current.favorited).toBe(true)
expect(templateSocialStore.setFavorited).toHaveBeenCalledWith(mockTemplateId, true)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -306,7 +315,7 @@ describe('useTemplateFavorite', () => {
await result.current.checkFavorited()
})
expect(result.current.favorited).toBe(false)
expect(templateSocialStore.setFavorited).toHaveBeenCalledWith(mockTemplateId, false)
})
})

View File

@@ -3,6 +3,7 @@ import { useTemplateLike } from './use-template-like'
import { root } from '@repo/core'
import { TemplateSocialController } from '@repo/sdk'
import { handleError } from './use-error'
import { templateSocialStore } from '@/stores/templateSocialStore'
jest.mock('@repo/core', () => ({
root: {
@@ -10,6 +11,15 @@ jest.mock('@repo/core', () => ({
},
}))
jest.mock('@/stores/templateSocialStore', () => ({
templateSocialStore: {
getLikeCount: jest.fn().mockReturnValue(0),
setLiked: jest.fn(),
setLikeCount: jest.fn(),
isLiked: jest.fn().mockReturnValue(false),
},
}))
jest.mock('./use-error', () => ({
handleError: jest.fn(async (cb) => {
try {
@@ -34,7 +44,6 @@ describe('useTemplateLike', () => {
it('should return initial state with templateId', () => {
const { result } = renderHook(() => useTemplateLike('template-1'))
expect(result.current.liked).toBe(false)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -42,7 +51,6 @@ describe('useTemplateLike', () => {
it('should return initial state without templateId', () => {
const { result } = renderHook(() => useTemplateLike())
expect(result.current.liked).toBe(false)
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
@@ -63,7 +71,7 @@ describe('useTemplateLike', () => {
})
expect(mockController.like).toHaveBeenCalledWith({ templateId: 'template-1' })
expect(result.current.liked).toBe(true)
expect(templateSocialStore.setLiked).toHaveBeenCalledWith('template-1', true)
expect(result.current.error).toBeNull()
})
@@ -111,18 +119,13 @@ describe('useTemplateLike', () => {
const { result } = renderHook(() => useTemplateLike('template-1'))
// 先设置为已点赞
act(() => {
result.current.like = jest.fn().mockResolvedValue({})
})
let response
await act(async () => {
response = await result.current.unlike()
})
expect(mockController.unlike).toHaveBeenCalledWith({ templateId: 'template-1' })
expect(result.current.liked).toBe(false)
expect(templateSocialStore.setLiked).toHaveBeenCalledWith('template-1', false)
expect(result.current.error).toBeNull()
})
@@ -177,7 +180,7 @@ describe('useTemplateLike', () => {
})
expect(mockController.checkLiked).toHaveBeenCalledWith({ templateId: 'template-1' })
expect(result.current.liked).toBe(true)
expect(templateSocialStore.setLiked).toHaveBeenCalledWith('template-1', true)
expect(result.current.error).toBeNull()
})
@@ -194,7 +197,7 @@ describe('useTemplateLike', () => {
await result.current.checkLiked()
})
expect(result.current.liked).toBe(false)
expect(templateSocialStore.setLiked).toHaveBeenCalledWith('template-1', false)
})
it('should handle API errors when checking liked status', async () => {

View File

@@ -4,8 +4,16 @@
* 这个示例展示了如何使用 useUserFavorites Hook 来获取用户的收藏列表
*/
import { useEffect, useState } from 'react'
import { FlatList, View, Text } from 'react-native'
import { useUserFavorites } from '@/hooks'
// 占位组件类型声明(实际项目中应从组件库导入)
declare const LoadingSpinner: React.FC
declare const LoadingMoreSpinner: React.FC
declare const ErrorMessage: React.FC<{ message: string }>
declare const FavoriteItem: React.FC<{ favorite: unknown }>
// 示例1基本用法
function MyFavoritesList() {
const {
@@ -72,7 +80,7 @@ function SearchableFavorites() {
// 示例4下拉刷新
function RefreshableFavorites() {
const { favorites, loading, refreshing, refetch } = useUserFavorites()
const { favorites, loading, refetch } = useUserFavorites()
const handleRefresh = () => {
refetch()

View File

@@ -3,7 +3,6 @@ import {
type GetUserFavoritesInput,
type GetUserFavoritesResponse,
TemplateSocialController,
type UserFavoriteItem,
} from '@repo/sdk'
import { useCallback, useRef, useState } from 'react'
@@ -11,8 +10,10 @@ import { type ApiError } from '@/lib/types'
import { handleError } from './use-error'
/** 从 GetUserFavoritesResponse 中提取单个收藏项的类型 */
type UserFavoriteItem = GetUserFavoritesResponse['favorites'][number]
type GetUserFavoritesParams = Partial<Omit<GetUserFavoritesInput, 'page' | 'limit'>>
type GetUserFavoritesParams = Partial<GetUserFavoritesInput>
const DEFAULT_PARAMS = {
limit: 20,

View File

@@ -10,7 +10,7 @@ import { type ApiError } from '@/lib/types'
import { handleError } from './use-error'
type GetUserLikesParams = Partial<Omit<GetUserLikesInput, 'page'>>
type GetUserLikesParams = Partial<GetUserLikesInput>
const DEFAULT_PARAMS = {
limit: 20,