From ffed84a90ca2650363c8729b83aa62e35c433345 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 28 Jan 2026 16:04:14 +0800 Subject: [PATCH] fix: bug --- app/__tests__/membership.test.tsx | 259 ++++++++++++++++++++++++ app/_layout.tsx | 25 ++- app/membership.tsx | 99 +++++---- components/drawer/EditProfileDrawer.tsx | 16 +- 4 files changed, 339 insertions(+), 60 deletions(-) create mode 100644 app/__tests__/membership.test.tsx diff --git a/app/__tests__/membership.test.tsx b/app/__tests__/membership.test.tsx new file mode 100644 index 0000000..f3ef3aa --- /dev/null +++ b/app/__tests__/membership.test.tsx @@ -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() + + // 轮播图不应该显示 + 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() + + // 轮播图应该显示 + expect(getByTestId('carousel')).toBeTruthy() + }) + + it('应该在组件挂载时加载广告数据', () => { + render() + + // 应该调用 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() + + // 点击第一个广告 + 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() + + // 应该有2个指示点 + const dots = getAllByTestId(/^dot-/) + expect(dots).toHaveLength(2) + }) + }) +}) diff --git a/app/_layout.tsx b/app/_layout.tsx index 08e49d6..067d781 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 ( - - - {children} - {/* modals */} + + + + {children} + {/* modals */} - {/* 挂载全局方法 */} - ((global as any).actionSheet = ref)} /> - ((global as any).modal = ref)} /> - ((global as any).loading = ref)} /> - ((global as any).toast = ref)} /> - - + {/* 挂载全局方法 */} + ((global as any).actionSheet = ref)} /> + ((global as any).modal = ref)} /> + ((global as any).loading = ref)} /> + ((global as any).toast = ref)} /> + + + ) diff --git a/app/membership.tsx b/app/membership.tsx index 34da696..48d6c87 100644 --- a/app/membership.tsx +++ b/app/membership.tsx @@ -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} > - ( - 0 && ( + <> + ( + router.push(item.link as any)} + style={{ width: screenWidth, height: screenWidth / 1.1 }} + > + + + )} + 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" - /> - - {carouselImages.map((_, index) => ( - + {activities.map((_, index) => ( + + ))} + + - ))} - - + + )} {/* 订阅计划标题 */} diff --git a/components/drawer/EditProfileDrawer.tsx b/components/drawer/EditProfileDrawer.tsx index 04fb647..00accc0 100644 --- a/components/drawer/EditProfileDrawer.tsx +++ b/components/drawer/EditProfileDrawer.tsx @@ -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(null) + const bottomSheetRef = useRef(null) const [name, setName] = useState(initialName) const [avatar, setAvatar] = useState(initialAvatar) const [selectedImage, setSelectedImage] = useState(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 ( - - + ) }