diff --git a/.maestro/flows/all-tests.yaml b/.maestro/flows/all-tests.yaml index ebc3915..41a5169 100644 --- a/.maestro/flows/all-tests.yaml +++ b/.maestro/flows/all-tests.yaml @@ -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 diff --git a/.maestro/flows/collection-detail.yaml b/.maestro/flows/collection-detail.yaml new file mode 100644 index 0000000..fcef3c3 --- /dev/null +++ b/.maestro/flows/collection-detail.yaml @@ -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 diff --git a/.maestro/flows/explore-freemium.yaml b/.maestro/flows/explore-freemium.yaml new file mode 100644 index 0000000..c8717f6 --- /dev/null +++ b/.maestro/flows/explore-freemium.yaml @@ -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 diff --git a/.maestro/flows/profile-settings.yaml b/.maestro/flows/profile-settings.yaml index d824e9a..515a727 100644 --- a/.maestro/flows/profile-settings.yaml +++ b/.maestro/flows/profile-settings.yaml @@ -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: diff --git a/.maestro/flows/tab-navigation.yaml b/.maestro/flows/tab-navigation.yaml index ea22fa3..341f68a 100644 --- a/.maestro/flows/tab-navigation.yaml +++ b/.maestro/flows/tab-navigation.yaml @@ -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" diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index d14ffcc..b0c2eec 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -77,6 +77,7 @@ function CollectionCard({ return ( { haptics.buttonTap() @@ -121,6 +122,7 @@ function WorkoutCard({ return ( @@ -138,7 +140,7 @@ function WorkoutCard({ {isLocked && ( - + )} @@ -279,8 +281,9 @@ export default function ExploreScreen() { } return ( - + {collections.length > 0 && ( - + {t('screens:explore.collections')} @@ -320,7 +323,7 @@ export default function ExploreScreen() { )} {featured.length > 0 && ( - + {t('screens:explore.featured')} @@ -337,7 +340,7 @@ export default function ExploreScreen() { )} - + {t('screens:explore.allWorkouts')} diff --git a/app/collection/[id].tsx b/app/collection/[id].tsx index 45d1516..85d855b 100644 --- a/app/collection/[id].tsx +++ b/app/collection/[id].tsx @@ -79,10 +79,10 @@ export default function CollectionDetailScreen() { } return ( - + {/* Header */} - + @@ -97,7 +97,7 @@ export default function CollectionDetailScreen() { showsVerticalScrollIndicator={false} > {/* Hero Card */} - + ( handleWorkoutPress(workout.id)} > diff --git a/app/workout/[id].tsx b/app/workout/[id].tsx index d50291f..7ae8039 100644 --- a/app/workout/[id].tsx +++ b/app/workout/[id].tsx @@ -90,7 +90,7 @@ export default function WorkoutDetailScreen() { const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length)) return ( - + {/* Header with SwiftUI glass button */} @@ -131,6 +131,7 @@ export default function WorkoutDetailScreen() { mode="preview" isPlaying={true} style={styles.videoPreview} + testID="workout-video-preview" /> {/* Quick stats */} @@ -204,6 +205,7 @@ export default function WorkoutDetailScreen() { [ styles.startButton, isLocked && styles.lockedButton, @@ -214,7 +216,7 @@ export default function WorkoutDetailScreen() { {isLocked && ( )} - + {isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')} diff --git a/src/__tests__/components/rendering/VideoPlayerPreview.test.tsx b/src/__tests__/components/rendering/VideoPlayerPreview.test.tsx new file mode 100644 index 0000000..ab788a8 --- /dev/null +++ b/src/__tests__/components/rendering/VideoPlayerPreview.test.tsx @@ -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( + + ) + const tree = toJSON() + expect(tree).toBeTruthy() + expect(tree).toMatchSnapshot() + }) + + it('renders video view when videoUrl is provided', () => { + const { toJSON } = render( + + ) + const tree = toJSON() + expect(tree).toBeTruthy() + expect(tree).toMatchSnapshot() + }) + + it('renders with custom style', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toMatchSnapshot() + }) + + it('renders with testID prop', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('my-video-player')).toBeTruthy() + }) + }) + + describe('background mode', () => { + it('renders gradient fallback when no videoUrl', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toMatchSnapshot() + }) + + it('renders video view when videoUrl is provided', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toMatchSnapshot() + }) + }) + + describe('custom gradient colors', () => { + it('renders with custom gradient colors when no video', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toMatchSnapshot() + }) + }) +}) diff --git a/src/__tests__/components/rendering/__snapshots__/VideoPlayerPreview.test.tsx.snap b/src/__tests__/components/rendering/__snapshots__/VideoPlayerPreview.test.tsx.snap new file mode 100644 index 0000000..29e569a --- /dev/null +++ b/src/__tests__/components/rendering/__snapshots__/VideoPlayerPreview.test.tsx.snap @@ -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`] = ` + + + +`; + +exports[`VideoPlayer rendering > background mode > renders video view when videoUrl is provided 1`] = ` + + + +`; + +exports[`VideoPlayer rendering > custom gradient colors > renders with custom gradient colors when no video 1`] = ` + + + +`; + +exports[`VideoPlayer rendering > preview mode > renders gradient fallback when no videoUrl 1`] = ` + + + +`; + +exports[`VideoPlayer rendering > preview mode > renders video view when videoUrl is provided 1`] = ` + + + +`; + +exports[`VideoPlayer rendering > preview mode > renders with custom style 1`] = ` + + + +`; diff --git a/src/__tests__/services/access.test.ts b/src/__tests__/services/access.test.ts new file mode 100644 index 0000000..2ead285 --- /dev/null +++ b/src/__tests__/services/access.test.ts @@ -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) + }) + }) + }) +}) diff --git a/src/shared/components/VideoPlayer.tsx b/src/shared/components/VideoPlayer.tsx index 214b09f..fec3520 100644 --- a/src/shared/components/VideoPlayer.tsx +++ b/src/shared/components/VideoPlayer.tsx @@ -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 ( - + +