expo-popcore-app/app/(tabs)/__tests__/message.test.tsx

685 lines
20 KiB
TypeScript

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()
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(<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 refetch on mount via useFocusEffect', () => {
render(<MessageScreen />)
// 现在使用 useFocusEffect + refetch 替代 useEffect + execute
expect(mockRefetch).toHaveBeenCalled()
})
it('should call announcement execute on mount', () => {
render(<MessageScreen />)
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(<MessageScreen />)
const flatList = getByTestId('message-list')
expect(flatList.props.refreshControl).toBeTruthy()
})
it('should call refetch when pull-to-refresh is triggered', async () => {
const { getByTestId } = render(<MessageScreen />)
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(<MessageScreen />)
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(<MessageScreen />)
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(<MessageScreen />)
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(<MessageScreen />)
// useFocusEffect 在 mock 中会立即执行 callback
// 所以 refetch 应该被调用
expect(mockRefetch).toHaveBeenCalled()
})
it('should call executeAnnouncements when tab gains focus', () => {
render(<MessageScreen />)
expect(mockAnnouncementExecute).toHaveBeenCalled()
})
it('should use useFocusEffect hook', () => {
render(<MessageScreen />)
// 验证 useFocusEffect 被调用
expect(mockUseFocusEffect).toHaveBeenCalled()
})
})
})