fix: bug
This commit is contained in:
@@ -5,6 +5,13 @@ import { Stack, usePathname, useRouter } from 'expo-router'
|
|||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import 'react-native-reanimated'
|
import 'react-native-reanimated'
|
||||||
|
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'
|
||||||
|
|
||||||
|
// 禁用 Reanimated strict 模式警告(第三方库导致的警告)
|
||||||
|
configureReanimatedLogger({
|
||||||
|
level: ReanimatedLogLevel.warn,
|
||||||
|
strict: false,
|
||||||
|
})
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
|
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
|
||||||
import * as Updates from 'expo-updates'
|
import * as Updates from 'expo-updates'
|
||||||
|
|||||||
136
app/membership.test.tsx
Normal file
136
app/membership.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react-native'
|
||||||
|
import { View } from 'react-native'
|
||||||
|
import MembershipScreen from './membership'
|
||||||
|
import { useMembership } from '@/hooks/use-membership'
|
||||||
|
|
||||||
|
jest.mock('expo-router', () => ({
|
||||||
|
useRouter: jest.fn(() => ({
|
||||||
|
back: jest.fn(),
|
||||||
|
push: jest.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('react-i18next', () => ({
|
||||||
|
useTranslation: jest.fn(() => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('expo-status-bar', () => ({
|
||||||
|
StatusBar: 'StatusBar',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('react-native-safe-area-context', () => ({
|
||||||
|
SafeAreaView: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('expo-linear-gradient', () => ({
|
||||||
|
LinearGradient: 'LinearGradient',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('expo-image', () => ({
|
||||||
|
Image: 'Image',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('react-native-reanimated-carousel', () => 'Carousel')
|
||||||
|
|
||||||
|
jest.mock('react-native-svg', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: 'Svg',
|
||||||
|
Svg: 'Svg',
|
||||||
|
Path: 'Path',
|
||||||
|
Defs: 'Defs',
|
||||||
|
LinearGradient: 'LinearGradient',
|
||||||
|
Stop: 'Stop',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/components/icon', () => ({
|
||||||
|
LeftArrowIcon: 'LeftArrowIcon',
|
||||||
|
OmitIcon: 'OmitIcon',
|
||||||
|
CheckIcon: 'CheckIcon',
|
||||||
|
UncheckedIcon: 'UncheckedIcon',
|
||||||
|
TermsIcon: 'TermsIcon',
|
||||||
|
PrivacyIcon: 'PrivacyIcon',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/components/icon/checkMark', () => ({
|
||||||
|
CheckMarkIcon: 'CheckMarkIcon',
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/components/drawer/PointsDrawer', () => 'PointsDrawer')
|
||||||
|
jest.mock('@/components/ui/dropdown', () => 'Dropdown')
|
||||||
|
jest.mock('@/components/GradientText', () => 'GradientText')
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-membership')
|
||||||
|
|
||||||
|
describe('MembershipScreen - 订阅按钮Loading状态', () => {
|
||||||
|
const mockUseMembership = useMembership as jest.MockedFunction<typeof useMembership>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
mockUseMembership.mockReturnValue({
|
||||||
|
creditPlans: [
|
||||||
|
{ amountInCents: 1000, credits: 750, popular: false },
|
||||||
|
{ amountInCents: 2000, credits: 1500, popular: true },
|
||||||
|
],
|
||||||
|
creditBalance: 100,
|
||||||
|
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: {
|
||||||
|
pricing_table_items: [
|
||||||
|
{ price_id: 'price-1', product_id: 'prod-1' },
|
||||||
|
{ price_id: 'price-2', product_id: 'prod-2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该在点击订阅按钮后显示loading状态', async () => {
|
||||||
|
const createSubscription = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||||
|
mockUseMembership.mockReturnValue({
|
||||||
|
...mockUseMembership(),
|
||||||
|
createSubscription,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const { getByText, getByTestId } = render(<MembershipScreen />)
|
||||||
|
|
||||||
|
const checkbox = getByText('membership.agreementText').parent?.parent
|
||||||
|
if (checkbox) fireEvent.press(checkbox)
|
||||||
|
|
||||||
|
const subscribeButton = getByText('membership.subscribeNow')
|
||||||
|
fireEvent.press(subscribeButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createSubscription).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该在订阅过程中禁用按钮', async () => {
|
||||||
|
mockUseMembership.mockReturnValue({
|
||||||
|
...mockUseMembership(),
|
||||||
|
isLoadingSubscriptions: true,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const { getByText } = render(<MembershipScreen />)
|
||||||
|
|
||||||
|
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||||
|
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该在未同意协议时禁用按钮', () => {
|
||||||
|
const { getByText } = render(<MembershipScreen />)
|
||||||
|
|
||||||
|
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||||
|
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,6 +68,7 @@ export default function MembershipScreen() {
|
|||||||
const { width: screenWidth } = useWindowDimensions()
|
const { width: screenWidth } = useWindowDimensions()
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||||
|
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -138,25 +139,30 @@ export default function MembershipScreen() {
|
|||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
|
|
||||||
// 处理订阅按钮点击
|
// 处理订阅按钮点击
|
||||||
const handleSubscribe = () => {
|
const handleSubscribe = async () => {
|
||||||
if (!agreed || !selectedPlan) return
|
if (!agreed || !selectedPlan) return
|
||||||
|
|
||||||
const planData = creditPlans[selectedPlanIndex]
|
const planData = creditPlans[selectedPlanIndex]
|
||||||
if (!planData) return
|
if (!planData) return
|
||||||
|
|
||||||
|
setIsSubscribing(true)
|
||||||
|
try {
|
||||||
if (hasActiveSubscription) {
|
if (hasActiveSubscription) {
|
||||||
upgradeSubscription({ credits: planData.credits })
|
await upgradeSubscription({ credits: planData.credits })
|
||||||
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
||||||
restoreSubscription()
|
await restoreSubscription()
|
||||||
} else {
|
} else {
|
||||||
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
||||||
if (pricingItem) {
|
if (pricingItem) {
|
||||||
createSubscription({
|
await createSubscription({
|
||||||
priceId: pricingItem.price_id,
|
priceId: pricingItem.price_id,
|
||||||
productId: pricingItem.product_id,
|
productId: pricingItem.product_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubscribing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前选中计划的信息
|
// 获取当前选中计划的信息
|
||||||
@@ -393,7 +399,7 @@ export default function MembershipScreen() {
|
|||||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||||
{/* 立即开通按钮 */}
|
{/* 立即开通按钮 */}
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
|
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||||
style={styles.subscribeButtonPressable}
|
style={styles.subscribeButtonPressable}
|
||||||
onPress={handleSubscribe}
|
onPress={handleSubscribe}
|
||||||
>
|
>
|
||||||
@@ -410,10 +416,10 @@ export default function MembershipScreen() {
|
|||||||
end={{ x: 0, y: 0 }}
|
end={{ x: 0, y: 0 }}
|
||||||
style={[
|
style={[
|
||||||
styles.subscribeButton,
|
styles.subscribeButton,
|
||||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled,
|
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isLoadingSubscriptions || isStripePricingLoading ? (
|
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||||
<ActivityIndicator color="#F5F5F5" />
|
<ActivityIndicator color="#F5F5F5" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
|
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import Text from './ui/Text'
|
import Text from './ui/Text'
|
||||||
@@ -177,14 +178,21 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
|||||||
// 直接从相册选择图片并上传
|
// 直接从相册选择图片并上传
|
||||||
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
|
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
|
||||||
try {
|
try {
|
||||||
const [imageUri] = await imgPicker({ maxImages: 1 })
|
const [asset] = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
|
||||||
console.log('[DynamicForm] 选择图片成功:', imageUri)
|
if (!asset) {
|
||||||
|
console.warn('No asset selected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (asset?.uri) {
|
||||||
|
Toast.showLoading({ title: '文件传输中...', duration: 0 })
|
||||||
|
console.log('[DynamicForm] 选择图片成功:', asset)
|
||||||
// 上传图片
|
// 上传图片
|
||||||
const url = await uploadFile({ uri: imageUri })
|
const url = await uploadFile({ uri: asset.uri })
|
||||||
console.log('[DynamicForm] 上传成功,URL:', url)
|
console.log('[DynamicForm] 上传成功,URL:', url)
|
||||||
updateFormData(nodeId, url)
|
updateFormData(nodeId, url)
|
||||||
setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri }))
|
setPreviewImages((prev) => ({ ...prev, [nodeId]: url }))
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 用户取消选择不显示错误
|
// 用户取消选择不显示错误
|
||||||
if (error?.message === '未选择任何图片') {
|
if (error?.message === '未选择任何图片') {
|
||||||
@@ -192,6 +200,8 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
|||||||
}
|
}
|
||||||
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
|
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
|
||||||
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
|
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
|
||||||
|
} finally {
|
||||||
|
Toast.hideLoading()
|
||||||
}
|
}
|
||||||
}, [t, updateFormData])
|
}, [t, updateFormData])
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TextInput } from "react-native";
|
import { ActivityIndicator, TextInput, Pressable } from "react-native";
|
||||||
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
||||||
import type { ApiError } from "../../lib/types";
|
import type { ApiError } from "../../lib/types";
|
||||||
import { Block } from "../ui";
|
import { Block } from "../ui";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import Text from "../ui/Text";
|
import Text from "../ui/Text";
|
||||||
|
import { storage } from "../../lib/storage";
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
|
|
||||||
@@ -26,9 +27,23 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
|
|
||||||
const isLogin = currentMode === "login";
|
const isLogin = currentMode === "login";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSavedCredentials = async () => {
|
||||||
|
const saved = await storage.getItem("saved_credentials");
|
||||||
|
if (saved) {
|
||||||
|
const { username: savedUsername, password: savedPassword } = JSON.parse(saved);
|
||||||
|
setUsername(savedUsername);
|
||||||
|
setPassword(savedPassword);
|
||||||
|
setRememberPassword(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSavedCredentials();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 根据错误代码获取翻译后的错误信息
|
// 根据错误代码获取翻译后的错误信息
|
||||||
const getErrorMessage = (error: ApiError): string => {
|
const getErrorMessage = (error: ApiError): string => {
|
||||||
if (error.code) {
|
if (error.code) {
|
||||||
@@ -77,7 +92,13 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功
|
// 登录成功后保存凭证
|
||||||
|
if (rememberPassword) {
|
||||||
|
await storage.setItem("saved_credentials", JSON.stringify({ username, password }));
|
||||||
|
} else {
|
||||||
|
await storage.removeItem("saved_credentials");
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
const result = await signUp.email({ email, password, name: username }, {
|
const result = await signUp.email({ email, password, name: username }, {
|
||||||
@@ -157,6 +178,21 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
secureTextEntry
|
secureTextEntry
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isLogin && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setRememberPassword(!rememberPassword)}
|
||||||
|
className="flex-row items-center mb-4"
|
||||||
|
>
|
||||||
|
<Block
|
||||||
|
className="w-5 h-5 rounded border-2 border-white/30 mr-2 items-center justify-center"
|
||||||
|
style={{ backgroundColor: rememberPassword ? '#ffffff' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{rememberPassword && <Text style={{ color: '#000', fontSize: 12 }}>✓</Text>}
|
||||||
|
</Block>
|
||||||
|
<Text className="text-white/70 text-sm">{t("authForm.rememberPassword")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Text, View } from 'react-native'
|
||||||
|
import { render } from '@testing-library/react-native'
|
||||||
|
|
||||||
|
// 最简单的测试组件
|
||||||
|
const SimpleComponent = () => (
|
||||||
|
<View>
|
||||||
|
<Text>Hello Test</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('Simple Test', () => {
|
||||||
|
test('renders simple component', () => {
|
||||||
|
const { getByText } = render(<SimpleComponent />)
|
||||||
|
expect(getByText('Hello Test')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react-native'
|
||||||
|
import { AuthForm } from '../AuthForm'
|
||||||
|
import { storage } from '@/lib/storage'
|
||||||
|
|
||||||
|
// Mock entire UI module to avoid complex dependencies
|
||||||
|
jest.mock('@/components/ui', () => {
|
||||||
|
const mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
|
||||||
|
const Block = ({ children, onClick, ...props }: any) => {
|
||||||
|
if (onClick) {
|
||||||
|
return mockReact.createElement(mockRN.Pressable, { onPress: onClick, ...props }, children)
|
||||||
|
}
|
||||||
|
return mockReact.createElement(mockRN.View, props, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Text = ({ children, onClick, ...props }: any) => {
|
||||||
|
if (onClick) {
|
||||||
|
return mockReact.createElement(mockRN.Pressable, { onPress: onClick },
|
||||||
|
mockReact.createElement(mockRN.Text, props, children)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return mockReact.createElement(mockRN.Text, props, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Block,
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock button component separately since it's imported from a different path
|
||||||
|
jest.mock('../../ui/button', () => {
|
||||||
|
const mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
|
||||||
|
const Button = ({ children, onPress, disabled, ...props }: any) => (
|
||||||
|
mockReact.createElement(mockRN.Pressable, { onPress, disabled, ...props }, children)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
Button,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock ModalPortal to avoid react-native-css-interop issues
|
||||||
|
jest.mock('@/components/ui/ModalPortal', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ children }: any) => children,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock storage
|
||||||
|
jest.mock('@/lib/storage', () => ({
|
||||||
|
storage: {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock auth client
|
||||||
|
jest.mock('@/lib/auth', () => ({
|
||||||
|
authClient: {
|
||||||
|
signIn: {
|
||||||
|
username: jest.fn(),
|
||||||
|
},
|
||||||
|
signUp: {
|
||||||
|
email: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setAuthToken: jest.fn(),
|
||||||
|
useSession: jest.fn(() => ({ data: null, isPending: false })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
jest.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AuthForm - Remember Password', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders remember password checkbox', () => {
|
||||||
|
const { getByText } = render(<AuthForm />)
|
||||||
|
expect(getByText('authForm.rememberPassword')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
113
jest.setup.js
113
jest.setup.js
@@ -1,3 +1,8 @@
|
|||||||
|
// Mock react-native-css-interop FIRST to avoid appearance listener issues
|
||||||
|
jest.mock('react-native-css-interop', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock react-native BEFORE any other imports to avoid flow type issues
|
// Mock react-native BEFORE any other imports to avoid flow type issues
|
||||||
jest.mock('react-native', () => {
|
jest.mock('react-native', () => {
|
||||||
const React = require('react')
|
const React = require('react')
|
||||||
@@ -11,7 +16,11 @@ jest.mock('react-native', () => {
|
|||||||
Pressable: 'Pressable',
|
Pressable: 'Pressable',
|
||||||
TouchableOpacity: 'TouchableOpacity',
|
TouchableOpacity: 'TouchableOpacity',
|
||||||
TextInput: 'TextInput',
|
TextInput: 'TextInput',
|
||||||
Platform: { OS: 'web' },
|
ActivityIndicator: 'ActivityIndicator',
|
||||||
|
Platform: {
|
||||||
|
OS: 'web',
|
||||||
|
select: (obj) => obj.web || obj.default,
|
||||||
|
},
|
||||||
Animated: {
|
Animated: {
|
||||||
View: 'Animated.View',
|
View: 'Animated.View',
|
||||||
Text: 'Animated.Text',
|
Text: 'Animated.Text',
|
||||||
@@ -49,11 +58,27 @@ jest.mock('react-native', () => {
|
|||||||
Linking: {
|
Linking: {
|
||||||
openURL: jest.fn(),
|
openURL: jest.fn(),
|
||||||
},
|
},
|
||||||
|
Appearance: {
|
||||||
|
getColorScheme: jest.fn(() => 'light'),
|
||||||
|
addChangeListener: jest.fn(),
|
||||||
|
removeChangeListener: jest.fn(),
|
||||||
|
},
|
||||||
|
BackHandler: {
|
||||||
|
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
require('@testing-library/jest-native/extend-expect')
|
require('@testing-library/jest-native/extend-expect')
|
||||||
|
|
||||||
|
// Mock global Appearance for react-native-css-interop
|
||||||
|
global.Appearance = {
|
||||||
|
getColorScheme: jest.fn(() => 'light'),
|
||||||
|
addChangeListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||||
|
removeChangeListener: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize globals for react-native-reanimated
|
// Initialize globals for react-native-reanimated
|
||||||
global._tagToJSPropNamesMapping = {}
|
global._tagToJSPropNamesMapping = {}
|
||||||
global._WORKLET = false
|
global._WORKLET = false
|
||||||
@@ -95,6 +120,18 @@ jest.mock('expo-linear-gradient', () => ({
|
|||||||
LinearGradient: 'LinearGradient',
|
LinearGradient: 'LinearGradient',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock expo-blur
|
||||||
|
jest.mock('expo-blur', () => ({
|
||||||
|
BlurView: 'BlurView',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock react-native-safe-area-context
|
||||||
|
jest.mock('react-native-safe-area-context', () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||||
|
SafeAreaProvider: ({ children }: any) => children,
|
||||||
|
SafeAreaView: 'SafeAreaView',
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock react-native-gesture-handler
|
// Mock react-native-gesture-handler
|
||||||
jest.mock('react-native-gesture-handler', () => {
|
jest.mock('react-native-gesture-handler', () => {
|
||||||
const { View } = require('react-native')
|
const { View } = require('react-native')
|
||||||
@@ -142,11 +179,34 @@ jest.mock('expo-splash-screen', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock react-native-reanimated
|
// Mock react-native-reanimated
|
||||||
jest.mock('react-native-reanimated', () => {
|
jest.mock('react-native-reanimated', () => ({
|
||||||
const Reanimated = require('react-native-reanimated/mock')
|
__esModule: true,
|
||||||
Reanimated.default.call = () => {}
|
default: {
|
||||||
return Reanimated
|
createAnimatedComponent: (Component) => Component,
|
||||||
})
|
call: () => {},
|
||||||
|
},
|
||||||
|
useSharedValue: (v) => ({ value: v }),
|
||||||
|
useAnimatedStyle: (fn) => fn(),
|
||||||
|
withTiming: (v) => v,
|
||||||
|
withSpring: (v) => v,
|
||||||
|
withDecay: (v) => v,
|
||||||
|
withRepeat: (v) => v,
|
||||||
|
withSequence: (...args) => args[0],
|
||||||
|
Easing: {
|
||||||
|
linear: (v) => v,
|
||||||
|
ease: (v) => v,
|
||||||
|
quad: (v) => v,
|
||||||
|
cubic: (v) => v,
|
||||||
|
},
|
||||||
|
runOnJS: (fn) => fn,
|
||||||
|
runOnUI: (fn) => fn,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock react-native-screens
|
||||||
|
jest.mock('react-native-screens', () => ({
|
||||||
|
FullWindowOverlay: 'FullWindowOverlay',
|
||||||
|
enableScreens: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock nativewind
|
// Mock nativewind
|
||||||
jest.mock('nativewind', () => ({
|
jest.mock('nativewind', () => ({
|
||||||
@@ -225,8 +285,49 @@ jest.mock('@/components/icon', () => ({
|
|||||||
LeftArrowIcon: () => null,
|
LeftArrowIcon: () => null,
|
||||||
UploadIcon: () => null,
|
UploadIcon: () => null,
|
||||||
WhitePointsIcon: () => null,
|
WhitePointsIcon: () => null,
|
||||||
|
RightArrowIcon: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock UI components that have complex dependencies
|
||||||
|
jest.mock('@/components/ui/Text', () => {
|
||||||
|
const mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
const Text = mockReact.forwardRef(({ children, ...props }, ref) => (
|
||||||
|
mockReact.createElement(mockRN.Text, { ref, ...props }, children)
|
||||||
|
))
|
||||||
|
Text.displayName = 'Text'
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: Text,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('@/components/ui/Block', () => {
|
||||||
|
const mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
const Block = mockReact.forwardRef(({ children, onClick, ...props }, ref) => {
|
||||||
|
if (onClick) {
|
||||||
|
return mockReact.createElement(mockRN.Pressable, { ref, onPress: onClick, ...props }, children)
|
||||||
|
}
|
||||||
|
return mockReact.createElement(mockRN.View, { ref, ...props }, children)
|
||||||
|
})
|
||||||
|
Block.displayName = 'Block'
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: Block,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('@/components/ui/button', () => {
|
||||||
|
const mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
return {
|
||||||
|
Button: mockReact.forwardRef(({ children, onPress, disabled, ...props }, ref) => (
|
||||||
|
mockReact.createElement(mockRN.Pressable, { ref, onPress, disabled, ...props }, children)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
jest.mock('@/components/skeleton/HomeSkeleton', () => ({
|
jest.mock('@/components/skeleton/HomeSkeleton', () => ({
|
||||||
HomeSkeleton: () => null,
|
HomeSkeleton: () => null,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
|||||||
headers['authorization'] = `Bearer ${token}`
|
headers['authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 Content-Type 正确设置
|
// 确保 Content-Type 正确设置(FormData 不需要手动设置,让浏览器自动处理 boundary)
|
||||||
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
|
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type'] && !(init?.body instanceof FormData)) {
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,27 @@ import { Platform } from 'react-native'
|
|||||||
import { handleError } from '@/hooks/use-error'
|
import { handleError } from '@/hooks/use-error'
|
||||||
|
|
||||||
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
|
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
|
||||||
const { uri, mimeType, fileName } = params
|
const { uri, mimeType, fileName } = params || {}
|
||||||
|
|
||||||
console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS })
|
console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS })
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', {
|
|
||||||
uri: uri,
|
// React Native 中 FormData 需要使用特定格式
|
||||||
name: fileName || 'uploaded_file.jpg',
|
const fileObject = {
|
||||||
|
uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''),
|
||||||
type: mimeType || 'image/jpeg',
|
type: mimeType || 'image/jpeg',
|
||||||
} as any)
|
name: fileName || 'uploaded_file.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[uploadFile] FormData file object:', fileObject)
|
||||||
|
formData.append('file', fileObject as any)
|
||||||
|
|
||||||
const fileController = root.get(FileController)
|
const fileController = root.get(FileController)
|
||||||
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
|
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
|
||||||
|
|
||||||
if (error || !data?.data) {
|
if (error || !data?.data) {
|
||||||
console.error('[uploadFile] 上传失败:', { error, data, uri })
|
console.error('[uploadFile] 上传失败:', { error, data, uri, formData })
|
||||||
throw error || new Error('上传失败')
|
throw error || new Error('上传失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"rememberPassword": "Remember Password",
|
||||||
"fillCompleteInfo": "Please fill in all information",
|
"fillCompleteInfo": "Please fill in all information",
|
||||||
"fillEmail": "Please fill in email",
|
"fillEmail": "Please fill in email",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"rememberPassword": "记住密码",
|
||||||
"fillCompleteInfo": "请填写完整信息",
|
"fillCompleteInfo": "请填写完整信息",
|
||||||
"fillEmail": "请填写邮箱",
|
"fillEmail": "请填写邮箱",
|
||||||
"loginFailed": "登录失败",
|
"loginFailed": "登录失败",
|
||||||
|
|||||||
Reference in New Issue
Block a user