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 = { 'message.title': '消息', 'message.markAllRead': '全部已读', 'message.noMessages': '暂无消息', } return translations[key] || key }, }), })) // Mock expo-router const mockPush = jest.fn() const mockUseFocusEffect = jest.fn((callback: () => void) => { // 立即执行 callback 来模拟页面获得焦点 callback() }) jest.mock('expo-router', () => ({ useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn(), }), useLocalSearchParams: () => ({}), useFocusEffect: (callback: () => void) => mockUseFocusEffect(callback), })) // 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, refreshing: 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() expect(getByText('消息')).toBeTruthy() }) it('should render "全部已读" button in header', () => { const { getByText } = render() expect(getByText('全部已读')).toBeTruthy() }) it('should render MessageTabBar component', () => { const { getByTestId } = render() 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() 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() expect(queryByTestId('announcement-banner')).toBeNull() }) }) describe('Message List', () => { it('should render message list with MessageCard components', () => { const { getAllByTestId } = render() 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() 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() 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() const messageCards = getAllByTestId('message-card') expect(messageCards.length).toBe(3) }) it('should filter ACTIVITY messages when "互动" tab is clicked', async () => { const { getByTestId, getAllByTestId } = render() 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() 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() 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() 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() 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() // 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() const errorStates = UNSAFE_queryAllByType('ErrorState' as any) expect(errorStates.length).toBeGreaterThan(0) }) }) describe('Initial Data Fetch', () => { it('should call refetch on mount via useFocusEffect', () => { render() // 现在使用 useFocusEffect + refetch 替代 useEffect + execute expect(mockRefetch).toHaveBeenCalled() }) it('should call announcement execute on mount', () => { render() expect(mockAnnouncementExecute).toHaveBeenCalled() }) }) describe('Pull-to-Refresh', () => { beforeEach(() => { jest.clearAllMocks() const { useMessages } = require('@/hooks/use-messages') useMessages.mockReturnValue({ messages: mockMessages, loading: false, loadingMore: false, refreshing: 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, refetch: jest.fn(), loadMore: jest.fn(), hasMore: false, }) }) it('should render FlatList with RefreshControl', () => { const { getByTestId } = render() const flatList = getByTestId('message-list') expect(flatList.props.refreshControl).toBeTruthy() }) it('should call refetch when pull-to-refresh is triggered', async () => { const { getByTestId } = render() const flatList = getByTestId('message-list') // 触发 onRefresh const refreshControl = flatList.props.refreshControl refreshControl.props.onRefresh() await waitFor(() => { expect(mockRefetch).toHaveBeenCalled() }) }) it('should call executeAnnouncements when pull-to-refresh is triggered', async () => { const { getByTestId } = render() const flatList = getByTestId('message-list') // 触发 onRefresh const refreshControl = flatList.props.refreshControl refreshControl.props.onRefresh() await waitFor(() => { expect(mockAnnouncementExecute).toHaveBeenCalled() }) }) it('should show refreshing indicator when refreshing is true', () => { const { useMessages } = require('@/hooks/use-messages') useMessages.mockReturnValue({ messages: mockMessages, loading: false, loadingMore: false, refreshing: true, error: null, execute: mockExecute, refetch: mockRefetch, loadMore: mockLoadMore, hasMore: true, }) const { getByTestId } = render() const flatList = getByTestId('message-list') const refreshControl = flatList.props.refreshControl expect(refreshControl.props.refreshing).toBe(true) }) it('should not show refreshing indicator when refreshing is false', () => { const { getByTestId } = render() const flatList = getByTestId('message-list') const refreshControl = flatList.props.refreshControl expect(refreshControl.props.refreshing).toBe(false) }) }) describe('Tab Focus Reload', () => { beforeEach(() => { jest.clearAllMocks() const { useMessages } = require('@/hooks/use-messages') useMessages.mockReturnValue({ messages: mockMessages, loading: false, loadingMore: false, refreshing: 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, refetch: jest.fn(), loadMore: jest.fn(), hasMore: false, }) }) it('should call refetch when tab gains focus', () => { render() // useFocusEffect 在 mock 中会立即执行 callback // 所以 refetch 应该被调用 expect(mockRefetch).toHaveBeenCalled() }) it('should call executeAnnouncements when tab gains focus', () => { render() expect(mockAnnouncementExecute).toHaveBeenCalled() }) it('should use useFocusEffect hook', () => { render() // 验证 useFocusEffect 被调用 expect(mockUseFocusEffect).toHaveBeenCalled() }) }) })