test: add QA coverage — access unit tests, VideoPlayer snapshots, Maestro E2E flows, testIDs
- Add testIDs to explore, workout detail, and collection detail screens - Add testID prop to VideoPlayer component - Create access service unit tests (isFreeWorkout, canAccessWorkout) - Create VideoPlayer rendering snapshot tests (preview/background modes) - Create Maestro E2E flows: explore-freemium, collection-detail - Update tab-navigation flow with Explore screen assertions - Update profile-settings flow with real activity stat assertions - Update all-tests suite to include new flows
This commit is contained in:
@@ -17,6 +17,12 @@ env:
|
||||
# Run tab navigation
|
||||
- runFlow: ./tab-navigation.yaml
|
||||
|
||||
# Run explore freemium (lock badges, paywall gating)
|
||||
- runFlow: ./explore-freemium.yaml
|
||||
|
||||
# Run collection detail
|
||||
- runFlow: ./collection-detail.yaml
|
||||
|
||||
# Run workout player
|
||||
- runFlow: ./workout-player.yaml
|
||||
|
||||
|
||||
93
.maestro/flows/collection-detail.yaml
Normal file
93
.maestro/flows/collection-detail.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# Collection Detail Test
|
||||
# Tests navigating to a collection and viewing its workouts
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Collection Detail
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Tap the first collection card
|
||||
- tapOn:
|
||||
text: ".*collection.*"
|
||||
optional: true
|
||||
|
||||
# If collection-card testIDs are visible, tap by testID instead
|
||||
- tapOn:
|
||||
id: "collection-card-.*"
|
||||
optional: true
|
||||
|
||||
# Verify collection detail screen loaded
|
||||
- assertVisible:
|
||||
id: "collection-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify hero card is visible
|
||||
- assertVisible:
|
||||
id: "collection-hero"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify back button exists
|
||||
- assertVisible:
|
||||
id: "collection-back-button"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify workouts are listed
|
||||
- assertVisible:
|
||||
text: ".*Workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to see more workouts
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a workout in the collection
|
||||
- tapOn:
|
||||
id: "collection-workout-.*"
|
||||
optional: true
|
||||
|
||||
# Verify workout detail opened
|
||||
- assertVisible:
|
||||
id: "workout-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back to collection
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Go back to explore via back button
|
||||
- tapOn:
|
||||
id: "collection-back-button"
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
106
.maestro/flows/explore-freemium.yaml
Normal file
106
.maestro/flows/explore-freemium.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# Explore Tab Freemium Test
|
||||
# Tests lock badges on non-free workouts, free workout access,
|
||||
# and paywall gating for locked workouts.
|
||||
# Prerequisite: User must have completed onboarding (free user, not premium)
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Explore Freemium
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section is visible
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify featured section is visible
|
||||
- assertVisible:
|
||||
id: "featured-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify filters section is visible
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
|
||||
# Scroll down to see workout cards
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a free workout (ID 1 — Full Body Ignite) — should go to detail, not paywall
|
||||
- tapOn:
|
||||
id: "workout-card-1"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify start button (not unlock)
|
||||
- assertVisible:
|
||||
id: "workout-start-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify video preview is rendered
|
||||
- assertVisible:
|
||||
id: "workout-video-preview"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Go back to explore
|
||||
- pressKey: back
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
|
||||
# Scroll to find a locked workout
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 800
|
||||
|
||||
# Tap a locked workout (ID 2 — not in free tier)
|
||||
- tapOn:
|
||||
id: "workout-card-2"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify unlock/locked button
|
||||
- assertVisible:
|
||||
id: "workout-unlock-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Tap unlock button — should navigate to paywall
|
||||
- tapOn:
|
||||
id: "workout-unlock-button"
|
||||
optional: true
|
||||
|
||||
# Verify paywall screen appeared
|
||||
- assertVisible:
|
||||
text: ".*Premium.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back from paywall
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
@@ -28,11 +28,19 @@ name: Profile Settings
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check stats are visible
|
||||
# Check stats section — real activity store data (may show 0 if no workouts done)
|
||||
- assertVisible:
|
||||
text: ".*workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*min.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*cal.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to settings section
|
||||
- scroll:
|
||||
|
||||
@@ -17,6 +17,16 @@ name: Tab Navigation
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded with key sections
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Navigate to Activity tab
|
||||
- tapOn:
|
||||
text: "Activity"
|
||||
|
||||
@@ -77,6 +77,7 @@ function CollectionCard({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={`collection-card-${title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
style={styles.collectionCard}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
@@ -121,6 +122,7 @@ function WorkoutCard({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={`workout-card-${workout.id}`}
|
||||
style={[styles.workoutCard, { borderColor: colors.border.glassLight }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
@@ -138,7 +140,7 @@ function WorkoutCard({
|
||||
</View>
|
||||
|
||||
{isLocked && (
|
||||
<View style={styles.lockBadge}>
|
||||
<View testID={`lock-badge-${workout.id}`} style={styles.lockBadge}>
|
||||
<Ionicons name="lock-closed" size={10} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
@@ -279,8 +281,9 @@ export default function ExploreScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
|
||||
<View testID="explore-screen" style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
testID="explore-scroll"
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -295,7 +298,7 @@ export default function ExploreScreen() {
|
||||
</View>
|
||||
|
||||
{collections.length > 0 && (
|
||||
<View style={styles.collectionsSection}>
|
||||
<View testID="collections-section" style={styles.collectionsSection}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.collections')}
|
||||
</StyledText>
|
||||
@@ -320,7 +323,7 @@ export default function ExploreScreen() {
|
||||
)}
|
||||
|
||||
{featured.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View testID="featured-section" style={styles.section}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.featured')}
|
||||
</StyledText>
|
||||
@@ -337,7 +340,7 @@ export default function ExploreScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.filtersSection}>
|
||||
<View testID="filters-section" style={styles.filtersSection}>
|
||||
<View style={styles.filterHeader}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.allWorkouts')}
|
||||
|
||||
@@ -79,10 +79,10 @@ export default function CollectionDetailScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<View testID="collection-detail-screen" style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={handleBack} style={styles.backButton}>
|
||||
<Pressable testID="collection-back-button" onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary} numberOfLines={1} style={{ flex: 1, textAlign: 'center' }}>
|
||||
@@ -97,7 +97,7 @@ export default function CollectionDetailScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Hero Card */}
|
||||
<View style={styles.heroCard}>
|
||||
<View testID="collection-hero" style={styles.heroCard}>
|
||||
<LinearGradient
|
||||
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
@@ -133,6 +133,7 @@ export default function CollectionDetailScreen() {
|
||||
{workouts.map((workout) => (
|
||||
<Pressable
|
||||
key={workout.id}
|
||||
testID={`collection-workout-${workout.id}`}
|
||||
style={styles.workoutCard}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function WorkoutDetailScreen() {
|
||||
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View testID="workout-detail-screen" style={styles.container}>
|
||||
{/* Header with SwiftUI glass button */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
|
||||
<RNText style={styles.headerTitle} numberOfLines={1}>
|
||||
@@ -131,6 +131,7 @@ export default function WorkoutDetailScreen() {
|
||||
mode="preview"
|
||||
isPlaying={true}
|
||||
style={styles.videoPreview}
|
||||
testID="workout-video-preview"
|
||||
/>
|
||||
{/* Quick stats */}
|
||||
<View style={styles.quickStats}>
|
||||
@@ -204,6 +205,7 @@ export default function WorkoutDetailScreen() {
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Pressable
|
||||
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
isLocked && styles.lockedButton,
|
||||
@@ -214,7 +216,7 @@ export default function WorkoutDetailScreen() {
|
||||
{isLocked && (
|
||||
<Ionicons name="lock-closed" size={18} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<RNText style={styles.startButtonText}>
|
||||
<RNText testID="workout-cta-text" style={styles.startButtonText}>
|
||||
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
|
||||
</RNText>
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react-native'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
|
||||
describe('VideoPlayer rendering', () => {
|
||||
describe('preview mode', () => {
|
||||
it('renders gradient fallback when no videoUrl', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer mode="preview" isPlaying={false} />
|
||||
)
|
||||
const tree = toJSON()
|
||||
expect(tree).toBeTruthy()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders video view when videoUrl is provided', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
|
||||
mode="preview"
|
||||
isPlaying={true}
|
||||
/>
|
||||
)
|
||||
const tree = toJSON()
|
||||
expect(tree).toBeTruthy()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with custom style', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
mode="preview"
|
||||
style={{ height: 220, borderRadius: 20 }}
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with testID prop', () => {
|
||||
const { getByTestId } = render(
|
||||
<VideoPlayer
|
||||
mode="preview"
|
||||
testID="my-video-player"
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('my-video-player')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('background mode', () => {
|
||||
it('renders gradient fallback when no videoUrl', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer mode="background" isPlaying={false} />
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders video view when videoUrl is provided', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
|
||||
mode="background"
|
||||
isPlaying={true}
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom gradient colors', () => {
|
||||
it('renders with custom gradient colors when no video', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
gradientColors={['#FF0000', '#0000FF']}
|
||||
mode="preview"
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`VideoPlayer rendering > background mode > renders gradient fallback when no videoUrl 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > background mode > renders video view when videoUrl is provided 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<VideoView
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
player={
|
||||
{
|
||||
"currentTime": 0,
|
||||
"duration": 100,
|
||||
"muted": false,
|
||||
"pause": [MockFunction],
|
||||
"play": [MockFunction] {
|
||||
"calls": [
|
||||
[],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"playing": false,
|
||||
"replace": [MockFunction],
|
||||
"volume": 1,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="video-view"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > custom gradient colors > renders with custom gradient colors when no video 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF0000",
|
||||
"#0000FF",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders gradient fallback when no videoUrl 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders video view when videoUrl is provided 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<VideoView
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
player={
|
||||
{
|
||||
"currentTime": 0,
|
||||
"duration": 100,
|
||||
"muted": false,
|
||||
"pause": [MockFunction],
|
||||
"play": [MockFunction] {
|
||||
"calls": [
|
||||
[],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"playing": false,
|
||||
"replace": [MockFunction],
|
||||
"volume": 1,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="video-view"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders with custom style 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"borderRadius": 20,
|
||||
"height": 220,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
123
src/__tests__/services/access.test.ts
Normal file
123
src/__tests__/services/access.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
FREE_WORKOUT_IDS,
|
||||
FREE_WORKOUT_COUNT,
|
||||
isFreeWorkout,
|
||||
canAccessWorkout,
|
||||
} from '../../shared/services/access'
|
||||
|
||||
describe('access service', () => {
|
||||
describe('FREE_WORKOUT_IDS', () => {
|
||||
it('should contain exactly 3 free workout IDs', () => {
|
||||
expect(FREE_WORKOUT_IDS).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should include workout 1 (Full Body Ignite)', () => {
|
||||
expect(FREE_WORKOUT_IDS).toContain('1')
|
||||
})
|
||||
|
||||
it('should include workout 11 (Core Crusher)', () => {
|
||||
expect(FREE_WORKOUT_IDS).toContain('11')
|
||||
})
|
||||
|
||||
it('should include workout 43 (Dance Cardio)', () => {
|
||||
expect(FREE_WORKOUT_IDS).toContain('43')
|
||||
})
|
||||
|
||||
it('should be readonly (immutable)', () => {
|
||||
// TypeScript enforces readonly at compile time; at runtime we verify the array content is stable
|
||||
const snapshot = [...FREE_WORKOUT_IDS]
|
||||
expect(FREE_WORKOUT_IDS).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FREE_WORKOUT_COUNT', () => {
|
||||
it('should equal the length of FREE_WORKOUT_IDS', () => {
|
||||
expect(FREE_WORKOUT_COUNT).toBe(FREE_WORKOUT_IDS.length)
|
||||
})
|
||||
|
||||
it('should be 3', () => {
|
||||
expect(FREE_WORKOUT_COUNT).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFreeWorkout', () => {
|
||||
it('should return true for free workout ID "1"', () => {
|
||||
expect(isFreeWorkout('1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for free workout ID "11"', () => {
|
||||
expect(isFreeWorkout('11')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for free workout ID "43"', () => {
|
||||
expect(isFreeWorkout('43')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-free workout ID "2"', () => {
|
||||
expect(isFreeWorkout('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-free workout ID "10"', () => {
|
||||
expect(isFreeWorkout('10')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-free workout ID "44"', () => {
|
||||
expect(isFreeWorkout('44')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isFreeWorkout('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-existent ID', () => {
|
||||
expect(isFreeWorkout('999')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canAccessWorkout', () => {
|
||||
describe('premium user', () => {
|
||||
it('should access free workout', () => {
|
||||
expect(canAccessWorkout('1', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('should access non-free workout', () => {
|
||||
expect(canAccessWorkout('2', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('should access any workout ID', () => {
|
||||
expect(canAccessWorkout('999', true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('free user', () => {
|
||||
it('should access free workout ID "1"', () => {
|
||||
expect(canAccessWorkout('1', false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should access free workout ID "11"', () => {
|
||||
expect(canAccessWorkout('11', false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should access free workout ID "43"', () => {
|
||||
expect(canAccessWorkout('43', false)).toBe(true)
|
||||
})
|
||||
|
||||
it('should NOT access non-free workout ID "2"', () => {
|
||||
expect(canAccessWorkout('2', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT access non-free workout ID "10"', () => {
|
||||
expect(canAccessWorkout('10', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT access non-free workout ID "50"', () => {
|
||||
expect(canAccessWorkout('50', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT access empty string ID', () => {
|
||||
expect(canAccessWorkout('', false)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ interface VideoPlayerProps {
|
||||
/** Whether to play the video */
|
||||
isPlaying?: boolean
|
||||
style?: object
|
||||
/** Test identifier for QA automation */
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
@@ -28,6 +30,7 @@ export function VideoPlayer({
|
||||
mode = 'preview',
|
||||
isPlaying = true,
|
||||
style,
|
||||
testID,
|
||||
}: VideoPlayerProps) {
|
||||
const player = useVideoPlayer(videoUrl ?? null, (p) => {
|
||||
p.loop = true
|
||||
@@ -47,7 +50,7 @@ export function VideoPlayer({
|
||||
// No video URL — show gradient fallback
|
||||
if (!videoUrl) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View testID={testID} style={[styles.container, style]}>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
@@ -59,7 +62,7 @@ export function VideoPlayer({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View testID={testID} style={[styles.container, style]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={StyleSheet.absoluteFill}
|
||||
|
||||
Reference in New Issue
Block a user