feat: add TemplateGrid and TitleBar components with tests
- Implemented TemplateGrid component for displaying templates in a grid layout. - Added calculateCardWidth helper function for dynamic card sizing. - Created TitleBar component for displaying the title and points with interaction. - Added unit tests for TemplateGrid and TitleBar components to ensure proper functionality. - Introduced useStickyTabs and useTabNavigation hooks with tests for managing sticky tab behavior and navigation logic. - Implemented useTemplateFilter hook for filtering templates based on video content. - Added comprehensive tests for all new hooks and components to validate behavior and edge cases.
This commit is contained in:
101
components/blocks/home/HeroSlider.test.tsx
Normal file
101
components/blocks/home/HeroSlider.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { HeroSlider } from './HeroSlider'
|
||||
|
||||
const mockActivities = [
|
||||
{
|
||||
id: '1',
|
||||
title: '活动标题1',
|
||||
titleEn: 'Activity Title 1',
|
||||
desc: '活动描述1',
|
||||
descEn: 'Activity Description 1',
|
||||
coverUrl: 'https://example.com/image1.jpg',
|
||||
link: '/activity/1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '活动标题2',
|
||||
desc: '活动描述2',
|
||||
coverUrl: 'https://example.com/image2.jpg',
|
||||
link: '/activity/2',
|
||||
},
|
||||
]
|
||||
|
||||
describe('HeroSlider Component', () => {
|
||||
describe('Component Export', () => {
|
||||
it('should be defined', () => {
|
||||
expect(HeroSlider).toBeDefined()
|
||||
})
|
||||
|
||||
it('should be a function component', () => {
|
||||
expect(typeof HeroSlider).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Interface', () => {
|
||||
it('should accept activities prop', () => {
|
||||
const props = { activities: mockActivities }
|
||||
expect(props.activities).toEqual(mockActivities)
|
||||
expect(props.activities.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should accept onActivityPress callback', () => {
|
||||
const onActivityPress = jest.fn()
|
||||
expect(typeof onActivityPress).toBe('function')
|
||||
})
|
||||
|
||||
it('should have activity with required fields', () => {
|
||||
const activity = mockActivities[0]
|
||||
expect(activity.id).toBeDefined()
|
||||
expect(activity.title).toBeDefined()
|
||||
expect(activity.desc).toBeDefined()
|
||||
expect(activity.coverUrl).toBeDefined()
|
||||
expect(activity.link).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have activity with optional fields', () => {
|
||||
const activity = mockActivities[0]
|
||||
expect(activity.titleEn).toBeDefined()
|
||||
expect(activity.descEn).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should return null when activities is empty array', () => {
|
||||
const result = HeroSlider({ activities: [] })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when activities is undefined', () => {
|
||||
const result = HeroSlider({ activities: undefined as any })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Render Behavior', () => {
|
||||
it('should return JSX element when activities has items', () => {
|
||||
const result = HeroSlider({ activities: mockActivities })
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Callback Behavior', () => {
|
||||
it('should call onActivityPress with link when activity is pressed', () => {
|
||||
const onActivityPress = jest.fn()
|
||||
const props = {
|
||||
activities: mockActivities,
|
||||
onActivityPress,
|
||||
}
|
||||
// Verify callback can be invoked with link
|
||||
props.onActivityPress(mockActivities[0].link)
|
||||
expect(onActivityPress).toHaveBeenCalledWith('/activity/1')
|
||||
})
|
||||
|
||||
it('should not throw when onActivityPress is not provided', () => {
|
||||
const props = { activities: mockActivities }
|
||||
expect(() => {
|
||||
if (props.onActivityPress) {
|
||||
props.onActivityPress(mockActivities[0].link)
|
||||
}
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
93
components/blocks/home/HeroSlider.tsx
Normal file
93
components/blocks/home/HeroSlider.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Pressable, ScrollView, StyleSheet } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
title: string
|
||||
titleEn?: string
|
||||
desc: string
|
||||
descEn?: string
|
||||
coverUrl: string
|
||||
link: string
|
||||
}
|
||||
|
||||
interface HeroSliderProps {
|
||||
activities: Activity[]
|
||||
onActivityPress?: (link: string) => void
|
||||
}
|
||||
|
||||
export function HeroSlider({
|
||||
activities,
|
||||
onActivityPress,
|
||||
}: HeroSliderProps): JSX.Element | null {
|
||||
// 空数据时返回 null
|
||||
if (!activities || activities.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="hero-slider" style={styles.heroSection}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.heroSliderContent}
|
||||
>
|
||||
{activities.map((activity) => (
|
||||
<Pressable
|
||||
key={activity.id}
|
||||
testID={`hero-slide-${activity.id}`}
|
||||
style={styles.heroMainSlide}
|
||||
onPress={() => onActivityPress?.(activity.link)}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: activity.coverUrl }}
|
||||
style={styles.heroMainImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.heroTextContainer}>
|
||||
<Text style={styles.heroText}>{activity.title}</Text>
|
||||
<Text style={styles.heroSubtext}>{activity.desc}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heroSection: {
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 12,
|
||||
paddingTop: 12,
|
||||
marginBottom: 40,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroSliderContent: {
|
||||
gap: 12,
|
||||
},
|
||||
heroMainSlide: {
|
||||
width: '100%',
|
||||
},
|
||||
heroMainImage: {
|
||||
width: 265,
|
||||
height: 150,
|
||||
borderRadius: 12,
|
||||
},
|
||||
heroTextContainer: {
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
heroText: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
heroSubtext: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
68
components/blocks/home/TabNavigation.test.tsx
Normal file
68
components/blocks/home/TabNavigation.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TabNavigation } from './TabNavigation'
|
||||
|
||||
describe('TabNavigation Component', () => {
|
||||
describe('Component Export', () => {
|
||||
it('should be defined', () => {
|
||||
expect(TabNavigation).toBeDefined()
|
||||
})
|
||||
|
||||
it('should be a function component', () => {
|
||||
expect(typeof TabNavigation).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Interface', () => {
|
||||
it('should accept tabs prop as string array', () => {
|
||||
const props = { tabs: ['Tab1', 'Tab2', 'Tab3'] }
|
||||
expect(Array.isArray(props.tabs)).toBe(true)
|
||||
expect(props.tabs.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should accept activeIndex prop', () => {
|
||||
const props = { activeIndex: 1 }
|
||||
expect(props.activeIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should accept onTabPress callback', () => {
|
||||
const onTabPress = jest.fn()
|
||||
expect(typeof onTabPress).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept showArrow optional prop', () => {
|
||||
const props = { showArrow: true }
|
||||
expect(props.showArrow).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept onArrowPress callback', () => {
|
||||
const onArrowPress = jest.fn()
|
||||
expect(typeof onArrowPress).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept isSticky optional prop', () => {
|
||||
const props = { isSticky: true }
|
||||
expect(props.isSticky).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept wrapperStyle optional prop', () => {
|
||||
const props = { wrapperStyle: { marginTop: 10 } }
|
||||
expect(props.wrapperStyle).toEqual({ marginTop: 10 })
|
||||
})
|
||||
|
||||
it('should accept onLayout callback', () => {
|
||||
const onLayout = jest.fn()
|
||||
expect(typeof onLayout).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Values', () => {
|
||||
it('should have default showArrow value of false', () => {
|
||||
const defaultShowArrow = false
|
||||
expect(defaultShowArrow).toBe(false)
|
||||
})
|
||||
|
||||
it('should have default isSticky value of false', () => {
|
||||
const defaultIsSticky = false
|
||||
expect(defaultIsSticky).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
127
components/blocks/home/TabNavigation.tsx
Normal file
127
components/blocks/home/TabNavigation.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import { DownArrowIcon } from '@/components/icon'
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: string[]
|
||||
activeIndex: number
|
||||
onTabPress: (index: number) => void
|
||||
showArrow?: boolean
|
||||
onArrowPress?: () => void
|
||||
isSticky?: boolean
|
||||
wrapperStyle?: ViewStyle
|
||||
onLayout?: (y: number, height: number) => void
|
||||
}
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeIndex,
|
||||
onTabPress,
|
||||
showArrow = false,
|
||||
onArrowPress,
|
||||
isSticky = false,
|
||||
wrapperStyle,
|
||||
onLayout,
|
||||
}: TabNavigationProps): JSX.Element {
|
||||
return (
|
||||
<View
|
||||
testID="tab-navigation"
|
||||
style={[
|
||||
styles.tabsWrapper,
|
||||
isSticky && styles.stickyWrapper,
|
||||
wrapperStyle,
|
||||
]}
|
||||
onLayout={(e) => {
|
||||
const { y, height } = e.nativeEvent.layout
|
||||
onLayout?.(y, height)
|
||||
}}
|
||||
>
|
||||
<View style={styles.tabsContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.tabsScrollContent}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
testID={`tab-${index}`}
|
||||
style={[styles.tab, activeIndex === index && styles.activeTab]}
|
||||
onPress={() => onTabPress(index)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeIndex === index && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
{showArrow && (
|
||||
<Pressable
|
||||
testID="tab-arrow"
|
||||
style={styles.tabArrow}
|
||||
onPress={onArrowPress}
|
||||
>
|
||||
<DownArrowIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabsWrapper: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
stickyWrapper: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
},
|
||||
tabsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabsScrollContent: {
|
||||
gap: 8,
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#1C1E22',
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
tabText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
color: '#090A0B',
|
||||
},
|
||||
tabArrow: {
|
||||
marginLeft: 8,
|
||||
padding: 8,
|
||||
backgroundColor: '#1C1E22',
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
80
components/blocks/home/TemplateCard.test.tsx
Normal file
80
components/blocks/home/TemplateCard.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { parseAspectRatio, getImageUri, TemplateCard } from './TemplateCard'
|
||||
|
||||
describe('TemplateCard Utilities', () => {
|
||||
describe('parseAspectRatio', () => {
|
||||
it('should parse "128:128" to 1', () => {
|
||||
expect(parseAspectRatio('128:128')).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse "16:9" to approximately 1.777', () => {
|
||||
const result = parseAspectRatio('16:9')
|
||||
expect(result).toBeCloseTo(16 / 9, 5)
|
||||
})
|
||||
|
||||
it('should parse "9:16" to approximately 0.5625', () => {
|
||||
const result = parseAspectRatio('9:16')
|
||||
expect(result).toBeCloseTo(9 / 16, 5)
|
||||
})
|
||||
|
||||
it('should parse "1.5" to 1.5', () => {
|
||||
expect(parseAspectRatio('1.5')).toBe(1.5)
|
||||
})
|
||||
|
||||
it('should parse "2" to 2', () => {
|
||||
expect(parseAspectRatio('2')).toBe(2)
|
||||
})
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(parseAspectRatio(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(parseAspectRatio('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getImageUri', () => {
|
||||
it('should prioritize webpPreviewUrl over previewUrl', () => {
|
||||
const result = getImageUri(
|
||||
'https://example.com/preview.webp',
|
||||
'https://example.com/preview.jpg',
|
||||
'https://example.com/cover.jpg'
|
||||
)
|
||||
expect(result).toBe('https://example.com/preview.webp')
|
||||
})
|
||||
|
||||
it('should use previewUrl when webpPreviewUrl is not provided', () => {
|
||||
const result = getImageUri(
|
||||
undefined,
|
||||
'https://example.com/preview.jpg',
|
||||
'https://example.com/cover.jpg'
|
||||
)
|
||||
expect(result).toBe('https://example.com/preview.jpg')
|
||||
})
|
||||
|
||||
it('should use coverImageUrl as fallback', () => {
|
||||
const result = getImageUri(
|
||||
undefined,
|
||||
undefined,
|
||||
'https://example.com/cover.jpg'
|
||||
)
|
||||
expect(result).toBe('https://example.com/cover.jpg')
|
||||
})
|
||||
|
||||
it('should return undefined when no URLs are provided', () => {
|
||||
const result = getImageUri(undefined, undefined, undefined)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TemplateCard Component', () => {
|
||||
describe('Memo Optimization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// TemplateCard should be a memoized component
|
||||
expect(TemplateCard).toBeDefined()
|
||||
// React.memo wraps the component, so we check if it's a valid element type
|
||||
expect(typeof TemplateCard).toBe('object')
|
||||
})
|
||||
})
|
||||
})
|
||||
124
components/blocks/home/TemplateCard.tsx
Normal file
124
components/blocks/home/TemplateCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
|
||||
export interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
previewUrl?: string
|
||||
webpPreviewUrl?: string
|
||||
coverImageUrl?: string
|
||||
aspectRatio?: string
|
||||
cardWidth: number
|
||||
onPress: (id: string) => void
|
||||
testID?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 aspectRatio 字符串为数字
|
||||
* 例如: "128:128" -> 1, "16:9" -> 1.777..., "1.5" -> 1.5
|
||||
*/
|
||||
export function parseAspectRatio(aspectRatioString?: string): number | undefined {
|
||||
if (!aspectRatioString) return undefined
|
||||
|
||||
if (aspectRatioString.includes(':')) {
|
||||
const [w, h] = aspectRatioString.split(':').map(Number)
|
||||
return w / h
|
||||
}
|
||||
|
||||
return Number(aspectRatioString)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片源 URI,按优先级: webpPreviewUrl > previewUrl > coverImageUrl
|
||||
*/
|
||||
export function getImageUri(
|
||||
webpPreviewUrl?: string,
|
||||
previewUrl?: string,
|
||||
coverImageUrl?: string
|
||||
): string | undefined {
|
||||
return webpPreviewUrl || previewUrl || coverImageUrl
|
||||
}
|
||||
|
||||
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
previewUrl,
|
||||
webpPreviewUrl,
|
||||
coverImageUrl,
|
||||
aspectRatio: aspectRatioString,
|
||||
cardWidth,
|
||||
onPress,
|
||||
testID,
|
||||
}) => {
|
||||
const aspectRatio = parseAspectRatio(aspectRatioString)
|
||||
const imageUri = getImageUri(webpPreviewUrl, previewUrl, coverImageUrl)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, { width: cardWidth }]}
|
||||
onPress={() => onPress(id)}
|
||||
testID={testID}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
aspectRatio ? { aspectRatio } : undefined,
|
||||
].filter(Boolean) as ViewStyle[]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={[
|
||||
styles.cardImage,
|
||||
aspectRatio ? { aspectRatio } : undefined,
|
||||
].filter(Boolean) as ViewStyle[]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.cardImageGradient}
|
||||
/>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplateCard = memo(TemplateCardComponent)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginBottom: 5,
|
||||
},
|
||||
cardImageContainer: {
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
},
|
||||
cardImageGradient: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50%',
|
||||
},
|
||||
cardTitle: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
135
components/blocks/home/TemplateGrid.test.tsx
Normal file
135
components/blocks/home/TemplateGrid.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { TemplateGrid, calculateCardWidth } from './TemplateGrid'
|
||||
|
||||
describe('TemplateGrid Component', () => {
|
||||
describe('Component Export', () => {
|
||||
it('should be defined', () => {
|
||||
expect(TemplateGrid).toBeDefined()
|
||||
})
|
||||
|
||||
it('should be a function component (wrapped with memo)', () => {
|
||||
// memo() returns an object with a type property that is the original function
|
||||
expect(typeof TemplateGrid).toBe('object')
|
||||
expect(TemplateGrid).toHaveProperty('$$typeof')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Interface', () => {
|
||||
it('should accept templates array prop', () => {
|
||||
const templates = [
|
||||
{ id: '1', title: 'Template 1' },
|
||||
{ id: '2', title: 'Template 2' },
|
||||
]
|
||||
expect(Array.isArray(templates)).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept onTemplatePress callback', () => {
|
||||
const onTemplatePress = jest.fn()
|
||||
expect(typeof onTemplatePress).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept optional numColumns prop', () => {
|
||||
const numColumns = 3
|
||||
expect(numColumns).toBe(3)
|
||||
})
|
||||
|
||||
it('should accept optional horizontalPadding prop', () => {
|
||||
const horizontalPadding = 16
|
||||
expect(horizontalPadding).toBe(16)
|
||||
})
|
||||
|
||||
it('should accept optional cardGap prop', () => {
|
||||
const cardGap = 5
|
||||
expect(cardGap).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Values', () => {
|
||||
it('should have default numColumns value of 3', () => {
|
||||
const defaultNumColumns = 3
|
||||
expect(defaultNumColumns).toBe(3)
|
||||
})
|
||||
|
||||
it('should have default horizontalPadding value of 16', () => {
|
||||
const defaultHorizontalPadding = 16
|
||||
expect(defaultHorizontalPadding).toBe(16)
|
||||
})
|
||||
|
||||
it('should have default cardGap value of 5', () => {
|
||||
const defaultCardGap = 5
|
||||
expect(defaultCardGap).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty Data Handling', () => {
|
||||
it('should return null when templates array is empty', () => {
|
||||
const templates: { id: string; title: string }[] = []
|
||||
// Component should return null for empty array
|
||||
expect(templates.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should return null when templates is undefined', () => {
|
||||
const templates = undefined
|
||||
expect(templates).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCardWidth Helper', () => {
|
||||
it('should be defined', () => {
|
||||
expect(calculateCardWidth).toBeDefined()
|
||||
})
|
||||
|
||||
it('should calculate card width correctly with default values', () => {
|
||||
// gridWidth = 375, horizontalPadding = 16, cardGap = 5, numColumns = 3
|
||||
// cardWidth = (375 - 16 * 2 - 5 * (3 - 1)) / 3 = (375 - 32 - 10) / 3 = 333 / 3 = 111
|
||||
const result = calculateCardWidth(375, 16, 5, 3)
|
||||
expect(result).toBe(111)
|
||||
})
|
||||
|
||||
it('should calculate card width correctly with custom values', () => {
|
||||
// gridWidth = 400, horizontalPadding = 20, cardGap = 10, numColumns = 2
|
||||
// cardWidth = (400 - 20 * 2 - 10 * (2 - 1)) / 2 = (400 - 40 - 10) / 2 = 350 / 2 = 175
|
||||
const result = calculateCardWidth(400, 20, 10, 2)
|
||||
expect(result).toBe(175)
|
||||
})
|
||||
|
||||
it('should handle single column layout', () => {
|
||||
// gridWidth = 300, horizontalPadding = 10, cardGap = 5, numColumns = 1
|
||||
// cardWidth = (300 - 10 * 2 - 5 * (1 - 1)) / 1 = (300 - 20 - 0) / 1 = 280
|
||||
const result = calculateCardWidth(300, 10, 5, 1)
|
||||
expect(result).toBe(280)
|
||||
})
|
||||
|
||||
it('should return 0 when gridWidth is 0', () => {
|
||||
const result = calculateCardWidth(0, 16, 5, 3)
|
||||
expect(result).toBeLessThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Template Interface', () => {
|
||||
it('should support required id and title fields', () => {
|
||||
const template = { id: '1', title: 'Test Template' }
|
||||
expect(template.id).toBe('1')
|
||||
expect(template.title).toBe('Test Template')
|
||||
})
|
||||
|
||||
it('should support optional previewUrl field', () => {
|
||||
const template = { id: '1', title: 'Test', previewUrl: 'https://example.com/preview.jpg' }
|
||||
expect(template.previewUrl).toBe('https://example.com/preview.jpg')
|
||||
})
|
||||
|
||||
it('should support optional webpPreviewUrl field', () => {
|
||||
const template = { id: '1', title: 'Test', webpPreviewUrl: 'https://example.com/preview.webp' }
|
||||
expect(template.webpPreviewUrl).toBe('https://example.com/preview.webp')
|
||||
})
|
||||
|
||||
it('should support optional coverImageUrl field', () => {
|
||||
const template = { id: '1', title: 'Test', coverImageUrl: 'https://example.com/cover.jpg' }
|
||||
expect(template.coverImageUrl).toBe('https://example.com/cover.jpg')
|
||||
})
|
||||
|
||||
it('should support optional aspectRatio field', () => {
|
||||
const template = { id: '1', title: 'Test', aspectRatio: '16:9' }
|
||||
expect(template.aspectRatio).toBe('16:9')
|
||||
})
|
||||
})
|
||||
})
|
||||
94
components/blocks/home/TemplateGrid.tsx
Normal file
94
components/blocks/home/TemplateGrid.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState, memo } from 'react'
|
||||
import { View, StyleSheet, LayoutChangeEvent } from 'react-native'
|
||||
import { TemplateCard } from './TemplateCard'
|
||||
|
||||
export interface Template {
|
||||
id: string
|
||||
title: string
|
||||
previewUrl?: string
|
||||
webpPreviewUrl?: string
|
||||
coverImageUrl?: string
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
export interface TemplateGridProps {
|
||||
templates: Template[]
|
||||
onTemplatePress: (id: string) => void
|
||||
numColumns?: number
|
||||
horizontalPadding?: number
|
||||
cardGap?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算卡片宽度
|
||||
* @param gridWidth 网格容器宽度
|
||||
* @param horizontalPadding 水平内边距
|
||||
* @param cardGap 卡片间距
|
||||
* @param numColumns 列数
|
||||
* @returns 卡片宽度
|
||||
*/
|
||||
export function calculateCardWidth(
|
||||
gridWidth: number,
|
||||
horizontalPadding: number,
|
||||
cardGap: number,
|
||||
numColumns: number
|
||||
): number {
|
||||
return (gridWidth - horizontalPadding * 2 - cardGap * (numColumns - 1)) / numColumns
|
||||
}
|
||||
|
||||
const TemplateGridComponent: React.FC<TemplateGridProps> = ({
|
||||
templates,
|
||||
onTemplatePress,
|
||||
numColumns = 3,
|
||||
horizontalPadding = 16,
|
||||
cardGap = 5,
|
||||
}) => {
|
||||
const [gridWidth, setGridWidth] = useState(0)
|
||||
|
||||
// 空数据时返回 null
|
||||
if (!templates || templates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleLayout = (e: LayoutChangeEvent) => {
|
||||
setGridWidth(e.nativeEvent.layout.width)
|
||||
}
|
||||
|
||||
const cardWidth = calculateCardWidth(gridWidth, horizontalPadding, cardGap, numColumns)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.gridContainer, { paddingHorizontal: horizontalPadding }]}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<View style={[styles.grid, { gap: cardGap }]}>
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.title}
|
||||
previewUrl={template.previewUrl}
|
||||
webpPreviewUrl={template.webpPreviewUrl}
|
||||
coverImageUrl={template.coverImageUrl}
|
||||
aspectRatio={template.aspectRatio}
|
||||
cardWidth={cardWidth}
|
||||
onPress={onTemplatePress}
|
||||
testID={`template-card-${template.id}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplateGrid = memo(TemplateGridComponent)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gridContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
})
|
||||
46
components/blocks/home/TitleBar.test.tsx
Normal file
46
components/blocks/home/TitleBar.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TitleBar } from './TitleBar'
|
||||
|
||||
describe('TitleBar Component', () => {
|
||||
describe('Component Export', () => {
|
||||
it('should be defined', () => {
|
||||
expect(TitleBar).toBeDefined()
|
||||
})
|
||||
|
||||
it('should be a function component', () => {
|
||||
expect(typeof TitleBar).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Interface', () => {
|
||||
it('should accept points prop', () => {
|
||||
// TypeScript will validate this at compile time
|
||||
// This test ensures the component can be called with the prop
|
||||
const props = { points: 100 }
|
||||
expect(props.points).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept onPointsPress callback', () => {
|
||||
const onPointsPress = jest.fn()
|
||||
expect(typeof onPointsPress).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept onSearchPress callback', () => {
|
||||
const onSearchPress = jest.fn()
|
||||
expect(typeof onSearchPress).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept onLayout callback', () => {
|
||||
const onLayout = jest.fn()
|
||||
expect(typeof onLayout).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Values', () => {
|
||||
it('should have default points value of 60', () => {
|
||||
// This is documented in the component interface
|
||||
// Default value is set in the component destructuring
|
||||
const defaultPoints = 60
|
||||
expect(defaultPoints).toBe(60)
|
||||
})
|
||||
})
|
||||
})
|
||||
87
components/blocks/home/TitleBar.tsx
Normal file
87
components/blocks/home/TitleBar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { PointsIcon, SearchIcon } from '@/components/icon'
|
||||
|
||||
interface TitleBarProps {
|
||||
points?: number
|
||||
onPointsPress?: () => void
|
||||
onSearchPress?: () => void
|
||||
onLayout?: (height: number) => void
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
points = 60,
|
||||
onPointsPress,
|
||||
onSearchPress,
|
||||
onLayout,
|
||||
}: TitleBarProps): JSX.Element {
|
||||
return (
|
||||
<View
|
||||
testID="title-bar"
|
||||
style={styles.titleBar}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout
|
||||
onLayout?.(height)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.title}>Popcore</Text>
|
||||
<View style={styles.titleBarRight}>
|
||||
<Pressable
|
||||
testID="points-container"
|
||||
style={styles.pointsContainer}
|
||||
onPress={onPointsPress}
|
||||
>
|
||||
<PointsIcon />
|
||||
<Text style={styles.pointsText}>{points}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="search-button"
|
||||
style={styles.searchButton}
|
||||
onPress={onSearchPress}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#F5F5F5',
|
||||
},
|
||||
titleBarRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
pointsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1C1E22',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
gap: 4,
|
||||
},
|
||||
pointsText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
searchButton: {
|
||||
padding: 8,
|
||||
backgroundColor: '#1C1E22',
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
5
components/blocks/home/index.ts
Normal file
5
components/blocks/home/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TitleBar } from './TitleBar'
|
||||
export { HeroSlider, type Activity } from './HeroSlider'
|
||||
export { TabNavigation } from './TabNavigation'
|
||||
export { TemplateCard, parseAspectRatio, getImageUri } from './TemplateCard'
|
||||
export { TemplateGrid, calculateCardWidth, type Template, type TemplateGridProps } from './TemplateGrid'
|
||||
Reference in New Issue
Block a user