This commit is contained in:
imeepos
2026-01-28 16:04:14 +08:00
parent efd4aba8c1
commit ffed84a90c
4 changed files with 339 additions and 60 deletions

View File

@@ -0,0 +1,259 @@
import React from 'react'
import { render, waitFor, fireEvent } from '@testing-library/react-native'
import MembershipScreen from '../membership'
// Mock react-native-safe-area-context FIRST
jest.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: any) => children,
SafeAreaView: ({ children }: any) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}))
// Mock expo-linear-gradient
jest.mock('expo-linear-gradient', () => ({
LinearGradient: 'LinearGradient',
}))
// Mock expo-image
jest.mock('expo-image', () => ({
Image: 'Image',
}))
// Mock expo-status-bar
jest.mock('expo-status-bar', () => ({
StatusBar: 'StatusBar',
}))
// Mock react-native-svg
jest.mock('react-native-svg', () => ({
Svg: 'Svg',
Path: 'Path',
Defs: 'Defs',
LinearGradient: 'LinearGradient',
Stop: 'Stop',
}))
// Mock react-native-reanimated-carousel
jest.mock('react-native-reanimated-carousel', () => {
const React = require('react')
return {
__esModule: true,
default: ({ data, renderItem, onSnapToItem }: any) => {
return React.createElement(
'View',
{ testID: 'carousel' },
data.map((item: any, index: number) =>
React.createElement(
'View',
{ key: index, testID: `carousel-item-${index}` },
renderItem({ item, index })
)
)
)
},
}
})
// Mock icons
jest.mock('@/components/icon', () => ({
CheckIcon: () => 'CheckIcon',
LeftArrowIcon: () => 'LeftArrowIcon',
OmitIcon: () => 'OmitIcon',
UncheckedIcon: () => 'UncheckedIcon',
TermsIcon: () => 'TermsIcon',
PrivacyIcon: () => 'PrivacyIcon',
}))
jest.mock('@/components/icon/checkMark', () => ({
CheckMarkIcon: () => 'CheckMarkIcon',
}))
// Mock components
jest.mock('@/components/drawer/PointsDrawer', () => 'PointsDrawer')
jest.mock('@/components/ui/dropdown', () => {
const React = require('react')
return {
__esModule: true,
default: ({ renderTrigger }: any) => {
return renderTrigger ? renderTrigger(null, false, jest.fn()) : null
},
}
})
jest.mock('@/components/GradientText', () => 'GradientText')
// Mock expo-router
jest.mock('expo-router', () => ({
useRouter: jest.fn(() => ({
push: jest.fn(),
back: jest.fn(),
})),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock hooks - 默认返回空广告数据
const mockLoadActivates = jest.fn()
const mockActivatesData = {
activities: [],
}
jest.mock('@/hooks/use-activates', () => ({
useActivates: jest.fn(() => ({
load: mockLoadActivates,
data: mockActivatesData,
})),
}))
jest.mock('@/hooks/use-membership', () => ({
useMembership: jest.fn(() => ({
creditPlans: [
{
name: 'Plus',
amountInCents: 999,
popular: false,
credits: 100,
currency: 'USD',
featureList: ['feature1', 'feature2'],
},
],
creditBalance: 50,
selectedPlanIndex: 0,
setSelectedPlanIndex: jest.fn(),
isLoadingSubscriptions: false,
isStripePricingLoading: false,
createSubscription: jest.fn(),
upgradeSubscription: jest.fn(),
restoreSubscription: jest.fn(),
rechargeToken: jest.fn(),
activeAuthSubscription: null,
hasActiveSubscription: false,
stripePricingData: null,
})),
}))
describe('MembershipScreen - Advertisement Carousel', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('广告轮播显示', () => {
it('当广告数据为空时,应该隐藏轮播图', () => {
const { queryByTestId } = render(<MembershipScreen />)
// 轮播图不应该显示
expect(queryByTestId('carousel')).toBeNull()
})
it('当广告数据存在时,应该显示轮播图', () => {
// 设置有广告数据的mock
const { useActivates } = require('@/hooks/use-activates')
useActivates.mockReturnValue({
load: mockLoadActivates,
data: {
activities: [
{
id: '1',
title: 'Test Ad 1',
desc: 'Description 1',
coverUrl: 'https://example.com/ad1.jpg',
link: '/ad1',
},
{
id: '2',
title: 'Test Ad 2',
desc: 'Description 2',
coverUrl: 'https://example.com/ad2.jpg',
link: '/ad2',
},
],
},
})
const { getByTestId } = render(<MembershipScreen />)
// 轮播图应该显示
expect(getByTestId('carousel')).toBeTruthy()
})
it('应该在组件挂载时加载广告数据', () => {
render(<MembershipScreen />)
// 应该调用 loadActivates
expect(mockLoadActivates).toHaveBeenCalled()
})
})
describe('广告点击跳转', () => {
it('点击广告时应该跳转到正确的链接', () => {
const mockPush = jest.fn()
const { useRouter } = require('expo-router')
useRouter.mockReturnValue({
push: mockPush,
back: jest.fn(),
})
const { useActivates } = require('@/hooks/use-activates')
useActivates.mockReturnValue({
load: mockLoadActivates,
data: {
activities: [
{
id: '1',
title: 'Test Ad',
desc: 'Description',
coverUrl: 'https://example.com/ad.jpg',
link: '/test-link',
},
],
},
})
const { getByTestId } = render(<MembershipScreen />)
// 点击第一个广告
const carouselItem = getByTestId('carousel-item-0')
fireEvent.press(carouselItem)
// 应该跳转到广告链接
expect(mockPush).toHaveBeenCalledWith('/test-link')
})
})
describe('广告轮播指示点', () => {
it('当有多个广告时,应该显示指示点', () => {
const { useActivates } = require('@/hooks/use-activates')
useActivates.mockReturnValue({
load: mockLoadActivates,
data: {
activities: [
{
id: '1',
title: 'Ad 1',
desc: 'Desc 1',
coverUrl: 'https://example.com/ad1.jpg',
link: '/ad1',
},
{
id: '2',
title: 'Ad 2',
desc: 'Desc 2',
coverUrl: 'https://example.com/ad2.jpg',
link: '/ad2',
},
],
},
})
const { getAllByTestId } = render(<MembershipScreen />)
// 应该有2个指示点
const dots = getAllByTestId(/^dot-/)
expect(dots).toHaveLength(2)
})
})
})

View File

@@ -13,6 +13,7 @@ configureReanimatedLogger({
strict: false,
})
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
import * as Updates from 'expo-updates'
@@ -168,18 +169,20 @@ function Providers({ children }: { children: React.ReactNode }) {
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
{children}
{/* modals */}
<BottomSheetModalProvider>
<KeyboardProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
{children}
{/* modals */}
{/* 挂载全局方法 */}
<ModalPortal ref={(ref) => ((global as any).actionSheet = ref)} />
<ModalPortal ref={(ref) => ((global as any).modal = ref)} />
<ModalPortal ref={(ref) => ((global as any).loading = ref)} />
<ModalPortal ref={(ref) => ((global as any).toast = ref)} />
</ThemeProvider>
</KeyboardProvider>
{/* 挂载全局方法 */}
<ModalPortal ref={(ref) => ((global as any).actionSheet = ref)} />
<ModalPortal ref={(ref) => ((global as any).modal = ref)} />
<ModalPortal ref={(ref) => ((global as any).loading = ref)} />
<ModalPortal ref={(ref) => ((global as any).toast = ref)} />
</ThemeProvider>
</KeyboardProvider>
</BottomSheetModalProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
)

View File

@@ -24,6 +24,7 @@ import PointsDrawer from '@/components/drawer/PointsDrawer'
import Dropdown from '@/components/ui/dropdown'
import GradientText from '@/components/GradientText'
import { useMembership } from '@/hooks/use-membership'
import { useActivates } from '@/hooks/use-activates'
// 使用唯一 id 的 PointsIcon避免与其他页面的图标 id 冲突
const MembershipPointsIcon = () => {
@@ -102,6 +103,9 @@ export default function MembershipScreen() {
stripePricingData,
} = useMembership()
// 使用 useActivates hook 获取广告数据
const { load: loadActivates, data: activatesData } = useActivates()
// 映射 API 数据到 UI 格式
const plans: Plan[] = creditPlans.map((plan, index) => ({
@@ -131,14 +135,15 @@ export default function MembershipScreen() {
}
}
// 轮播图相关
const carouselImages = [
require('@/assets/images/membership.png'),
require('@/assets/images/icon.png'),
require('@/assets/images/generate.png'),
]
// 轮播图相关 - 使用动态广告数据
const activities = activatesData?.activities || []
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// 加载广告数据
useEffect(() => {
loadActivates()
}, [])
// 处理订阅按钮点击
const handleSubscribe = async () => {
if (!agreed || !selectedPlan) return
@@ -243,43 +248,55 @@ export default function MembershipScreen() {
showsVerticalScrollIndicator={false}
>
<View style={styles.imageContainer}>
<Carousel
width={screenWidth}
height={screenWidth / 1.1}
data={carouselImages}
renderItem={({ item }) => (
<Image
source={item}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover"
{/* 只在有广告数据时显示轮播图 */}
{activities.length > 0 && (
<>
<Carousel
testID="carousel"
width={screenWidth}
height={screenWidth / 1.1}
data={activities}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(item.link as any)}
style={{ width: screenWidth, height: screenWidth / 1.1 }}
>
<Image
source={{ uri: item.coverUrl }}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover"
/>
</Pressable>
)}
autoPlay
autoPlayInterval={2000}
loop
onSnapToItem={(index) => setCurrentImageIndex(index)}
enabled={false}
windowSize={1}
mode="parallax"
/>
)}
autoPlay
autoPlayInterval={2000}
loop
onSnapToItem={(index) => setCurrentImageIndex(index)}
enabled={false}
windowSize={1}
mode="parallax"
/>
<View style={styles.dotsContainer}>
{carouselImages.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentImageIndex && styles.dotActive,
]}
<View style={styles.dotsContainer}>
{activities.map((_, index) => (
<View
key={index}
testID={`dot-${index}`}
style={[
styles.dot,
index === currentImageIndex && styles.dotActive,
]}
/>
))}
</View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
))}
</View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
</>
)}
</View>
{/* 订阅计划标题 */}

