fix: bug
This commit is contained in:
283
app/(tabs)/__tests__/_layout.test.tsx
Normal file
283
app/(tabs)/__tests__/_layout.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render } from '@testing-library/react-native'
|
||||||
|
import TabLayout from '../_layout'
|
||||||
|
|
||||||
|
// Mock expo-router Tabs
|
||||||
|
jest.mock('expo-router', () => {
|
||||||
|
const React = require('react')
|
||||||
|
const { View, Text } = require('react-native')
|
||||||
|
|
||||||
|
// Mock Tabs component that renders children and captures screen options
|
||||||
|
const MockTabs = ({ children, screenOptions }: any) => {
|
||||||
|
return React.createElement(View, { testID: 'tabs-container' }, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Tabs.Screen that renders the icon
|
||||||
|
MockTabs.Screen = ({ name, options }: any) => {
|
||||||
|
const React = require('react')
|
||||||
|
const { View } = require('react-native')
|
||||||
|
|
||||||
|
// Render the icon to test badge display
|
||||||
|
const icon = options?.tabBarIcon?.({ focused: false })
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
View,
|
||||||
|
{ testID: `tab-screen-${name}` },
|
||||||
|
icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Tabs: MockTabs,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
back: jest.fn(),
|
||||||
|
}),
|
||||||
|
useLocalSearchParams: () => ({}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
jest.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'tabs.home': '首页',
|
||||||
|
'tabs.video': '视频',
|
||||||
|
'tabs.message': '消息',
|
||||||
|
'tabs.my': '我的',
|
||||||
|
}
|
||||||
|
return translations[key] || key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock expo-linear-gradient
|
||||||
|
jest.mock('expo-linear-gradient', () => ({
|
||||||
|
LinearGradient: 'LinearGradient',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock react-native-safe-area-context
|
||||||
|
jest.mock('react-native-safe-area-context', () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock icon components
|
||||||
|
jest.mock('@/components/icon', () => ({
|
||||||
|
HomeIcon: () => null,
|
||||||
|
MessageIcon: () => null,
|
||||||
|
MyIcon: () => null,
|
||||||
|
VideoIcon: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock unread count hooks
|
||||||
|
const mockMessageUnreadCount = jest.fn()
|
||||||
|
const mockAnnouncementUnreadCount = jest.fn()
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-message-unread-count', () => ({
|
||||||
|
useMessageUnreadCount: () => mockMessageUnreadCount(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-announcement-unread-count', () => ({
|
||||||
|
useAnnouncementUnreadCount: () => mockAnnouncementUnreadCount(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('TabLayout - Message Unread Badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
// Default: no unread messages
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Badge Visibility', () => {
|
||||||
|
it('should show unread badge when there are unread messages', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 5 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
|
||||||
|
// Badge should be visible
|
||||||
|
expect(getByTestId('message-unread-badge')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show unread badge when there are unread announcements', () => {
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 3 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
|
||||||
|
// Badge should be visible
|
||||||
|
expect(getByTestId('message-unread-badge')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show unread badge when there are both unread messages and announcements', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 2 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 1 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
|
||||||
|
// Badge should be visible
|
||||||
|
expect(getByTestId('message-unread-badge')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should NOT show unread badge when there are no unread messages or announcements', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<TabLayout />)
|
||||||
|
|
||||||
|
// Badge should NOT be visible
|
||||||
|
expect(queryByTestId('message-unread-badge')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should NOT show unread badge when data is undefined', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<TabLayout />)
|
||||||
|
|
||||||
|
// Badge should NOT be visible
|
||||||
|
expect(queryByTestId('message-unread-badge')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Badge Styling', () => {
|
||||||
|
it('should have correct badge color (#00FF66)', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 5 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
const badge = getByTestId('message-unread-badge')
|
||||||
|
|
||||||
|
// Check badge has correct background color
|
||||||
|
expect(badge.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backgroundColor: '#00FF66',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct badge size (8px width and height)', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 5 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
const badge = getByTestId('message-unread-badge')
|
||||||
|
|
||||||
|
// Check badge has correct size
|
||||||
|
expect(badge.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have circular shape (borderRadius: 4)', () => {
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 5 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TabLayout />)
|
||||||
|
const badge = getByTestId('message-unread-badge')
|
||||||
|
|
||||||
|
// Check badge is circular (borderRadius = width/2 = 4)
|
||||||
|
expect(badge.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
borderRadius: 4,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Data Fetching', () => {
|
||||||
|
it('should call refetch on mount for message unread count', () => {
|
||||||
|
const mockRefetch = jest.fn()
|
||||||
|
mockMessageUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<TabLayout />)
|
||||||
|
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call refetch on mount for announcement unread count', () => {
|
||||||
|
const mockRefetch = jest.fn()
|
||||||
|
mockAnnouncementUnreadCount.mockReturnValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<TabLayout />)
|
||||||
|
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
537
app/(tabs)/__tests__/message.test.tsx
Normal file
537
app/(tabs)/__tests__/message.test.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react-native'
|
||||||
|
import MessageScreen from '../message'
|
||||||
|
|
||||||
|
// Mock expo-status-bar
|
||||||
|
jest.mock('expo-status-bar', () => ({
|
||||||
|
StatusBar: 'StatusBar',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
jest.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'message.title': '消息',
|
||||||
|
'message.markAllRead': '全部已读',
|
||||||
|
'message.noMessages': '暂无消息',
|
||||||
|
}
|
||||||
|
return translations[key] || key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock expo-router
|
||||||
|
const mockPush = jest.fn()
|
||||||
|
jest.mock('expo-router', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
replace: jest.fn(),
|
||||||
|
back: jest.fn(),
|
||||||
|
}),
|
||||||
|
useLocalSearchParams: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
const mockExecute = jest.fn()
|
||||||
|
const mockLoadMore = jest.fn()
|
||||||
|
const mockRefetch = jest.fn()
|
||||||
|
const mockMarkRead = jest.fn()
|
||||||
|
const mockBatchMarkRead = jest.fn()
|
||||||
|
const mockDeleteMessage = jest.fn()
|
||||||
|
const mockAnnouncementExecute = jest.fn()
|
||||||
|
|
||||||
|
// Default mock data
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'ACTIVITY' as const,
|
||||||
|
title: '有人点赞了你的作品',
|
||||||
|
content: '用户A点赞了你的模板',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date('2026-01-29T10:00:00'),
|
||||||
|
data: { subType: 'TEMPLATE_LIKED', templateId: 'template-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'SYSTEM' as const,
|
||||||
|
title: '生成成功',
|
||||||
|
content: '你的模板已生成完成',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: new Date('2026-01-28T10:00:00'),
|
||||||
|
data: { subType: 'TEMPLATE_GENERATION_SUCCESS', generationId: 'gen-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'BILLING' as const,
|
||||||
|
title: '积分扣除',
|
||||||
|
content: '消耗10积分',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date('2026-01-27T10:00:00'),
|
||||||
|
data: { subType: 'CREDITS_DEDUCTED', credits: 10 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAnnouncements = [
|
||||||
|
{
|
||||||
|
id: 'ann-1',
|
||||||
|
title: '系统公告:新功能上线',
|
||||||
|
content: '我们上线了新功能',
|
||||||
|
link: 'https://example.com/announcement',
|
||||||
|
createdAt: new Date('2026-01-29T08:00:00'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-messages', () => ({
|
||||||
|
useMessages: jest.fn(() => ({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-message-actions', () => ({
|
||||||
|
useMessageActions: jest.fn(() => ({
|
||||||
|
markRead: mockMarkRead,
|
||||||
|
markReadLoading: false,
|
||||||
|
markReadError: null,
|
||||||
|
batchMarkRead: mockBatchMarkRead,
|
||||||
|
batchMarkReadLoading: false,
|
||||||
|
batchMarkReadError: null,
|
||||||
|
deleteMessage: mockDeleteMessage,
|
||||||
|
deleteLoading: false,
|
||||||
|
deleteError: null,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-announcements', () => ({
|
||||||
|
useAnnouncements: jest.fn(() => ({
|
||||||
|
announcements: mockAnnouncements,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockAnnouncementExecute,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
loadMore: jest.fn(),
|
||||||
|
hasMore: false,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-message-unread-count', () => ({
|
||||||
|
useMessageUnreadCount: jest.fn(() => ({
|
||||||
|
count: 2,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
execute: jest.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock components
|
||||||
|
jest.mock('@/components/LoadingState', () => 'LoadingState')
|
||||||
|
jest.mock('@/components/ErrorState', () => 'ErrorState')
|
||||||
|
jest.mock('@/components/PaginationLoader', () => 'PaginationLoader')
|
||||||
|
|
||||||
|
describe('MessageScreen', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Page Structure', () => {
|
||||||
|
it('should render page header with title "消息"', () => {
|
||||||
|
const { getByText } = render(<MessageScreen />)
|
||||||
|
expect(getByText('消息')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render "全部已读" button in header', () => {
|
||||||
|
const { getByText } = render(<MessageScreen />)
|
||||||
|
expect(getByText('全部已读')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render MessageTabBar component', () => {
|
||||||
|
const { getByTestId } = render(<MessageScreen />)
|
||||||
|
expect(getByTestId('tab-all')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-activity')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-system')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-billing')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Announcement Banner', () => {
|
||||||
|
it('should render AnnouncementBanner when announcements exist', () => {
|
||||||
|
const { getByTestId } = render(<MessageScreen />)
|
||||||
|
expect(getByTestId('announcement-banner')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render AnnouncementBanner when no announcements', () => {
|
||||||
|
const { useAnnouncements } = require('@/hooks/use-announcements')
|
||||||
|
useAnnouncements.mockReturnValue({
|
||||||
|
announcements: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockAnnouncementExecute,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<MessageScreen />)
|
||||||
|
expect(queryByTestId('announcement-banner')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Message List', () => {
|
||||||
|
it('should render message list with MessageCard components', () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
expect(messageCards.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render MessageEmptyState when no messages', () => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId } = render(<MessageScreen />)
|
||||||
|
expect(getByTestId('message-empty-state')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wrap MessageCard with SwipeToDelete', () => {
|
||||||
|
// Reset mock to default with messages
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
const swipeContainers = getAllByTestId('swipe-container')
|
||||||
|
expect(swipeContainers.length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tab Switching', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset to default mock
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { useAnnouncements } = require('@/hooks/use-announcements')
|
||||||
|
useAnnouncements.mockReturnValue({
|
||||||
|
announcements: mockAnnouncements,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockAnnouncementExecute,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show all messages when "全部" tab is active', () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
expect(messageCards.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter ACTIVITY messages when "互动" tab is clicked', async () => {
|
||||||
|
const { getByTestId, getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-activity'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
expect(messageCards.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter SYSTEM messages when "系统" tab is clicked', async () => {
|
||||||
|
const { getByTestId, getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-system'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
expect(messageCards.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter BILLING messages when "账单" tab is clicked', async () => {
|
||||||
|
const { getByTestId, getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-billing'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
expect(messageCards.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show empty state when filtered tab has no messages', async () => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: [mockMessages[0]], // Only ACTIVITY message
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByTestId, queryByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-billing'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByTestId('message-empty-state')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mark All Read', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call batchMarkRead with unread message ids when "全部已读" is clicked', async () => {
|
||||||
|
const { getByText } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByText('全部已读'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockBatchMarkRead).toHaveBeenCalledWith(['1', '3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only mark unread messages in current tab as read', async () => {
|
||||||
|
const { getByText, getByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
// Switch to ACTIVITY tab
|
||||||
|
fireEvent.press(getByTestId('tab-activity'))
|
||||||
|
|
||||||
|
// Click mark all read
|
||||||
|
fireEvent.press(getByText('全部已读'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockBatchMarkRead).toHaveBeenCalledWith(['1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refetch messages after marking all as read', async () => {
|
||||||
|
const { getByText } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
fireEvent.press(getByText('全部已读'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Swipe to Delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call deleteMessage when swipe delete button is pressed', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const deleteButtons = getAllByTestId('swipe-delete-button')
|
||||||
|
fireEvent.press(deleteButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteMessage).toHaveBeenCalledWith('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refetch messages after deletion', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const deleteButtons = getAllByTestId('swipe-delete-button')
|
||||||
|
fireEvent.press(deleteButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Message Click Navigation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: mockMessages,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should navigate to /templateDetail when TEMPLATE_LIKED message is clicked', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
fireEvent.press(messageCards[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/templateDetail'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should navigate to /generationRecord when TEMPLATE_GENERATION_SUCCESS message is clicked', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
fireEvent.press(messageCards[1])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/generationRecord'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should navigate to /membership when CREDITS_DEDUCTED message is clicked', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
fireEvent.press(messageCards[2])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/membership'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should mark message as read when clicked', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
fireEvent.press(messageCards[0])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMarkRead).toHaveBeenCalledWith('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call markRead for already read messages', async () => {
|
||||||
|
const { getAllByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const messageCards = getAllByTestId('message-card')
|
||||||
|
fireEvent.press(messageCards[1]) // This is the read message
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMarkRead).not.toHaveBeenCalledWith('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('should call loadMore when scrolling to bottom', async () => {
|
||||||
|
const { getByTestId } = render(<MessageScreen />)
|
||||||
|
|
||||||
|
const flatList = getByTestId('message-list')
|
||||||
|
|
||||||
|
fireEvent(flatList, 'onEndReached')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadMore).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Loading State', () => {
|
||||||
|
it('should show loading state when loading is true and no messages', () => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: [],
|
||||||
|
loading: true,
|
||||||
|
loadingMore: false,
|
||||||
|
error: null,
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { UNSAFE_queryAllByType } = render(<MessageScreen />)
|
||||||
|
const loadingStates = UNSAFE_queryAllByType('LoadingState' as any)
|
||||||
|
expect(loadingStates.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error State', () => {
|
||||||
|
it('should show error state when error exists and no messages', () => {
|
||||||
|
const { useMessages } = require('@/hooks/use-messages')
|
||||||
|
useMessages.mockReturnValue({
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: { message: 'Network error' },
|
||||||
|
execute: mockExecute,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
hasMore: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { UNSAFE_queryAllByType } = render(<MessageScreen />)
|
||||||
|
const errorStates = UNSAFE_queryAllByType('ErrorState' as any)
|
||||||
|
expect(errorStates.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Initial Data Fetch', () => {
|
||||||
|
it('should call execute on mount', () => {
|
||||||
|
render(<MessageScreen />)
|
||||||
|
expect(mockExecute).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call announcement execute on mount', () => {
|
||||||
|
render(<MessageScreen />)
|
||||||
|
expect(mockAnnouncementExecute).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Tabs } from 'expo-router'
|
import { Tabs } from 'expo-router'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { StyleSheet, View, Text, Platform } from 'react-native'
|
import { StyleSheet, View, Text, Platform } from 'react-native'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
import { HomeIcon, MessageIcon, MyIcon, VideoIcon } from '@/components/icon'
|
import { HomeIcon, MessageIcon, MyIcon, VideoIcon } from '@/components/icon'
|
||||||
|
import { useMessageUnreadCount } from '@/hooks/use-message-unread-count'
|
||||||
|
import { useAnnouncementUnreadCount } from '@/hooks/use-announcement-unread-count'
|
||||||
|
|
||||||
const TAB_BAR_HEIGHT = 80
|
const TAB_BAR_HEIGHT = 80
|
||||||
|
|
||||||
@@ -15,6 +17,19 @@ export default function TabLayout() {
|
|||||||
// Android 不需要额外的底部安全区域,iOS 和 Web 需要
|
// Android 不需要额外的底部安全区域,iOS 和 Web 需要
|
||||||
const bottomInset = Platform.OS === 'android' ? 0 : insets.bottom
|
const bottomInset = Platform.OS === 'android' ? 0 : insets.bottom
|
||||||
|
|
||||||
|
// 获取未读消息数和公告数
|
||||||
|
const { data: messageData, refetch: refetchMessages } = useMessageUnreadCount()
|
||||||
|
const { data: announcementData, refetch: refetchAnnouncements } = useAnnouncementUnreadCount()
|
||||||
|
|
||||||
|
// 计算总未读数
|
||||||
|
const totalUnreadCount = (messageData?.count || 0) + (announcementData?.count || 0)
|
||||||
|
|
||||||
|
// 组件挂载时获取未读数
|
||||||
|
useEffect(() => {
|
||||||
|
refetchMessages()
|
||||||
|
refetchAnnouncements()
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -96,14 +111,22 @@ export default function TabLayout() {
|
|||||||
tabBarIcon: ({ focused }: { focused: boolean }) => {
|
tabBarIcon: ({ focused }: { focused: boolean }) => {
|
||||||
if (focused) {
|
if (focused) {
|
||||||
return (
|
return (
|
||||||
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
|
<View>
|
||||||
<MessageIcon />
|
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
|
||||||
</LinearGradient>
|
<MessageIcon />
|
||||||
|
</LinearGradient>
|
||||||
|
{totalUnreadCount > 0 && (
|
||||||
|
<View testID="message-unread-badge" style={styles.unreadBadge} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={{ opacity: focused ? 1 : 0.6 }}>
|
<View style={{ opacity: focused ? 1 : 0.6 }}>
|
||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
|
{totalUnreadCount > 0 && (
|
||||||
|
<View testID="message-unread-badge" style={styles.unreadBadge} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -147,4 +170,13 @@ const styles = StyleSheet.create({
|
|||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
unreadBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#00FF66',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,195 +1,203 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ScrollView,
|
FlatList,
|
||||||
StatusBar as RNStatusBar,
|
StatusBar as RNStatusBar,
|
||||||
Pressable,
|
Pressable,
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
|
||||||
import { useMessages, type Message } from '@/hooks/use-messages'
|
import { useMessages, type Message } from '@/hooks/use-messages'
|
||||||
import { useMessageActions } from '@/hooks/use-message-actions'
|
import { useMessageActions } from '@/hooks/use-message-actions'
|
||||||
|
import { useAnnouncements } from '@/hooks/use-announcements'
|
||||||
import LoadingState from '@/components/LoadingState'
|
import LoadingState from '@/components/LoadingState'
|
||||||
import ErrorState from '@/components/ErrorState'
|
import ErrorState from '@/components/ErrorState'
|
||||||
import PaginationLoader from '@/components/PaginationLoader'
|
import PaginationLoader from '@/components/PaginationLoader'
|
||||||
|
|
||||||
const filterMessagesByTab = (messages: Message[], tab: 'all' | 'notice' | 'other') => {
|
import { MessageCard } from '@/components/message/MessageCard'
|
||||||
if (tab === 'all') return messages
|
import { MessageTabBar, type MessageType } from '@/components/message/MessageTabBar'
|
||||||
if (tab === 'notice') {
|
import { AnnouncementBanner } from '@/components/message/AnnouncementBanner'
|
||||||
return messages.filter(m => m.type === 'SYSTEM' || m.type === 'ACTIVITY')
|
import { SwipeToDelete } from '@/components/message/SwipeToDelete'
|
||||||
}
|
import { MessageEmptyState } from '@/components/message/MessageEmptyState'
|
||||||
return messages.filter(m => m.type === 'BILLING' || m.type === 'MARKETING')
|
|
||||||
|
// Navigation map for message click routing
|
||||||
|
const NAVIGATION_MAP: Record<string, string> = {
|
||||||
|
TEMPLATE_GENERATION_SUCCESS: '/generationRecord',
|
||||||
|
TEMPLATE_GENERATION_FAILED: '/generationRecord',
|
||||||
|
TEMPLATE_LIKED: '/templateDetail',
|
||||||
|
TEMPLATE_FAVORITED: '/templateDetail',
|
||||||
|
TEMPLATE_COMMENTED: '/templateDetail',
|
||||||
|
COMMENT_REPLIED: '/templateDetail',
|
||||||
|
CREDITS_DEDUCTED: '/membership',
|
||||||
|
CREDITS_REFUNDED: '/membership',
|
||||||
|
CREDITS_RECHARGED: '/membership',
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
// Filter messages by tab type
|
||||||
const d = new Date(date)
|
const filterMessagesByTab = (messages: Message[], tabType: MessageType): Message[] => {
|
||||||
const year = d.getFullYear()
|
if (tabType === undefined) return messages
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
return messages.filter(m => m.type === tabType)
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(d.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageScreen() {
|
export default function MessageScreen() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
|
const router = useRouter()
|
||||||
const { markRead } = useMessageActions()
|
const [activeTab, setActiveTab] = useState<MessageType>(undefined)
|
||||||
|
|
||||||
const { messages: allMessages, loading, loadingMore, error, refetch, loadMore, hasMore, execute } = useMessages({
|
// Hooks
|
||||||
limit: 20,
|
const {
|
||||||
})
|
messages: allMessages,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
|
execute,
|
||||||
|
} = useMessages({ limit: 20 })
|
||||||
|
|
||||||
const messages = filterMessagesByTab(allMessages, activeTab)
|
const {
|
||||||
|
markRead,
|
||||||
|
batchMarkRead,
|
||||||
|
deleteMessage,
|
||||||
|
} = useMessageActions()
|
||||||
|
|
||||||
|
const {
|
||||||
|
announcements,
|
||||||
|
execute: executeAnnouncements,
|
||||||
|
} = useAnnouncements({ limit: 10 })
|
||||||
|
|
||||||
|
// Filter messages based on active tab
|
||||||
|
const filteredMessages = filterMessagesByTab(allMessages, activeTab)
|
||||||
|
|
||||||
|
// Initial data fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
execute()
|
execute()
|
||||||
|
executeAnnouncements()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
// Handle tab change
|
||||||
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
|
const handleTabChange = useCallback((type: MessageType) => {
|
||||||
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 50
|
setActiveTab(type)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (isCloseToBottom && hasMore && !loadingMore && !loading) {
|
// Handle mark all read
|
||||||
loadMore()
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
}
|
const unreadMessages = filteredMessages.filter(m => !m.isRead)
|
||||||
}
|
if (unreadMessages.length === 0) return
|
||||||
|
|
||||||
const handleMessagePress = async (message: Message) => {
|
const unreadIds = unreadMessages.map(m => m.id)
|
||||||
|
await batchMarkRead(unreadIds)
|
||||||
|
refetch()
|
||||||
|
}, [filteredMessages, batchMarkRead, refetch])
|
||||||
|
|
||||||
|
// Handle message press - navigate and mark as read
|
||||||
|
const handleMessagePress = useCallback(async (message: Message) => {
|
||||||
|
// Mark as read if unread
|
||||||
if (!message.isRead) {
|
if (!message.isRead) {
|
||||||
await markRead(message.id)
|
await markRead(message.id)
|
||||||
refetch()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && messages.length === 0) {
|
// Navigate based on message subType
|
||||||
|
const subType = message.data?.subType
|
||||||
|
if (subType && NAVIGATION_MAP[subType]) {
|
||||||
|
const route = NAVIGATION_MAP[subType]
|
||||||
|
// Add relevant params based on message data
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (message.data?.templateId) {
|
||||||
|
params.id = message.data.templateId
|
||||||
|
}
|
||||||
|
if (message.data?.generationId) {
|
||||||
|
params.id = message.data.generationId
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = Object.keys(params).length > 0
|
||||||
|
? `?${new URLSearchParams(params).toString()}`
|
||||||
|
: ''
|
||||||
|
router.push(`${route}${queryString}`)
|
||||||
|
}
|
||||||
|
}, [markRead, router])
|
||||||
|
|
||||||
|
// Handle delete message
|
||||||
|
const handleDeleteMessage = useCallback(async (id: string) => {
|
||||||
|
await deleteMessage(id)
|
||||||
|
refetch()
|
||||||
|
}, [deleteMessage, refetch])
|
||||||
|
|
||||||
|
// Handle announcement press
|
||||||
|
const handleAnnouncementPress = useCallback((announcement: { id: string; title: string; link?: string }) => {
|
||||||
|
if (announcement.link) {
|
||||||
|
// Navigate to external link or internal route
|
||||||
|
router.push(announcement.link as any)
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// Handle load more
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasMore && !loadingMore && !loading) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, [hasMore, loadingMore, loading, loadMore])
|
||||||
|
|
||||||
|
// Render message item
|
||||||
|
const renderMessageItem = useCallback(({ item }: { item: Message }) => (
|
||||||
|
<SwipeToDelete onDelete={() => handleDeleteMessage(item.id)}>
|
||||||
|
<MessageCard
|
||||||
|
message={item}
|
||||||
|
onPress={handleMessagePress}
|
||||||
|
/>
|
||||||
|
</SwipeToDelete>
|
||||||
|
), [handleDeleteMessage, handleMessagePress])
|
||||||
|
|
||||||
|
// Render list footer
|
||||||
|
const renderFooter = useCallback(() => {
|
||||||
|
if (loadingMore) {
|
||||||
|
return <PaginationLoader color="#FFFFFF" />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [loadingMore])
|
||||||
|
|
||||||
|
// Render empty state
|
||||||
|
const renderEmptyComponent = useCallback(() => (
|
||||||
|
<MessageEmptyState message={t('message.noMessages')} />
|
||||||
|
), [t])
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading && allMessages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<RNStatusBar barStyle="light-content" />
|
<RNStatusBar barStyle="light-content" />
|
||||||
<View style={styles.segment}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => setActiveTab('all')}>
|
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||||
{activeTab === 'all' ? (
|
<Pressable onPress={handleMarkAllRead}>
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.all')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('notice')}>
|
|
||||||
{activeTab === 'notice' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.notice')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('other')}>
|
|
||||||
{activeTab === 'other' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.other')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
<LoadingState color="#FFFFFF" />
|
<LoadingState color="#FFFFFF" />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && messages.length === 0) {
|
// Error state
|
||||||
|
if (error && allMessages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<RNStatusBar barStyle="light-content" />
|
<RNStatusBar barStyle="light-content" />
|
||||||
<View style={styles.segment}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => setActiveTab('all')}>
|
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||||
{activeTab === 'all' ? (
|
<Pressable onPress={handleMarkAllRead}>
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.all')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('notice')}>
|
|
||||||
{activeTab === 'notice' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.notice')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('other')}>
|
|
||||||
{activeTab === 'other' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
|
||||||
{t('message.other')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
<ErrorState message={error.message} onRetry={refetch} />
|
<ErrorState message={error.message} onRetry={refetch} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@@ -200,123 +208,41 @@ export default function MessageScreen() {
|
|||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<RNStatusBar barStyle="light-content" />
|
<RNStatusBar barStyle="light-content" />
|
||||||
|
|
||||||
<View style={styles.segment}>
|
{/* Header */}
|
||||||
<Pressable onPress={() => setActiveTab('all')}>
|
<View style={styles.header}>
|
||||||
{activeTab === 'all' ? (
|
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
<Pressable onPress={handleMarkAllRead}>
|
||||||
<LinearGradient
|
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.segmentText,
|
|
||||||
styles.segmentTextActiveText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{t('message.all')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('notice')}>
|
|
||||||
{activeTab === 'notice' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.segmentText,
|
|
||||||
styles.segmentTextActiveText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{t('message.notice')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={() => setActiveTab('other')}>
|
|
||||||
{activeTab === 'other' ? (
|
|
||||||
<View style={styles.segmentTextActiveWrapper}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.segmentTextActiveBg}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.segmentText,
|
|
||||||
styles.segmentTextActiveText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{t('message.other')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
{/* Announcement Banner */}
|
||||||
style={styles.scrollView}
|
{announcements.length > 0 && (
|
||||||
contentContainerStyle={styles.scrollContent}
|
<AnnouncementBanner
|
||||||
|
announcements={announcements}
|
||||||
|
onPress={handleAnnouncementPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
|
|
||||||
|
{/* Message List */}
|
||||||
|
<FlatList
|
||||||
|
testID="message-list"
|
||||||
|
data={filteredMessages}
|
||||||
|
renderItem={renderMessageItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.listContent,
|
||||||
|
filteredMessages.length === 0 && styles.emptyListContent,
|
||||||
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onEndReached={handleEndReached}
|
||||||
scrollEventThrottle={400}
|
onEndReachedThreshold={0.5}
|
||||||
>
|
ListFooterComponent={renderFooter}
|
||||||
{messages.length > 0 ? (
|
ListEmptyComponent={renderEmptyComponent}
|
||||||
<>
|
/>
|
||||||
{messages.map((message) => (
|
|
||||||
<Pressable
|
|
||||||
key={message.id}
|
|
||||||
onPress={() => handleMessagePress(message)}
|
|
||||||
>
|
|
||||||
<View style={styles.cardContainer}>
|
|
||||||
{!message.isRead && (
|
|
||||||
<View style={styles.newMessageDotContainer}>
|
|
||||||
<View style={styles.newMessageDot} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.cardTitle}>
|
|
||||||
{message.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.cardSubtitle} numberOfLines={2}>
|
|
||||||
{message.content}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.cardBody}>
|
|
||||||
<Text style={styles.cardBodyText}>
|
|
||||||
{message.data || ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.cardTime}>
|
|
||||||
{formatTime(message.createdAt)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
{loadingMore && <PaginationLoader color="#FFFFFF" />}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<View style={styles.emptyContainer}>
|
|
||||||
<Text style={styles.emptyIcon}>💭</Text>
|
|
||||||
<Text style={styles.emptyText}>{t('message.noMessages')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -326,109 +252,27 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
scrollView: {
|
header: {
|
||||||
flex: 1,
|
flexDirection: 'row',
|
||||||
backgroundColor: '#090A0B',
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
markAllReadText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#8A8A8A',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
paddingTop: 12,
|
paddingTop: 12,
|
||||||
},
|
},
|
||||||
segment: {
|
emptyListContent: {
|
||||||
flexDirection: 'row',
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingTop: 19,
|
|
||||||
paddingBottom: 12,
|
|
||||||
backgroundColor: '#090A0B',
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
segmentText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
segmentTextActiveWrapper: {
|
|
||||||
position: 'relative',
|
|
||||||
paddingBottom: 2,
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
},
|
|
||||||
segmentTextActiveBg: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: '#FF9966',
|
|
||||||
},
|
|
||||||
segmentTextActiveText: {
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
cardContainer: {
|
|
||||||
backgroundColor: '#16181B',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 12,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
newMessageDotContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: -4,
|
|
||||||
right: -2,
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderWidth: 4,
|
|
||||||
borderColor: '#090A0B',
|
|
||||||
borderRadius: 8,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
newMessageDot: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: '#00FF66',
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
cardSubtitle: {
|
|
||||||
color: '#ABABAB',
|
|
||||||
fontSize: 11,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
cardBody: {
|
|
||||||
backgroundColor: '#26292E',
|
|
||||||
borderRadius: 12,
|
|
||||||
height: 100,
|
|
||||||
marginBottom: 12,
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
cardBodyText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 12,
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
cardTime: {
|
|
||||||
color: '#8A8A8A',
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingTop: 200,
|
|
||||||
},
|
|
||||||
emptyIcon: {
|
|
||||||
fontSize: 48,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
color: '#8A8A8A',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 16,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
202
components/message/AnnouncementBanner.test.tsx
Normal file
202
components/message/AnnouncementBanner.test.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent } from '@testing-library/react-native'
|
||||||
|
|
||||||
|
import { AnnouncementBanner, type Announcement } from './AnnouncementBanner'
|
||||||
|
|
||||||
|
// Mock expo-linear-gradient
|
||||||
|
jest.mock('expo-linear-gradient', () => ({
|
||||||
|
LinearGradient: ({ children, ...props }: any) => {
|
||||||
|
const { View } = require('react-native')
|
||||||
|
return <View {...props}>{children}</View>
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AnnouncementBanner', () => {
|
||||||
|
const mockAnnouncements: Announcement[] = [
|
||||||
|
{
|
||||||
|
id: 'ann-1',
|
||||||
|
title: '系统维护通知',
|
||||||
|
content: '系统将于今晚进行维护',
|
||||||
|
link: 'https://example.com/maintenance',
|
||||||
|
createdAt: new Date('2026-01-29T10:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ann-2',
|
||||||
|
title: '新功能上线',
|
||||||
|
content: '我们推出了全新的AI生成功能',
|
||||||
|
createdAt: new Date('2026-01-28T10:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ann-3',
|
||||||
|
title: '活动公告',
|
||||||
|
content: '春节活动即将开始',
|
||||||
|
link: 'https://example.com/event',
|
||||||
|
createdAt: new Date('2026-01-27T10:00:00Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockOnPress = jest.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty State', () => {
|
||||||
|
it('should not render anything when announcements array is empty', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<AnnouncementBanner announcements={[]} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(toJSON()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render anything when announcements is undefined', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<AnnouncementBanner announcements={undefined as any} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(toJSON()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Single Announcement', () => {
|
||||||
|
it('should render single announcement title', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByText } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(getByText('系统维护通知')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render announcement banner container', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(getByTestId('announcement-banner')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Multiple Announcements', () => {
|
||||||
|
it('should render multiple announcements', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(getByText('系统维护通知')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render horizontal scroll view for multiple announcements', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(getByTestId('announcement-scroll-view')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render all announcement items', () => {
|
||||||
|
const { getAllByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
const items = getAllByTestId(/^announcement-item-/)
|
||||||
|
expect(items.length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Press Interaction', () => {
|
||||||
|
it('should call onPress with correct announcement when pressed', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
fireEvent.press(getByTestId('announcement-item-ann-1'))
|
||||||
|
expect(mockOnPress).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onPress with correct announcement for each item', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('announcement-item-ann-2'))
|
||||||
|
expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not crash when onPress is not provided', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} />
|
||||||
|
)
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.press(getByTestId('announcement-item-ann-1'))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Gradient Background Style', () => {
|
||||||
|
it('should render with gradient background', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
const banner = getByTestId('announcement-banner')
|
||||||
|
expect(banner).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply gradient colors', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
const gradientContainer = getByTestId('gradient-container')
|
||||||
|
expect(gradientContainer.props.colors).toEqual(['#FF9966', '#FF6699', '#9966FF'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Announcement Icon', () => {
|
||||||
|
it('should render announcement icon', () => {
|
||||||
|
const singleAnnouncement = [mockAnnouncements[0]]
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||||
|
)
|
||||||
|
expect(getByTestId('announcement-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Announcement Type', () => {
|
||||||
|
it('should have required id field', () => {
|
||||||
|
const announcement: Announcement = {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
expect(announcement.id).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have required title field', () => {
|
||||||
|
const announcement: Announcement = {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
expect(announcement.title).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have optional content field', () => {
|
||||||
|
const announcement: Announcement = {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
content: 'Test Content',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
expect(announcement.content).toBe('Test Content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have optional link field', () => {
|
||||||
|
const announcement: Announcement = {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Title',
|
||||||
|
link: 'https://example.com',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
expect(announcement.link).toBe('https://example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
112
components/message/AnnouncementBanner.tsx
Normal file
112
components/message/AnnouncementBanner.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native'
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
link?: string
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementBannerProps {
|
||||||
|
announcements: Announcement[]
|
||||||
|
onPress?: (announcement: Announcement) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADIENT_COLORS = ['#FF9966', '#FF6699', '#9966FF'] as const
|
||||||
|
|
||||||
|
export const AnnouncementBanner: React.FC<AnnouncementBannerProps> = ({
|
||||||
|
announcements,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
// 无公告时不渲染
|
||||||
|
if (!announcements || announcements.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePress = (announcement: Announcement) => {
|
||||||
|
onPress?.(announcement)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID="announcement-banner" style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
testID="gradient-container"
|
||||||
|
colors={[...GRADIENT_COLORS]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.gradientContainer}
|
||||||
|
>
|
||||||
|
<View testID="announcement-icon" style={styles.iconContainer}>
|
||||||
|
<Text style={styles.icon}>📢</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
testID="announcement-scroll-view"
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={announcement.id}
|
||||||
|
testID={`announcement-item-${announcement.id}`}
|
||||||
|
style={styles.announcementItem}
|
||||||
|
onPress={() => handlePress(announcement)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{announcement.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
gradientContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
announcementItem: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default AnnouncementBanner
|
||||||
@@ -223,10 +223,16 @@ describe('MessageCard Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render message icon based on subType', () => {
|
it('should render message icon based on subType', () => {
|
||||||
|
// Use a message without avatar to test icon display
|
||||||
|
const billingMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
type: 'BILLING' as const,
|
||||||
|
data: { subType: 'CREDITS_RECHARGED', credits: 1000 },
|
||||||
|
}
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
<MessageCard message={billingMessage} onPress={mockOnPress} />
|
||||||
)
|
)
|
||||||
expect(getByText('👍')).toBeTruthy()
|
expect(getByText('💰')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Avatar Display', () => {
|
describe('Avatar Display', () => {
|
||||||
|
|||||||
104
components/message/MessageEmptyState.test.tsx
Normal file
104
components/message/MessageEmptyState.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render } from '@testing-library/react-native'
|
||||||
|
import { Text } from 'react-native'
|
||||||
|
|
||||||
|
import { MessageEmptyState } from './MessageEmptyState'
|
||||||
|
|
||||||
|
describe('MessageEmptyState Component', () => {
|
||||||
|
describe('Default Rendering', () => {
|
||||||
|
it('should render default message "暂无消息"', () => {
|
||||||
|
const { getByText } = render(<MessageEmptyState />)
|
||||||
|
expect(getByText('暂无消息')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render default icon', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
expect(getByTestId('empty-state-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render container with testID', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
expect(getByTestId('message-empty-state')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Custom Props', () => {
|
||||||
|
it('should render custom message when provided', () => {
|
||||||
|
const customMessage = '没有互动消息'
|
||||||
|
const { getByText, queryByText } = render(
|
||||||
|
<MessageEmptyState message={customMessage} />
|
||||||
|
)
|
||||||
|
expect(getByText(customMessage)).toBeTruthy()
|
||||||
|
expect(queryByText('暂无消息')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render custom icon when provided', () => {
|
||||||
|
const CustomIcon = <Text testID="custom-icon">🎨</Text>
|
||||||
|
const { getByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
|
||||||
|
expect(getByTestId('custom-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render default icon when custom icon is provided', () => {
|
||||||
|
const CustomIcon = <Text testID="custom-icon">🎨</Text>
|
||||||
|
const { queryByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
|
||||||
|
// Custom icon container should exist, but default icon should be replaced
|
||||||
|
expect(queryByTestId('custom-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Styling', () => {
|
||||||
|
it('should have centered content', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
const container = getByTestId('message-empty-state')
|
||||||
|
|
||||||
|
// Check that container has centered alignment styles
|
||||||
|
expect(container.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have transparent background', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
const container = getByTestId('message-empty-state')
|
||||||
|
|
||||||
|
expect(container.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct text color #8A8A8A', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
const messageText = getByTestId('empty-state-message')
|
||||||
|
|
||||||
|
expect(messageText.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
color: '#8A8A8A',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have flex: 1 to fill available space', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
const container = getByTestId('message-empty-state')
|
||||||
|
|
||||||
|
expect(container.props.style).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
flex: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should have accessible message text', () => {
|
||||||
|
const { getByTestId } = render(<MessageEmptyState />)
|
||||||
|
const messageText = getByTestId('empty-state-message')
|
||||||
|
expect(messageText).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
42
components/message/MessageEmptyState.tsx
Normal file
42
components/message/MessageEmptyState.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
export interface MessageEmptyStateProps {
|
||||||
|
message?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageEmptyState({
|
||||||
|
message = '暂无消息',
|
||||||
|
icon,
|
||||||
|
}: MessageEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<View testID="message-empty-state" style={styles.container}>
|
||||||
|
<View testID="empty-state-icon" style={styles.iconContainer}>
|
||||||
|
{icon || <Text style={styles.defaultIcon}>📭</Text>}
|
||||||
|
</View>
|
||||||
|
<Text testID="empty-state-message" style={styles.message}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
defaultIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#8A8A8A',
|
||||||
|
},
|
||||||
|
})
|
||||||
283
components/message/MessageTabBar.test.tsx
Normal file
283
components/message/MessageTabBar.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, fireEvent } from '@testing-library/react-native'
|
||||||
|
|
||||||
|
import { MessageTabBar, TAB_ITEMS } from './MessageTabBar'
|
||||||
|
import type { MessageType } from './MessageTabBar'
|
||||||
|
|
||||||
|
describe('MessageTabBar Constants', () => {
|
||||||
|
describe('TAB_ITEMS', () => {
|
||||||
|
it('should have 4 tabs', () => {
|
||||||
|
expect(TAB_ITEMS).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct tab keys', () => {
|
||||||
|
const keys = TAB_ITEMS.map((tab) => tab.key)
|
||||||
|
expect(keys).toEqual(['all', 'activity', 'system', 'billing'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct tab labels', () => {
|
||||||
|
const labels = TAB_ITEMS.map((tab) => tab.label)
|
||||||
|
expect(labels).toEqual(['全部', '互动', '系统', '账单'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct tab types', () => {
|
||||||
|
const types = TAB_ITEMS.map((tab) => tab.type)
|
||||||
|
expect(types).toEqual([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MessageTabBar Component', () => {
|
||||||
|
const mockOnTabChange = jest.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render all 4 tabs', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByText('全部')).toBeTruthy()
|
||||||
|
expect(getByText('互动')).toBeTruthy()
|
||||||
|
expect(getByText('系统')).toBeTruthy()
|
||||||
|
expect(getByText('账单')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with testID for each tab', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-all')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-activity')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-system')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-billing')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Active Tab Highlighting', () => {
|
||||||
|
it('should highlight "全部" tab when activeTab is undefined', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const allTab = getByTestId('tab-all')
|
||||||
|
const activityTab = getByTestId('tab-activity')
|
||||||
|
|
||||||
|
// Check that all tab has active indicator
|
||||||
|
expect(getByTestId('tab-all-active-indicator')).toBeTruthy()
|
||||||
|
// Check that activity tab does not have active indicator
|
||||||
|
expect(() => getByTestId('tab-activity-active-indicator')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should highlight "互动" tab when activeTab is ACTIVITY', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-activity-active-indicator')).toBeTruthy()
|
||||||
|
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should highlight "系统" tab when activeTab is SYSTEM', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab="SYSTEM" onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-system-active-indicator')).toBeTruthy()
|
||||||
|
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should highlight "账单" tab when activeTab is BILLING', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab="BILLING" onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-billing-active-indicator')).toBeTruthy()
|
||||||
|
expect(() => getByTestId('tab-all-active-indicator')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tab Press Interaction', () => {
|
||||||
|
it('should call onTabChange with undefined when "全部" tab is pressed', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-all'))
|
||||||
|
expect(mockOnTabChange).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onTabChange with ACTIVITY when "互动" tab is pressed', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-activity'))
|
||||||
|
expect(mockOnTabChange).toHaveBeenCalledWith('ACTIVITY')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onTabChange with SYSTEM when "系统" tab is pressed', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-system'))
|
||||||
|
expect(mockOnTabChange).toHaveBeenCalledWith('SYSTEM')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onTabChange with BILLING when "账单" tab is pressed', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-billing'))
|
||||||
|
expect(mockOnTabChange).toHaveBeenCalledWith('BILLING')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onTabChange even when pressing the already active tab', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('tab-all'))
|
||||||
|
expect(mockOnTabChange).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Unread Badge Display', () => {
|
||||||
|
it('should show unread badge on "全部" tab when all count > 0', () => {
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ all: 5 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-all-badge')).toBeTruthy()
|
||||||
|
expect(getByText('5')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show unread badge on "互动" tab when activity count > 0', () => {
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ activity: 3 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-activity-badge')).toBeTruthy()
|
||||||
|
expect(getByText('3')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show unread badge on "系统" tab when system count > 0', () => {
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ system: 10 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-system-badge')).toBeTruthy()
|
||||||
|
expect(getByText('10')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show unread badge on "账单" tab when billing count > 0', () => {
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ billing: 2 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-billing-badge')).toBeTruthy()
|
||||||
|
expect(getByText('2')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show multiple badges when multiple counts > 0', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ all: 15, activity: 5, system: 8, billing: 2 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByTestId('tab-all-badge')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-activity-badge')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-system-badge')).toBeTruthy()
|
||||||
|
expect(getByTestId('tab-billing-badge')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show "99+" when count exceeds 99', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ all: 150 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getByText('99+')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('No Badge Display', () => {
|
||||||
|
it('should not show badge when unreadCounts is not provided', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(queryByTestId('tab-all-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-activity-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-system-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-billing-badge')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show badge when count is 0', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{ all: 0, activity: 0, system: 0, billing: 0 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(queryByTestId('tab-all-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-activity-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-system-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-billing-badge')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show badge when count is undefined', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<MessageTabBar
|
||||||
|
activeTab={undefined}
|
||||||
|
onTabChange={mockOnTabChange}
|
||||||
|
unreadCounts={{}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(queryByTestId('tab-all-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-activity-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-system-badge')).toBeNull()
|
||||||
|
expect(queryByTestId('tab-billing-badge')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MessageTabBar Types', () => {
|
||||||
|
it('should accept valid MessageType values', () => {
|
||||||
|
const types: MessageType[] = [undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']
|
||||||
|
types.forEach((type) => {
|
||||||
|
expect([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']).toContain(type)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
161
components/message/MessageTabBar.tsx
Normal file
161
components/message/MessageTabBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type MessageType = 'ACTIVITY' | 'SYSTEM' | 'BILLING' | undefined
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MessageType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageTabBarProps {
|
||||||
|
activeTab: MessageType
|
||||||
|
onTabChange: (type: MessageType) => void
|
||||||
|
unreadCounts?: {
|
||||||
|
all?: number
|
||||||
|
activity?: number
|
||||||
|
system?: number
|
||||||
|
billing?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export const TAB_ITEMS: TabItem[] = [
|
||||||
|
{ key: 'all', label: '全部', type: undefined },
|
||||||
|
{ key: 'activity', label: '互动', type: 'ACTIVITY' },
|
||||||
|
{ key: 'system', label: '系统', type: 'SYSTEM' },
|
||||||
|
{ key: 'billing', label: '账单', type: 'BILLING' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Helper function to format badge count
|
||||||
|
const formatBadgeCount = (count: number): string => {
|
||||||
|
if (count > 99) {
|
||||||
|
return '99+'
|
||||||
|
}
|
||||||
|
return String(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get unread count for a tab
|
||||||
|
const getUnreadCount = (
|
||||||
|
key: string,
|
||||||
|
unreadCounts?: MessageTabBarProps['unreadCounts']
|
||||||
|
): number | undefined => {
|
||||||
|
if (!unreadCounts) return undefined
|
||||||
|
switch (key) {
|
||||||
|
case 'all':
|
||||||
|
return unreadCounts.all
|
||||||
|
case 'activity':
|
||||||
|
return unreadCounts.activity
|
||||||
|
case 'system':
|
||||||
|
return unreadCounts.system
|
||||||
|
case 'billing':
|
||||||
|
return unreadCounts.billing
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageTabBar: React.FC<MessageTabBarProps> = ({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
unreadCounts,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{TAB_ITEMS.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.type
|
||||||
|
const unreadCount = getUnreadCount(tab.key, unreadCounts)
|
||||||
|
const showBadge = unreadCount !== undefined && unreadCount > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
testID={`tab-${tab.key}`}
|
||||||
|
style={styles.tabItem}
|
||||||
|
onPress={() => onTabChange(tab.type)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.tabContent}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
isActive ? styles.tabLabelActive : styles.tabLabelInactive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
{showBadge && (
|
||||||
|
<View testID={`tab-${tab.key}-badge`} style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{formatBadgeCount(unreadCount)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{isActive && (
|
||||||
|
<LinearGradient
|
||||||
|
testID={`tab-${tab.key}-active-indicator`}
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.activeIndicator}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#090A0B',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
tabItem: {
|
||||||
|
marginRight: 24,
|
||||||
|
paddingBottom: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tabContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tabLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
tabLabelActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
tabLabelInactive: {
|
||||||
|
color: '#8A8A8A',
|
||||||
|
},
|
||||||
|
activeIndicator: {
|
||||||
|
height: 2,
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: '#FF3B30',
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
marginLeft: 4,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
})
|
||||||
75
components/message/SwipeToDelete.tsx
Normal file
75
components/message/SwipeToDelete.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ViewStyle,
|
||||||
|
StyleProp,
|
||||||
|
} from 'react-native'
|
||||||
|
import { Swipeable } from 'react-native-gesture-handler'
|
||||||
|
|
||||||
|
export interface SwipeToDeleteProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
onDelete: () => void
|
||||||
|
enabled?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
deleteText?: string
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwipeToDelete({
|
||||||
|
children,
|
||||||
|
onDelete,
|
||||||
|
enabled = true,
|
||||||
|
disabled = false,
|
||||||
|
deleteText = '删除',
|
||||||
|
style,
|
||||||
|
}: SwipeToDeleteProps) {
|
||||||
|
const isDisabled = disabled || !enabled
|
||||||
|
|
||||||
|
const renderRightActions = () => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="swipe-delete-button"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="删除"
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => {
|
||||||
|
if (!isDisabled) {
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteText}>{deleteText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable
|
||||||
|
testID="swipe-container"
|
||||||
|
renderRightActions={renderRightActions}
|
||||||
|
enabled={!isDisabled}
|
||||||
|
overshootRight={false}
|
||||||
|
friction={2}
|
||||||
|
>
|
||||||
|
<View style={style}>{children}</View>
|
||||||
|
</Swipeable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
deleteButton: {
|
||||||
|
backgroundColor: '#FF3B30',
|
||||||
|
width: 80,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
deleteText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
})
|
||||||
14
components/message/index.ts
Normal file
14
components/message/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { MessageCard, getMessageIcon, getMessageConfig, formatRelativeTime } from './MessageCard'
|
||||||
|
export type { Message, MessageData } from './MessageCard'
|
||||||
|
|
||||||
|
export { AnnouncementBanner } from './AnnouncementBanner'
|
||||||
|
export type { Announcement, AnnouncementBannerProps } from './AnnouncementBanner'
|
||||||
|
|
||||||
|
export { SwipeToDelete } from './SwipeToDelete'
|
||||||
|
export type { SwipeToDeleteProps } from './SwipeToDelete'
|
||||||
|
|
||||||
|
export { MessageTabBar, TAB_ITEMS } from './MessageTabBar'
|
||||||
|
export type { MessageType, TabItem, MessageTabBarProps } from './MessageTabBar'
|
||||||
|
|
||||||
|
export { MessageEmptyState } from './MessageEmptyState'
|
||||||
|
export type { MessageEmptyStateProps } from './MessageEmptyState'
|
||||||
117
jest.setup.js
117
jest.setup.js
@@ -3,95 +3,8 @@ jest.mock('react-native-css-interop', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock react-native BEFORE any other imports to avoid flow type issues
|
// Import extend-expect for jest matchers
|
||||||
jest.mock('react-native', () => {
|
import '@testing-library/react-native/extend-expect'
|
||||||
const mockReact = require('react')
|
|
||||||
|
|
||||||
// Helper to create mock components that render as actual React elements
|
|
||||||
const mockCreateMockComponent = (mockName) => {
|
|
||||||
const MockComponent = mockReact.forwardRef(({ children, ...mockProps }, mockRef) => {
|
|
||||||
return mockReact.createElement(mockName, { ...mockProps, ref: mockRef }, children)
|
|
||||||
})
|
|
||||||
MockComponent.displayName = mockName
|
|
||||||
return MockComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Pressable with onPress support
|
|
||||||
const MockPressable = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => {
|
|
||||||
return mockReact.createElement('Pressable', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children)
|
|
||||||
})
|
|
||||||
MockPressable.displayName = 'Pressable'
|
|
||||||
|
|
||||||
// Create TouchableOpacity with onPress support
|
|
||||||
const MockTouchableOpacity = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => {
|
|
||||||
return mockReact.createElement('TouchableOpacity', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children)
|
|
||||||
})
|
|
||||||
MockTouchableOpacity.displayName = 'TouchableOpacity'
|
|
||||||
|
|
||||||
return {
|
|
||||||
RefreshControl: mockCreateMockComponent('RefreshControl'),
|
|
||||||
ScrollView: mockCreateMockComponent('ScrollView'),
|
|
||||||
FlatList: mockCreateMockComponent('FlatList'),
|
|
||||||
View: mockCreateMockComponent('View'),
|
|
||||||
Text: mockCreateMockComponent('Text'),
|
|
||||||
Image: mockCreateMockComponent('Image'),
|
|
||||||
Pressable: MockPressable,
|
|
||||||
TouchableOpacity: MockTouchableOpacity,
|
|
||||||
TextInput: mockCreateMockComponent('TextInput'),
|
|
||||||
ActivityIndicator: mockCreateMockComponent('ActivityIndicator'),
|
|
||||||
Platform: {
|
|
||||||
OS: 'web',
|
|
||||||
select: (mockObj) => mockObj.web || mockObj.default,
|
|
||||||
},
|
|
||||||
Animated: {
|
|
||||||
View: mockCreateMockComponent('Animated.View'),
|
|
||||||
Text: mockCreateMockComponent('Animated.Text'),
|
|
||||||
Image: mockCreateMockComponent('Animated.Image'),
|
|
||||||
Value: class MockValue {
|
|
||||||
constructor(mockV) { this._value = mockV }
|
|
||||||
setValue(mockV) { this._value = mockV }
|
|
||||||
__getValue() { return this._value }
|
|
||||||
interpolate() { return this }
|
|
||||||
},
|
|
||||||
timing: () => ({ start: jest.fn() }),
|
|
||||||
spring: () => ({ start: jest.fn() }),
|
|
||||||
event: () => jest.fn(),
|
|
||||||
},
|
|
||||||
PanResponder: {
|
|
||||||
create: () => ({ panHandlers: {} }),
|
|
||||||
},
|
|
||||||
StyleSheet: { create: (mockStyles) => mockStyles },
|
|
||||||
Dimensions: { get: () => ({ width: 375, height: 812 }) },
|
|
||||||
StatusBar: mockCreateMockComponent('StatusBar'),
|
|
||||||
SafeAreaView: mockCreateMockComponent('SafeAreaView'),
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
||||||
NativeModules: {
|
|
||||||
DevMenu: {},
|
|
||||||
SettingsManager: {},
|
|
||||||
},
|
|
||||||
Settings: {
|
|
||||||
get: jest.fn(),
|
|
||||||
set: jest.fn(),
|
|
||||||
},
|
|
||||||
Alert: {
|
|
||||||
alert: jest.fn(),
|
|
||||||
},
|
|
||||||
Linking: {
|
|
||||||
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')
|
|
||||||
|
|
||||||
// Mock global Appearance for react-native-css-interop
|
// Mock global Appearance for react-native-css-interop
|
||||||
global.Appearance = {
|
global.Appearance = {
|
||||||
@@ -168,9 +81,20 @@ jest.mock('react-native-safe-area-context', () => ({
|
|||||||
|
|
||||||
// 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 mockReact = require('react')
|
||||||
|
const mockRN = require('react-native')
|
||||||
|
|
||||||
|
// Mock Swipeable component that renders children and right actions
|
||||||
|
const MockSwipeable = mockReact.forwardRef(({ children, renderRightActions, testID, enabled, ...props }, ref) => {
|
||||||
|
return mockReact.createElement(mockRN.View, { testID, ref, ...props }, [
|
||||||
|
children,
|
||||||
|
renderRightActions && renderRightActions(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
MockSwipeable.displayName = 'Swipeable'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
GestureDetector: View,
|
GestureDetector: mockRN.View,
|
||||||
Gesture: {
|
Gesture: {
|
||||||
Tap: () => ({}),
|
Tap: () => ({}),
|
||||||
Pan: () => ({}),
|
Pan: () => ({}),
|
||||||
@@ -182,11 +106,12 @@ jest.mock('react-native-gesture-handler', () => {
|
|||||||
ForceTouch: () => ({}),
|
ForceTouch: () => ({}),
|
||||||
ManualGesture: () => ({}),
|
ManualGesture: () => ({}),
|
||||||
},
|
},
|
||||||
GestureHandlerRootView: View,
|
GestureHandlerRootView: mockRN.View,
|
||||||
RawButton: View,
|
RawButton: mockRN.View,
|
||||||
BaseButton: View,
|
BaseButton: mockRN.View,
|
||||||
RectButton: View,
|
RectButton: mockRN.View,
|
||||||
BorderlessButton: View,
|
BorderlessButton: mockRN.View,
|
||||||
|
Swipeable: MockSwipeable,
|
||||||
State: {},
|
State: {},
|
||||||
Directions: {},
|
Directions: {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"title": "Messages",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"notice": "Activity Notice",
|
"notice": "Activity Notice",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"noMessages": "No messages"
|
"noMessages": "No messages",
|
||||||
|
"activity": "Activity",
|
||||||
|
"system": "System",
|
||||||
|
"billing": "Billing",
|
||||||
|
"markAllRead": "Mark all read",
|
||||||
|
"delete": "Delete",
|
||||||
|
"templateLiked": "liked your work",
|
||||||
|
"templateFavorited": "favorited your work",
|
||||||
|
"templateCommented": "commented on your work",
|
||||||
|
"commentReplied": "replied to your comment",
|
||||||
|
"generationSuccess": "Work generated successfully",
|
||||||
|
"generationFailed": "Work generation failed",
|
||||||
|
"creditsDeducted": "Credits deducted",
|
||||||
|
"creditsRefunded": "Credits refunded",
|
||||||
|
"creditsRecharged": "Credits recharged"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"makeSame": "Make Same Style"
|
"makeSame": "Make Same Style"
|
||||||
|
|||||||
@@ -108,10 +108,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"title": "消息",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"notice": "活动通知",
|
"notice": "活动通知",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"noMessages": "暂无消息"
|
"noMessages": "暂无消息",
|
||||||
|
"activity": "互动",
|
||||||
|
"system": "系统",
|
||||||
|
"billing": "账单",
|
||||||
|
"markAllRead": "全部已读",
|
||||||
|
"delete": "删除",
|
||||||
|
"templateLiked": "赞了你的作品",
|
||||||
|
"templateFavorited": "收藏了你的作品",
|
||||||
|
"templateCommented": "评论了你的作品",
|
||||||
|
"commentReplied": "回复了你的评论",
|
||||||
|
"generationSuccess": "作品生成成功",
|
||||||
|
"generationFailed": "作品生成失败",
|
||||||
|
"creditsDeducted": "积分已扣除",
|
||||||
|
"creditsRefunded": "积分已退还",
|
||||||
|
"creditsRecharged": "积分充值成功"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"makeSame": "做同款"
|
"makeSame": "做同款"
|
||||||
|
|||||||
Reference in New Issue
Block a user