fix: bug
This commit is contained in:
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', () => {
|
||||
// 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(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
<MessageCard message={billingMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('👍')).toBeTruthy()
|
||||
expect(getByText('💰')).toBeTruthy()
|
||||
})
|
||||
|
||||
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'
|
||||
Reference in New Issue
Block a user