View File

@@ -14,7 +14,7 @@ import {
import { Image } from 'expo-image'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
import BottomSheet, { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
import { LinearGradient } from 'expo-linear-gradient'
import { type ImagePickerAsset } from 'expo-image-picker'
@@ -37,7 +37,7 @@ export default function EditProfileDrawer({
onSave,
}: EditProfileDrawerProps) {
const { t } = useTranslation()
const bottomSheetRef = useRef<BottomSheet>(null)
const bottomSheetRef = useRef<BottomSheetModal>(null)
const [name, setName] = useState(initialName)
const [avatar, setAvatar] = useState<string | undefined>(initialAvatar)
const [selectedImage, setSelectedImage] = useState<ImagePickerAsset | null>(null)
@@ -45,17 +45,18 @@ export default function EditProfileDrawer({
const { loading, pickImage, updateProfile } = useUpdateProfile()
const snapPoints = useMemo(() => [280], [])
// 增加高度以避免被底部 Tab 栏遮挡,考虑底部安全区域
const snapPoints = useMemo(() => [280 + insets.bottom + 60], [insets.bottom])
useEffect(() => {
if (visible) {
bottomSheetRef.current?.expand()
bottomSheetRef.current?.present()
// 重置为初始值
setName(initialName)
setAvatar(initialAvatar)
setSelectedImage(null)
} else {
bottomSheetRef.current?.close()
bottomSheetRef.current?.dismiss()
}
}, [visible, initialName, initialAvatar])
@@ -118,9 +119,8 @@ export default function EditProfileDrawer({
}
return (
<BottomSheet
<BottomSheetModal
ref={bottomSheetRef}
index={visible ? 0 : -1}
snapPoints={snapPoints}
onChange={handleSheetChanges}
enablePanDownToClose={!loading}
@@ -205,7 +205,7 @@ export default function EditProfileDrawer({
</Pressable>
</View>
</BottomSheetScrollView>
</BottomSheet>
</BottomSheetModal>
)
}