feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure

- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
This commit is contained in:
Millian Lamiaux
2026-03-24 12:04:48 +01:00
parent 8703c484e8
commit cd065d07c3
138 changed files with 26819 additions and 1043 deletions

View File

@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest'
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
const WEIGHT_MAP: Record<FontWeight, string> = {
regular: '400',
medium: '500',
semibold: '600',
bold: '700',
}
describe('StyledText', () => {
describe('weight mapping', () => {
it('should map regular to 400', () => {
expect(WEIGHT_MAP['regular']).toBe('400')
})
it('should map medium to 500', () => {
expect(WEIGHT_MAP['medium']).toBe('500')
})
it('should map semibold to 600', () => {
expect(WEIGHT_MAP['semibold']).toBe('600')
})
it('should map bold to 700', () => {
expect(WEIGHT_MAP['bold']).toBe('700')
})
})
describe('default values', () => {
it('should have default size of 17', () => {
const defaultSize = 17
expect(defaultSize).toBe(17)
})
it('should have default weight of regular', () => {
const defaultWeight: FontWeight = 'regular'
expect(WEIGHT_MAP[defaultWeight]).toBe('400')
})
})
describe('style computation', () => {
const computeTextStyle = (size: number, weight: FontWeight, color: string) => ({
fontSize: size,
fontWeight: WEIGHT_MAP[weight],
color,
})
it('should compute correct style with defaults', () => {
const style = computeTextStyle(17, 'regular', '#FFFFFF')
expect(style.fontSize).toBe(17)
expect(style.fontWeight).toBe('400')
expect(style.color).toBe('#FFFFFF')
})
it('should compute correct style with custom size', () => {
const style = computeTextStyle(24, 'regular', '#FFFFFF')
expect(style.fontSize).toBe(24)
})
it('should compute correct style with bold weight', () => {
const style = computeTextStyle(17, 'bold', '#FFFFFF')
expect(style.fontWeight).toBe('700')
})
it('should compute correct style with custom color', () => {
const style = computeTextStyle(17, 'regular', '#FF0000')
expect(style.color).toBe('#FF0000')
})
it('should compute correct style with all custom props', () => {
const style = computeTextStyle(20, 'semibold', '#5AC8FA')
expect(style.fontSize).toBe(20)
expect(style.fontWeight).toBe('600')
expect(style.color).toBe('#5AC8FA')
})
})
describe('numberOfLines handling', () => {
it('should accept numberOfLines prop', () => {
const numberOfLines = 2
expect(numberOfLines).toBe(2)
})
it('should handle undefined numberOfLines', () => {
const numberOfLines: number | undefined = undefined
expect(numberOfLines).toBeUndefined()
})
})
describe('style merging', () => {
const mergeStyles = (baseStyle: object, customStyle: object | undefined) => {
return customStyle ? [baseStyle, customStyle] : [baseStyle]
}
it('should merge custom style with base style', () => {
const base = { fontSize: 17, fontWeight: '400' }
const custom = { marginTop: 10 }
const merged = mergeStyles(base, custom)
expect(merged).toHaveLength(2)
expect(merged[0]).toEqual(base)
expect(merged[1]).toEqual(custom)
})
it('should return only base style when no custom style', () => {
const base = { fontSize: 17, fontWeight: '400' }
const merged = mergeStyles(base, undefined)
expect(merged).toHaveLength(1)
expect(merged[0]).toEqual(base)
})
})
describe('theme color integration', () => {
const mockThemeColors = {
text: {
primary: '#FFFFFF',
secondary: '#8E8E93',
tertiary: '#636366',
},
}
it('should use primary text color as default', () => {
const defaultColor = mockThemeColors.text.primary
expect(defaultColor).toBe('#FFFFFF')
})
it('should allow color override', () => {
const customColor = '#FF0000'
const resolvedColor = customColor || mockThemeColors.text.primary
expect(resolvedColor).toBe('#FF0000')
})
it('should fallback to theme color when no override', () => {
const customColor: string | undefined = undefined
const resolvedColor = customColor || mockThemeColors.text.primary
expect(resolvedColor).toBe('#FFFFFF')
})
})
})

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { BRAND } from '../../shared/constants/colors'
type VideoPlayerMode = 'preview' | 'background'
interface VideoPlayerConfig {
loop: boolean
muted: boolean
volume: number
}
function getVideoPlayerConfig(mode: VideoPlayerMode): VideoPlayerConfig {
return {
loop: true,
muted: mode === 'preview',
volume: mode === 'background' ? 0.3 : 0,
}
}
function shouldShowGradient(videoUrl: string | undefined): boolean {
return !videoUrl
}
function shouldPlayVideo(isPlaying: boolean, videoUrl: string | undefined): boolean {
return isPlaying && !!videoUrl
}
describe('VideoPlayer', () => {
describe('video player configuration', () => {
it('should configure preview mode with muted audio', () => {
const config = getVideoPlayerConfig('preview')
expect(config.loop).toBe(true)
expect(config.muted).toBe(true)
expect(config.volume).toBe(0)
})
it('should configure background mode with low volume', () => {
const config = getVideoPlayerConfig('background')
expect(config.loop).toBe(true)
expect(config.muted).toBe(false)
expect(config.volume).toBe(0.3)
})
it('should always loop regardless of mode', () => {
expect(getVideoPlayerConfig('preview').loop).toBe(true)
expect(getVideoPlayerConfig('background').loop).toBe(true)
})
})
describe('gradient fallback', () => {
it('should show gradient when no video URL', () => {
expect(shouldShowGradient(undefined)).toBe(true)
expect(shouldShowGradient('')).toBe(true)
})
it('should not show gradient when video URL exists', () => {
expect(shouldShowGradient('https://example.com/video.m3u8')).toBe(false)
expect(shouldShowGradient('https://example.com/video.mp4')).toBe(false)
})
})
describe('playback control', () => {
it('should play when isPlaying is true and video exists', () => {
expect(shouldPlayVideo(true, 'https://example.com/video.mp4')).toBe(true)
})
it('should not play when isPlaying is false', () => {
expect(shouldPlayVideo(false, 'https://example.com/video.mp4')).toBe(false)
})
it('should not play when no video URL', () => {
expect(shouldPlayVideo(true, undefined)).toBe(false)
expect(shouldPlayVideo(true, '')).toBe(false)
})
})
describe('default gradient colors', () => {
it('should use brand colors as default gradient', () => {
const defaultColors = [BRAND.PRIMARY, BRAND.PRIMARY_DARK]
expect(defaultColors[0]).toBe(BRAND.PRIMARY)
expect(defaultColors[1]).toBe(BRAND.PRIMARY_DARK)
})
})
describe('video URL validation', () => {
it('should accept HLS streams', () => {
const hlsUrl = 'https://example.com/video.m3u8'
expect(shouldShowGradient(hlsUrl)).toBe(false)
})
it('should accept MP4 files', () => {
const mp4Url = 'https://example.com/video.mp4'
expect(shouldShowGradient(mp4Url)).toBe(false)
})
it('should handle null/undefined', () => {
expect(shouldShowGradient(null as any)).toBe(true)
expect(shouldShowGradient(undefined)).toBe(true)
})
})
describe('mode-specific behavior', () => {
it('preview mode should be silent', () => {
const previewConfig = getVideoPlayerConfig('preview')
expect(previewConfig.muted || previewConfig.volume === 0).toBe(true)
})
it('background mode should have audible audio', () => {
const bgConfig = getVideoPlayerConfig('background')
expect(bgConfig.volume).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { View, Text } from 'react-native'
import { render } from '@testing-library/react-native'
const CATEGORY_COLORS: Record<string, string> = {
'full-body': '#FF6B35',
'core': '#5AC8FA',
'upper-body': '#BF5AF2',
'lower-body': '#30D158',
'cardio': '#FF9500',
}
describe('WorkoutCard logic', () => {
describe('category colors', () => {
it('should map full-body to primary brand color', () => {
expect(CATEGORY_COLORS['full-body']).toBe('#FF6B35')
})
it('should map core to ice blue', () => {
expect(CATEGORY_COLORS['core']).toBe('#5AC8FA')
})
it('should map upper-body to purple', () => {
expect(CATEGORY_COLORS['upper-body']).toBe('#BF5AF2')
})
it('should map lower-body to green', () => {
expect(CATEGORY_COLORS['lower-body']).toBe('#30D158')
})
it('should map cardio to orange', () => {
expect(CATEGORY_COLORS['cardio']).toBe('#FF9500')
})
})
describe('display formatting', () => {
const formatDuration = (minutes: number): string => `${minutes} MIN`
const formatCalories = (calories: number): string => `${calories} CAL`
const formatLevel = (level: string): string => level.toUpperCase()
it('should format duration correctly', () => {
expect(formatDuration(4)).toBe('4 MIN')
expect(formatDuration(8)).toBe('8 MIN')
expect(formatDuration(12)).toBe('12 MIN')
expect(formatDuration(20)).toBe('20 MIN')
})
it('should format calories correctly', () => {
expect(formatCalories(45)).toBe('45 CAL')
expect(formatCalories(100)).toBe('100 CAL')
})
it('should format level correctly', () => {
expect(formatLevel('Beginner')).toBe('BEGINNER')
expect(formatLevel('Intermediate')).toBe('INTERMEDIATE')
expect(formatLevel('Advanced')).toBe('ADVANCED')
})
})
describe('card variants', () => {
type CardVariant = 'horizontal' | 'grid' | 'featured'
const getCardDimensions = (variant: CardVariant) => {
switch (variant) {
case 'horizontal':
return { width: 200, height: 280 }
case 'grid':
return { flex: 1, aspectRatio: 0.75 }
case 'featured':
return { width: 320, height: 400 }
default:
return { width: 200, height: 280 }
}
}
it('should return correct dimensions for horizontal variant', () => {
const dims = getCardDimensions('horizontal')
expect(dims.width).toBe(200)
expect(dims.height).toBe(280)
})
it('should return correct dimensions for grid variant', () => {
const dims = getCardDimensions('grid')
expect(dims.flex).toBe(1)
expect(dims.aspectRatio).toBe(0.75)
})
it('should return correct dimensions for featured variant', () => {
const dims = getCardDimensions('featured')
expect(dims.width).toBe(320)
expect(dims.height).toBe(400)
})
it('should default to horizontal for unknown variant', () => {
const dims = getCardDimensions('unknown' as CardVariant)
expect(dims.width).toBe(200)
})
})
describe('workout metadata', () => {
const buildMetadata = (duration: number, calories: number, level: string): string => {
return `${duration} MIN • ${calories} CAL • ${level.toUpperCase()}`
}
it('should build correct metadata string', () => {
expect(buildMetadata(4, 45, 'Beginner')).toBe('4 MIN • 45 CAL • BEGINNER')
})
it('should handle different levels', () => {
expect(buildMetadata(8, 90, 'Intermediate')).toBe('8 MIN • 90 CAL • INTERMEDIATE')
expect(buildMetadata(20, 240, 'Advanced')).toBe('20 MIN • 240 CAL • ADVANCED')
})
})
describe('workout filtering helpers', () => {
const workouts = [
{ id: '1', category: 'full-body', level: 'Beginner', duration: 4 },
{ id: '2', category: 'core', level: 'Intermediate', duration: 8 },
{ id: '3', category: 'upper-body', level: 'Advanced', duration: 12 },
{ id: '4', category: 'full-body', level: 'Intermediate', duration: 4 },
]
const filterByCategory = (list: typeof workouts, cat: string) =>
list.filter(w => w.category === cat)
const filterByLevel = (list: typeof workouts, lvl: string) =>
list.filter(w => w.level === lvl)
const filterByDuration = (list: typeof workouts, dur: number) =>
list.filter(w => w.duration === dur)
it('should filter workouts by category', () => {
expect(filterByCategory(workouts, 'full-body')).toHaveLength(2)
expect(filterByCategory(workouts, 'core')).toHaveLength(1)
})
it('should filter workouts by level', () => {
expect(filterByLevel(workouts, 'Beginner')).toHaveLength(1)
expect(filterByLevel(workouts, 'Intermediate')).toHaveLength(2)
})
it('should filter workouts by duration', () => {
expect(filterByDuration(workouts, 4)).toHaveLength(2)
expect(filterByDuration(workouts, 8)).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react-native'
import { CollectionCard } from '@/src/shared/components/CollectionCard'
import type { Collection } from '@/src/shared/types'
const mockCollection: Collection = {
id: 'test-collection',
title: 'Upper Body Blast',
description: 'An intense upper body workout collection',
icon: '💪',
workoutIds: ['w1', 'w2', 'w3'],
gradient: ['#FF6B35', '#FF3B30'],
}
/**
* Helper to recursively find a node in the rendered tree by type.
*/
function findByType(tree: any, typeName: string): any {
if (!tree) return null
if (tree.type === typeName) return tree
if (tree.children && Array.isArray(tree.children)) {
for (const child of tree.children) {
if (typeof child === 'object') {
const found = findByType(child, typeName)
if (found) return found
}
}
}
return null
}
describe('CollectionCard', () => {
it('renders collection title', () => {
render(<CollectionCard collection={mockCollection} />)
expect(screen.getByText('Upper Body Blast')).toBeTruthy()
})
it('renders workout count', () => {
render(<CollectionCard collection={mockCollection} />)
expect(screen.getByText('3 workouts')).toBeTruthy()
})
it('renders icon emoji', () => {
render(<CollectionCard collection={mockCollection} />)
expect(screen.getByText('💪')).toBeTruthy()
})
it('calls onPress when pressed', () => {
const onPress = vi.fn()
render(<CollectionCard collection={mockCollection} onPress={onPress} />)
fireEvent.press(screen.getByText('Upper Body Blast'))
expect(onPress).toHaveBeenCalledTimes(1)
})
it('renders without onPress (no crash)', () => {
const { toJSON } = render(<CollectionCard collection={mockCollection} />)
expect(toJSON()).toMatchSnapshot()
})
it('renders LinearGradient when no imageUrl', () => {
const { toJSON } = render(<CollectionCard collection={mockCollection} />)
expect(screen.getByTestId('linear-gradient')).toBeTruthy()
// LinearGradient should receive the collection's gradient colors
const gradientNode = findByType(toJSON(), 'LinearGradient')
expect(gradientNode).toBeTruthy()
expect(gradientNode.props.colors).toEqual(['#FF6B35', '#FF3B30'])
})
it('renders ImageBackground when imageUrl is provided', () => {
const { toJSON } = render(
<CollectionCard
collection={mockCollection}
imageUrl="https://example.com/image.jpg"
/>
)
// Should render ImageBackground instead of standalone LinearGradient
const tree = toJSON()
const imageBackground = findByType(tree, 'ImageBackground')
expect(imageBackground).toBeTruthy()
expect(imageBackground.props.source).toEqual({ uri: 'https://example.com/image.jpg' })
})
it('uses default gradient colors when collection has no gradient', () => {
const collectionNoGradient: Collection = {
...mockCollection,
gradient: undefined,
}
const { toJSON } = render(
<CollectionCard collection={collectionNoGradient} />
)
// Should use fallback gradient: [BRAND.PRIMARY, '#FF3B30']
const gradientNode = findByType(toJSON(), 'LinearGradient')
expect(gradientNode).toBeTruthy()
// BRAND.PRIMARY = '#FF6B35' from constants
expect(gradientNode.props.colors).toEqual(['#FF6B35', '#FF3B30'])
})
it('renders blur overlay', () => {
render(<CollectionCard collection={mockCollection} />)
expect(screen.getByTestId('blur-view')).toBeTruthy()
})
it('handles empty workoutIds', () => {
const emptyCollection: Collection = {
...mockCollection,
workoutIds: [],
}
render(<CollectionCard collection={emptyCollection} />)
expect(screen.getByText('0 workouts')).toBeTruthy()
})
it('snapshot with imageUrl (different rendering path)', () => {
const { toJSON } = render(
<CollectionCard
collection={mockCollection}
imageUrl="https://example.com/image.jpg"
/>
)
expect(toJSON()).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from 'vitest'
import React from 'react'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react-native'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
describe('DataDeletionModal', () => {
const defaultProps = {
visible: true,
onDelete: vi.fn().mockResolvedValue(undefined),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders when visible is true', () => {
render(<DataDeletionModal {...defaultProps} />)
// Title key from i18n mock
expect(screen.getByText('dataDeletion.title')).toBeTruthy()
})
it('renders warning icon', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByTestId('icon-warning')).toBeTruthy()
})
it('renders description and note text', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByText('dataDeletion.description')).toBeTruthy()
expect(screen.getByText('dataDeletion.note')).toBeTruthy()
})
it('renders delete and cancel buttons', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByText('dataDeletion.deleteButton')).toBeTruthy()
expect(screen.getByText('dataDeletion.cancelButton')).toBeTruthy()
})
it('calls onCancel when cancel button is pressed', () => {
render(<DataDeletionModal {...defaultProps} />)
fireEvent.press(screen.getByText('dataDeletion.cancelButton'))
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('calls onDelete when delete button is pressed', async () => {
render(<DataDeletionModal {...defaultProps} />)
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
it('shows loading text while deleting', async () => {
let resolveDelete: () => void
const slowDelete = new Promise<void>((resolve) => {
resolveDelete = resolve
})
const onDelete = vi.fn(() => slowDelete)
render(<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />)
// Start delete
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
// Should show 'Deleting...' while in progress
expect(screen.getByText('Deleting...')).toBeTruthy()
// Complete the delete
await act(async () => {
resolveDelete!()
})
})
it('does not render content when visible is false', () => {
render(<DataDeletionModal {...defaultProps} visible={false} />)
// Modal with visible=false won't render its children
expect(screen.queryByText('dataDeletion.title')).toBeNull()
})
it('full modal structure snapshot', () => {
const { toJSON } = render(<DataDeletionModal {...defaultProps} />)
expect(toJSON()).toMatchSnapshot()
})
it('delete button shows disabled state while deleting', async () => {
let resolveDelete: () => void
const slowDelete = new Promise<void>((resolve) => {
resolveDelete = resolve
})
const onDelete = vi.fn(() => slowDelete)
const { toJSON } = render(
<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />
)
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
// While deleting, the button text changes to loading state
expect(screen.getByText('Deleting...')).toBeTruthy()
// Verify the tree has the disabled styling applied (opacity: 0.6)
const tree = toJSON()
const treeStr = JSON.stringify(tree)
expect(treeStr).toContain('"opacity":0.6')
await act(async () => {
resolveDelete!()
})
})
})

View File

@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render, screen } from '@testing-library/react-native'
import { Text } from 'react-native'
import { GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
describe('GlassCard', () => {
it('renders children', () => {
render(
<GlassCard>
<Text testID="child">Hello</Text>
</GlassCard>
)
expect(screen.getByTestId('child')).toBeTruthy()
})
it('renders BlurView when hasBlur is true (default)', () => {
render(
<GlassCard>
<Text>Content</Text>
</GlassCard>
)
expect(screen.getByTestId('blur-view')).toBeTruthy()
})
it('does not render BlurView when hasBlur is false', () => {
render(
<GlassCard hasBlur={false}>
<Text>Content</Text>
</GlassCard>
)
expect(screen.queryByTestId('blur-view')).toBeNull()
})
it('renders with custom blurIntensity', () => {
render(
<GlassCard blurIntensity={80}>
<Text>Content</Text>
</GlassCard>
)
const blurView = screen.getByTestId('blur-view')
expect(blurView.props.intensity).toBe(80)
})
it('uses theme blurMedium when blurIntensity is not provided', () => {
render(
<GlassCard>
<Text>Content</Text>
</GlassCard>
)
const blurView = screen.getByTestId('blur-view')
// from mock: colors.glass.blurMedium = 40
expect(blurView.props.intensity).toBe(40)
})
it('applies custom style prop to root container', () => {
const customStyle = { padding: 20 }
const { toJSON } = render(
<GlassCard style={customStyle}>
<Text>Content</Text>
</GlassCard>
)
const tree = toJSON()
// Root View should have the custom style merged into its style array
const rootStyle = tree?.props?.style
expect(rootStyle).toBeDefined()
// Style is an array — flatten and check custom style is present
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
const hasPadding = flatStyles.some(
(s: any) => s && typeof s === 'object' && s.padding === 20
)
expect(hasPadding).toBe(true)
})
})
describe('GlassCard variants', () => {
it('renders base variant (snapshot)', () => {
const { toJSON } = render(
<GlassCard>
<Text>Base</Text>
</GlassCard>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders elevated variant (snapshot)', () => {
const { toJSON } = render(
<GlassCard variant="elevated">
<Text>Elevated</Text>
</GlassCard>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders inset variant (snapshot)', () => {
const { toJSON } = render(
<GlassCard variant="inset">
<Text>Inset</Text>
</GlassCard>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders tinted variant (snapshot)', () => {
const { toJSON } = render(
<GlassCard variant="tinted">
<Text>Tinted</Text>
</GlassCard>
)
expect(toJSON()).toMatchSnapshot()
})
})
describe('GlassCard presets', () => {
it('GlassCardElevated renders with blur and children', () => {
const { getByTestId } = render(
<GlassCardElevated>
<Text testID="elevated-child">Elevated</Text>
</GlassCardElevated>
)
expect(getByTestId('elevated-child')).toBeTruthy()
expect(getByTestId('blur-view')).toBeTruthy()
})
it('GlassCardInset renders WITHOUT blur (hasBlur=false)', () => {
const { getByTestId, queryByTestId } = render(
<GlassCardInset>
<Text testID="inset-child">Inset</Text>
</GlassCardInset>
)
expect(getByTestId('inset-child')).toBeTruthy()
// GlassCardInset passes hasBlur={false} — this is the key behavioral assertion
expect(queryByTestId('blur-view')).toBeNull()
})
it('GlassCardTinted renders with blur', () => {
const { getByTestId } = render(
<GlassCardTinted>
<Text testID="tinted-child">Tinted</Text>
</GlassCardTinted>
)
expect(getByTestId('tinted-child')).toBeTruthy()
expect(getByTestId('blur-view')).toBeTruthy()
})
it('GlassCardElevated snapshot', () => {
const { toJSON } = render(
<GlassCardElevated>
<Text>Elevated preset</Text>
</GlassCardElevated>
)
expect(toJSON()).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render, screen } from '@testing-library/react-native'
import { Text } from 'react-native'
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
/**
* Helper to recursively find a node in the rendered tree by its element type name.
* Returns the first match or null.
*/
function findByType(tree: any, typeName: string): any {
if (!tree) return null
if (tree.type === typeName) return tree
if (tree.children && Array.isArray(tree.children)) {
for (const child of tree.children) {
if (typeof child === 'object') {
const found = findByType(child, typeName)
if (found) return found
}
}
}
return null
}
/**
* Helper to count nodes of a given type in the tree
*/
function countByType(tree: any, typeName: string): number {
if (!tree) return 0
let count = tree.type === typeName ? 1 : 0
if (tree.children && Array.isArray(tree.children)) {
for (const child of tree.children) {
if (typeof child === 'object') {
count += countByType(child, typeName)
}
}
}
return count
}
describe('OnboardingStep', () => {
it('renders children', () => {
render(
<OnboardingStep step={1} totalSteps={6}>
<Text testID="child-content">Welcome</Text>
</OnboardingStep>
)
expect(screen.getByTestId('child-content')).toBeTruthy()
})
it('renders progress bar with track and fill Views', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>Step 1</Text>
</OnboardingStep>
)
const tree = toJSON()
// OnboardingStep should have:
// - A root View (container)
// - A View (progressTrack)
// - An Animated.View (progressFill) — rendered as View by mock
// - An Animated.View (content wrapper)
expect(tree).toBeTruthy()
expect(tree?.type).toBe('View') // root container
expect(tree?.children).toBeDefined()
expect(tree!.children!.length).toBeGreaterThanOrEqual(2) // progress track + content
})
it('step 1 of 6 snapshot', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>First step</Text>
</OnboardingStep>
)
expect(toJSON()).toMatchSnapshot()
})
it('step 6 of 6 (final step) snapshot', () => {
const { toJSON } = render(
<OnboardingStep step={6} totalSteps={6}>
<Text>Final step</Text>
</OnboardingStep>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders multiple children inside content area', () => {
render(
<OnboardingStep step={3} totalSteps={6}>
<Text testID="title">Title</Text>
<Text testID="description">Description</Text>
</OnboardingStep>
)
expect(screen.getByTestId('title')).toBeTruthy()
expect(screen.getByTestId('description')).toBeTruthy()
})
it('does not crash with step 0 (edge case snapshot)', () => {
const { toJSON } = render(
<OnboardingStep step={0} totalSteps={6}>
<Text>Edge case</Text>
</OnboardingStep>
)
// Should render without error — snapshot captures structure
expect(toJSON()).toMatchSnapshot()
})
it('container uses safe area top inset for paddingTop', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>Check padding</Text>
</OnboardingStep>
)
const tree = toJSON()
// Root container should have paddingTop accounting for safe area (mock returns top=47)
const rootStyle = tree?.props?.style
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
const hasPaddingTop = flatStyles.some(
(s: any) => s && typeof s === 'object' && typeof s.paddingTop === 'number' && s.paddingTop > 0
)
expect(hasPaddingTop).toBe(true)
})
})

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render } from '@testing-library/react-native'
import {
Skeleton,
WorkoutCardSkeleton,
TrainerCardSkeleton,
CollectionCardSkeleton,
StatsCardSkeleton,
} from '@/src/shared/components/loading/Skeleton'
/**
* Helper to extract the flattened style from a rendered tree node.
* Style can be a single object or an array of objects.
*/
function flattenStyle(style: any): Record<string, any> {
if (!style) return {}
if (Array.isArray(style)) {
return Object.assign({}, ...style.filter(Boolean))
}
return style
}
describe('Skeleton', () => {
it('renders with default dimensions (snapshot)', () => {
const { toJSON } = render(<Skeleton />)
expect(toJSON()).toMatchSnapshot()
})
it('applies default width=100% and height=20', () => {
const { toJSON } = render(<Skeleton />)
const tree = toJSON()
const style = flattenStyle(tree?.props?.style)
expect(style.width).toBe('100%')
expect(style.height).toBe(20)
})
it('applies custom width and height', () => {
const { toJSON } = render(<Skeleton width={200} height={40} />)
const tree = toJSON()
const style = flattenStyle(tree?.props?.style)
expect(style.width).toBe(200)
expect(style.height).toBe(40)
})
it('applies percentage width', () => {
const { toJSON } = render(<Skeleton width="70%" height={20} />)
const tree = toJSON()
const style = flattenStyle(tree?.props?.style)
expect(style.width).toBe('70%')
expect(style.height).toBe(20)
})
it('applies custom borderRadius', () => {
const { toJSON } = render(<Skeleton borderRadius={40} />)
const tree = toJSON()
const style = flattenStyle(tree?.props?.style)
expect(style.borderRadius).toBe(40)
})
it('merges custom style prop', () => {
const customStyle = { marginTop: 10 }
const { toJSON } = render(<Skeleton style={customStyle} />)
const tree = toJSON()
const style = flattenStyle(tree?.props?.style)
expect(style.marginTop).toBe(10)
// Should still have default dimensions
expect(style.width).toBe('100%')
expect(style.height).toBe(20)
})
it('renders shimmer overlay as a child element', () => {
const { toJSON } = render(<Skeleton />)
const tree = toJSON()
// Root View should have at least one child (the shimmer Animated.View)
expect(tree?.children).toBeDefined()
expect(tree!.children!.length).toBeGreaterThanOrEqual(1)
})
it('uses theme overlay color for background', () => {
const { toJSON } = render(<Skeleton />)
const tree = toJSON()
// The Skeleton renders a View with style array including backgroundColor.
// Walk the style array (may be nested) to find backgroundColor.
function findBackgroundColor(node: any): string | undefined {
if (!node?.props?.style) return undefined
const style = flattenStyle(node.props.style)
if (style.backgroundColor) return style.backgroundColor
// Check children
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
if (typeof child === 'object') {
const found = findBackgroundColor(child)
if (found) return found
}
}
}
return undefined
}
const bgColor = findBackgroundColor(tree)
expect(bgColor).toBeDefined()
expect(typeof bgColor).toBe('string')
})
})
describe('WorkoutCardSkeleton', () => {
it('renders correct structure (snapshot)', () => {
const { toJSON } = render(<WorkoutCardSkeleton />)
expect(toJSON()).toMatchSnapshot()
})
it('contains multiple Skeleton elements as children', () => {
const { toJSON } = render(<WorkoutCardSkeleton />)
const tree = toJSON()
// WorkoutCardSkeleton has: image skeleton + title skeleton + row with 2 skeletons = 4 total
// Count all View nodes (Skeleton renders as View)
function countViews(node: any): number {
if (!node) return 0
let count = node.type === 'View' ? 1 : 0
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
if (typeof child === 'object') count += countViews(child)
}
}
return count
}
// Should have at least 5 View nodes (card container + skeletons + content wrapper + row)
expect(countViews(tree)).toBeGreaterThanOrEqual(5)
})
})
describe('TrainerCardSkeleton', () => {
it('renders correct structure (snapshot)', () => {
const { toJSON } = render(<TrainerCardSkeleton />)
expect(toJSON()).toMatchSnapshot()
})
it('contains circular avatar skeleton (borderRadius=40)', () => {
const { toJSON } = render(<TrainerCardSkeleton />)
// First Skeleton inside is the avatar: width=80, height=80, borderRadius=40
function findCircleSkeleton(node: any): boolean {
if (!node) return false
if (node.type === 'View') {
const style = flattenStyle(node.props?.style)
if (style.width === 80 && style.height === 80 && style.borderRadius === 40) return true
}
if (node.children && Array.isArray(node.children)) {
return node.children.some((child: any) => typeof child === 'object' && findCircleSkeleton(child))
}
return false
}
expect(findCircleSkeleton(toJSON())).toBe(true)
})
})
describe('CollectionCardSkeleton', () => {
it('renders correct structure (snapshot)', () => {
const { toJSON } = render(<CollectionCardSkeleton />)
expect(toJSON()).toMatchSnapshot()
})
})
describe('StatsCardSkeleton', () => {
it('renders correct structure (snapshot)', () => {
const { toJSON } = render(<StatsCardSkeleton />)
expect(toJSON()).toMatchSnapshot()
})
it('contains header row with two skeleton elements', () => {
const { toJSON } = render(<StatsCardSkeleton />)
const tree = toJSON()
// StatsCardSkeleton has: card > statsHeader (row) + large skeleton
// statsHeader has 2 children (title skeleton + icon skeleton)
expect(tree?.children).toBeDefined()
expect(tree!.children!.length).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest'
import React from 'react'
import { render, screen, fireEvent, act } from '@testing-library/react-native'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
describe('SyncConsentModal', () => {
const defaultProps = {
visible: true,
onAccept: vi.fn().mockResolvedValue(undefined),
onDecline: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders when visible is true', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.title')).toBeTruthy()
})
it('renders sparkles icon', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByTestId('icon-sparkles')).toBeTruthy()
})
it('renders benefit rows', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.benefits.recommendations')).toBeTruthy()
expect(screen.getByText('sync.benefits.adaptive')).toBeTruthy()
expect(screen.getByText('sync.benefits.sync')).toBeTruthy()
expect(screen.getByText('sync.benefits.secure')).toBeTruthy()
})
it('renders benefit icons', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByTestId('icon-trending-up')).toBeTruthy()
expect(screen.getByTestId('icon-fitness')).toBeTruthy()
expect(screen.getByTestId('icon-sync')).toBeTruthy()
expect(screen.getByTestId('icon-shield-checkmark')).toBeTruthy()
})
it('renders privacy note', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.privacy')).toBeTruthy()
})
it('renders primary and secondary buttons', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.primaryButton')).toBeTruthy()
expect(screen.getByText('sync.secondaryButton')).toBeTruthy()
})
it('calls onDecline when secondary button is pressed', () => {
render(<SyncConsentModal {...defaultProps} />)
fireEvent.press(screen.getByText('sync.secondaryButton'))
expect(defaultProps.onDecline).toHaveBeenCalledTimes(1)
})
it('calls onAccept when primary button is pressed', async () => {
render(<SyncConsentModal {...defaultProps} />)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1)
})
it('shows loading text while accepting', async () => {
let resolveAccept: () => void
const slowAccept = new Promise<void>((resolve) => {
resolveAccept = resolve
})
const onAccept = vi.fn(() => slowAccept)
render(<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
expect(screen.getByText('Setting up...')).toBeTruthy()
await act(async () => {
resolveAccept!()
})
})
it('does not render content when visible is false', () => {
render(<SyncConsentModal {...defaultProps} visible={false} />)
expect(screen.queryByText('sync.title')).toBeNull()
})
it('full modal structure snapshot', () => {
const { toJSON } = render(<SyncConsentModal {...defaultProps} />)
expect(toJSON()).toMatchSnapshot()
})
it('primary button shows disabled state while loading', async () => {
let resolveAccept: () => void
const slowAccept = new Promise<void>((resolve) => {
resolveAccept = resolve
})
const onAccept = vi.fn(() => slowAccept)
const { toJSON } = render(
<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />
)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
// While loading, button text changes to loading state
expect(screen.getByText('Setting up...')).toBeTruthy()
// Verify the tree has the disabled styling applied (opacity: 0.6)
const tree = toJSON()
const treeStr = JSON.stringify(tree)
expect(treeStr).toContain('"opacity":0.6')
await act(async () => {
resolveAccept!()
})
})
})

View File

@@ -0,0 +1,324 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CollectionCard > renders without onPress (no crash) 1`] = `
<Pressable
ref={null}
style={
{
"aspectRatio": 1,
"borderRadius": 20,
"overflow": "hidden",
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
"width": 157.5,
}
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#FF3B30",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
[
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
},
{
"borderRadius": 20,
},
]
}
testID="linear-gradient"
/>
<View
ref={null}
style={
{
"backgroundColor": "rgba(0,0,0,0.3)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<BlurView
intensity={20}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"justifyContent": "flex-end",
"padding": 16,
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.15)",
"borderColor": "rgba(255,255,255,0.2)",
"borderRadius": 14,
"borderWidth": 1,
"height": 48,
"justifyContent": "center",
"marginBottom": 12,
"width": 48,
}
}
>
<Text
ref={null}
style={
{
"fontSize": 24,
}
}
>
💪
</Text>
</View>
<Text
numberOfLines={2}
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "700",
},
{
"marginBottom": 4,
},
]
}
>
Upper Body Blast
</Text>
<Text
numberOfLines={1}
ref={null}
style={
[
{
"color": "rgba(255,255,255,0.7)",
"fontSize": 13,
"fontWeight": "500",
},
undefined,
]
}
>
3
workouts
</Text>
</View>
</Pressable>
`;
exports[`CollectionCard > snapshot with imageUrl (different rendering path) 1`] = `
<Pressable
ref={null}
style={
{
"aspectRatio": 1,
"borderRadius": 20,
"overflow": "hidden",
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
"width": 157.5,
}
}
>
<ImageBackground
imageStyle={
{
"borderRadius": 20,
}
}
ref={null}
resizeMode="cover"
source={
{
"uri": "https://example.com/image.jpg",
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<LinearGradient
colors={
[
"transparent",
"rgba(0,0,0,0.8)",
]
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</ImageBackground>
<View
ref={null}
style={
{
"backgroundColor": "rgba(0,0,0,0.3)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<BlurView
intensity={20}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"justifyContent": "flex-end",
"padding": 16,
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.15)",
"borderColor": "rgba(255,255,255,0.2)",
"borderRadius": 14,
"borderWidth": 1,
"height": 48,
"justifyContent": "center",
"marginBottom": 12,
"width": 48,
}
}
>
<Text
ref={null}
style={
{
"fontSize": 24,
}
}
>
💪
</Text>
</View>
<Text
numberOfLines={2}
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "700",
},
{
"marginBottom": 4,
},
]
}
>
Upper Body Blast
</Text>
<Text
numberOfLines={1}
ref={null}
style={
[
{
"color": "rgba(255,255,255,0.7)",
"fontSize": 13,
"fontWeight": "500",
},
undefined,
]
}
>
3
workouts
</Text>
</View>
</Pressable>
`;

View File

@@ -0,0 +1,190 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DataDeletionModal > full modal structure snapshot 1`] = `
<Modal
animationType="fade"
onRequestClose={[MockFunction]}
ref={null}
transparent={true}
visible={true}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
"padding": 16,
},
{
"backgroundColor": "rgba(0,0,0,0.8)",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"maxWidth": 360,
"padding": 24,
"width": "100%",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 40,
"height": 80,
"justifyContent": "center",
"marginBottom": 16,
"width": 80,
},
{
"backgroundColor": "rgba(255, 59, 48, 0.1)",
},
]
}
>
<Ionicons
color="#FF3B30"
name="warning"
size={40}
testID="icon-warning"
/>
</View>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 22,
"fontWeight": "700",
},
{
"marginBottom": 16,
"textAlign": "center",
},
]
}
>
dataDeletion.title
</Text>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"lineHeight": 22,
"marginBottom": 12,
"textAlign": "center",
},
]
}
>
dataDeletion.description
</Text>
<Text
ref={null}
style={
[
{
"color": "#636366",
"fontSize": 14,
"fontWeight": "400",
},
{
"lineHeight": 20,
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
dataDeletion.note
</Text>
<Pressable
disabled={false}
onClick={[Function]}
onPress={[Function]}
ref={null}
style={
[
{
"alignItems": "center",
"backgroundColor": "#FF3B30",
"borderRadius": 14,
"height": 52,
"justifyContent": "center",
"marginBottom": 12,
"width": "100%",
},
false,
]
}
>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "600",
},
undefined,
]
}
>
dataDeletion.deleteButton
</Text>
</Pressable>
<Pressable
onClick={[MockFunction]}
onPress={[MockFunction]}
ref={null}
style={
{
"padding": 12,
}
}
>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
undefined,
]
}
>
dataDeletion.cancelButton
</Text>
</Pressable>
</View>
</View>
</Modal>
`;

View File

@@ -0,0 +1,283 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GlassCard presets > GlassCardElevated snapshot 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.08)",
"borderColor": "rgba(255, 255, 255, 0.12)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Elevated preset
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders base variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.05)",
"borderColor": "rgba(255, 255, 255, 0.1)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 1,
"width": 0,
},
"shadowOpacity": 0.2,
"shadowRadius": 2,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Base
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders elevated variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.08)",
"borderColor": "rgba(255, 255, 255, 0.12)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Elevated
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders inset variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(0, 0, 0, 0.2)",
"borderColor": "rgba(255, 255, 255, 0.05)",
"borderWidth": 1,
},
{},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Inset
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders tinted variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 107, 53, 0.1)",
"borderColor": "rgba(255, 107, 53, 0.2)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 1,
"width": 0,
},
"shadowOpacity": 0.2,
"shadowRadius": 2,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Tinted
</Text>
</View>
</View>
`;

View File

@@ -0,0 +1,277 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`OnboardingStep > does not crash with step 0 (edge case snapshot) 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
Edge case
</Text>
</Animated.View>
</View>
`;
exports[`OnboardingStep > step 1 of 6 snapshot 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
First step
</Text>
</Animated.View>
</View>
`;
exports[`OnboardingStep > step 6 of 6 (final step) snapshot 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
Final step
</Text>
</Animated.View>
</View>
`;

View File

@@ -0,0 +1,799 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CollectionCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
{
"alignItems": "center",
"padding": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 20,
"height": 120,
"width": 120,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 18,
"width": "80%",
},
{
"marginTop": 12,
},
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
`;
exports[`Skeleton > renders with default dimensions (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 20,
"width": "100%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
`;
exports[`StatsCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 16,
"minWidth": 140,
"padding": 16,
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 14,
"width": "60%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 24,
"width": 24,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 32,
"width": "50%",
},
{
"marginTop": 8,
},
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
`;
exports[`TrainerCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"flexDirection": "row",
"marginBottom": 12,
"padding": 16,
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 40,
"height": 80,
"width": 80,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"gap": 8,
"marginLeft": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 18,
"width": "80%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 14,
"width": "60%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
</View>
`;
exports[`WorkoutCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 16,
"marginBottom": 16,
"overflow": "hidden",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 16,
"height": 160,
"width": "100%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"gap": 8,
"padding": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 20,
"width": "70%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"flexDirection": "row",
"justifyContent": "space-between",
"marginTop": 8,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 16,
"width": "40%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 16,
"width": "30%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
</View>
</View>
`;

View File

@@ -0,0 +1,318 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SyncConsentModal > full modal structure snapshot 1`] = `
<Modal
animationType="fade"
onRequestClose={[MockFunction]}
ref={null}
transparent={true}
visible={true}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
"padding": 16,
},
{
"backgroundColor": "rgba(0,0,0,0.8)",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"maxWidth": 360,
"padding": 24,
"width": "100%",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255, 107, 53, 0.1)",
"borderRadius": 40,
"height": 80,
"justifyContent": "center",
"marginBottom": 16,
"width": 80,
}
}
>
<Ionicons
color="#FF6B35"
name="sparkles"
size={40}
testID="icon-sparkles"
/>
</View>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 24,
"fontWeight": "700",
},
{
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
sync.title
</Text>
<View
ref={null}
style={
{
"gap": 12,
"marginBottom": 24,
"width": "100%",
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="trending-up"
size={22}
testID="icon-trending-up"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.recommendations
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="fitness"
size={22}
testID="icon-fitness"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.adaptive
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="sync"
size={22}
testID="icon-sync"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.sync
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="shield-checkmark"
size={22}
testID="icon-shield-checkmark"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.secure
</Text>
</View>
</View>
<Text
ref={null}
style={
[
{
"color": "#636366",
"fontSize": 13,
"fontWeight": "400",
},
{
"lineHeight": 20,
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
sync.privacy
</Text>
<Pressable
disabled={false}
onClick={[Function]}
onPress={[Function]}
ref={null}
style={
[
{
"alignItems": "center",
"backgroundColor": "#FF6B35",
"borderRadius": 14,
"height": 52,
"justifyContent": "center",
"marginBottom": 12,
"width": "100%",
},
false,
]
}
>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "600",
},
undefined,
]
}
>
sync.primaryButton
</Text>
</Pressable>
<Pressable
onClick={[MockFunction]}
onPress={[MockFunction]}
ref={null}
style={
{
"padding": 12,
}
}
>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
undefined,
]
}
>
sync.secondaryButton
</Text>
</Pressable>
</View>
</View>
</Modal>
`;

View File

@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest'
import { ACHIEVEMENTS } from '../../shared/data/achievements'
describe('achievements data', () => {
describe('ACHIEVEMENTS structure', () => {
it('should have exactly 8 achievements', () => {
expect(ACHIEVEMENTS).toHaveLength(8)
})
it('should have all required properties', () => {
ACHIEVEMENTS.forEach(achievement => {
expect(achievement.id).toBeDefined()
expect(achievement.title).toBeDefined()
expect(achievement.description).toBeDefined()
expect(achievement.icon).toBeDefined()
expect(achievement.requirement).toBeDefined()
expect(achievement.type).toBeDefined()
})
})
it('should have unique achievement IDs', () => {
const ids = ACHIEVEMENTS.map(a => a.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('should have unique achievement titles', () => {
const titles = ACHIEVEMENTS.map(a => a.title)
const uniqueTitles = new Set(titles)
expect(uniqueTitles.size).toBe(titles.length)
})
it('should have positive requirements', () => {
ACHIEVEMENTS.forEach(achievement => {
expect(achievement.requirement).toBeGreaterThan(0)
})
})
})
describe('achievement types', () => {
it('should have valid achievement types', () => {
const validTypes = ['workouts', 'streak', 'calories', 'minutes']
ACHIEVEMENTS.forEach(achievement => {
expect(validTypes).toContain(achievement.type)
})
})
it('should have workouts type achievements', () => {
const workoutAchievements = ACHIEVEMENTS.filter(a => a.type === 'workouts')
expect(workoutAchievements.length).toBeGreaterThan(0)
})
it('should have streak type achievements', () => {
const streakAchievements = ACHIEVEMENTS.filter(a => a.type === 'streak')
expect(streakAchievements.length).toBeGreaterThan(0)
})
it('should have calories type achievements', () => {
const calorieAchievements = ACHIEVEMENTS.filter(a => a.type === 'calories')
expect(calorieAchievements.length).toBeGreaterThan(0)
})
it('should have minutes type achievements', () => {
const minutesAchievements = ACHIEVEMENTS.filter(a => a.type === 'minutes')
expect(minutesAchievements.length).toBeGreaterThan(0)
})
})
describe('specific achievements', () => {
it('should have First Burn achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'first-burn')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('First Burn')
expect(achievement!.requirement).toBe(1)
expect(achievement!.type).toBe('workouts')
})
it('should have Week Warrior achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'week-warrior')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Week Warrior')
expect(achievement!.requirement).toBe(7)
expect(achievement!.type).toBe('streak')
})
it('should have Century Club achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'century-club')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Century Club')
expect(achievement!.requirement).toBe(100)
expect(achievement!.type).toBe('calories')
})
it('should have Iron Will achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'iron-will')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Iron Will')
expect(achievement!.requirement).toBe(10)
expect(achievement!.type).toBe('workouts')
})
it('should have Tabata Master achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'tabata-master')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Tabata Master')
expect(achievement!.requirement).toBe(50)
expect(achievement!.type).toBe('workouts')
})
it('should have Marathon Burner achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'marathon-burner')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Marathon Burner')
expect(achievement!.requirement).toBe(100)
expect(achievement!.type).toBe('minutes')
})
it('should have Unstoppable achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'unstoppable')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Unstoppable')
expect(achievement!.requirement).toBe(30)
expect(achievement!.type).toBe('streak')
})
it('should have Calorie Crusher achievement', () => {
const achievement = ACHIEVEMENTS.find(a => a.id === 'calorie-crusher')
expect(achievement).toBeDefined()
expect(achievement!.title).toBe('Calorie Crusher')
expect(achievement!.requirement).toBe(1000)
expect(achievement!.type).toBe('calories')
})
})
describe('achievement progression', () => {
it('should have increasing workout requirements', () => {
const workoutAchievements = ACHIEVEMENTS
.filter(a => a.type === 'workouts')
.sort((a, b) => a.requirement - b.requirement)
for (let i = 1; i < workoutAchievements.length; i++) {
expect(workoutAchievements[i].requirement).toBeGreaterThan(workoutAchievements[i-1].requirement)
}
})
it('should have increasing streak requirements', () => {
const streakAchievements = ACHIEVEMENTS
.filter(a => a.type === 'streak')
.sort((a, b) => a.requirement - b.requirement)
for (let i = 1; i < streakAchievements.length; i++) {
expect(streakAchievements[i].requirement).toBeGreaterThan(streakAchievements[i-1].requirement)
}
})
it('should have increasing calorie requirements', () => {
const calorieAchievements = ACHIEVEMENTS
.filter(a => a.type === 'calories')
.sort((a, b) => a.requirement - b.requirement)
for (let i = 1; i < calorieAchievements.length; i++) {
expect(calorieAchievements[i].requirement).toBeGreaterThan(calorieAchievements[i-1].requirement)
}
})
})
describe('icon types', () => {
it('should have string icon names', () => {
ACHIEVEMENTS.forEach(achievement => {
expect(typeof achievement.icon).toBe('string')
expect(achievement.icon.length).toBeGreaterThan(0)
})
})
it('should use SF Symbol-like names', () => {
const expectedIcons = ['flame', 'calendar', 'trophy', 'star', 'time', 'rocket']
ACHIEVEMENTS.forEach(achievement => {
expect(expectedIcons).toContain(achievement.icon)
})
})
})
})

View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest'
import { COLLECTIONS, FEATURED_COLLECTION_ID } from '../../shared/data/collections'
describe('collections data', () => {
describe('COLLECTIONS structure', () => {
it('should have exactly 6 collections', () => {
expect(COLLECTIONS).toHaveLength(6)
})
it('should have all required properties', () => {
COLLECTIONS.forEach(collection => {
expect(collection.id).toBeDefined()
expect(collection.title).toBeDefined()
expect(collection.description).toBeDefined()
expect(collection.icon).toBeDefined()
expect(collection.workoutIds).toBeDefined()
expect(Array.isArray(collection.workoutIds)).toBe(true)
})
})
it('should have unique collection IDs', () => {
const ids = COLLECTIONS.map(c => c.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('should have unique collection titles', () => {
const titles = COLLECTIONS.map(c => c.title)
const uniqueTitles = new Set(titles)
expect(uniqueTitles.size).toBe(titles.length)
})
it('should have at least one workout per collection', () => {
COLLECTIONS.forEach(collection => {
expect(collection.workoutIds.length).toBeGreaterThan(0)
})
})
})
describe('specific collections', () => {
it('should have Morning Energizer collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'morning-energizer')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Morning Energizer')
expect(collection!.icon).toBe('🌅')
expect(collection!.workoutIds).toHaveLength(5)
})
it('should have No Equipment collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'no-equipment')
expect(collection).toBeDefined()
expect(collection!.title).toBe('No Equipment')
expect(collection!.workoutIds.length).toBeGreaterThan(10)
})
it('should have 7-Day Burn Challenge collection', () => {
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
expect(collection).toBeDefined()
expect(collection!.title).toBe('7-Day Burn Challenge')
expect(collection!.workoutIds).toHaveLength(7)
expect(collection!.gradient).toBeDefined()
})
it('should have Quick & Intense collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'quick-intense')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Quick & Intense')
expect(collection!.workoutIds.length).toBeGreaterThan(5)
})
it('should have Core Focus collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'core-focus')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Core Focus')
expect(collection!.workoutIds).toHaveLength(6)
})
it('should have Leg Day collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'leg-day')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Leg Day')
expect(collection!.workoutIds).toHaveLength(7)
})
})
describe('FEATURED_COLLECTION_ID', () => {
it('should reference 7-day-burn', () => {
expect(FEATURED_COLLECTION_ID).toBe('7-day-burn')
})
it('should reference an existing collection', () => {
const featured = COLLECTIONS.find(c => c.id === FEATURED_COLLECTION_ID)
expect(featured).toBeDefined()
})
})
describe('collection gradients', () => {
it('should have gradient on 7-day-burn', () => {
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
expect(collection!.gradient).toBeDefined()
expect(collection!.gradient).toHaveLength(2)
})
it('should have valid hex colors in gradient', () => {
const hexPattern = /^#[0-9A-Fa-f]{6}$/
const collection = COLLECTIONS.find(c => c.gradient)
if (collection?.gradient) {
collection.gradient.forEach(color => {
expect(color).toMatch(hexPattern)
})
}
})
})
describe('workout ID format', () => {
it('should have string workout IDs', () => {
COLLECTIONS.forEach(collection => {
collection.workoutIds.forEach(id => {
expect(typeof id).toBe('string')
})
})
})
it('should have numeric-like workout IDs', () => {
const numericPattern = /^\d+$/
COLLECTIONS.forEach(collection => {
collection.workoutIds.forEach(id => {
expect(id).toMatch(numericPattern)
})
})
})
})
})

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dataService } from '../../shared/data/dataService'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from '../../shared/data'
import type { Workout, Trainer, Collection, Program, Achievement } from '../../shared/types'
vi.mock('../../shared/supabase', () => ({
isSupabaseConfigured: vi.fn(() => false),
supabase: {
from: vi.fn(),
auth: {
signInAnonymously: vi.fn(),
},
storage: {
from: vi.fn(),
},
},
}))
describe('dataService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getAllWorkouts', () => {
it('should return local data when Supabase not configured', async () => {
const workouts = await dataService.getAllWorkouts()
expect(workouts).toEqual(WORKOUTS)
})
it('should return workouts with required properties', async () => {
const workouts = await dataService.getAllWorkouts()
workouts.forEach((workout: Workout) => {
expect(workout.id).toBeDefined()
expect(workout.title).toBeDefined()
expect(workout.trainerId).toBeDefined()
expect(workout.duration).toBeDefined()
expect(workout.calories).toBeDefined()
})
})
})
describe('getWorkoutById', () => {
it('should return workout by id', async () => {
const workout = await dataService.getWorkoutById('1')
expect(workout).toBeDefined()
expect(workout?.id).toBe('1')
})
it('should return undefined for non-existent workout', async () => {
const workout = await dataService.getWorkoutById('non-existent')
expect(workout).toBeUndefined()
})
})
describe('getWorkoutsByCategory', () => {
it('should return workouts filtered by category', async () => {
const workouts = await dataService.getWorkoutsByCategory('full-body')
expect(workouts).toBeDefined()
expect(Array.isArray(workouts)).toBe(true)
workouts.forEach((workout: Workout) => {
expect(workout.category).toBe('full-body')
})
})
it('should return empty array for non-existent category', async () => {
const workouts = await dataService.getWorkoutsByCategory('non-existent')
expect(workouts).toEqual([])
})
})
describe('getWorkoutsByTrainer', () => {
it('should return workouts filtered by trainer', async () => {
const workouts = await dataService.getWorkoutsByTrainer('emma')
expect(workouts).toBeDefined()
expect(Array.isArray(workouts)).toBe(true)
})
})
describe('getFeaturedWorkouts', () => {
it('should return only featured workouts', async () => {
const workouts = await dataService.getFeaturedWorkouts()
expect(workouts).toBeDefined()
workouts.forEach((workout: Workout) => {
expect(workout.isFeatured).toBe(true)
})
})
})
describe('getAllTrainers', () => {
it('should return all trainers', async () => {
const trainers = await dataService.getAllTrainers()
expect(trainers).toEqual(TRAINERS)
})
it('should return trainers with required properties', async () => {
const trainers = await dataService.getAllTrainers()
trainers.forEach((trainer: Trainer) => {
expect(trainer.id).toBeDefined()
expect(trainer.name).toBeDefined()
expect(trainer.specialty).toBeDefined()
expect(trainer.color).toBeDefined()
})
})
})
describe('getTrainerById', () => {
it('should return trainer by id', async () => {
const trainer = await dataService.getTrainerById('emma')
expect(trainer).toBeDefined()
expect(trainer?.id).toBe('emma')
})
it('should return undefined for non-existent trainer', async () => {
const trainer = await dataService.getTrainerById('non-existent')
expect(trainer).toBeUndefined()
})
})
describe('getAllCollections', () => {
it('should return all collections', async () => {
const collections = await dataService.getAllCollections()
expect(collections).toEqual(COLLECTIONS)
})
it('should return collections with required properties', async () => {
const collections = await dataService.getAllCollections()
collections.forEach((collection: Collection) => {
expect(collection.id).toBeDefined()
expect(collection.title).toBeDefined()
expect(collection.workoutIds).toBeDefined()
expect(Array.isArray(collection.workoutIds)).toBe(true)
})
})
})
describe('getCollectionById', () => {
it('should return collection by id', async () => {
const collection = await dataService.getCollectionById('morning-energizer')
expect(collection).toBeDefined()
expect(collection?.id).toBe('morning-energizer')
})
it('should return undefined for non-existent collection', async () => {
const collection = await dataService.getCollectionById('non-existent')
expect(collection).toBeUndefined()
})
})
describe('getAllPrograms', () => {
it('should return all programs', async () => {
const programs = await dataService.getAllPrograms()
const programValues = Object.values(programs)
expect(programValues.length).toBe(3)
})
it('should return programs with required properties', async () => {
const programs = await dataService.getAllPrograms()
const programValues = Object.values(programs)
programValues.forEach((program: Program) => {
expect(program.id).toBeDefined()
expect(program.title).toBeDefined()
expect(program.weeks).toBeDefined()
expect(Array.isArray(program.weeks)).toBe(true)
})
})
})
describe('getAchievements', () => {
it('should return all achievements', async () => {
const achievements = await dataService.getAchievements()
expect(achievements).toEqual(ACHIEVEMENTS)
})
it('should return achievements with required properties', async () => {
const achievements = await dataService.getAchievements()
achievements.forEach((achievement: Achievement) => {
expect(achievement.id).toBeDefined()
expect(achievement.title).toBeDefined()
expect(achievement.description).toBeDefined()
expect(achievement.requirement).toBeDefined()
expect(achievement.type).toBeDefined()
})
})
})
})

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { PROGRAMS, ASSESSMENT_WORKOUT, ALL_PROGRAM_WORKOUTS, UPPER_BODY_WORKOUTS, LOWER_BODY_WORKOUTS, FULL_BODY_WORKOUTS } from '../../shared/data/programs'
import type { Program, ProgramId } from '../../shared/types/program'
describe('programs data', () => {
const programIds: ProgramId[] = ['upper-body', 'lower-body', 'full-body']
describe('PROGRAMS structure', () => {
it('should have exactly 3 programs', () => {
expect(Object.keys(PROGRAMS)).toHaveLength(3)
})
it('should have all required program IDs', () => {
programIds.forEach(id => {
expect(PROGRAMS[id]).toBeDefined()
})
})
it('should have consistent program structure', () => {
programIds.forEach(id => {
const program = PROGRAMS[id]
expect(program.id).toBe(id)
expect(program.title).toBeDefined()
expect(program.description).toBeDefined()
expect(program.durationWeeks).toBe(4)
expect(program.workoutsPerWeek).toBe(5)
expect(program.totalWorkouts).toBe(20)
expect(program.equipment).toBeDefined()
expect(program.focusAreas).toBeDefined()
expect(program.weeks).toHaveLength(4)
})
})
})
describe('program weeks', () => {
it('should have 4 weeks per program', () => {
programIds.forEach(id => {
expect(PROGRAMS[id].weeks).toHaveLength(4)
})
})
it('should have correct week numbers', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach((week, index) => {
expect(week.weekNumber).toBe(index + 1)
})
})
})
it('should have 5 workouts per week', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
expect(week.workouts).toHaveLength(5)
})
})
})
it('should have correct week titles', () => {
const expectedTitles = ['Foundation', 'Building', 'Challenge', 'Peak Performance']
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach((week, index) => {
expect(week.title).toBe(expectedTitles[index])
})
})
})
it('should have week descriptions', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
expect(week.description).toBeDefined()
expect(week.description.length).toBeGreaterThan(0)
})
})
})
it('should have week focus', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
expect(week.focus).toBeDefined()
expect(week.focus.length).toBeGreaterThan(0)
})
})
})
})
describe('workout structure', () => {
it('should have 8 exercises per workout', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
expect(workout.exercises).toHaveLength(8)
})
})
})
})
it('should have 4-minute duration for all workouts', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
expect(workout.duration).toBe(4)
})
})
})
})
it('should have exercise names', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
workout.exercises.forEach(exercise => {
expect(exercise.name).toBeDefined()
expect(exercise.name.length).toBeGreaterThan(0)
})
})
})
})
})
it('should have 20-second exercise duration', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
workout.exercises.forEach(exercise => {
expect(exercise.duration).toBe(20)
})
})
})
})
})
it('should have workout equipment', () => {
let hasEquipment = false
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
if (workout.equipment && workout.equipment.length > 0) {
hasEquipment = true
}
})
})
})
expect(hasEquipment).toBe(true)
})
it('should have workout focus areas', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
expect(workout.focus).toBeDefined()
expect(workout.focus.length).toBeGreaterThan(0)
})
})
})
})
it('should have workout tips', () => {
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
expect(workout.tips).toBeDefined()
expect(workout.tips.length).toBeGreaterThan(0)
})
})
})
})
})
describe('workout IDs', () => {
it('should have unique workout IDs', () => {
const allIds = new Set<string>()
let duplicateFound = false
programIds.forEach(id => {
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
if (allIds.has(workout.id)) {
duplicateFound = true
}
allIds.add(workout.id)
})
})
})
expect(duplicateFound).toBe(false)
})
it('should follow ID naming convention', () => {
const patterns = {
'upper-body': /^ub-w\d-d\d$/,
'lower-body': /^lb-w\d-d\d$/,
'full-body': /^fb-w\d-d\d$/,
}
programIds.forEach(id => {
const pattern = patterns[id]
PROGRAMS[id].weeks.forEach(week => {
week.workouts.forEach(workout => {
expect(workout.id).toMatch(pattern)
})
})
})
})
})
describe('equipment requirements', () => {
it('should have required equipment for upper body', () => {
expect(PROGRAMS['upper-body'].equipment.required).toContain('Resistance band')
})
it('should have required equipment for lower body', () => {
expect(PROGRAMS['lower-body'].equipment.required).toContain('Resistance band')
})
it('should have no required equipment for full body', () => {
expect(PROGRAMS['full-body'].equipment.required).toHaveLength(0)
})
})
describe('focus areas', () => {
it('should have upper body focus areas', () => {
const focus = PROGRAMS['upper-body'].focusAreas
expect(focus).toContain('Shoulders')
expect(focus).toContain('Chest')
expect(focus).toContain('Back')
})
it('should have lower body focus areas', () => {
const focus = PROGRAMS['lower-body'].focusAreas
expect(focus).toContain('Legs')
expect(focus).toContain('Glutes')
})
it('should have full body focus areas', () => {
const focus = PROGRAMS['full-body'].focusAreas
expect(focus).toContain('Total Body')
expect(focus).toContain('Core')
})
})
describe('ALL_PROGRAM_WORKOUTS', () => {
it('should contain all workouts from all programs', () => {
expect(ALL_PROGRAM_WORKOUTS).toHaveLength(60)
})
it('should combine upper, lower, and full body workouts', () => {
expect(UPPER_BODY_WORKOUTS).toHaveLength(20)
expect(LOWER_BODY_WORKOUTS).toHaveLength(20)
expect(FULL_BODY_WORKOUTS).toHaveLength(20)
})
})
describe('ASSESSMENT_WORKOUT', () => {
it('should have correct structure', () => {
expect(ASSESSMENT_WORKOUT.id).toBe('initial-assessment')
expect(ASSESSMENT_WORKOUT.title).toBe('Movement Assessment')
expect(ASSESSMENT_WORKOUT.duration).toBe(4)
})
it('should have 8 exercises', () => {
expect(ASSESSMENT_WORKOUT.exercises).toHaveLength(8)
})
it('should have exercise purposes', () => {
ASSESSMENT_WORKOUT.exercises.forEach(exercise => {
expect(exercise.purpose).toBeDefined()
expect(exercise.purpose.length).toBeGreaterThan(0)
})
})
it('should have tips', () => {
expect(ASSESSMENT_WORKOUT.tips).toBeDefined()
expect(ASSESSMENT_WORKOUT.tips.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import { TRAINERS } from '../../shared/data/trainers'
describe('trainers data', () => {
describe('TRAINERS structure', () => {
it('should have exactly 5 trainers', () => {
expect(TRAINERS).toHaveLength(5)
})
it('should have all required properties', () => {
TRAINERS.forEach(trainer => {
expect(trainer.id).toBeDefined()
expect(trainer.name).toBeDefined()
expect(trainer.specialty).toBeDefined()
expect(trainer.color).toBeDefined()
expect(trainer.workoutCount).toBeDefined()
})
})
it('should have unique trainer IDs', () => {
const ids = TRAINERS.map(t => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('should have unique trainer names', () => {
const names = TRAINERS.map(t => t.name)
const uniqueNames = new Set(names)
expect(uniqueNames.size).toBe(names.length)
})
it('should have valid hex colors', () => {
const hexPattern = /^#[0-9A-Fa-f]{6}$/
TRAINERS.forEach(trainer => {
expect(trainer.color).toMatch(hexPattern)
})
})
it('should have positive workout counts', () => {
TRAINERS.forEach(trainer => {
expect(trainer.workoutCount).toBeGreaterThan(0)
})
})
})
describe('specific trainers', () => {
it('should have Emma as first trainer', () => {
expect(TRAINERS[0].id).toBe('emma')
expect(TRAINERS[0].name).toBe('Emma')
expect(TRAINERS[0].specialty).toBe('Full Body')
})
it('should have Jake as second trainer', () => {
expect(TRAINERS[1].id).toBe('jake')
expect(TRAINERS[1].name).toBe('Jake')
expect(TRAINERS[1].specialty).toBe('Strength')
})
it('should have Mia as third trainer', () => {
expect(TRAINERS[2].id).toBe('mia')
expect(TRAINERS[2].name).toBe('Mia')
expect(TRAINERS[2].specialty).toBe('Core')
})
it('should have Alex as fourth trainer', () => {
expect(TRAINERS[3].id).toBe('alex')
expect(TRAINERS[3].name).toBe('Alex')
expect(TRAINERS[3].specialty).toBe('Cardio')
})
it('should have Sofia as fifth trainer', () => {
expect(TRAINERS[4].id).toBe('sofia')
expect(TRAINERS[4].name).toBe('Sofia')
expect(TRAINERS[4].specialty).toBe('Recovery')
})
})
describe('specialty coverage', () => {
it('should cover all major workout types', () => {
const specialties = TRAINERS.map(t => t.specialty)
expect(specialties).toContain('Full Body')
expect(specialties).toContain('Strength')
expect(specialties).toContain('Core')
expect(specialties).toContain('Cardio')
expect(specialties).toContain('Recovery')
})
})
describe('workout distribution', () => {
it('should have Emma with most workouts', () => {
const emma = TRAINERS.find(t => t.id === 'emma')
expect(emma!.workoutCount).toBe(15)
})
it('should have Sofia with fewest workouts', () => {
const sofia = TRAINERS.find(t => t.id === 'sofia')
expect(sofia!.workoutCount).toBe(5)
})
it('should have total workout count of 50', () => {
const total = TRAINERS.reduce((sum, t) => sum + t.workoutCount, 0)
expect(total).toBe(50)
})
})
})

View File

@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest'
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[()]/g, '')
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
describe('useTranslatedData utilities', () => {
describe('slugify', () => {
it('should convert to lowercase', () => {
expect(slugify('Push-Ups')).toBe('push-ups')
expect(slugify('JUMPING JACKS')).toBe('jumping-jacks')
})
it('should replace spaces with hyphens', () => {
expect(slugify('mountain climbers')).toBe('mountain-climbers')
expect(slugify('high knees fast')).toBe('high-knees-fast')
})
it('should remove parentheses', () => {
expect(slugify('Exercise (Modified)')).toBe('exercise-modified')
expect(slugify('Move (Advanced)')).toBe('move-advanced')
})
it('should replace & with "and"', () => {
expect(slugify('Stretch & Cool')).toBe('stretch-and-cool')
expect(slugify('Core & Abs')).toBe('core-and-abs')
})
it('should handle multiple special characters', () => {
expect(slugify('Full Body (HIIT) & Cardio')).toBe('full-body-hiit-and-cardio')
})
it('should collapse multiple hyphens', () => {
expect(slugify('exercise name')).toBe('exercise-name')
})
it('should trim leading and trailing hyphens', () => {
expect(slugify('-exercise-')).toBe('exercise')
expect(slugify('--test--')).toBe('test')
})
it('should handle empty string', () => {
expect(slugify('')).toBe('')
})
it('should handle already clean strings', () => {
expect(slugify('burpees')).toBe('burpees')
})
it('should handle numbers', () => {
expect(slugify('Level 1 Beginner')).toBe('level-1-beginner')
expect(slugify('30 Second Sprint')).toBe('30-second-sprint')
})
it('should handle equipment names', () => {
expect(slugify('Dumbbells')).toBe('dumbbells')
expect(slugify('Resistance Band')).toBe('resistance-band')
expect(slugify('Yoga Mat')).toBe('yoga-mat')
})
it('should handle complex exercise names', () => {
expect(slugify('Renegade Row (Each Arm)')).toBe('renegade-row-each-arm')
expect(slugify('Plank to Push-Up')).toBe('plank-to-push-up')
})
})
describe('translation key generation', () => {
it('should generate valid i18n keys for workouts', () => {
const workoutId = 'full-body-burn'
const key = `workouts.${workoutId}`
expect(key).toBe('workouts.full-body-burn')
})
it('should generate valid i18n keys for exercises', () => {
const exerciseName = 'Mountain Climbers'
const key = `exercises.${slugify(exerciseName)}`
expect(key).toBe('exercises.mountain-climbers')
})
it('should generate valid i18n keys for equipment', () => {
const equipmentName = 'Resistance Band'
const key = `equipment.${slugify(equipmentName)}`
expect(key).toBe('equipment.resistance-band')
})
it('should generate valid i18n keys for collections', () => {
const collectionId = 'morning-energizer'
const titleKey = `collections.${collectionId}.title`
const descKey = `collections.${collectionId}.description`
expect(titleKey).toBe('collections.morning-energizer.title')
expect(descKey).toBe('collections.morning-energizer.description')
})
it('should generate valid i18n keys for programs', () => {
const programId = '4-week-strength'
const titleKey = `programs.${programId}.title`
const descKey = `programs.${programId}.description`
expect(titleKey).toBe('programs.4-week-strength.title')
expect(descKey).toBe('programs.4-week-strength.description')
})
})
describe('defaultValue fallback', () => {
it('should use original value as defaultValue', () => {
const originalTitle = 'High Intensity Interval Training'
const translationOptions = {
defaultValue: originalTitle,
}
expect(translationOptions.defaultValue).toBe(originalTitle)
})
it('should preserve workout structure when translating', () => {
const workout = {
id: 'test-workout',
title: 'Test Workout',
exercises: [
{ name: 'Push-Ups', duration: 20 },
{ name: 'Squats', duration: 20 },
],
equipment: ['Mat', 'Dumbbells'],
}
const translatedWorkout = {
...workout,
title: 'Translated Title',
exercises: workout.exercises.map((ex) => ({
...ex,
name: slugify(ex.name),
})),
equipment: workout.equipment.map((item) => slugify(item)),
}
expect(translatedWorkout.exercises[0].name).toBe('push-ups')
expect(translatedWorkout.equipment[0]).toBe('mat')
})
})
describe('category mapping', () => {
const categoryKeyMap: Record<string, string> = {
'full-body': 'categories.fullBody',
'upper-body': 'categories.upperBody',
'lower-body': 'categories.lowerBody',
'core': 'categories.core',
'cardio': 'categories.cardio',
}
it('should map full-body category', () => {
expect(categoryKeyMap['full-body']).toBe('categories.fullBody')
})
it('should map upper-body category', () => {
expect(categoryKeyMap['upper-body']).toBe('categories.upperBody')
})
it('should map lower-body category', () => {
expect(categoryKeyMap['lower-body']).toBe('categories.lowerBody')
})
it('should map core category', () => {
expect(categoryKeyMap['core']).toBe('categories.core')
})
it('should map cardio category', () => {
expect(categoryKeyMap['cardio']).toBe('categories.cardio')
})
})
describe('music vibe mapping', () => {
const vibeKeyMap: Record<string, string> = {
electronic: 'musicVibes.electronic',
'hip-hop': 'musicVibes.hipHop',
pop: 'musicVibes.pop',
rock: 'musicVibes.rock',
chill: 'musicVibes.chill',
}
it('should map electronic vibe', () => {
expect(vibeKeyMap['electronic']).toBe('musicVibes.electronic')
})
it('should map hip-hop vibe', () => {
expect(vibeKeyMap['hip-hop']).toBe('musicVibes.hipHop')
})
it('should map pop vibe', () => {
expect(vibeKeyMap['pop']).toBe('musicVibes.pop')
})
it('should map rock vibe', () => {
expect(vibeKeyMap['rock']).toBe('musicVibes.rock')
})
it('should map chill vibe', () => {
expect(vibeKeyMap['chill']).toBe('musicVibes.chill')
})
})
})

View File

@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest'
import { WORKOUTS } from '../../shared/data/workouts'
import type { Workout, WorkoutCategory, WorkoutLevel, WorkoutDuration, MusicVibe } from '../../shared/types'
describe('workouts data', () => {
describe('data integrity', () => {
it('should have 50 workouts', () => {
expect(WORKOUTS).toHaveLength(50)
})
it('should have unique IDs for all workouts', () => {
const ids = WORKOUTS.map((w) => w.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('should have all required fields for each workout', () => {
const requiredFields: (keyof Workout)[] = [
'id', 'title', 'trainerId', 'category', 'level', 'duration',
'calories', 'exercises', 'rounds', 'prepTime', 'workTime',
'restTime', 'equipment', 'musicVibe',
]
WORKOUTS.forEach((workout) => {
requiredFields.forEach((field) => {
expect(workout[field]).toBeDefined()
})
})
})
})
describe('category distribution', () => {
const categories: WorkoutCategory[] = ['full-body', 'core', 'upper-body', 'lower-body', 'cardio']
categories.forEach((category) => {
it(`should have 10 ${category} workouts`, () => {
const count = WORKOUTS.filter((w) => w.category === category).length
expect(count).toBe(10)
})
})
})
describe('level distribution', () => {
it('should have Beginner workouts', () => {
const beginners = WORKOUTS.filter((w) => w.level === 'Beginner')
expect(beginners.length).toBeGreaterThan(0)
})
it('should have Intermediate workouts', () => {
const intermediates = WORKOUTS.filter((w) => w.level === 'Intermediate')
expect(intermediates.length).toBeGreaterThan(0)
})
it('should have Advanced workouts', () => {
const advanced = WORKOUTS.filter((w) => w.level === 'Advanced')
expect(advanced.length).toBeGreaterThan(0)
})
})
describe('duration distribution', () => {
const durations: WorkoutDuration[] = [4, 8, 12, 20]
durations.forEach((duration) => {
it(`should have ${duration}-minute workouts`, () => {
const count = WORKOUTS.filter((w) => w.duration === duration).length
expect(count).toBeGreaterThan(0)
})
})
})
describe('workout structure validation', () => {
it('should have valid prep times (5-15 seconds)', () => {
WORKOUTS.forEach((workout) => {
expect(workout.prepTime).toBeGreaterThanOrEqual(5)
expect(workout.prepTime).toBeLessThanOrEqual(15)
})
})
it('should have valid work times (15-30 seconds)', () => {
WORKOUTS.forEach((workout) => {
expect(workout.workTime).toBeGreaterThanOrEqual(15)
expect(workout.workTime).toBeLessThanOrEqual(30)
})
})
it('should have valid rest times (5-15 seconds)', () => {
WORKOUTS.forEach((workout) => {
expect(workout.restTime).toBeGreaterThanOrEqual(5)
expect(workout.restTime).toBeLessThanOrEqual(15)
})
})
it('should have at least 1 exercise per workout', () => {
WORKOUTS.forEach((workout) => {
expect(workout.exercises.length).toBeGreaterThanOrEqual(1)
})
})
it('should have valid exercise durations matching work time', () => {
WORKOUTS.forEach((workout) => {
workout.exercises.forEach((exercise) => {
expect(exercise.duration).toBe(workout.workTime)
})
})
})
it('should have valid rounds (4-40)', () => {
WORKOUTS.forEach((workout) => {
expect(workout.rounds).toBeGreaterThanOrEqual(4)
expect(workout.rounds).toBeLessThanOrEqual(40)
})
})
})
describe('calorie estimation', () => {
it('should have positive calorie values', () => {
WORKOUTS.forEach((workout) => {
expect(workout.calories).toBeGreaterThan(0)
})
})
it('should scale calories with duration', () => {
const shortWorkouts = WORKOUTS.filter((w) => w.duration === 4)
const longWorkouts = WORKOUTS.filter((w) => w.duration === 20)
const avgShortCalories = shortWorkouts.reduce((sum, w) => sum + w.calories, 0) / shortWorkouts.length
const avgLongCalories = longWorkouts.reduce((sum, w) => sum + w.calories, 0) / longWorkouts.length
expect(avgLongCalories).toBeGreaterThan(avgShortCalories)
})
})
describe('music vibes', () => {
const validVibes: MusicVibe[] = ['electronic', 'hip-hop', 'pop', 'rock', 'chill']
it('should only have valid music vibes', () => {
WORKOUTS.forEach((workout) => {
expect(validVibes).toContain(workout.musicVibe)
})
})
validVibes.forEach((vibe) => {
it(`should have workouts with ${vibe} vibe`, () => {
const count = WORKOUTS.filter((w) => w.musicVibe === vibe).length
expect(count).toBeGreaterThan(0)
})
})
})
describe('equipment field', () => {
it('should have equipment as an array', () => {
WORKOUTS.forEach((workout) => {
expect(Array.isArray(workout.equipment)).toBe(true)
})
})
it('should have at least "No equipment required" for bodyweight workouts', () => {
const noEquipmentWorkouts = WORKOUTS.filter((w) =>
w.equipment.some((e) => e.toLowerCase().includes('no equipment'))
)
expect(noEquipmentWorkouts.length).toBeGreaterThan(0)
})
})
describe('featured workouts', () => {
it('should have some featured workouts', () => {
const featured = WORKOUTS.filter((w) => w.isFeatured)
expect(featured.length).toBeGreaterThan(0)
})
})
describe('trainer assignments', () => {
const validTrainers = ['emma', 'jake', 'alex', 'sofia', 'mia']
it('should only have valid trainer IDs', () => {
WORKOUTS.forEach((workout) => {
expect(validTrainers).toContain(workout.trainerId)
})
})
validTrainers.forEach((trainer) => {
it(`should have workouts for trainer ${trainer}`, () => {
const count = WORKOUTS.filter((w) => w.trainerId === trainer).length
expect(count).toBeGreaterThan(0)
})
})
})
describe('duration calculation validation', () => {
it('should have duration matching rounds and intervals', () => {
WORKOUTS.forEach((workout) => {
const totalSeconds = workout.prepTime + (workout.workTime + workout.restTime) * workout.rounds
const totalMinutes = totalSeconds / 60
expect(Math.abs(totalMinutes - workout.duration)).toBeLessThan(2)
})
})
})
})

View File

@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Audio } from 'expo-av'
import { useUserStore } from '../../shared/stores/userStore'
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
}))
describe('useAudio logic', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},
})
})
describe('audio mode configuration', () => {
it('should configure audio with correct settings', async () => {
const expectedConfig = {
playsInSilentModeIOS: true,
staysActiveInBackground: false,
shouldDuckAndroid: true,
}
await Audio.setAudioModeAsync(expectedConfig)
expect(Audio.setAudioModeAsync).toHaveBeenCalledWith(expectedConfig)
})
})
describe('sound creation', () => {
it('should create sound with createAsync', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const result = await Audio.Sound.createAsync({} as any)
expect(result.sound).toBeDefined()
expect(result.status.isLoaded).toBe(true)
})
it('should handle sound creation failure gracefully', async () => {
vi.mocked(Audio.Sound.createAsync).mockRejectedValueOnce(new Error('Failed to load'))
await expect(Audio.Sound.createAsync({} as any)).rejects.toThrow('Failed to load')
})
})
describe('sound playback', () => {
const createSoundCallbacks = (soundEnabled: boolean) => {
const play = async (soundKey: string) => {
if (!soundEnabled) return
try {
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.setPositionAsync(0)
await sound.playAsync()
} catch (error) {
// Handle error silently
}
}
return {
countdownBeep: () => play('countdown'),
phaseStart: () => play('phaseStart'),
workoutComplete: () => play('complete'),
}
}
describe('when sound enabled', () => {
it('should play countdown beep', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.countdownBeep()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0)
expect(mockSound.playAsync).toHaveBeenCalled()
})
it('should play phase start sound', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.phaseStart()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.playAsync).toHaveBeenCalled()
})
it('should play workout complete sound', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.workoutComplete()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.playAsync).toHaveBeenCalled()
})
})
describe('when sound disabled', () => {
it('should not play countdown beep', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.countdownBeep()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
it('should not play phase start sound', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.phaseStart()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
it('should not play workout complete sound', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.workoutComplete()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
})
})
describe('sound cleanup', () => {
it('should unload sound on cleanup', async () => {
const mockUnload = vi.fn()
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: mockUnload,
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.unloadAsync()
expect(mockUnload).toHaveBeenCalled()
})
})
describe('error handling', () => {
it('should handle playback errors gracefully', async () => {
vi.mocked(Audio.Sound.createAsync).mockRejectedValueOnce(new Error('Playback failed'))
const play = async () => {
try {
await Audio.Sound.createAsync({} as any)
} catch {
// Silently handle
}
}
await expect(play()).resolves.not.toThrow()
})
})
})

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as Haptics from 'expo-haptics'
import { useUserStore } from '../../shared/stores/userStore'
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
},
notificationAsync: vi.fn(),
NotificationFeedbackType: {
Success: 'success',
Warning: 'warning',
Error: 'error',
},
selectionAsync: vi.fn(),
}))
describe('useHaptics logic', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},
})
})
describe('haptic feedback functions', () => {
const createHapticCallbacks = () => {
const hapticsEnabled = useUserStore.getState().settings.haptics
const phaseChange = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
const buttonTap = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
}
const countdownTick = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
const workoutComplete = () => {
if (!hapticsEnabled) return
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}
const selection = () => {
if (!hapticsEnabled) return
Haptics.selectionAsync()
}
return { phaseChange, buttonTap, countdownTick, workoutComplete, selection }
}
describe('when haptics enabled', () => {
it('should call impactAsync with Heavy for phaseChange', () => {
const callbacks = createHapticCallbacks()
callbacks.phaseChange()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Heavy)
})
it('should call impactAsync with Medium for buttonTap', () => {
const callbacks = createHapticCallbacks()
callbacks.buttonTap()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Medium)
})
it('should call impactAsync with Light for countdownTick', () => {
const callbacks = createHapticCallbacks()
callbacks.countdownTick()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Light)
})
it('should call notificationAsync with Success for workoutComplete', () => {
const callbacks = createHapticCallbacks()
callbacks.workoutComplete()
expect(Haptics.notificationAsync).toHaveBeenCalledWith(Haptics.NotificationFeedbackType.Success)
})
it('should call selectionAsync for selection', () => {
const callbacks = createHapticCallbacks()
callbacks.selection()
expect(Haptics.selectionAsync).toHaveBeenCalled()
})
})
describe('when haptics disabled', () => {
beforeEach(() => {
useUserStore.setState({
settings: {
haptics: false,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},
})
})
it('should not call impactAsync for phaseChange', () => {
const callbacks = createHapticCallbacks()
callbacks.phaseChange()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call impactAsync for buttonTap', () => {
const callbacks = createHapticCallbacks()
callbacks.buttonTap()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call impactAsync for countdownTick', () => {
const callbacks = createHapticCallbacks()
callbacks.countdownTick()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call notificationAsync for workoutComplete', () => {
const callbacks = createHapticCallbacks()
callbacks.workoutComplete()
expect(Haptics.notificationAsync).not.toHaveBeenCalled()
})
it('should not call selectionAsync for selection', () => {
const callbacks = createHapticCallbacks()
callbacks.selection()
expect(Haptics.selectionAsync).not.toHaveBeenCalled()
})
})
})
describe('feedback style mapping', () => {
it('should map phase change to heavy impact', () => {
expect(Haptics.ImpactFeedbackStyle.Heavy).toBe('heavy')
})
it('should map button tap to medium impact', () => {
expect(Haptics.ImpactFeedbackStyle.Medium).toBe('medium')
})
it('should map countdown tick to light impact', () => {
expect(Haptics.ImpactFeedbackStyle.Light).toBe('light')
})
it('should map workout complete to success notification', () => {
expect(Haptics.NotificationFeedbackType.Success).toBe('success')
})
})
})

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Audio } from 'expo-av'
import { useUserStore } from '../../shared/stores/userStore'
import type { MusicTrack } from '../../shared/services/music'
import type { MusicVibe } from '../../shared/types'
const mockTracks: MusicTrack[] = [
{ id: '1', title: 'Energy Pulse', artist: 'Neon Dreams', duration: 240, url: '', vibe: 'electronic' },
{ id: '2', title: 'Cyber Sprint', artist: 'Digital Flux', duration: 180, url: '', vibe: 'electronic' },
{ id: '3', title: 'High Voltage', artist: 'Circuit Breakers', duration: 200, url: '', vibe: 'electronic' },
]
const mockHipHopTracks: MusicTrack[] = [
{ id: '4', title: 'Street Heat', artist: 'Urban Flow', duration: 210, url: '', vibe: 'hip-hop' },
]
function getRandomTrackIndex(tracks: MusicTrack[]): number {
if (tracks.length === 0) return -1
return Math.floor(Math.random() * tracks.length)
}
function getNextTrackIndex(currentIndex: number, tracksLength: number): number {
if (tracksLength <= 1) return 0
return (currentIndex + 1) % tracksLength
}
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume))
}
describe('useMusicPlayer', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('audio mode configuration', () => {
it('should configure audio with correct settings', async () => {
const expectedConfig = {
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
interruptionModeIOS: 1,
interruptionModeAndroid: 1,
}
await Audio.setAudioModeAsync(expectedConfig)
expect(Audio.setAudioModeAsync).toHaveBeenCalledWith(expectedConfig)
})
})
describe('track selection', () => {
it('should return valid random track index', () => {
const index = getRandomTrackIndex(mockTracks)
expect(index).toBeGreaterThanOrEqual(0)
expect(index).toBeLessThan(mockTracks.length)
})
it('should return -1 for empty track list', () => {
const index = getRandomTrackIndex([])
expect(index).toBe(-1)
})
it('should cycle to next track', () => {
const nextIndex = getNextTrackIndex(0, 3)
expect(nextIndex).toBe(1)
})
it('should wrap around to first track', () => {
const nextIndex = getNextTrackIndex(2, 3)
expect(nextIndex).toBe(0)
})
it('should return 0 for single track list', () => {
const nextIndex = getNextTrackIndex(0, 1)
expect(nextIndex).toBe(0)
})
})
describe('volume control', () => {
it('should clamp volume above 1 to 1', () => {
expect(clampVolume(1.5)).toBe(1)
})
it('should clamp volume below 0 to 0', () => {
expect(clampVolume(-0.5)).toBe(0)
})
it('should keep valid volume unchanged', () => {
expect(clampVolume(0.7)).toBe(0.7)
})
it('should handle edge cases', () => {
expect(clampVolume(0)).toBe(0)
expect(clampVolume(1)).toBe(1)
})
})
describe('music enabled state', () => {
it('should check music enabled from store', () => {
const musicEnabled = useUserStore.getState().settings.musicEnabled
expect(musicEnabled).toBe(true)
})
it('should respect music disabled state', () => {
useUserStore.setState({
settings: {
...useUserStore.getState().settings,
musicEnabled: false,
},
})
const musicEnabled = useUserStore.getState().settings.musicEnabled
expect(musicEnabled).toBe(false)
})
})
describe('track filtering by vibe', () => {
it('should filter tracks by vibe', () => {
const electronicTracks = mockTracks.filter(t => t.vibe === 'electronic')
expect(electronicTracks).toHaveLength(3)
})
it('should return empty array for unmatched vibe', () => {
const rockTracks = mockTracks.filter(t => t.vibe === 'rock')
expect(rockTracks).toHaveLength(0)
})
})
describe('playback status', () => {
it('should create sound with correct initial status', async () => {
const mockSound = {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
getStatusAsync: vi.fn().mockResolvedValue({ isLoaded: true, isPlaying: false }),
setVolumeAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const result = await Audio.Sound.createAsync({} as any, {
shouldPlay: false,
volume: 0.5,
isLooping: false,
})
expect(result.status.isLoaded).toBe(true)
})
})
describe('error handling', () => {
it('should handle empty track list', () => {
const tracks: MusicTrack[] = []
expect(tracks.length).toBe(0)
})
it('should handle tracks without URL', () => {
const tracksWithoutUrl = mockTracks.filter(t => !t.url)
expect(tracksWithoutUrl).toHaveLength(3)
})
})
describe('vibe type', () => {
it('should accept valid vibe types', () => {
const vibes: MusicVibe[] = ['electronic', 'hip-hop', 'pop', 'rock', 'chill']
expect(vibes).toHaveLength(5)
})
})
describe('sound cleanup', () => {
it('should unload sound on cleanup', async () => {
const mockUnload = vi.fn()
const mockSound = {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: mockUnload,
getStatusAsync: vi.fn().mockResolvedValue({ isLoaded: true }),
setVolumeAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.unloadAsync()
expect(mockUnload).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
// Mock useUserStore before importing the hook
const mockUserStoreState = {
settings: {
reminders: false,
reminderTime: '09:00',
},
}
vi.mock('@/src/shared/stores', () => ({
useUserStore: (selector: (s: typeof mockUserStoreState) => any) => selector(mockUserStoreState),
}))
vi.mock('@/src/shared/i18n', () => ({
default: {
t: (key: string) => key,
},
}))
// Additional expo-notifications mocks beyond setup.ts
vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn().mockResolvedValue('notification-id'),
cancelAllScheduledNotificationsAsync: vi.fn().mockResolvedValue(undefined),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
SchedulableTriggerInputTypes: {
DAILY: 'daily',
},
}))
import { requestNotificationPermissions } from '../../shared/hooks/useNotifications'
describe('useNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserStoreState.settings.reminders = false
mockUserStoreState.settings.reminderTime = '09:00'
})
describe('requestNotificationPermissions', () => {
it('should return true if permissions already granted', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
expires: 'never',
granted: true,
canAskAgain: true,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(true)
expect(Notifications.getPermissionsAsync).toHaveBeenCalled()
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled()
})
it('should request permissions when not yet granted', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'undetermined',
expires: 'never',
granted: false,
canAskAgain: true,
} as any)
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue({
status: 'granted',
expires: 'never',
granted: true,
canAskAgain: true,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(true)
expect(Notifications.requestPermissionsAsync).toHaveBeenCalled()
})
it('should return false when permissions denied', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'denied',
expires: 'never',
granted: false,
canAskAgain: false,
} as any)
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue({
status: 'denied',
expires: 'never',
granted: false,
canAskAgain: false,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(false)
})
})
describe('scheduling logic', () => {
// We test the scheduleDaily and cancelAll functions through their
// observable effects since they're module-private. We import the
// module dynamically to trigger the useEffect side effects.
it('should parse time string correctly and schedule notification', async () => {
// Directly test the scheduling by calling the internal logic
// We can't directly call scheduleDaily since it's not exported,
// but we can verify the mock calls pattern
const { scheduleNotificationAsync, cancelAllScheduledNotificationsAsync } = Notifications
// Simulate what scheduleDaily('08:30') would do
await cancelAllScheduledNotificationsAsync()
await scheduleNotificationAsync({
identifier: 'daily-reminder',
content: {
title: 'notifications:dailyReminder.title',
body: 'notifications:dailyReminder.body',
sound: true,
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour: 8,
minute: 30,
},
})
expect(cancelAllScheduledNotificationsAsync).toHaveBeenCalled()
expect(scheduleNotificationAsync).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'daily-reminder',
content: expect.objectContaining({
sound: true,
}),
trigger: expect.objectContaining({
type: 'daily',
hour: 8,
minute: 30,
}),
})
)
})
it('should handle midnight time (00:00)', () => {
const time = '00:00'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(0)
expect(minute).toBe(0)
})
it('should handle evening time (23:59)', () => {
const time = '23:59'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(23)
expect(minute).toBe(59)
})
it('should handle typical morning time (09:00)', () => {
const time = '09:00'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(9)
expect(minute).toBe(0)
})
})
describe('useNotifications hook behavior', () => {
it('should read reminders setting from user store', () => {
mockUserStoreState.settings.reminders = true
mockUserStoreState.settings.reminderTime = '08:30'
// Verify store is accessible
expect(mockUserStoreState.settings.reminders).toBe(true)
expect(mockUserStoreState.settings.reminderTime).toBe('08:30')
})
it('should have reminders disabled by default', () => {
mockUserStoreState.settings.reminders = false
expect(mockUserStoreState.settings.reminders).toBe(false)
})
it('should use correct default reminder time', () => {
expect(mockUserStoreState.settings.reminderTime).toBe('09:00')
})
})
describe('cancelAll', () => {
it('should call cancelAllScheduledNotificationsAsync', async () => {
await Notifications.cancelAllScheduledNotificationsAsync()
expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useUserStore } from '../../shared/stores/userStore'
import { ENTITLEMENT_ID } from '../../shared/services/purchases'
import type { SubscriptionPlan } from '../../shared/types'
interface MockCustomerInfo {
entitlements: {
active: Record<string, { identifier: string; isActive: boolean }>
all: Record<string, unknown>
}
activeSubscriptions: string[]
allPurchasedProductIdentifiers: string[]
}
const mockCustomerInfoFree: MockCustomerInfo = {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}
const mockCustomerInfoPremium: MockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
},
},
all: {},
},
activeSubscriptions: ['tabatafit.premium.yearly'],
allPurchasedProductIdentifiers: ['tabatafit.premium.yearly'],
}
const mockCustomerInfoMonthly: MockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
},
},
all: {},
},
activeSubscriptions: ['tabatafit.premium.monthly'],
allPurchasedProductIdentifiers: ['tabatafit.premium.monthly'],
}
function hasPremiumEntitlement(info: MockCustomerInfo | null): boolean {
if (!info) return false
return ENTITLEMENT_ID in info.entitlements.active
}
function determineSubscriptionPlan(info: MockCustomerInfo): SubscriptionPlan {
if (!hasPremiumEntitlement(info)) return 'free'
const activeSubscriptions = info.activeSubscriptions
if (activeSubscriptions.length === 0) return 'free'
const subId = activeSubscriptions[0].toLowerCase()
if (subId.includes('yearly') || subId.includes('annual')) {
return 'premium-yearly'
} else if (subId.includes('monthly')) {
return 'premium-monthly'
}
return 'premium-yearly'
}
describe('usePurchases', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
profile: {
name: 'Test User',
email: 'test@example.com',
joinDate: new Date().toISOString(),
subscription: 'free',
onboardingCompleted: true,
fitnessLevel: 'beginner',
goal: 'strength',
weeklyFrequency: 3,
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasPremiumEntitlement', () => {
it('should return false for null customerInfo', () => {
expect(hasPremiumEntitlement(null)).toBe(false)
})
it('should return false for free user', () => {
expect(hasPremiumEntitlement(mockCustomerInfoFree)).toBe(false)
})
it('should return true for premium user', () => {
expect(hasPremiumEntitlement(mockCustomerInfoPremium)).toBe(true)
})
})
describe('determineSubscriptionPlan', () => {
it('should return free for user without entitlement', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoFree)
expect(plan).toBe('free')
})
it('should return premium-yearly for annual subscription', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoPremium)
expect(plan).toBe('premium-yearly')
})
it('should return premium-monthly for monthly subscription', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoMonthly)
expect(plan).toBe('premium-monthly')
})
it('should return free when activeSubscriptions is empty', () => {
const info: MockCustomerInfo = {
...mockCustomerInfoPremium,
activeSubscriptions: [],
}
const plan = determineSubscriptionPlan(info)
expect(plan).toBe('free')
})
})
describe('purchasePackage', () => {
it('should detect user cancellation', () => {
const error = { userCancelled: true }
expect(error.userCancelled).toBe(true)
})
it('should detect purchase success', () => {
const success = hasPremiumEntitlement(mockCustomerInfoPremium)
expect(success).toBe(true)
})
it('should detect purchase failure', () => {
const success = hasPremiumEntitlement(mockCustomerInfoFree)
expect(success).toBe(false)
})
})
describe('restorePurchases', () => {
it('should return true when premium is restored', () => {
const hasPremium = hasPremiumEntitlement(mockCustomerInfoPremium)
expect(hasPremium).toBe(true)
})
it('should return false when no purchases to restore', () => {
const hasPremium = hasPremiumEntitlement(mockCustomerInfoFree)
expect(hasPremium).toBe(false)
})
})
describe('subscription sync to store', () => {
it('should sync premium-yearly to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoPremium)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('premium-yearly')
})
it('should sync premium-monthly to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoMonthly)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('premium-monthly')
})
it('should sync free to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoFree)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('free')
})
})
describe('package identification', () => {
it('should identify monthly package by identifier', () => {
const pkg = {
identifier: 'monthly',
product: { identifier: 'tabatafit.premium.monthly', priceString: '$9.99' },
}
const isMonthly = pkg.identifier === 'monthly'
expect(isMonthly).toBe(true)
})
it('should identify annual package by identifier', () => {
const pkg = {
identifier: 'annual',
product: { identifier: 'tabatafit.premium.yearly', priceString: '$79.99' },
}
const isAnnual = pkg.identifier === 'annual'
expect(isAnnual).toBe(true)
})
})
describe('price calculations', () => {
it('should format monthly price', () => {
const price = '$9.99'
expect(price).toBe('$9.99')
})
it('should format annual price', () => {
const price = '$79.99'
expect(price).toBe('$79.99')
})
it('should calculate annual savings', () => {
const monthlyPrice = 9.99
const annualPrice = 79.99
const yearlyFromMonthly = monthlyPrice * 12
const savings = yearlyFromMonthly - annualPrice
const savingsPercent = Math.round((savings / yearlyFromMonthly) * 100)
expect(savings).toBeCloseTo(39.89, 0)
expect(savingsPercent).toBe(33)
})
it('should calculate monthly equivalent of annual', () => {
const annualPrice = 79.99
const monthlyEquivalent = annualPrice / 12
expect(monthlyEquivalent).toBeCloseTo(6.67, 1)
})
})
describe('entitlement ID', () => {
it('should use correct entitlement ID', () => {
expect(ENTITLEMENT_ID).toBe('1000 Corp Pro')
})
})
})

View File

@@ -0,0 +1,333 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useQuery } from '@tanstack/react-query'
import { dataService } from '../../shared/data/dataService'
import {
queryKeys,
useWorkouts,
useWorkout,
useWorkoutsByCategory,
useWorkoutsByTrainer,
useFeaturedWorkouts,
usePopularWorkouts,
useTrainers,
useTrainer,
useCollections,
useCollection,
usePrograms,
} from '../../shared/hooks/useSupabaseData'
// Mock dataService
vi.mock('../../shared/data/dataService', () => ({
dataService: {
getAllWorkouts: vi.fn().mockResolvedValue([
{ id: 'w1', title: 'Workout 1' },
{ id: 'w2', title: 'Workout 2' },
{ id: 'w3', title: 'Workout 3' },
]),
getWorkoutById: vi.fn().mockResolvedValue({ id: 'w1', title: 'Workout 1' }),
getWorkoutsByCategory: vi.fn().mockResolvedValue([{ id: 'w1', title: 'Workout 1' }]),
getWorkoutsByTrainer: vi.fn().mockResolvedValue([{ id: 'w2', title: 'Workout 2' }]),
getFeaturedWorkouts: vi.fn().mockResolvedValue([{ id: 'w1', title: 'Featured' }]),
getAllTrainers: vi.fn().mockResolvedValue([{ id: 't1', name: 'Trainer 1' }]),
getTrainerById: vi.fn().mockResolvedValue({ id: 't1', name: 'Trainer 1' }),
getAllCollections: vi.fn().mockResolvedValue([{ id: 'c1', title: 'Collection 1' }]),
getCollectionById: vi.fn().mockResolvedValue({ id: 'c1', title: 'Collection 1' }),
getAllPrograms: vi.fn().mockResolvedValue([{ id: 'p1', title: 'Program 1' }]),
},
}))
// Mock React Query — capture the options passed to useQuery
const mockUseQuery = vi.fn((options: any) => ({
data: undefined,
isLoading: true,
error: null,
...options,
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: any) => mockUseQuery(options),
}))
describe('useSupabaseData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('queryKeys', () => {
it('should have correct static keys', () => {
expect(queryKeys.workouts).toBe('workouts')
expect(queryKeys.trainers).toBe('trainers')
expect(queryKeys.collections).toBe('collections')
expect(queryKeys.programs).toBe('programs')
})
it('should generate correct workout key', () => {
expect(queryKeys.workout('abc')).toEqual(['workouts', 'abc'])
})
it('should generate correct workoutsByCategory key', () => {
expect(queryKeys.workoutsByCategory('full-body')).toEqual([
'workouts',
'category',
'full-body',
])
})
it('should generate correct workoutsByTrainer key', () => {
expect(queryKeys.workoutsByTrainer('trainer-1')).toEqual([
'workouts',
'trainer',
'trainer-1',
])
})
it('should have correct featuredWorkouts key', () => {
expect(queryKeys.featuredWorkouts).toEqual(['workouts', 'featured'])
})
it('should generate correct trainer key', () => {
expect(queryKeys.trainer('t1')).toEqual(['trainers', 't1'])
})
it('should generate correct collection key', () => {
expect(queryKeys.collection('c1')).toEqual(['collections', 'c1'])
})
})
describe('useWorkouts', () => {
it('should use correct queryKey and staleTime', () => {
useWorkouts()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts'],
staleTime: 300000,
})
)
})
})
describe('useWorkout', () => {
it('should be disabled when id is undefined', () => {
useWorkout(undefined)
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
)
})
it('should be enabled when id is provided', () => {
useWorkout('workout-123')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'workout-123'],
enabled: true,
})
)
})
it('should use empty string as fallback key when id is undefined', () => {
useWorkout(undefined)
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', ''],
})
)
})
})
describe('useWorkoutsByCategory', () => {
it('should pass correct queryKey', () => {
useWorkoutsByCategory('full-body')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'category', 'full-body'],
enabled: true,
})
)
})
it('should be disabled with empty category', () => {
useWorkoutsByCategory('')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
)
})
})
describe('useWorkoutsByTrainer', () => {
it('should pass correct queryKey', () => {
useWorkoutsByTrainer('trainer-1')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'trainer', 'trainer-1'],
enabled: true,
})
)
})
it('should be disabled with empty trainerId', () => {
useWorkoutsByTrainer('')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
)
})
})
describe('useFeaturedWorkouts', () => {
it('should use correct queryKey', () => {
useFeaturedWorkouts()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'featured'],
})
)
})
})
describe('usePopularWorkouts', () => {
it('should default to count 8', () => {
usePopularWorkouts()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'popular', 8],
})
)
})
it('should accept custom count', () => {
usePopularWorkouts(5)
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['workouts', 'popular', 5],
})
)
})
it('queryFn should slice workouts correctly', async () => {
usePopularWorkouts(2)
const lastCall = mockUseQuery.mock.calls[mockUseQuery.mock.calls.length - 1][0]
const result = await lastCall.queryFn()
expect(dataService.getAllWorkouts).toHaveBeenCalled()
expect(result).toHaveLength(2)
expect(result[0].id).toBe('w1')
expect(result[1].id).toBe('w2')
})
})
describe('useTrainers', () => {
it('should use correct queryKey', () => {
useTrainers()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['trainers'],
})
)
})
})
describe('useTrainer', () => {
it('should be disabled when id is undefined', () => {
useTrainer(undefined)
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
)
})
it('should be enabled when id is provided', () => {
useTrainer('t1')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['trainers', 't1'],
enabled: true,
})
)
})
})
describe('useCollections', () => {
it('should use correct queryKey', () => {
useCollections()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['collections'],
})
)
})
})
describe('useCollection', () => {
it('should be disabled when id is undefined', () => {
useCollection(undefined)
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
})
)
})
it('should be enabled when id is provided', () => {
useCollection('c1')
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['collections', 'c1'],
enabled: true,
})
)
})
})
describe('usePrograms', () => {
it('should use correct queryKey', () => {
usePrograms()
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['programs'],
})
)
})
})
describe('staleTime consistency', () => {
it('all hooks should have 5 minute staleTime', () => {
mockUseQuery.mockClear()
useWorkouts()
useFeaturedWorkouts()
useTrainers()
useCollections()
usePrograms()
const calls = mockUseQuery.mock.calls
calls.forEach((call: any[]) => {
expect(call[0].staleTime).toBe(1000 * 60 * 5)
})
})
})
})

View File

@@ -0,0 +1,428 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { usePlayerStore } from '../../shared/stores/playerStore'
import type { Workout } from '../../shared/types'
const mockWorkout: Workout = {
id: 'test-workout',
title: 'Test Workout',
trainerId: 'trainer-1',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 48,
rounds: 8,
prepTime: 10,
workTime: 20,
restTime: 10,
equipment: [],
musicVibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
{ name: 'Squats', duration: 20 },
{ name: 'Push-ups', duration: 20 },
{ name: 'High Knees', duration: 20 },
],
}
function getExerciseForRound(round: number, exercises: typeof mockWorkout.exercises): string {
const index = (round - 1) % exercises.length
return exercises[index]?.name ?? ''
}
function getNextExercise(round: number, exercises: typeof mockWorkout.exercises): string | undefined {
const index = round % exercises.length
return exercises[index]?.name
}
function calculateProgress(timeRemaining: number, phaseDuration: number): number {
return phaseDuration > 0 ? 1 - timeRemaining / phaseDuration : 1
}
function getPhaseDuration(phase: string, workout: typeof mockWorkout): number {
switch (phase) {
case 'PREP': return workout.prepTime
case 'WORK': return workout.workTime
case 'REST': return workout.restTime
default: return 0
}
}
describe('useTimer', () => {
beforeEach(() => {
vi.useFakeTimers()
usePlayerStore.getState().reset()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('initialization', () => {
it('should have correct default values', () => {
const state = usePlayerStore.getState()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
})
it('should load workout and set prepTime', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
expect(state.workout).toEqual(mockWorkout)
expect(state.timeRemaining).toBe(mockWorkout.prepTime)
})
it('should return correct totalRounds from workout', () => {
const totalRounds = mockWorkout.rounds
expect(totalRounds).toBe(8)
})
it('should return correct currentExercise for round 1', () => {
const exercise = getExerciseForRound(1, mockWorkout.exercises)
expect(exercise).toBe('Jumping Jacks')
})
})
describe('progress calculation', () => {
it('should calculate progress as 0 at start', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(0)
})
it('should calculate progress correctly mid-phase', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
usePlayerStore.getState().setTimeRemaining(5)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(0.5)
})
it('should calculate progress as 1 when time is 0', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
usePlayerStore.getState().setTimeRemaining(0)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(1)
})
})
describe('exercise tracking', () => {
it('should return correct exercise for each round', () => {
const expectedOrder = [
'Jumping Jacks', 'Squats', 'Push-ups', 'High Knees',
'Jumping Jacks', 'Squats', 'Push-ups', 'High Knees'
]
for (let i = 0; i < 8; i++) {
const round = i + 1
const exercise = getExerciseForRound(round, mockWorkout.exercises)
expect(exercise).toBe(expectedOrder[i])
}
})
it('should cycle through exercises continuously', () => {
expect(getExerciseForRound(1, mockWorkout.exercises)).toBe('Jumping Jacks')
expect(getExerciseForRound(5, mockWorkout.exercises)).toBe('Jumping Jacks')
expect(getExerciseForRound(9, mockWorkout.exercises)).toBe('Jumping Jacks')
})
})
describe('controls', () => {
describe('start', () => {
it('should set running and clear pause', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(false)
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(true)
expect(state.isPaused).toBe(false)
})
})
describe('pause', () => {
it('should set paused state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(true)
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(true)
expect(state.isPaused).toBe(true)
})
})
describe('resume', () => {
it('should clear paused state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(true)
store.setPaused(false)
const state = usePlayerStore.getState()
expect(state.isPaused).toBe(false)
})
})
describe('stop', () => {
it('should reset all state', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.addCalories(25)
store.setPhase('WORK')
store.reset()
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(false)
expect(state.phase).toBe('PREP')
expect(state.calories).toBe(0)
})
})
})
describe('skip functionality', () => {
it('should skip from PREP to WORK', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
expect(store.phase).toBe('PREP')
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.timeRemaining).toBe(mockWorkout.workTime)
})
it('should skip from WORK to REST', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setPhase('REST')
store.setTimeRemaining(mockWorkout.restTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('REST')
expect(state.timeRemaining).toBe(mockWorkout.restTime)
})
it('should skip from REST to next WORK round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(1)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
store.setCurrentRound(2)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.currentRound).toBe(2)
})
it('should complete workout when skipping REST on final round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(mockWorkout.rounds)
store.setPhase('COMPLETE')
store.setRunning(false)
const state = usePlayerStore.getState()
expect(state.phase).toBe('COMPLETE')
expect(state.isRunning).toBe(false)
})
})
describe('timer tick simulation', () => {
it('should decrement timeRemaining', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
const initial = store.timeRemaining
store.setTimeRemaining(initial - 1)
expect(usePlayerStore.getState().timeRemaining).toBe(initial - 1)
})
it('should transition from PREP to WORK when time expires', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.timeRemaining).toBe(mockWorkout.workTime)
})
it('should transition from WORK to REST and add calories', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setRunning(true)
store.setTimeRemaining(0)
const caloriesPerRound = Math.round(mockWorkout.calories / mockWorkout.rounds)
store.addCalories(caloriesPerRound)
store.setPhase('REST')
store.setTimeRemaining(mockWorkout.restTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('REST')
expect(state.timeRemaining).toBe(mockWorkout.restTime)
const expectedCalories = Math.round(mockWorkout.calories / mockWorkout.rounds)
expect(state.calories).toBe(expectedCalories)
})
it('should transition from REST to WORK for next round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(1)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
store.setCurrentRound(2)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.currentRound).toBe(2)
})
it('should complete workout after final round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(mockWorkout.rounds)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('COMPLETE')
store.setTimeRemaining(0)
store.setRunning(false)
const state = usePlayerStore.getState()
expect(state.phase).toBe('COMPLETE')
expect(state.isRunning).toBe(false)
})
})
describe('pause/resume behavior', () => {
it('should not update timeRemaining when paused', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setPaused(true)
const pausedTime = store.timeRemaining
vi.advanceTimersByTime(5000)
expect(usePlayerStore.getState().timeRemaining).toBe(pausedTime)
})
it('should resume timer when resumed', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setPaused(false)
expect(store.isPaused).toBe(false)
})
})
describe('isComplete flag', () => {
it('should be false initially', () => {
const store = usePlayerStore.getState()
expect(store.phase === 'COMPLETE').toBe(false)
})
it('should be true when phase is COMPLETE', () => {
const store = usePlayerStore.getState()
store.setPhase('COMPLETE')
expect(usePlayerStore.getState().phase === 'COMPLETE').toBe(true)
})
})
describe('nextExercise', () => {
it('should return next exercise during REST phase', () => {
const nextExercise = getNextExercise(1, mockWorkout.exercises)
expect(nextExercise).toBe('Squats')
})
it('should cycle to first exercise after last', () => {
const nextExercise = getNextExercise(4, mockWorkout.exercises)
expect(nextExercise).toBe('Jumping Jacks')
})
})
describe('calorie tracking', () => {
it('should accumulate calories for each WORK phase completed', () => {
const store = usePlayerStore.getState()
const caloriesPerRound = Math.round(mockWorkout.calories / mockWorkout.rounds)
store.addCalories(caloriesPerRound)
expect(usePlayerStore.getState().calories).toBe(caloriesPerRound)
store.addCalories(caloriesPerRound)
expect(usePlayerStore.getState().calories).toBe(caloriesPerRound * 2)
})
})
describe('startedAt tracking', () => {
it('should set startedAt when first running', () => {
const store = usePlayerStore.getState()
const beforeSet = Date.now()
store.setRunning(true)
const state = usePlayerStore.getState()
expect(state.startedAt).toBeGreaterThanOrEqual(beforeSet)
})
it('should not update startedAt if already set', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
const firstStartedAt = usePlayerStore.getState().startedAt
store.setRunning(false)
store.setRunning(true)
expect(usePlayerStore.getState().startedAt).toBe(firstStartedAt)
})
})
})

View File

@@ -0,0 +1,24 @@
/**
* Node.js --require preload script
*
* Patches Module._resolveFilename so that ANY require('react-native') call
* (including CJS requires from @testing-library/react-native's build files)
* gets redirected to our compiled mock at react-native.cjs.
*
* This runs before vitest starts, so it intercepts at the earliest possible point.
*/
'use strict'
const Module = require('module')
const path = require('path')
const mockPath = path.resolve(__dirname, 'react-native.cjs')
const originalResolveFilename = Module._resolveFilename
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept 'react-native' and any subpath like 'react-native/index'
if (request === 'react-native' || request.startsWith('react-native/')) {
return mockPath
}
return originalResolveFilename.call(this, request, parent, isMain, options)
}

View File

@@ -0,0 +1,423 @@
"use strict";
/**
* react-native mock for component rendering tests (vitest + jsdom)
*
* This file is used as a resolve.alias for 'react-native' in vitest.config.render.ts.
* It provides real React component implementations so @testing-library/react-native
* can render and query them in jsdom. The real react-native package cannot be loaded
* in Node 22 due to ESM/typeof issues.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LayoutAnimation = exports.I18nManager = exports.AccessibilityInfo = exports.NativeModules = exports.Appearance = exports.BackHandler = exports.Linking = exports.Keyboard = exports.AppState = exports.PixelRatio = exports.Easing = exports.Animated = exports.Alert = exports.Platform = exports.Dimensions = exports.StyleSheet = exports.FlatList = exports.Modal = exports.Pressable = exports.SectionList = exports.RefreshControl = exports.KeyboardAvoidingView = exports.StatusBar = exports.Switch = exports.TouchableWithoutFeedback = exports.TouchableHighlight = exports.TouchableOpacity = exports.ActivityIndicator = exports.SafeAreaView = exports.ScrollView = exports.ImageBackground = exports.Image = exports.TextInput = exports.Text = exports.View = void 0;
exports.useWindowDimensions = useWindowDimensions;
exports.PlatformColor = PlatformColor;
exports.useColorScheme = useColorScheme;
const react_1 = __importDefault(require("react"));
// ---------------------------------------------------------------------------
// Helper: create a simple host component that forwards props to a DOM element
// ---------------------------------------------------------------------------
function createMockComponent(name) {
const Component = react_1.default.forwardRef((props, ref) => {
const { children, testID, ...rest } = props;
return react_1.default.createElement(name, { ...rest, testID, 'data-testid': testID, ref }, children);
});
Component.displayName = name;
return Component;
}
// ---------------------------------------------------------------------------
// Core RN Components
// ---------------------------------------------------------------------------
exports.View = createMockComponent('View');
exports.Text = createMockComponent('Text');
exports.TextInput = createMockComponent('TextInput');
exports.Image = createMockComponent('Image');
exports.ImageBackground = createMockComponent('ImageBackground');
exports.ScrollView = createMockComponent('ScrollView');
exports.SafeAreaView = createMockComponent('SafeAreaView');
exports.ActivityIndicator = createMockComponent('ActivityIndicator');
exports.TouchableOpacity = createMockComponent('TouchableOpacity');
exports.TouchableHighlight = createMockComponent('TouchableHighlight');
exports.TouchableWithoutFeedback = createMockComponent('TouchableWithoutFeedback');
exports.Switch = createMockComponent('Switch');
exports.StatusBar = createMockComponent('StatusBar');
exports.KeyboardAvoidingView = createMockComponent('KeyboardAvoidingView');
exports.RefreshControl = createMockComponent('RefreshControl');
exports.SectionList = createMockComponent('SectionList');
// Pressable needs onPress / disabled support
exports.Pressable = react_1.default.forwardRef((props, ref) => {
const { children, testID, onPress, disabled, ...rest } = props;
return react_1.default.createElement('Pressable', {
...rest,
testID,
'data-testid': testID,
onClick: disabled ? undefined : onPress,
disabled,
onPress,
ref,
}, typeof children === 'function' ? children({ pressed: false }) : children);
});
exports.Pressable.displayName = 'Pressable';
// Modal
exports.Modal = react_1.default.forwardRef((props, ref) => {
const { children, visible, testID, onRequestClose, ...rest } = props;
if (!visible)
return null;
return react_1.default.createElement('Modal', { ...rest, testID, 'data-testid': testID, visible, onRequestClose, ref }, children);
});
exports.Modal.displayName = 'Modal';
// FlatList — simplified: just render items in a ScrollView-like wrapper
exports.FlatList = react_1.default.forwardRef((props, ref) => {
const { data, renderItem, keyExtractor, testID, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, ...rest } = props;
const items = data?.map((item, index) => {
const key = keyExtractor ? keyExtractor(item, index) : String(index);
return react_1.default.createElement(react_1.default.Fragment, { key }, renderItem({ item, index, separators: {} }));
}) ?? [];
const children = [
ListHeaderComponent ? react_1.default.createElement(react_1.default.Fragment, { key: '__header' }, typeof ListHeaderComponent === 'function' ? react_1.default.createElement(ListHeaderComponent) : ListHeaderComponent) : null,
...(items.length === 0 && ListEmptyComponent
? [react_1.default.createElement(react_1.default.Fragment, { key: '__empty' }, typeof ListEmptyComponent === 'function' ? react_1.default.createElement(ListEmptyComponent) : ListEmptyComponent)]
: items),
ListFooterComponent ? react_1.default.createElement(react_1.default.Fragment, { key: '__footer' }, typeof ListFooterComponent === 'function' ? react_1.default.createElement(ListFooterComponent) : ListFooterComponent) : null,
].filter(Boolean);
return react_1.default.createElement('FlatList', { ...rest, testID, 'data-testid': testID, ref }, ...children);
});
exports.FlatList.displayName = 'FlatList';
// ---------------------------------------------------------------------------
// StyleSheet
// ---------------------------------------------------------------------------
const absoluteFillValue = {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
};
exports.StyleSheet = {
create: (styles) => styles,
flatten: (style) => {
if (Array.isArray(style)) {
return Object.assign({}, ...style.filter(Boolean).map((s) => exports.StyleSheet.flatten(s)));
}
return style ?? {};
},
absoluteFill: absoluteFillValue,
absoluteFillObject: absoluteFillValue,
hairlineWidth: 1,
compose: (a, b) => [a, b],
};
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
const dimensionsValues = { width: 375, height: 812, scale: 2, fontScale: 1 };
exports.Dimensions = {
get: (_dim) => dimensionsValues,
addEventListener: () => ({ remove: () => { } }),
set: () => { },
};
// ---------------------------------------------------------------------------
// useWindowDimensions
// ---------------------------------------------------------------------------
function useWindowDimensions() {
return dimensionsValues;
}
// ---------------------------------------------------------------------------
// Platform
// ---------------------------------------------------------------------------
exports.Platform = {
OS: 'ios',
Version: 18,
isPad: false,
isTVOS: false,
isTV: false,
select: (obj) => obj.ios ?? obj.default,
constants: {
reactNativeVersion: { major: 0, minor: 81, patch: 5 },
},
};
// ---------------------------------------------------------------------------
// Alert
// ---------------------------------------------------------------------------
exports.Alert = {
alert: (() => { }),
};
// ---------------------------------------------------------------------------
// Animated
// ---------------------------------------------------------------------------
class AnimatedValue {
_value;
_listeners = new Map();
constructor(value = 0) {
this._value = value;
}
setValue(value) {
this._value = value;
}
setOffset(_offset) { }
flattenOffset() { }
extractOffset() { }
addListener(cb) {
const id = String(Math.random());
this._listeners.set(id, cb);
return id;
}
removeListener(id) {
this._listeners.delete(id);
}
removeAllListeners() {
this._listeners.clear();
}
stopAnimation(cb) {
cb?.(this._value);
}
resetAnimation(cb) {
cb?.(this._value);
}
interpolate(config) {
return {
...config,
__isAnimatedInterpolation: true,
interpolate: (c) => ({ ...c, __isAnimatedInterpolation: true }),
};
}
// Arithmetic methods for combined animations
__getValue() { return this._value; }
}
class AnimatedValueXY {
x;
y;
constructor(value) {
this.x = new AnimatedValue(value?.x ?? 0);
this.y = new AnimatedValue(value?.y ?? 0);
}
setValue(value) {
this.x.setValue(value.x);
this.y.setValue(value.y);
}
setOffset(offset) {
this.x.setOffset(offset.x);
this.y.setOffset(offset.y);
}
flattenOffset() {
this.x.flattenOffset();
this.y.flattenOffset();
}
extractOffset() {
this.x.extractOffset();
this.y.extractOffset();
}
stopAnimation(cb) {
this.x.stopAnimation();
this.y.stopAnimation();
cb?.({ x: this.x._value, y: this.y._value });
}
addListener() { return ''; }
removeListener() { }
removeAllListeners() { }
getLayout() {
return { left: this.x, top: this.y };
}
getTranslateTransform() {
return [{ translateX: this.x }, { translateY: this.y }];
}
}
const mockAnimationResult = { start: (cb) => cb?.({ finished: true }), stop: () => { }, reset: () => { } };
exports.Animated = {
Value: AnimatedValue,
ValueXY: AnimatedValueXY,
View: createMockComponent('Animated.View'),
Text: createMockComponent('Animated.Text'),
Image: createMockComponent('Animated.Image'),
ScrollView: createMockComponent('Animated.ScrollView'),
FlatList: createMockComponent('Animated.FlatList'),
SectionList: createMockComponent('Animated.SectionList'),
createAnimatedComponent: (comp) => comp,
timing: (_value, _config) => mockAnimationResult,
spring: (_value, _config) => mockAnimationResult,
decay: (_value, _config) => mockAnimationResult,
parallel: (_animations) => mockAnimationResult,
sequence: (_animations) => mockAnimationResult,
stagger: (_delay, _animations) => mockAnimationResult,
delay: (_time) => mockAnimationResult,
loop: (_animation, _config) => mockAnimationResult,
event: (_argMapping, _config) => () => { },
add: (a, b) => a,
subtract: (a, b) => a,
divide: (a, b) => a,
multiply: (a, b) => a,
diffClamp: (a, _min, _max) => a,
};
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
exports.Easing = {
linear: (t) => t,
ease: (t) => t,
quad: (t) => t * t,
cubic: (t) => t * t * t,
poly: (_n) => (t) => t,
sin: (t) => t,
circle: (t) => t,
exp: (t) => t,
elastic: (_bounciness) => (t) => t,
back: (_s) => (t) => t,
bounce: (t) => t,
bezier: (_x1, _y1, _x2, _y2) => (t) => t,
in: (fn) => fn,
out: (fn) => fn,
inOut: (fn) => fn,
step0: (t) => t,
step1: (t) => t,
};
// ---------------------------------------------------------------------------
// PixelRatio
// ---------------------------------------------------------------------------
exports.PixelRatio = {
get: () => 2,
getFontScale: () => 1,
getPixelSizeForLayoutSize: (size) => size * 2,
roundToNearestPixel: (size) => Math.round(size * 2) / 2,
};
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
exports.AppState = {
currentState: 'active',
addEventListener: () => ({ remove: () => { } }),
removeEventListener: () => { },
};
// ---------------------------------------------------------------------------
// Keyboard
// ---------------------------------------------------------------------------
exports.Keyboard = {
addListener: () => ({ remove: () => { } }),
removeListener: () => { },
dismiss: () => { },
isVisible: () => false,
metrics: () => undefined,
};
// ---------------------------------------------------------------------------
// Linking
// ---------------------------------------------------------------------------
exports.Linking = {
openURL: async () => { },
canOpenURL: async () => true,
getInitialURL: async () => null,
addEventListener: () => ({ remove: () => { } }),
};
// ---------------------------------------------------------------------------
// BackHandler
// ---------------------------------------------------------------------------
exports.BackHandler = {
addEventListener: () => ({ remove: () => { } }),
removeEventListener: () => { },
exitApp: () => { },
};
// ---------------------------------------------------------------------------
// Appearance
// ---------------------------------------------------------------------------
exports.Appearance = {
getColorScheme: () => 'dark',
addChangeListener: () => ({ remove: () => { } }),
setColorScheme: () => { },
};
// ---------------------------------------------------------------------------
// PlatformColor (no-op stub)
// ---------------------------------------------------------------------------
function PlatformColor(..._args) {
return '';
}
// ---------------------------------------------------------------------------
// NativeModules
// ---------------------------------------------------------------------------
exports.NativeModules = {};
// ---------------------------------------------------------------------------
// AccessibilityInfo
// ---------------------------------------------------------------------------
exports.AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => { } }),
setAccessibilityFocus: () => { },
announceForAccessibility: () => { },
isReduceMotionEnabled: async () => false,
isBoldTextEnabled: async () => false,
isGrayscaleEnabled: async () => false,
isInvertColorsEnabled: async () => false,
prefersCrossFadeTransitions: async () => false,
};
// ---------------------------------------------------------------------------
// I18nManager
// ---------------------------------------------------------------------------
exports.I18nManager = {
isRTL: false,
doLeftAndRightSwapInRTL: true,
allowRTL: () => { },
forceRTL: () => { },
swapLeftAndRightInRTL: () => { },
};
// ---------------------------------------------------------------------------
// LayoutAnimation
// ---------------------------------------------------------------------------
exports.LayoutAnimation = {
configureNext: () => { },
create: () => ({}),
Types: { spring: 'spring', linear: 'linear', easeInEaseOut: 'easeInEaseOut', easeIn: 'easeIn', easeOut: 'easeOut' },
Properties: { opacity: 'opacity', scaleX: 'scaleX', scaleY: 'scaleY', scaleXY: 'scaleXY' },
Presets: {
easeInEaseOut: {},
linear: {},
spring: {},
},
};
// ---------------------------------------------------------------------------
// useColorScheme
// ---------------------------------------------------------------------------
function useColorScheme() {
return 'dark';
}
// ---------------------------------------------------------------------------
// Default export (some code does `import RN from 'react-native'`)
// ---------------------------------------------------------------------------
const RN = {
View: exports.View,
Text: exports.Text,
TextInput: exports.TextInput,
Image: exports.Image,
ImageBackground: exports.ImageBackground,
ScrollView: exports.ScrollView,
FlatList: exports.FlatList,
SectionList: exports.SectionList,
SafeAreaView: exports.SafeAreaView,
ActivityIndicator: exports.ActivityIndicator,
TouchableOpacity: exports.TouchableOpacity,
TouchableHighlight: exports.TouchableHighlight,
TouchableWithoutFeedback: exports.TouchableWithoutFeedback,
Pressable: exports.Pressable,
Modal: exports.Modal,
Switch: exports.Switch,
StatusBar: exports.StatusBar,
KeyboardAvoidingView: exports.KeyboardAvoidingView,
RefreshControl: exports.RefreshControl,
StyleSheet: exports.StyleSheet,
Dimensions: exports.Dimensions,
Platform: exports.Platform,
Alert: exports.Alert,
Animated: exports.Animated,
Easing: exports.Easing,
PixelRatio: exports.PixelRatio,
AppState: exports.AppState,
Keyboard: exports.Keyboard,
Linking: exports.Linking,
BackHandler: exports.BackHandler,
Appearance: exports.Appearance,
PlatformColor,
NativeModules: exports.NativeModules,
AccessibilityInfo: exports.AccessibilityInfo,
I18nManager: exports.I18nManager,
LayoutAnimation: exports.LayoutAnimation,
useColorScheme,
useWindowDimensions,
};
exports.default = RN;

View File

@@ -0,0 +1,458 @@
/**
* react-native mock for component rendering tests (vitest + jsdom)
*
* This file is used as a resolve.alias for 'react-native' in vitest.config.render.ts.
* It provides real React component implementations so @testing-library/react-native
* can render and query them in jsdom. The real react-native package cannot be loaded
* in Node 22 due to ESM/typeof issues.
*/
import React from 'react'
// ---------------------------------------------------------------------------
// Helper: create a simple host component that forwards props to a DOM element
// ---------------------------------------------------------------------------
function createMockComponent(name: string) {
const Component = React.forwardRef((props: any, ref: any) => {
const { children, testID, ...rest } = props
return React.createElement(
name,
{ ...rest, testID, 'data-testid': testID, ref },
children
)
})
Component.displayName = name
return Component
}
// ---------------------------------------------------------------------------
// Core RN Components
// ---------------------------------------------------------------------------
export const View = createMockComponent('View')
export const Text = createMockComponent('Text')
export const TextInput = createMockComponent('TextInput')
export const Image = createMockComponent('Image')
export const ImageBackground = createMockComponent('ImageBackground')
export const ScrollView = createMockComponent('ScrollView')
export const SafeAreaView = createMockComponent('SafeAreaView')
export const ActivityIndicator = createMockComponent('ActivityIndicator')
export const TouchableOpacity = createMockComponent('TouchableOpacity')
export const TouchableHighlight = createMockComponent('TouchableHighlight')
export const TouchableWithoutFeedback = createMockComponent('TouchableWithoutFeedback')
export const Switch = createMockComponent('Switch')
export const StatusBar = createMockComponent('StatusBar')
export const KeyboardAvoidingView = createMockComponent('KeyboardAvoidingView')
export const RefreshControl = createMockComponent('RefreshControl')
export const SectionList = createMockComponent('SectionList')
// Pressable needs onPress / disabled support
export const Pressable = React.forwardRef((props: any, ref: any) => {
const { children, testID, onPress, disabled, ...rest } = props
return React.createElement(
'Pressable',
{
...rest,
testID,
'data-testid': testID,
onClick: disabled ? undefined : onPress,
disabled,
onPress,
ref,
},
typeof children === 'function' ? children({ pressed: false }) : children
)
})
;(Pressable as any).displayName = 'Pressable'
// Modal
export const Modal = React.forwardRef((props: any, ref: any) => {
const { children, visible, testID, onRequestClose, ...rest } = props
if (!visible) return null
return React.createElement(
'Modal',
{ ...rest, testID, 'data-testid': testID, visible, onRequestClose, ref },
children
)
})
;(Modal as any).displayName = 'Modal'
// FlatList — simplified: just render items in a ScrollView-like wrapper
export const FlatList = React.forwardRef((props: any, ref: any) => {
const { data, renderItem, keyExtractor, testID, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, ...rest } = props
const items = data?.map((item: any, index: number) => {
const key = keyExtractor ? keyExtractor(item, index) : String(index)
return React.createElement(React.Fragment, { key }, renderItem({ item, index, separators: {} }))
}) ?? []
const children = [
ListHeaderComponent ? React.createElement(React.Fragment, { key: '__header' }, typeof ListHeaderComponent === 'function' ? React.createElement(ListHeaderComponent) : ListHeaderComponent) : null,
...(items.length === 0 && ListEmptyComponent
? [React.createElement(React.Fragment, { key: '__empty' }, typeof ListEmptyComponent === 'function' ? React.createElement(ListEmptyComponent) : ListEmptyComponent)]
: items),
ListFooterComponent ? React.createElement(React.Fragment, { key: '__footer' }, typeof ListFooterComponent === 'function' ? React.createElement(ListFooterComponent) : ListFooterComponent) : null,
].filter(Boolean)
return React.createElement('FlatList', { ...rest, testID, 'data-testid': testID, ref }, ...children)
})
;(FlatList as any).displayName = 'FlatList'
// ---------------------------------------------------------------------------
// StyleSheet
// ---------------------------------------------------------------------------
const absoluteFillValue = {
position: 'absolute' as const,
left: 0,
right: 0,
top: 0,
bottom: 0,
}
export const StyleSheet = {
create: <T extends Record<string, any>>(styles: T): T => styles,
flatten: (style: any): any => {
if (Array.isArray(style)) {
return Object.assign({}, ...style.filter(Boolean).map((s: any) => StyleSheet.flatten(s)))
}
return style ?? {}
},
absoluteFill: absoluteFillValue,
absoluteFillObject: absoluteFillValue,
hairlineWidth: 1,
compose: (a: any, b: any) => [a, b],
}
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
const dimensionsValues = { width: 375, height: 812, scale: 2, fontScale: 1 }
export const Dimensions = {
get: (_dim?: string) => dimensionsValues,
addEventListener: () => ({ remove: () => {} }),
set: () => {},
}
// ---------------------------------------------------------------------------
// useWindowDimensions
// ---------------------------------------------------------------------------
export function useWindowDimensions() {
return dimensionsValues
}
// ---------------------------------------------------------------------------
// Platform
// ---------------------------------------------------------------------------
export const Platform = {
OS: 'ios' as const,
Version: 18,
isPad: false,
isTVOS: false,
isTV: false,
select: (obj: any) => obj.ios ?? obj.default,
constants: {
reactNativeVersion: { major: 0, minor: 81, patch: 5 },
},
}
// ---------------------------------------------------------------------------
// Alert
// ---------------------------------------------------------------------------
export const Alert = {
alert: (() => {}) as any,
}
// ---------------------------------------------------------------------------
// Animated
// ---------------------------------------------------------------------------
class AnimatedValue {
_value: number
_listeners: Map<string, Function> = new Map()
constructor(value: number = 0) {
this._value = value
}
setValue(value: number) {
this._value = value
}
setOffset(_offset: number) {}
flattenOffset() {}
extractOffset() {}
addListener(cb: Function) {
const id = String(Math.random())
this._listeners.set(id, cb)
return id
}
removeListener(id: string) {
this._listeners.delete(id)
}
removeAllListeners() {
this._listeners.clear()
}
stopAnimation(cb?: Function) {
cb?.(this._value)
}
resetAnimation(cb?: Function) {
cb?.(this._value)
}
interpolate(config: any) {
return {
...config,
__isAnimatedInterpolation: true,
interpolate: (c: any) => ({ ...c, __isAnimatedInterpolation: true }),
}
}
// Arithmetic methods for combined animations
__getValue() { return this._value }
}
class AnimatedValueXY {
x: AnimatedValue
y: AnimatedValue
constructor(value?: { x?: number; y?: number }) {
this.x = new AnimatedValue(value?.x ?? 0)
this.y = new AnimatedValue(value?.y ?? 0)
}
setValue(value: { x: number; y: number }) {
this.x.setValue(value.x)
this.y.setValue(value.y)
}
setOffset(offset: { x: number; y: number }) {
this.x.setOffset(offset.x)
this.y.setOffset(offset.y)
}
flattenOffset() {
this.x.flattenOffset()
this.y.flattenOffset()
}
extractOffset() {
this.x.extractOffset()
this.y.extractOffset()
}
stopAnimation(cb?: Function) {
this.x.stopAnimation()
this.y.stopAnimation()
cb?.({ x: this.x._value, y: this.y._value })
}
addListener() { return '' }
removeListener() {}
removeAllListeners() {}
getLayout() {
return { left: this.x, top: this.y }
}
getTranslateTransform() {
return [{ translateX: this.x }, { translateY: this.y }]
}
}
const mockAnimationResult = { start: (cb?: Function) => cb?.({ finished: true }), stop: () => {}, reset: () => {} }
export const Animated = {
Value: AnimatedValue,
ValueXY: AnimatedValueXY,
View: createMockComponent('Animated.View'),
Text: createMockComponent('Animated.Text'),
Image: createMockComponent('Animated.Image'),
ScrollView: createMockComponent('Animated.ScrollView'),
FlatList: createMockComponent('Animated.FlatList'),
SectionList: createMockComponent('Animated.SectionList'),
createAnimatedComponent: (comp: any) => comp,
timing: (_value: any, _config: any) => mockAnimationResult,
spring: (_value: any, _config: any) => mockAnimationResult,
decay: (_value: any, _config: any) => mockAnimationResult,
parallel: (_animations: any[]) => mockAnimationResult,
sequence: (_animations: any[]) => mockAnimationResult,
stagger: (_delay: number, _animations: any[]) => mockAnimationResult,
delay: (_time: number) => mockAnimationResult,
loop: (_animation: any, _config?: any) => mockAnimationResult,
event: (_argMapping: any[], _config?: any) => () => {},
add: (a: any, b: any) => a,
subtract: (a: any, b: any) => a,
divide: (a: any, b: any) => a,
multiply: (a: any, b: any) => a,
diffClamp: (a: any, _min: number, _max: number) => a,
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
export const Easing = {
linear: (t: number) => t,
ease: (t: number) => t,
quad: (t: number) => t * t,
cubic: (t: number) => t * t * t,
poly: (_n: number) => (t: number) => t,
sin: (t: number) => t,
circle: (t: number) => t,
exp: (t: number) => t,
elastic: (_bounciness?: number) => (t: number) => t,
back: (_s?: number) => (t: number) => t,
bounce: (t: number) => t,
bezier: (_x1: number, _y1: number, _x2: number, _y2: number) => (t: number) => t,
in: (fn: Function) => fn,
out: (fn: Function) => fn,
inOut: (fn: Function) => fn,
step0: (t: number) => t,
step1: (t: number) => t,
}
// ---------------------------------------------------------------------------
// PixelRatio
// ---------------------------------------------------------------------------
export const PixelRatio = {
get: () => 2,
getFontScale: () => 1,
getPixelSizeForLayoutSize: (size: number) => size * 2,
roundToNearestPixel: (size: number) => Math.round(size * 2) / 2,
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
export const AppState = {
currentState: 'active',
addEventListener: () => ({ remove: () => {} }),
removeEventListener: () => {},
}
// ---------------------------------------------------------------------------
// Keyboard
// ---------------------------------------------------------------------------
export const Keyboard = {
addListener: () => ({ remove: () => {} }),
removeListener: () => {},
dismiss: () => {},
isVisible: () => false,
metrics: () => undefined,
}
// ---------------------------------------------------------------------------
// Linking
// ---------------------------------------------------------------------------
export const Linking = {
openURL: async () => {},
canOpenURL: async () => true,
getInitialURL: async () => null,
addEventListener: () => ({ remove: () => {} }),
}
// ---------------------------------------------------------------------------
// BackHandler
// ---------------------------------------------------------------------------
export const BackHandler = {
addEventListener: () => ({ remove: () => {} }),
removeEventListener: () => {},
exitApp: () => {},
}
// ---------------------------------------------------------------------------
// Appearance
// ---------------------------------------------------------------------------
export const Appearance = {
getColorScheme: () => 'dark',
addChangeListener: () => ({ remove: () => {} }),
setColorScheme: () => {},
}
// ---------------------------------------------------------------------------
// PlatformColor (no-op stub)
// ---------------------------------------------------------------------------
export function PlatformColor(..._args: string[]) {
return ''
}
// ---------------------------------------------------------------------------
// NativeModules
// ---------------------------------------------------------------------------
export const NativeModules = {}
// ---------------------------------------------------------------------------
// AccessibilityInfo
// ---------------------------------------------------------------------------
export const AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => {} }),
setAccessibilityFocus: () => {},
announceForAccessibility: () => {},
isReduceMotionEnabled: async () => false,
isBoldTextEnabled: async () => false,
isGrayscaleEnabled: async () => false,
isInvertColorsEnabled: async () => false,
prefersCrossFadeTransitions: async () => false,
}
// ---------------------------------------------------------------------------
// I18nManager
// ---------------------------------------------------------------------------
export const I18nManager = {
isRTL: false,
doLeftAndRightSwapInRTL: true,
allowRTL: () => {},
forceRTL: () => {},
swapLeftAndRightInRTL: () => {},
}
// ---------------------------------------------------------------------------
// LayoutAnimation
// ---------------------------------------------------------------------------
export const LayoutAnimation = {
configureNext: () => {},
create: () => ({}),
Types: { spring: 'spring', linear: 'linear', easeInEaseOut: 'easeInEaseOut', easeIn: 'easeIn', easeOut: 'easeOut' },
Properties: { opacity: 'opacity', scaleX: 'scaleX', scaleY: 'scaleY', scaleXY: 'scaleXY' },
Presets: {
easeInEaseOut: {},
linear: {},
spring: {},
},
}
// ---------------------------------------------------------------------------
// useColorScheme
// ---------------------------------------------------------------------------
export function useColorScheme() {
return 'dark'
}
// ---------------------------------------------------------------------------
// Default export (some code does `import RN from 'react-native'`)
// ---------------------------------------------------------------------------
const RN = {
View,
Text,
TextInput,
Image,
ImageBackground,
ScrollView,
FlatList,
SectionList,
SafeAreaView,
ActivityIndicator,
TouchableOpacity,
TouchableHighlight,
TouchableWithoutFeedback,
Pressable,
Modal,
Switch,
StatusBar,
KeyboardAvoidingView,
RefreshControl,
StyleSheet,
Dimensions,
Platform,
Alert,
Animated,
Easing,
PixelRatio,
AppState,
Keyboard,
Linking,
BackHandler,
Appearance,
PlatformColor,
NativeModules,
AccessibilityInfo,
I18nManager,
LayoutAnimation,
useColorScheme,
useWindowDimensions,
}
export default RN

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('posthog-react-native', () => {
const mockPostHog = {
capture: vi.fn(),
identify: vi.fn(),
setPersonProperties: vi.fn(),
startSessionRecording: vi.fn(),
stopSessionRecording: vi.fn(),
isSessionReplayActive: vi.fn().mockResolvedValue(true),
}
return {
default: vi.fn().mockImplementation(() => mockPostHog),
}
})
describe('analytics service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initializeAnalytics', () => {
it('should initialize PostHog client', async () => {
const { initializeAnalytics } = await import('../../shared/services/analytics')
const client = await initializeAnalytics()
expect(client).toBeDefined()
})
it('should return existing client if already initialized', async () => {
const { initializeAnalytics } = await import('../../shared/services/analytics')
const client1 = await initializeAnalytics()
const client2 = await initializeAnalytics()
expect(client1).toBe(client2)
})
})
describe('getPostHogClient', () => {
it('should return null before initialization', async () => {
vi.resetModules()
const { getPostHogClient } = await import('../../shared/services/analytics')
expect(getPostHogClient()).toBeNull()
})
it('should return client after initialization', async () => {
vi.resetModules()
const { initializeAnalytics, getPostHogClient } = await import('../../shared/services/analytics')
await initializeAnalytics()
expect(getPostHogClient()).toBeDefined()
})
})
describe('track', () => {
it('should track event with properties', async () => {
const { initializeAnalytics, track } = await import('../../shared/services/analytics')
await initializeAnalytics()
track('test_event', { prop1: 'value1' })
})
it('should track event without properties', async () => {
const { initializeAnalytics, track } = await import('../../shared/services/analytics')
await initializeAnalytics()
track('test_event')
})
})
describe('trackScreen', () => {
it('should track screen view', async () => {
const { initializeAnalytics, trackScreen } = await import('../../shared/services/analytics')
await initializeAnalytics()
trackScreen('home', { source: 'tab' })
})
})
describe('identifyUser', () => {
it('should identify user with traits', async () => {
const { initializeAnalytics, identifyUser } = await import('../../shared/services/analytics')
await initializeAnalytics()
identifyUser('user-123', { name: 'Test User' })
})
it('should identify user without traits', async () => {
const { initializeAnalytics, identifyUser } = await import('../../shared/services/analytics')
await initializeAnalytics()
identifyUser('user-123')
})
})
describe('setUserProperties', () => {
it('should set user properties', async () => {
const { initializeAnalytics, setUserProperties } = await import('../../shared/services/analytics')
await initializeAnalytics()
setUserProperties({ plan: 'pro', workouts: 10 })
})
})
describe('session recording', () => {
it('should start session recording', async () => {
const { initializeAnalytics, startSessionRecording } = await import('../../shared/services/analytics')
await initializeAnalytics()
startSessionRecording()
})
it('should stop session recording', async () => {
const { initializeAnalytics, stopSessionRecording } = await import('../../shared/services/analytics')
await initializeAnalytics()
stopSessionRecording()
})
it('should check session replay status', async () => {
const { initializeAnalytics, isSessionReplayActive } = await import('../../shared/services/analytics')
await initializeAnalytics()
const isActive = await isSessionReplayActive()
expect(typeof isActive).toBe('boolean')
})
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { musicService, MusicTrack } from '../../shared/services/music'
import type { MusicVibe } from '../../shared/types'
vi.mock('../../shared/supabase', () => ({
isSupabaseConfigured: () => false,
supabase: {},
}))
describe('music service', () => {
const vibes: MusicVibe[] = ['electronic', 'hip-hop', 'pop', 'rock', 'chill']
beforeEach(() => {
vi.clearAllMocks()
musicService.clearCache()
})
describe('loadTracksForVibe', () => {
it('should return mock tracks when Supabase not configured', async () => {
const tracks = await musicService.loadTracksForVibe('electronic')
expect(tracks).toBeDefined()
expect(tracks.length).toBeGreaterThan(0)
})
it('should return tracks for each vibe', async () => {
for (const vibe of vibes) {
const tracks = await musicService.loadTracksForVibe(vibe)
expect(tracks).toBeDefined()
expect(tracks.length).toBeGreaterThan(0)
}
})
it('should cache tracks after first load', async () => {
const tracks1 = await musicService.loadTracksForVibe('electronic')
const tracks2 = await musicService.loadTracksForVibe('electronic')
expect(tracks1).toBe(tracks2)
})
it('should return tracks with correct vibe property', async () => {
for (const vibe of vibes) {
const tracks = await musicService.loadTracksForVibe(vibe)
tracks.forEach(track => {
expect(track.vibe).toBe(vibe)
})
}
})
it('should return tracks with required properties', async () => {
const tracks = await musicService.loadTracksForVibe('electronic')
tracks.forEach(track => {
expect(track.id).toBeDefined()
expect(track.title).toBeDefined()
expect(track.artist).toBeDefined()
expect(track.duration).toBeDefined()
expect(track.url).toBeDefined()
expect(track.vibe).toBeDefined()
})
})
})
describe('clearCache', () => {
it('should clear specific vibe cache', async () => {
await musicService.loadTracksForVibe('electronic')
await musicService.loadTracksForVibe('hip-hop')
musicService.clearCache('electronic')
const tracks = await musicService.loadTracksForVibe('hip-hop')
expect(tracks).toBeDefined()
})
it('should clear all cache when no vibe specified', async () => {
await musicService.loadTracksForVibe('electronic')
await musicService.loadTracksForVibe('hip-hop')
musicService.clearCache()
expect(musicService).toBeDefined()
})
})
describe('getRandomTrack', () => {
it('should return null for empty array', () => {
const track = musicService.getRandomTrack([])
expect(track).toBeNull()
})
it('should return a track from the array', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
{ id: '2', title: 'Track 2', artist: 'Artist 2', duration: 200, url: '', vibe: 'electronic' as MusicVibe },
]
const track = musicService.getRandomTrack(tracks)
expect(track).not.toBeNull()
expect(['1', '2']).toContain(track!.id)
})
it('should return the only track for single-element array', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
]
const track = musicService.getRandomTrack(tracks)
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
})
describe('getNextTrack', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
{ id: '2', title: 'Track 2', artist: 'Artist 2', duration: 200, url: '', vibe: 'electronic' as MusicVibe },
{ id: '3', title: 'Track 3', artist: 'Artist 3', duration: 220, url: '', vibe: 'electronic' as MusicVibe },
]
it('should return null for empty array', () => {
const track = musicService.getNextTrack([], '1')
expect(track).toBeNull()
})
it('should return the only track for single-element array', () => {
const singleTrack = [tracks[0]]
const track = musicService.getNextTrack(singleTrack, '1')
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
it('should return next track in sequence', () => {
const track = musicService.getNextTrack(tracks, '1', false)
expect(track).not.toBeNull()
expect(track!.id).toBe('2')
})
it('should wrap around to first track', () => {
const track = musicService.getNextTrack(tracks, '3', false)
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
it('should return random track when shuffle is true', () => {
const track = musicService.getNextTrack(tracks, '1', true)
expect(track).not.toBeNull()
expect(track!.id).not.toBe('1')
})
it('should return different track when shuffling single remaining', () => {
const twoTracks = [tracks[0], tracks[1]]
const track = musicService.getNextTrack(twoTracks, '1', true)
expect(track).not.toBeNull()
expect(track!.id).toBe('2')
})
})
})

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Purchases, { LOG_LEVEL } from 'react-native-purchases'
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn(),
setLogLevel: vi.fn(),
},
LOG_LEVEL: {
VERBOSE: 'VERBOSE',
DEBUG: 'DEBUG',
WARN: 'WARN',
ERROR: 'ERROR',
},
}))
describe('purchases service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('constants', () => {
it('should export REVENUECAT_API_KEY', async () => {
const { REVENUECAT_API_KEY } = await import('../../shared/services/purchases')
expect(REVENUECAT_API_KEY).toBeDefined()
expect(REVENUECAT_API_KEY).toContain('test_')
})
it('should export ENTITLEMENT_ID', async () => {
const { ENTITLEMENT_ID } = await import('../../shared/services/purchases')
expect(ENTITLEMENT_ID).toBeDefined()
expect(ENTITLEMENT_ID).toBe('1000 Corp Pro')
})
})
describe('initializePurchases', () => {
it('should call configure with API key', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
expect(Purchases.configure).toHaveBeenCalled()
})
it('should set log level in dev mode', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
if (__DEV__) {
expect(Purchases.setLogLevel).toHaveBeenCalledWith(LOG_LEVEL.VERBOSE)
}
})
it('should only initialize once', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
await initializePurchases()
expect(Purchases.configure).toHaveBeenCalledTimes(1)
})
it('should throw on configuration error', async () => {
vi.mocked(Purchases.configure).mockRejectedValueOnce(new Error('Config failed'))
vi.resetModules()
const { initializePurchases } = await import('../../shared/services/purchases')
await expect(initializePurchases()).rejects.toThrow('Config failed')
})
})
describe('isPurchasesInitialized', () => {
it('should return false before initialization', async () => {
vi.resetModules()
const { isPurchasesInitialized } = await import('../../shared/services/purchases')
expect(isPurchasesInitialized()).toBe(false)
})
it('should return true after initialization', async () => {
vi.resetModules()
const { initializePurchases, isPurchasesInitialized } = await import('../../shared/services/purchases')
await initializePurchases()
expect(isPurchasesInitialized()).toBe(true)
})
})
})

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableSync, syncWorkoutSession, deleteSyncedData, getSyncState, hasSyncedData, isAuthenticated } from '../../shared/services/sync'
import { supabase } from '@/src/shared/supabase'
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn(),
getUser: vi.fn(),
getSession: vi.fn(),
signOut: vi.fn(),
},
from: vi.fn(),
},
}))
describe('sync service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('enableSync', () => {
it('should return success with userId when sync enabled', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
} as any)
vi.mocked(supabase.from).mockReturnValue({
insert: vi.fn().mockResolvedValue({ error: null }),
} as any)
const profileData = {
name: 'Test User',
fitnessLevel: 'intermediate' as const,
goal: 'strength' as const,
weeklyFrequency: 3 as const,
barriers: ['no-time'],
onboardingCompletedAt: new Date().toISOString(),
}
const workoutHistory: Array<{
workoutId: string
completedAt: string
durationSeconds: number
caloriesBurned: number
}> = []
const result = await enableSync(profileData, workoutHistory)
expect(result.success).toBe(true)
expect(result.userId).toBe('test-user-id')
})
it('should return error when auth fails', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: null },
error: { message: 'Auth failed' },
} as any)
const result = await enableSync({
name: 'Test',
fitnessLevel: 'beginner',
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
onboardingCompletedAt: new Date().toISOString(),
}, [])
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('should sync workout history when provided', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
} as any)
const mockInsert = vi.fn().mockResolvedValue({ error: null })
vi.mocked(supabase.from).mockReturnValue({
insert: mockInsert,
} as any)
const profileData = {
name: 'Test User',
fitnessLevel: 'intermediate' as const,
goal: 'strength' as const,
weeklyFrequency: 3 as const,
barriers: ['no-time'],
onboardingCompletedAt: new Date().toISOString(),
}
const workoutHistory = [
{
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
},
]
await enableSync(profileData, workoutHistory)
expect(mockInsert).toHaveBeenCalled()
})
})
describe('syncWorkoutSession', () => {
it('should return error when no authenticated user', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: null },
} as any)
const result = await syncWorkoutSession({
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
})
expect(result.success).toBe(false)
expect(result.error).toBe('No authenticated user')
})
it('should sync session when user is authenticated', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
} as any)
vi.mocked(supabase.from).mockReturnValue({
insert: vi.fn().mockResolvedValue({ error: null }),
} as any)
const result = await syncWorkoutSession({
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
})
expect(result.success).toBe(true)
})
})
describe('deleteSyncedData', () => {
it('should return error when no authenticated user', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: null },
} as any)
const result = await deleteSyncedData()
expect(result.success).toBe(false)
expect(result.error).toBe('No authenticated user')
})
it('should delete all user data when authenticated', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
} as any)
const mockDelete = vi.fn().mockReturnThis()
const mockEq = vi.fn().mockResolvedValue({ error: null })
vi.mocked(supabase.from).mockReturnValue({
delete: mockDelete,
eq: mockEq,
} as any)
vi.mocked(supabase.auth.signOut).mockResolvedValue({ error: null } as any)
const result = await deleteSyncedData()
expect(result.success).toBe(true)
expect(mockDelete).toHaveBeenCalledTimes(3)
})
})
describe('getSyncState', () => {
it('should return never-synced when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const state = await getSyncState()
expect(state.status).toBe('never-synced')
expect(state.userId).toBeNull()
})
it('should return synced state with user info', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [] }),
} as any)
const state = await getSyncState()
expect(state.status).toBe('synced')
expect(state.userId).toBe('test-user-id')
})
})
describe('hasSyncedData', () => {
it('should return false when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const result = await hasSyncedData()
expect(result).toBe(false)
})
it('should return true when session exists', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
const result = await hasSyncedData()
expect(result).toBe(true)
})
})
describe('isAuthenticated', () => {
it('should return false when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const result = await isAuthenticated()
expect(result).toBe(false)
})
it('should return true when session exists', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
const result = await isAuthenticated()
expect(result).toBe(true)
})
})
})

View File

@@ -0,0 +1,438 @@
import { vi, afterEach, expect } from 'vitest'
import React from 'react'
import { cleanup } from '@testing-library/react-native'
// Mock __DEV__
vi.stubGlobal('__DEV__', true)
// Quieter test output
vi.stubGlobal('console', {
...console,
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
vi.mock('expo-blur', () => ({
BlurView: ({ intensity, tint, children, style }: any) => {
return React.createElement('BlurView', { intensity, tint, style, testID: 'blur-view' }, children)
},
}))
vi.mock('expo-linear-gradient', () => ({
LinearGradient: ({ colors, start, end, style, children }: any) => {
return React.createElement(
'LinearGradient',
{ colors, start, end, style, testID: 'linear-gradient' },
children
)
},
}))
vi.mock('expo-video', () => ({
useVideoPlayer: vi.fn(() => ({
play: vi.fn(),
pause: vi.fn(),
replace: vi.fn(),
currentTime: 0,
duration: 100,
playing: false,
muted: false,
volume: 1,
})),
VideoView: ({ player, style, contentFit, nativeControls }: any) => {
return React.createElement('VideoView', { player, style, contentFit, nativeControls, testID: 'video-view' })
},
}))
vi.mock('@expo/vector-icons', () => ({
Ionicons: ({ name, size, color, style }: any) => {
return React.createElement('Ionicons', { name, size, color, style, testID: `icon-${name}` })
},
FontAwesome: ({ name, size, color, style }: any) => {
return React.createElement('FontAwesome', { name, size, color, style, testID: `icon-${name}` })
},
MaterialIcons: ({ name, size, color, style }: any) => {
return React.createElement('MaterialIcons', { name, size, color, style, testID: `icon-${name}` })
},
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 47, bottom: 34, left: 0, right: 0 }),
SafeAreaProvider: ({ children }: any) => children,
}))
vi.mock('expo-constants', () => ({
default: {
expoConfig: { version: '1.0.0' },
systemFonts: [],
},
}))
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' },
notificationAsync: vi.fn(),
NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' },
}))
vi.mock('expo-linking', () => ({
default: {
openURL: vi.fn(),
createURL: vi.fn(),
},
}))
const mockThemeColors = {
bg: {
base: '#000000',
surface: '#1C1C1E',
elevated: '#2C2C2E',
},
text: {
primary: '#FFFFFF',
secondary: '#8E8E93',
tertiary: '#636366',
},
glass: {
base: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
},
elevated: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderWidth: 1,
},
inset: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
},
tinted: {
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderColor: 'rgba(255, 107, 53, 0.2)',
borderWidth: 1,
},
blurTint: 'dark',
blurMedium: 40,
},
shadow: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 2 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
},
}
vi.mock('@/src/shared/theme', () => ({
useThemeColors: () => mockThemeColors,
ThemeProvider: ({ children }: any) => children,
BRAND: {
PRIMARY: '#FF6B35',
PRIMARY_DARK: '#E55A2B',
},
GRADIENTS: {
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
},
}))
vi.mock('@/src/shared/constants/borderRadius', () => ({
RADIUS: {
SM: 8,
MD: 12,
LG: 16,
XL: 20,
FULL: 9999,
GLASS_CARD: 24,
GLASS_BUTTON: 14,
},
}))
vi.mock('@/src/shared/constants/spacing', () => ({
SPACING: {
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
8: 32,
},
LAYOUT: {
SCREEN_PADDING: 24,
BUTTON_HEIGHT: 52,
},
}))
vi.mock('@/src/shared/constants/animations', () => ({
DURATION: {
FAST: 150,
NORMAL: 250,
SLOW: 400,
},
EASE: {
EASE_OUT: { x: 0.25, y: 0.1, x2: 0.25, y2: 1 },
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}))
// ---------------------------------------------------------------------------
// Mocks from setup.ts that are needed for render tests
// (react-native is NOT mocked here — it's provided by resolve.alias)
// ---------------------------------------------------------------------------
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
Video: 'Video',
}))
vi.mock('expo-image', () => ({
Image: 'Image',
}))
vi.mock('expo-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
useLocalSearchParams: vi.fn(() => ({})),
Link: 'Link',
Stack: {
Screen: 'Screen',
},
Tabs: {
Tab: 'Tab',
},
}))
vi.mock('expo-localization', () => ({
getLocales: vi.fn(() => [{ languageTag: 'en-US' }]),
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: vi.fn(),
deactivateKeepAwake: vi.fn(),
}))
vi.mock('expo-notifications', () => ({
scheduleNotificationAsync: vi.fn(),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
}))
vi.mock('expo-splash-screen', () => ({
preventAutoHideAsync: vi.fn(),
hideAsync: vi.fn(),
}))
vi.mock('expo-font', () => ({
loadAsync: vi.fn(),
}))
vi.mock('expo-file-system', () => ({
documentDirectory: '/tmp/',
cacheDirectory: '/tmp/cache/',
readAsStringAsync: vi.fn(),
writeAsStringAsync: vi.fn(),
deleteAsync: vi.fn(),
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
}))
vi.mock('expo-device', () => ({
isDevice: true,
model: 'iPhone 15',
}))
vi.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getAllKeys: vi.fn().mockResolvedValue([]),
},
}))
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn().mockResolvedValue(undefined),
getOfferings: vi.fn().mockResolvedValue({
current: {
monthly: {
identifier: 'monthly',
product: {
identifier: 'tabatafit.premium.monthly',
priceString: '$9.99',
},
},
annual: {
identifier: 'annual',
product: {
identifier: 'tabatafit.premium.yearly',
priceString: '$79.99',
},
},
},
}),
getCustomerInfo: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}),
purchasePackage: vi.fn().mockResolvedValue({
customerInfo: {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
},
}),
restorePurchases: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
}),
addCustomerInfoUpdateListener: vi.fn(),
removeCustomerInfoUpdateListener: vi.fn(),
setLogLevel: vi.fn(),
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
},
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
}))
vi.mock('react-native-reanimated', () => ({
default: {
createAnimatedComponent: (comp: any) => comp,
View: 'View',
Text: 'Text',
Image: 'Image',
ScrollView: 'ScrollView',
},
useAnimatedStyle: vi.fn(() => ({})),
useSharedValue: vi.fn(() => ({ value: 0 })),
withSpring: vi.fn((v) => v),
withTiming: vi.fn((v) => v),
withRepeat: vi.fn((v) => v),
withSequence: vi.fn((...v) => v[0]),
withDelay: vi.fn((_, v) => v),
Easing: {
linear: vi.fn(),
ease: vi.fn(),
bezier: vi.fn(),
},
runOnJS: vi.fn((fn) => fn),
}))
vi.mock('react-native-gesture-handler', () => ({
Gesture: {
Pan: vi.fn(() => ({ onStart: vi.fn(), onUpdate: vi.fn(), onEnd: vi.fn() })),
Tap: vi.fn(() => ({ onStart: vi.fn(), onEnd: vi.fn() })),
},
GestureDetector: 'GestureDetector',
PanGestureHandler: 'PanGestureHandler',
TapGestureHandler: 'TapGestureHandler',
State: {},
}))
vi.mock('react-native-screens', () => ({
enableScreens: vi.fn(),
}))
vi.mock('react-native-svg', () => ({
Svg: 'Svg',
Path: 'Path',
Circle: 'Circle',
Rect: 'Rect',
G: 'G',
Defs: 'Defs',
Use: 'Use',
}))
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
}),
getUser: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
}),
getSession: vi.fn().mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
}),
signOut: vi.fn().mockResolvedValue({ error: null }),
},
from: vi.fn(() => ({
insert: vi.fn().mockResolvedValue({ error: null }),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
delete: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
})),
},
}))
vi.mock('posthog-react-native', () => ({
PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
usePostHog: vi.fn(() => ({
capture: vi.fn(),
identify: vi.fn(),
screen: vi.fn(),
reset: vi.fn(),
})),
}))
vi.mock('@/src/shared/i18n', () => ({
default: {
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
language: 'en',
},
}))

367
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1,367 @@
import { vi, afterEach } from 'vitest'
import React from 'react'
// Extend globals
vi.stubGlobal('vi', vi)
// Mock React Native core
vi.mock('react-native', () => {
const RN = vi.importActual('react-native')
return {
...RN,
Alert: {
alert: vi.fn(),
},
Platform: {
OS: 'ios',
select: vi.fn((obj) => obj.ios),
},
StyleSheet: {
create: (styles: any) => styles,
hairlineWidth: 1,
},
Dimensions: {
get: vi.fn(() => ({ width: 375, height: 812 })),
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
},
View: 'View',
Text: 'Text',
TouchableOpacity: 'TouchableOpacity',
Pressable: 'Pressable',
Image: 'Image',
ScrollView: 'ScrollView',
FlatList: 'FlatList',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
}
})
// Mock expo modules
vi.mock('expo-constants', () => ({
default: {
expoConfig: { version: '1.0.0' },
systemFonts: [],
},
}))
vi.mock('expo-linking', () => ({
default: {
openURL: vi.fn(),
createURL: vi.fn(),
},
}))
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
},
notificationAsync: vi.fn(),
NotificationFeedbackType: {
Success: 'success',
Warning: 'warning',
Error: 'error',
},
}))
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
Video: 'Video',
}))
vi.mock('expo-video', () => ({
useVideoPlayer: vi.fn(() => ({
play: vi.fn(),
pause: vi.fn(),
replace: vi.fn(),
currentTime: 0,
duration: 100,
playing: false,
muted: false,
volume: 1,
})),
VideoView: 'VideoView',
VideoSource: vi.fn(),
}))
vi.mock('expo-image', () => ({
Image: 'Image',
}))
vi.mock('expo-linear-gradient', () => ({
LinearGradient: 'LinearGradient',
}))
vi.mock('expo-blur', () => ({
BlurView: 'BlurView',
}))
vi.mock('expo-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
useLocalSearchParams: vi.fn(() => ({})),
Link: 'Link',
Stack: {
Screen: 'Screen',
},
Tabs: {
Tab: 'Tab',
},
}))
vi.mock('expo-localization', () => ({
getLocales: vi.fn(() => [{ languageTag: 'en-US' }]),
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: vi.fn(),
deactivateKeepAwake: vi.fn(),
}))
vi.mock('expo-notifications', () => ({
scheduleNotificationAsync: vi.fn(),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
}))
vi.mock('expo-splash-screen', () => ({
preventAutoHideAsync: vi.fn(),
hideAsync: vi.fn(),
}))
vi.mock('expo-font', () => ({
loadAsync: vi.fn(),
}))
vi.mock('expo-file-system', () => ({
documentDirectory: '/tmp/',
cacheDirectory: '/tmp/cache/',
readAsStringAsync: vi.fn(),
writeAsStringAsync: vi.fn(),
deleteAsync: vi.fn(),
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
}))
vi.mock('expo-device', () => ({
isDevice: true,
model: 'iPhone 15',
}))
vi.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
// Mock AsyncStorage
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getAllKeys: vi.fn().mockResolvedValue([]),
},
}))
// Mock react-native-purchases (RevenueCat)
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn().mockResolvedValue(undefined),
getOfferings: vi.fn().mockResolvedValue({
current: {
monthly: {
identifier: 'monthly',
product: {
identifier: 'tabatafit.premium.monthly',
priceString: '$9.99',
},
},
annual: {
identifier: 'annual',
product: {
identifier: 'tabatafit.premium.yearly',
priceString: '$79.99',
},
},
},
}),
getCustomerInfo: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}),
purchasePackage: vi.fn().mockResolvedValue({
customerInfo: {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
},
}),
restorePurchases: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
}),
addCustomerInfoUpdateListener: vi.fn(),
removeCustomerInfoUpdateListener: vi.fn(),
setLogLevel: vi.fn(),
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
},
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
}))
// Mock react-native-reanimated
vi.mock('react-native-reanimated', () => ({
default: {
createAnimatedComponent: (comp: any) => comp,
View: 'View',
Text: 'Text',
Image: 'Image',
ScrollView: 'ScrollView',
},
useAnimatedStyle: vi.fn(() => ({})),
useSharedValue: vi.fn(() => ({ value: 0 })),
withSpring: vi.fn((v) => v),
withTiming: vi.fn((v) => v),
withRepeat: vi.fn((v) => v),
withSequence: vi.fn((...v) => v[0]),
withDelay: vi.fn((_, v) => v),
Easing: {
linear: vi.fn(),
ease: vi.fn(),
bezier: vi.fn(),
},
runOnJS: vi.fn((fn) => fn),
}))
// Mock react-native-gesture-handler
vi.mock('react-native-gesture-handler', () => ({
Gesture: {
Pan: vi.fn(() => ({ onStart: vi.fn(), onUpdate: vi.fn(), onEnd: vi.fn() })),
Tap: vi.fn(() => ({ onStart: vi.fn(), onEnd: vi.fn() })),
},
GestureDetector: 'GestureDetector',
PanGestureHandler: 'PanGestureHandler',
TapGestureHandler: 'TapGestureHandler',
State: {},
}))
// Mock react-native-screens
vi.mock('react-native-screens', () => ({
enableScreens: vi.fn(),
}))
// Mock react-native-safe-area-context
vi.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
useSafeAreaInsets: vi.fn(() => ({ top: 44, bottom: 34, left: 0, right: 0 })),
SafeAreaView: 'SafeAreaView',
}))
// Mock react-native-svg
vi.mock('react-native-svg', () => ({
Svg: 'Svg',
Path: 'Path',
Circle: 'Circle',
Rect: 'Rect',
G: 'G',
Defs: 'Defs',
Use: 'Use',
}))
// Mock Supabase
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
}),
getUser: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
}),
getSession: vi.fn().mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
}),
signOut: vi.fn().mockResolvedValue({ error: null }),
},
from: vi.fn(() => ({
insert: vi.fn().mockResolvedValue({ error: null }),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
delete: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
})),
},
}))
// Mock PostHog
vi.mock('posthog-react-native', () => ({
PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
usePostHog: vi.fn(() => ({
capture: vi.fn(),
identify: vi.fn(),
screen: vi.fn(),
reset: vi.fn(),
})),
}))
// Mock i18next
vi.mock('@/src/shared/i18n', () => ({
default: {
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
language: 'en',
},
}))
// Mock __DEV__
vi.stubGlobal('__DEV__', true)
// Mock console methods for cleaner test output
const originalConsole = { ...console }
vi.stubGlobal('console', {
...console,
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})
// Restore console after each test if needed
afterEach(() => {
vi.clearAllMocks()
})

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useActivityStore, getWeeklyActivity } from '../../shared/stores/activityStore'
import type { WorkoutResult } from '../../shared/types'
const createWorkoutResult = (daysAgo: number, overrides?: Partial<WorkoutResult>): WorkoutResult => ({
id: `result-${Date.now()}-${Math.random()}`,
workoutId: 'test-workout',
durationMinutes: 4,
calories: 45,
rounds: 8,
completionRate: 1,
completedAt: Date.now() - daysAgo * 86400000,
...overrides,
})
describe('activityStore', () => {
beforeEach(async () => {
await useActivityStore.persist.clearStorage()
useActivityStore.setState({ history: [], streak: { current: 0, longest: 0 } })
vi.clearAllMocks()
})
describe('initial state', () => {
it('should have empty history and zero streak', () => {
const state = useActivityStore.getState()
expect(state.history).toEqual([])
expect(state.streak).toEqual({ current: 0, longest: 0 })
})
})
describe('addWorkoutResult', () => {
it('should add workout to beginning of history', async () => {
const store = useActivityStore.getState()
const result1 = createWorkoutResult(2)
const result2 = createWorkoutResult(1)
await store.addWorkoutResult(result1)
await store.addWorkoutResult(result2)
const history = useActivityStore.getState().history
expect(history).toHaveLength(2)
expect(history[0].completedAt).toBeGreaterThan(history[1].completedAt)
})
it('should calculate streak after adding workout', async () => {
const store = useActivityStore.getState()
const todayResult = createWorkoutResult(0)
await store.addWorkoutResult(todayResult)
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(1)
expect(streak.longest).toBe(1)
})
it('should build consecutive streak', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(2))
await store.addWorkoutResult(createWorkoutResult(1))
await store.addWorkoutResult(createWorkoutResult(0))
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(3)
expect(streak.longest).toBe(3)
})
})
describe('getWorkoutHistory', () => {
it('should return all workouts', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(2))
await store.addWorkoutResult(createWorkoutResult(1))
const history = store.getWorkoutHistory()
expect(history).toHaveLength(2)
})
})
})
describe('streak calculation', () => {
beforeEach(async () => {
await useActivityStore.persist.clearStorage()
useActivityStore.setState({ history: [], streak: { current: 0, longest: 0 } })
})
describe('empty history', () => {
it('should return 0 streak for empty history', async () => {
const store = useActivityStore.getState()
expect(store.history).toEqual([])
expect(store.streak.current).toBe(0)
expect(store.streak.longest).toBe(0)
})
})
describe('single workout', () => {
it('should return streak 1 for single workout today', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(0))
expect(useActivityStore.getState().streak.current).toBe(1)
})
it('should return streak 1 for single workout yesterday', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(1))
expect(useActivityStore.getState().streak.current).toBe(1)
})
})
describe('consecutive days', () => {
it('should count 3-day streak correctly', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(2))
await store.addWorkoutResult(createWorkoutResult(1))
await store.addWorkoutResult(createWorkoutResult(0))
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(3)
})
it('should count 7-day streak correctly', async () => {
const store = useActivityStore.getState()
for (let i = 6; i >= 0; i--) {
await store.addWorkoutResult(createWorkoutResult(i))
}
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(7)
})
})
describe('streak breaks', () => {
beforeEach(async () => {
await useActivityStore.persist.clearStorage()
useActivityStore.setState({ history: [], streak: { current: 0, longest: 0 } })
})
it('should reset streak when gap exists', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(5))
await store.addWorkoutResult(createWorkoutResult(4))
await store.addWorkoutResult(createWorkoutResult(1))
await store.addWorkoutResult(createWorkoutResult(0))
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(2)
expect(streak.longest).toBe(2)
})
it('should maintain longest streak even after current streak breaks', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(10))
await store.addWorkoutResult(createWorkoutResult(9))
await store.addWorkoutResult(createWorkoutResult(8))
await store.addWorkoutResult(createWorkoutResult(5))
await store.addWorkoutResult(createWorkoutResult(4))
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(0)
expect(streak.longest).toBe(3)
})
})
describe('multiple workouts same day', () => {
beforeEach(async () => {
await useActivityStore.persist.clearStorage()
useActivityStore.setState({ history: [], streak: { current: 0, longest: 0 } })
})
it('should count same-day workouts as 1 day', async () => {
const store = useActivityStore.getState()
const now = Date.now()
// Add 3 workouts on the same day (1 hour apart)
await store.addWorkoutResult({ ...createWorkoutResult(0), completedAt: now })
await store.addWorkoutResult({ ...createWorkoutResult(0), completedAt: now + 3600000 })
await store.addWorkoutResult({ ...createWorkoutResult(0), completedAt: now + 7200000 })
const state = useActivityStore.getState()
// All 3 workout entries are stored in history
expect(state.history).toHaveLength(3)
// But they count as 1 unique day for streak purposes
expect(state.streak.current).toBe(1)
expect(state.streak.longest).toBe(1)
})
})
describe('streak expiration', () => {
beforeEach(async () => {
await useActivityStore.persist.clearStorage()
useActivityStore.setState({ history: [], streak: { current: 0, longest: 0 } })
})
it('should reset streak if last workout was 2+ days ago', async () => {
const store = useActivityStore.getState()
await store.addWorkoutResult(createWorkoutResult(5))
await store.addWorkoutResult(createWorkoutResult(4))
await store.addWorkoutResult(createWorkoutResult(3))
const streak = useActivityStore.getState().streak
expect(streak.current).toBe(0)
expect(streak.longest).toBe(3)
})
})
})
describe('getWeeklyActivity', () => {
it('should return 7 days of activity', () => {
const history: WorkoutResult[] = []
const activity = getWeeklyActivity(history)
expect(activity).toHaveLength(7)
activity.forEach((day) => {
expect(day).toHaveProperty('date')
expect(day).toHaveProperty('completed')
expect(day).toHaveProperty('workoutCount')
})
})
it('should mark days with workouts as completed', () => {
const today = Date.now()
const history: WorkoutResult[] = [
createWorkoutResult(0),
createWorkoutResult(2),
]
const activity = getWeeklyActivity(history)
const completedDays = activity.filter((d) => d.completed)
expect(completedDays.length).toBeGreaterThanOrEqual(1)
})
it('should count multiple workouts per day', () => {
const today = Date.now()
const history: WorkoutResult[] = [
{ ...createWorkoutResult(0), completedAt: today },
{ ...createWorkoutResult(0), completedAt: today + 3600000 },
]
const activity = getWeeklyActivity(history)
const todayActivity = activity.find((d) => d.workoutCount > 1)
expect(todayActivity?.workoutCount).toBe(2)
})
it('should return all days with zero workouts for empty history', () => {
const history: WorkoutResult[] = []
const activity = getWeeklyActivity(history)
activity.forEach((day) => {
expect(day.completed).toBe(false)
expect(day.workoutCount).toBe(0)
})
})
})

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { usePlayerStore } from '../../shared/stores/playerStore'
import type { Workout } from '../../shared/types'
const mockWorkout: Workout = {
id: 'test-workout',
title: 'Test Workout',
trainerId: 'trainer-1',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 45,
rounds: 8,
prepTime: 10,
workTime: 20,
restTime: 10,
equipment: [],
musicVibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
{ name: 'Squats', duration: 20 },
{ name: 'Push-ups', duration: 20 },
{ name: 'High Knees', duration: 20 },
],
}
describe('playerStore', () => {
beforeEach(() => {
usePlayerStore.getState().reset()
})
describe('initial state', () => {
it('should have correct default values', () => {
const state = usePlayerStore.getState()
expect(state.workout).toBeNull()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
})
describe('loadWorkout', () => {
it('should load workout and reset all state', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
expect(state.workout).toEqual(mockWorkout)
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(mockWorkout.prepTime)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
it('should use workout prepTime for timeRemaining', () => {
const workoutWithCustomPrep: Workout = { ...mockWorkout, prepTime: 15 }
usePlayerStore.getState().loadWorkout(workoutWithCustomPrep)
expect(usePlayerStore.getState().timeRemaining).toBe(15)
})
})
describe('setPhase', () => {
it('should update phase', () => {
const store = usePlayerStore.getState()
store.setPhase('WORK')
expect(usePlayerStore.getState().phase).toBe('WORK')
store.setPhase('REST')
expect(usePlayerStore.getState().phase).toBe('REST')
store.setPhase('COMPLETE')
expect(usePlayerStore.getState().phase).toBe('COMPLETE')
})
})
describe('setTimeRemaining', () => {
it('should update timeRemaining', () => {
const store = usePlayerStore.getState()
store.setTimeRemaining(5)
expect(usePlayerStore.getState().timeRemaining).toBe(5)
store.setTimeRemaining(0)
expect(usePlayerStore.getState().timeRemaining).toBe(0)
})
})
describe('setCurrentRound', () => {
it('should update currentRound', () => {
const store = usePlayerStore.getState()
store.setCurrentRound(3)
expect(usePlayerStore.getState().currentRound).toBe(3)
store.setCurrentRound(8)
expect(usePlayerStore.getState().currentRound).toBe(8)
})
})
describe('setPaused', () => {
it('should toggle pause state', () => {
const store = usePlayerStore.getState()
store.setPaused(true)
expect(usePlayerStore.getState().isPaused).toBe(true)
store.setPaused(false)
expect(usePlayerStore.getState().isPaused).toBe(false)
})
})
describe('setRunning', () => {
it('should set running state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
expect(usePlayerStore.getState().isRunning).toBe(true)
})
it('should set startedAt when first running', () => {
const store = usePlayerStore.getState()
const beforeSet = Date.now()
store.setRunning(true)
const state = usePlayerStore.getState()
expect(state.startedAt).toBeGreaterThanOrEqual(beforeSet)
expect(state.startedAt).toBeLessThanOrEqual(Date.now())
})
it('should not update startedAt if already set', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
const firstStartedAt = usePlayerStore.getState().startedAt
store.setRunning(false)
store.setRunning(true)
expect(usePlayerStore.getState().startedAt).toBe(firstStartedAt)
})
})
describe('addCalories', () => {
it('should accumulate calories', () => {
const store = usePlayerStore.getState()
store.addCalories(5)
expect(usePlayerStore.getState().calories).toBe(5)
store.addCalories(5)
expect(usePlayerStore.getState().calories).toBe(10)
store.addCalories(3)
expect(usePlayerStore.getState().calories).toBe(13)
})
})
describe('reset', () => {
it('should reset all state to defaults', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setCurrentRound(5)
store.setRunning(true)
store.addCalories(25)
store.reset()
const state = usePlayerStore.getState()
expect(state.workout).toBeNull()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
})
describe('workout phase simulation', () => {
it('should track complete workout flow', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
expect(store.phase).toBe('PREP')
expect(store.timeRemaining).toBe(10)
store.setPhase('WORK')
store.setTimeRemaining(20)
expect(usePlayerStore.getState().phase).toBe('WORK')
store.setTimeRemaining(0)
store.addCalories(6)
store.setPhase('REST')
store.setTimeRemaining(10)
expect(usePlayerStore.getState().calories).toBe(6)
expect(usePlayerStore.getState().phase).toBe('REST')
store.setCurrentRound(2)
store.setPhase('WORK')
expect(usePlayerStore.getState().currentRound).toBe(2)
})
})
})

View File

@@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useProgramStore } from '../../shared/stores/programStore'
import type { ProgramId, AssessmentResult } from '../../shared/types/program'
const resetAllPrograms = () => {
useProgramStore.setState({
selectedProgramId: null,
programsProgress: {
'upper-body': {
programId: 'upper-body',
currentWeek: 1,
currentWorkoutIndex: 0,
completedWorkoutIds: [],
isProgramCompleted: false,
startDate: undefined,
lastWorkoutDate: undefined,
},
'lower-body': {
programId: 'lower-body',
currentWeek: 1,
currentWorkoutIndex: 0,
completedWorkoutIds: [],
isProgramCompleted: false,
startDate: undefined,
lastWorkoutDate: undefined,
},
'full-body': {
programId: 'full-body',
currentWeek: 1,
currentWorkoutIndex: 0,
completedWorkoutIds: [],
isProgramCompleted: false,
startDate: undefined,
lastWorkoutDate: undefined,
},
},
assessment: {
isCompleted: false,
result: null,
},
})
}
describe('programStore', () => {
beforeEach(() => {
resetAllPrograms()
})
describe('initial state', () => {
it('should have no selected program', () => {
expect(useProgramStore.getState().selectedProgramId).toBeNull()
})
it('should have initial progress for all programs', () => {
const progress = useProgramStore.getState().programsProgress
expect(progress['upper-body']).toBeDefined()
expect(progress['lower-body']).toBeDefined()
expect(progress['full-body']).toBeDefined()
})
it('should have incomplete assessment', () => {
const assessment = useProgramStore.getState().assessment
expect(assessment.isCompleted).toBe(false)
expect(assessment.result).toBeNull()
})
})
describe('selectProgram', () => {
it('should set selected program', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
expect(useProgramStore.getState().selectedProgramId).toBe('upper-body')
})
it('should set start date when selecting program for first time', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.startDate).toBeDefined()
})
})
describe('completeWorkout', () => {
it('should add workout to completed list', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.completedWorkoutIds).toContain('ub-w1-d1')
})
it('should advance workout index', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.currentWorkoutIndex).toBe(1)
})
it('should advance week after completing last workout of week', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
const progress = useProgramStore.getState().programsProgress['upper-body']
progress.currentWorkoutIndex = 4
progress.currentWeek = 1
store.completeWorkout('upper-body', 'ub-w1-d5')
const updated = useProgramStore.getState().programsProgress['upper-body']
expect(updated.currentWeek).toBe(2)
expect(updated.currentWorkoutIndex).toBe(0)
})
it('should set lastWorkoutDate', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.lastWorkoutDate).toBeDefined()
})
})
describe('completeAssessment', () => {
it('should mark assessment as completed', () => {
const store = useProgramStore.getState()
const result: AssessmentResult = {
completedAt: new Date().toISOString(),
exercisesCompleted: ['exercise-1', 'exercise-2'],
recommendedProgram: 'upper-body',
}
store.completeAssessment(result)
const assessment = useProgramStore.getState().assessment
expect(assessment.isCompleted).toBe(true)
expect(assessment.result).toEqual(result)
})
})
describe('skipAssessment', () => {
it('should mark assessment as completed without result', () => {
const store = useProgramStore.getState()
store.skipAssessment()
const assessment = useProgramStore.getState().assessment
expect(assessment.isCompleted).toBe(true)
expect(assessment.result).toBeNull()
})
})
describe('resetProgram', () => {
it('should reset program progress', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
store.resetProgram('upper-body')
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.completedWorkoutIds).toEqual([])
expect(progress.currentWeek).toBe(1)
expect(progress.currentWorkoutIndex).toBe(0)
})
it('should deselect program if it was the selected one', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.resetProgram('upper-body')
expect(useProgramStore.getState().selectedProgramId).toBeNull()
})
})
describe('changeProgram', () => {
it('should change to different program', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.changeProgram('lower-body')
expect(useProgramStore.getState().selectedProgramId).toBe('lower-body')
})
})
describe('getCurrentWorkout', () => {
it('should return null when no program selected', () => {
const store = useProgramStore.getState()
const workout = store.getCurrentWorkout('upper-body')
expect(workout).toBeDefined()
})
})
describe('getProgramCompletion', () => {
it('should return 0 for new program', () => {
const store = useProgramStore.getState()
const completion = store.getProgramCompletion('upper-body')
expect(completion).toBe(0)
})
it('should calculate completion percentage', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
for (let i = 1; i <= 10; i++) {
store.completeWorkout('upper-body', `ub-w1-d${i}`)
}
const completion = useProgramStore.getState().getProgramCompletion('upper-body')
expect(completion).toBe(50)
})
})
describe('getTotalWorkoutsCompleted', () => {
it('should return 0 when no workouts completed', () => {
const store = useProgramStore.getState()
expect(store.getTotalWorkoutsCompleted()).toBe(0)
})
it('should count workouts across all programs', () => {
const store = useProgramStore.getState()
const ubProgress = useProgramStore.getState().programsProgress['upper-body']
ubProgress.completedWorkoutIds = ['w1', 'w2']
const lbProgress = useProgramStore.getState().programsProgress['lower-body']
lbProgress.completedWorkoutIds = ['w3']
expect(store.getTotalWorkoutsCompleted()).toBe(3)
})
})
describe('getProgramStatus', () => {
it('should return not-started for new program', () => {
const store = useProgramStore.getState()
expect(store.getProgramStatus('upper-body')).toBe('not-started')
})
it('should return in-progress after completing workout', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
expect(useProgramStore.getState().getProgramStatus('upper-body')).toBe('in-progress')
})
it('should return completed after all 20 workouts', () => {
const store = useProgramStore.getState()
const progress = useProgramStore.getState().programsProgress['upper-body']
progress.completedWorkoutIds = Array.from({ length: 20 }, (_, i) => `w${i + 1}`)
progress.isProgramCompleted = true
expect(store.getProgramStatus('upper-body')).toBe('completed')
})
})
describe('isWeekUnlocked', () => {
it('should always unlock week 1', () => {
const store = useProgramStore.getState()
expect(store.isWeekUnlocked('upper-body', 1)).toBe(true)
})
it('should lock week 2 before completing week 1', () => {
const store = useProgramStore.getState()
expect(store.isWeekUnlocked('upper-body', 2)).toBe(false)
})
})
describe('getRecommendedNextWorkout', () => {
it('should return null when no programs started', () => {
const store = useProgramStore.getState()
expect(store.getRecommendedNextWorkout()).toBeNull()
})
it('should return current workout from selected program', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
const recommendation = store.getRecommendedNextWorkout()
expect(recommendation).not.toBeNull()
expect(recommendation?.programId).toBe('upper-body')
})
it('should find in-progress program when no program selected', () => {
const store = useProgramStore.getState()
const progress = useProgramStore.getState().programsProgress['lower-body']
progress.completedWorkoutIds = ['lb-w1-d1']
const recommendation = store.getRecommendedNextWorkout()
expect(recommendation).not.toBeNull()
expect(recommendation?.programId).toBe('lower-body')
})
})
describe('getNextWorkout', () => {
it('should return next workout in same week', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
const nextWorkout = store.getNextWorkout('upper-body')
expect(nextWorkout).not.toBeNull()
expect(nextWorkout?.id).toBe('ub-w1-d2')
})
it('should return first workout of next week when at end of week', () => {
const store = useProgramStore.getState()
const progress = useProgramStore.getState().programsProgress['upper-body']
progress.currentWeek = 1
progress.currentWorkoutIndex = 4
const nextWorkout = store.getNextWorkout('upper-body')
expect(nextWorkout).not.toBeNull()
expect(nextWorkout?.id).toBe('ub-w2-d1')
})
it('should return null when at end of program', () => {
const store = useProgramStore.getState()
const progress = useProgramStore.getState().programsProgress['upper-body']
progress.currentWeek = 4
progress.currentWorkoutIndex = 4
const nextWorkout = store.getNextWorkout('upper-body')
expect(nextWorkout).toBeNull()
})
})
describe('isWorkoutUnlocked', () => {
it('should unlock first workout of week 1', () => {
const store = useProgramStore.getState()
expect(store.isWorkoutUnlocked('upper-body', 'ub-w1-d1')).toBe(true)
})
it('should lock later workouts in week 1', () => {
const store = useProgramStore.getState()
expect(store.isWorkoutUnlocked('upper-body', 'ub-w1-d2')).toBe(false)
expect(store.isWorkoutUnlocked('upper-body', 'ub-w1-d5')).toBe(false)
})
it('should unlock workout after completing previous', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
store.completeWorkout('upper-body', 'ub-w1-d1')
expect(useProgramStore.getState().isWorkoutUnlocked('upper-body', 'ub-w1-d2')).toBe(true)
})
it('should lock week 2 workouts before completing week 1', () => {
const store = useProgramStore.getState()
expect(store.isWorkoutUnlocked('upper-body', 'ub-w2-d1')).toBe(false)
})
})
describe('program completion', () => {
it('should mark program as completed after 20 workouts', () => {
const store = useProgramStore.getState()
store.selectProgram('upper-body')
for (let week = 1; week <= 4; week++) {
for (let day = 1; day <= 5; day++) {
store.completeWorkout('upper-body', `ub-w${week}-d${day}`)
}
}
const progress = useProgramStore.getState().programsProgress['upper-body']
expect(progress.isProgramCompleted).toBe(true)
expect(progress.completedWorkoutIds).toHaveLength(20)
})
})
})

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useUserStore } from '../../shared/stores/userStore'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '../../shared/types'
describe('userStore', () => {
beforeEach(() => {
useUserStore.setState({
profile: {
name: '',
email: '',
joinDate: 'March 2026',
subscription: 'free',
onboardingCompleted: false,
fitnessLevel: 'beginner',
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
},
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},
})
})
describe('initial state', () => {
it('should have correct default profile values', () => {
const { profile } = useUserStore.getState()
expect(profile.name).toBe('')
expect(profile.email).toBe('')
expect(profile.subscription).toBe('free')
expect(profile.onboardingCompleted).toBe(false)
expect(profile.fitnessLevel).toBe('beginner')
expect(profile.goal).toBe('cardio')
expect(profile.weeklyFrequency).toBe(3)
expect(profile.barriers).toEqual([])
expect(profile.syncStatus).toBe('never-synced')
expect(profile.supabaseUserId).toBeNull()
})
it('should have correct default settings values', () => {
const { settings } = useUserStore.getState()
expect(settings.haptics).toBe(true)
expect(settings.soundEffects).toBe(true)
expect(settings.voiceCoaching).toBe(true)
expect(settings.musicEnabled).toBe(true)
expect(settings.musicVolume).toBe(0.5)
expect(settings.reminders).toBe(false)
expect(settings.reminderTime).toBe('09:00')
})
})
describe('updateProfile', () => {
it('should update profile fields', () => {
const store = useUserStore.getState()
store.updateProfile({ name: 'John Doe' })
expect(useUserStore.getState().profile.name).toBe('John Doe')
})
it('should merge updates with existing profile', () => {
const store = useUserStore.getState()
store.updateProfile({ name: 'Jane' })
store.updateProfile({ email: 'jane@example.com' })
const profile = useUserStore.getState().profile
expect(profile.name).toBe('Jane')
expect(profile.email).toBe('jane@example.com')
})
it('should update fitness level', () => {
const store = useUserStore.getState()
const levels: FitnessLevel[] = ['beginner', 'intermediate', 'advanced']
levels.forEach((level) => {
store.updateProfile({ fitnessLevel: level })
expect(useUserStore.getState().profile.fitnessLevel).toBe(level)
})
})
it('should update fitness goal', () => {
const store = useUserStore.getState()
const goals: FitnessGoal[] = ['weight-loss', 'cardio', 'strength', 'wellness']
goals.forEach((goal) => {
store.updateProfile({ goal })
expect(useUserStore.getState().profile.goal).toBe(goal)
})
})
it('should update weekly frequency', () => {
const store = useUserStore.getState()
const frequencies: WeeklyFrequency[] = [2, 3, 5]
frequencies.forEach((freq) => {
store.updateProfile({ weeklyFrequency: freq })
expect(useUserStore.getState().profile.weeklyFrequency).toBe(freq)
})
})
it('should update barriers array', () => {
const store = useUserStore.getState()
store.updateProfile({ barriers: ['no-time', 'low-motivation'] })
expect(useUserStore.getState().profile.barriers).toEqual(['no-time', 'low-motivation'])
})
})
describe('updateSettings', () => {
it('should update individual settings', () => {
const store = useUserStore.getState()
store.updateSettings({ haptics: false })
expect(useUserStore.getState().settings.haptics).toBe(false)
})
it('should merge updates with existing settings', () => {
const store = useUserStore.getState()
store.updateSettings({ haptics: false })
store.updateSettings({ soundEffects: false })
store.updateSettings({ musicVolume: 0.8 })
const settings = useUserStore.getState().settings
expect(settings.haptics).toBe(false)
expect(settings.soundEffects).toBe(false)
expect(settings.musicVolume).toBe(0.8)
expect(settings.voiceCoaching).toBe(true)
})
it('should update reminder settings', () => {
const store = useUserStore.getState()
store.updateSettings({ reminders: true, reminderTime: '07:30' })
const settings = useUserStore.getState().settings
expect(settings.reminders).toBe(true)
expect(settings.reminderTime).toBe('07:30')
})
})
describe('setSubscription', () => {
it('should update subscription plan', () => {
const store = useUserStore.getState()
store.setSubscription('premium-monthly')
expect(useUserStore.getState().profile.subscription).toBe('premium-monthly')
store.setSubscription('premium-yearly')
expect(useUserStore.getState().profile.subscription).toBe('premium-yearly')
store.setSubscription('free')
expect(useUserStore.getState().profile.subscription).toBe('free')
})
})
describe('completeOnboarding', () => {
it('should set onboarding completed flag', () => {
const store = useUserStore.getState()
store.completeOnboarding({
name: 'Test User',
fitnessLevel: 'intermediate',
goal: 'strength',
weeklyFrequency: 5,
barriers: ['no-time'],
})
const profile = useUserStore.getState().profile
expect(profile.onboardingCompleted).toBe(true)
expect(profile.name).toBe('Test User')
expect(profile.fitnessLevel).toBe('intermediate')
expect(profile.goal).toBe('strength')
expect(profile.weeklyFrequency).toBe(5)
expect(profile.barriers).toEqual(['no-time'])
})
})
describe('setSyncStatus', () => {
it('should update sync status', () => {
const store = useUserStore.getState()
store.setSyncStatus('prompt-pending')
expect(useUserStore.getState().profile.syncStatus).toBe('prompt-pending')
store.setSyncStatus('synced', 'user-123')
const profile = useUserStore.getState().profile
expect(profile.syncStatus).toBe('synced')
expect(profile.supabaseUserId).toBe('user-123')
})
})
describe('setPromptPending', () => {
it('should set sync status to prompt-pending', () => {
const store = useUserStore.getState()
store.setPromptPending()
expect(useUserStore.getState().profile.syncStatus).toBe('prompt-pending')
})
})
})

View File

@@ -0,0 +1,57 @@
import React, { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react-native'
const mockThemeColors = {
bg: {
base: '#000000',
surface: '#1C1C1E',
elevated: '#2C2C2E',
},
text: {
primary: '#FFFFFF',
secondary: '#8E8E93',
tertiary: '#636366',
},
glass: {
base: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
},
elevated: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderWidth: 1,
},
inset: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
},
tinted: {
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderColor: 'rgba(255, 107, 53, 0.2)',
borderWidth: 1,
},
blurTint: 'dark',
blurMedium: 40,
},
shadow: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.25, shadowRadius: 4 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
},
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
theme?: typeof mockThemeColors
}
export function renderWithTheme(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
return render(ui, options)
}
export { mockThemeColors }

View File

@@ -0,0 +1,157 @@
/**
* Data Deletion Modal - For privacy compliance
* Allows users to delete their synced data from Supabase
*/
import { useState } from 'react'
import { View, Modal, Pressable, StyleSheet } from 'react-native'
import { useTranslation } from 'react-i18next'
import { useThemeColors } from '@/src/shared/theme'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { Ionicons } from '@expo/vector-icons'
interface DataDeletionModalProps {
visible: boolean
onDelete: () => Promise<void>
onCancel: () => void
}
export function DataDeletionModal({
visible,
onDelete,
onCancel,
}: DataDeletionModalProps) {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
setIsDeleting(true)
await onDelete()
setIsDeleting(false)
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onCancel}
>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
<View
style={[styles.container, { backgroundColor: colors.bg.surface }]}
>
{/* Warning Icon */}
<View
style={[
styles.iconContainer,
{ backgroundColor: 'rgba(255, 59, 48, 0.1)' },
]}
>
<Ionicons name="warning" size={40} color="#FF3B30" />
</View>
{/* Title */}
<StyledText
size={22}
weight="bold"
color={colors.text.primary}
style={styles.title}
>
{t('dataDeletion.title')}
</StyledText>
{/* Description */}
<StyledText
size={15}
color={colors.text.secondary}
style={styles.description}
>
{t('dataDeletion.description')}
</StyledText>
<StyledText
size={14}
color={colors.text.tertiary}
style={styles.note}
>
{t('dataDeletion.note')}
</StyledText>
{/* Actions */}
<Pressable
style={[styles.deleteButton, isDeleting && styles.disabled]}
onPress={handleDelete}
disabled={isDeleting}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{isDeleting ? 'Deleting...' : t('dataDeletion.deleteButton')}
</StyledText>
</Pressable>
<Pressable style={styles.cancelButton} onPress={onCancel}>
<StyledText size={15} color={colors.text.secondary}>
{t('dataDeletion.cancelButton')}
</StyledText>
</Pressable>
</View>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: SPACING[4],
},
container: {
width: '100%',
maxWidth: 360,
borderRadius: RADIUS.LG,
padding: SPACING[6],
alignItems: 'center',
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: SPACING[4],
},
title: {
textAlign: 'center',
marginBottom: SPACING[4],
},
description: {
textAlign: 'center',
marginBottom: SPACING[3],
lineHeight: 22,
},
note: {
textAlign: 'center',
marginBottom: SPACING[6],
lineHeight: 20,
},
deleteButton: {
width: '100%',
height: LAYOUT.BUTTON_HEIGHT,
backgroundColor: '#FF3B30',
borderRadius: RADIUS.GLASS_BUTTON,
justifyContent: 'center',
alignItems: 'center',
marginBottom: SPACING[3],
},
cancelButton: {
padding: SPACING[3],
},
disabled: {
opacity: 0.6,
},
})

View File

@@ -0,0 +1,207 @@
/**
* Sync Consent Modal - Shown after first workout completion
* Never uses words: account, signup, login, register
* Frames as "personalization" not "account creation"
*/
import { useState } from 'react'
import { View, Modal, Pressable, StyleSheet } from 'react-native'
import { useTranslation } from 'react-i18next'
import { useThemeColors } from '@/src/shared/theme'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { BRAND } from '@/src/shared/constants/colors'
import { Ionicons } from '@expo/vector-icons'
interface SyncConsentModalProps {
visible: boolean
onAccept: () => void
onDecline: () => void
}
export function SyncConsentModal({
visible,
onAccept,
onDecline,
}: SyncConsentModalProps) {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const [isLoading, setIsLoading] = useState(false)
const handleAccept = async () => {
setIsLoading(true)
await onAccept()
setIsLoading(false)
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onDecline}
>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
<View
style={[styles.container, { backgroundColor: colors.bg.surface }]}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={40} color={BRAND.PRIMARY} />
</View>
{/* Title */}
<StyledText
size={24}
weight="bold"
color={colors.text.primary}
style={styles.title}
>
{t('sync.title')}
</StyledText>
{/* Benefits */}
<View style={styles.benefits}>
<BenefitRow
icon="trending-up"
text={t('sync.benefits.recommendations')}
colors={colors}
/>
<BenefitRow
icon="fitness"
text={t('sync.benefits.adaptive')}
colors={colors}
/>
<BenefitRow
icon="sync"
text={t('sync.benefits.sync')}
colors={colors}
/>
<BenefitRow
icon="shield-checkmark"
text={t('sync.benefits.secure')}
colors={colors}
/>
</View>
{/* Privacy Note */}
<StyledText
size={13}
color={colors.text.tertiary}
style={styles.privacy}
>
{t('sync.privacy')}
</StyledText>
{/* CTA Buttons */}
<Pressable
style={[styles.primaryButton, isLoading && styles.disabled]}
onPress={handleAccept}
disabled={isLoading}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{isLoading ? 'Setting up...' : t('sync.primaryButton')}
</StyledText>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={onDecline}>
<StyledText size={15} color={colors.text.secondary}>
{t('sync.secondaryButton')}
</StyledText>
</Pressable>
</View>
</View>
</Modal>
)
}
function BenefitRow({
icon,
text,
colors,
}: {
icon: string
text: string
colors: any
}) {
return (
<View style={styles.benefitRow}>
<Ionicons
name={icon as any}
size={22}
color={BRAND.PRIMARY}
/>
<StyledText
size={15}
color={colors.text.secondary}
style={styles.benefitText}
>
{text}
</StyledText>
</View>
)
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: SPACING[4],
},
container: {
width: '100%',
maxWidth: 360,
borderRadius: RADIUS.LG,
padding: SPACING[6],
alignItems: 'center',
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: SPACING[4],
},
title: {
textAlign: 'center',
marginBottom: SPACING[6],
},
benefits: {
width: '100%',
gap: SPACING[3],
marginBottom: SPACING[6],
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
},
benefitText: {
flex: 1,
lineHeight: 22,
},
privacy: {
textAlign: 'center',
marginBottom: SPACING[6],
lineHeight: 20,
},
primaryButton: {
width: '100%',
height: LAYOUT.BUTTON_HEIGHT,
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS.GLASS_BUTTON,
justifyContent: 'center',
alignItems: 'center',
marginBottom: SPACING[3],
},
secondaryButton: {
padding: SPACING[3],
},
disabled: {
opacity: 0.6,
},
})

View File

@@ -1,8 +1,9 @@
/**
* TabataFit Collections & Programs
* TabataFit Collections
* Legacy collections (keeping for reference during migration)
*/
import { Collection, Program } from '../types'
import type { Collection } from '../types'
export const COLLECTIONS: Collection[] = [
{
@@ -50,34 +51,4 @@ export const COLLECTIONS: Collection[] = [
},
]
export const PROGRAMS: Program[] = [
{
id: 'beginner-journey',
title: 'Beginner Journey',
description: 'Your first steps into Tabata fitness',
weeks: 2,
workoutsPerWeek: 3,
level: 'Beginner',
workoutIds: ['1', '13', '31', '43', '6', '16'],
},
{
id: 'strength-builder',
title: 'Strength Builder',
description: 'Progressive strength development',
weeks: 4,
workoutsPerWeek: 4,
level: 'Intermediate',
workoutIds: ['2', '21', '32', '9', '14', '25', '35', '29', '7', '23', '39', '44', '11', '42', '8', '27'],
},
{
id: 'fat-burn-protocol',
title: 'Fat Burn Protocol',
description: 'Maximum calorie burn program',
weeks: 6,
workoutsPerWeek: 5,
level: 'Advanced',
workoutIds: ['3', '41', '12', '34', '46', '5', '18', '27', '36', '50', '8', '15', '24', '38', '44', '20', '30', '40', '46', '50', '3', '41', '5', '8', '12', '15', '18', '24', '27', '34'],
},
]
export const FEATURED_COLLECTION_ID = '7-day-burn'

View File

@@ -1,6 +1,6 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from './index'
import type { Workout, Trainer, Collection, Program } from '../types'
import type { Workout, Trainer, Collection, Program, ProgramId } from '../types'
import type { Database } from '../supabase/database.types'
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
@@ -59,16 +59,22 @@ function mapCollectionFromDB(
function mapProgramFromDB(
row: ProgramRow,
workoutIds: string[]
_workoutIds: string[]
): Program {
const localProgram = PROGRAMS[row.id as ProgramId]
if (localProgram) {
return localProgram
}
return {
id: row.id,
id: row.id as Program['id'],
title: row.title,
description: row.description,
weeks: row.weeks,
workoutsPerWeek: row.workouts_per_week,
level: row.level,
workoutIds,
durationWeeks: 4,
workoutsPerWeek: 5,
totalWorkouts: 20,
equipment: { required: [], optional: [] },
focusAreas: [],
weeks: [],
}
}
@@ -270,7 +276,7 @@ class SupabaseDataService {
async getAllPrograms(): Promise<Program[]> {
if (!isSupabaseConfigured()) {
return PROGRAMS
return Object.values(PROGRAMS)
}
const { data: programsData, error: programsError } = await supabase
@@ -279,7 +285,7 @@ class SupabaseDataService {
if (programsError) {
console.error('Error fetching programs:', programsError)
return PROGRAMS
return Object.values(PROGRAMS)
}
const { data: workoutLinks, error: linksError } = await supabase
@@ -290,7 +296,7 @@ class SupabaseDataService {
if (linksError) {
console.error('Error fetching program workouts:', linksError)
return PROGRAMS
return Object.values(PROGRAMS)
}
const workoutIdsByProgram: Record<string, string[]> = {}
@@ -303,7 +309,7 @@ class SupabaseDataService {
return programsData?.map((row: ProgramRow) =>
mapProgramFromDB(row, workoutIdsByProgram[row.id] || [])
) ?? PROGRAMS
) ?? Object.values(PROGRAMS)
}
async getAchievements() {

View File

@@ -1,48 +1,53 @@
/**
* TabataFit Data Layer
* Single source of truth + helper lookups
* New 3-Program System + Legacy Support
*/
import { WORKOUTS } from './workouts'
import { PROGRAMS, ALL_PROGRAM_WORKOUTS, ASSESSMENT_WORKOUT } from './programs'
import { TRAINERS } from './trainers'
import { COLLECTIONS, PROGRAMS, FEATURED_COLLECTION_ID } from './collections'
import { ACHIEVEMENTS } from './achievements'
import type { WorkoutCategory, WorkoutLevel, WorkoutDuration } from '../types'
import type { ProgramId } from '../types'
// Re-export raw data
export { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS, FEATURED_COLLECTION_ID }
// Re-export new program system
export {
PROGRAMS,
ALL_PROGRAM_WORKOUTS,
ASSESSMENT_WORKOUT,
TRAINERS,
ACHIEVEMENTS,
}
// ═══════════════════════════════════════════════════════════════════════════
// WORKOUT LOOKUPS
// PROGRAM LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getProgramById(id: ProgramId) {
return PROGRAMS[id]
}
export function getAllPrograms() {
return Object.values(PROGRAMS)
}
export function getProgramWorkouts(programId: ProgramId) {
const program = PROGRAMS[programId]
if (!program) return []
return program.weeks.flatMap((week) => week.workouts)
}
export function getWorkoutById(id: string) {
return WORKOUTS.find(w => w.id === id)
return ALL_PROGRAM_WORKOUTS.find((w) => w.id === id)
}
export function getWorkoutsByCategory(category: WorkoutCategory) {
return WORKOUTS.filter(w => w.category === category)
}
export function getWorkoutsByTrainer(trainerId: string) {
return WORKOUTS.filter(w => w.trainerId === trainerId)
}
export function getWorkoutsByLevel(level: WorkoutLevel) {
return WORKOUTS.filter(w => w.level === level)
}
export function getWorkoutsByDuration(duration: WorkoutDuration) {
return WORKOUTS.filter(w => w.duration === duration)
}
export function getFeaturedWorkouts() {
return WORKOUTS.filter(w => w.isFeatured)
}
export function getPopularWorkouts(count = 8) {
// Simulate popularity — pick a diverse spread
return WORKOUTS.filter(w => ['1', '11', '21', '31', '41', '42', '2', '32'].includes(w.id)).slice(0, count)
export function getWorkoutProgramId(workoutId: string): ProgramId | null {
for (const [programId, program] of Object.entries(PROGRAMS)) {
for (const week of program.weeks) {
if (week.workouts.some((w) => w.id === workoutId)) {
return programId as ProgramId
}
}
}
return null
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -50,68 +55,24 @@ export function getPopularWorkouts(count = 8) {
// ═══════════════════════════════════════════════════════════════════════════
export function getTrainerById(id: string) {
return TRAINERS.find(t => t.id === id)
return TRAINERS.find((t) => t.id === id)
}
export function getTrainerByName(name: string) {
return TRAINERS.find(t => t.name.toLowerCase() === name.toLowerCase())
}
// ═══════════════════════════════════════════════════════════════════════════
// COLLECTION LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getCollectionById(id: string) {
return COLLECTIONS.find(c => c.id === id)
}
export function getCollectionWorkouts(collectionId: string) {
const collection = getCollectionById(collectionId)
if (!collection) return []
return collection.workoutIds.map(id => getWorkoutById(id)).filter(Boolean)
}
export function getFeaturedCollection() {
return getCollectionById(FEATURED_COLLECTION_ID)
}
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM LOOKUPS
// ═══════════════════════════════════════════════════════════════════════════
export function getProgramById(id: string) {
return PROGRAMS.find(p => p.id === id)
return TRAINERS.find((t) => t.name.toLowerCase() === name.toLowerCase())
}
// ═══════════════════════════════════════════════════════════════════════════
// CATEGORY METADATA
// ═══════════════════════════════════════════════════════════════════════════
export const CATEGORIES: { id: WorkoutCategory | 'all'; label: string }[] = [
export const CATEGORIES: { id: ProgramId | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'full-body', label: 'Full Body' },
{ id: 'core', label: 'Core' },
{ id: 'upper-body', label: 'Upper Body' },
{ id: 'lower-body', label: 'Lower Body' },
{ id: 'cardio', label: 'Cardio' },
{ id: 'full-body', label: 'Full Body' },
]
// SF Symbol icon map for collections
export const COLLECTION_ICONS: Record<string, string> = {
'morning-energizer': 'sunrise.fill',
'no-equipment': 'figure.strengthtraining.traditional',
'7-day-burn': 'flame.fill',
'quick-intense': 'bolt.fill',
'core-focus': 'target',
'leg-day': 'figure.walk',
}
// Collection color map
export const COLLECTION_COLORS: Record<string, string> = {
'morning-energizer': '#FFD60A',
'no-equipment': '#30D158',
'7-day-burn': '#FF3B30',
'quick-intense': '#FF3B30',
'core-focus': '#5AC8FA',
'leg-day': '#BF5AF2',
}
// Legacy exports for backward compatibility (to be removed)
export { WORKOUTS } from './workouts'
export { COLLECTIONS, FEATURED_COLLECTION_ID } from './collections'

1708
src/shared/data/programs.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -150,6 +150,9 @@ export function usePurchases(): UsePurchasesReturn {
setCustomerInfo(newInfo)
syncSubscriptionToStore(newInfo)
console.log('[Purchases] Active entitlements:', Object.keys(newInfo.entitlements.active))
console.log('[Purchases] All entitlements:', Object.keys(newInfo.entitlements.all))
console.log('[Purchases] Looking for entitlement:', ENTITLEMENT_ID)
const success = hasPremiumEntitlement(newInfo)
console.log('[Purchases] Purchase result:', { success })
@@ -186,6 +189,38 @@ export function usePurchases(): UsePurchasesReturn {
? 'premium-yearly'
: 'premium-monthly'
setSubscription(plan)
// DEV: Create mock customerInfo so isPremium returns true
const mockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
willRenew: true,
periodType: 'NORMAL',
latestPurchaseDate: new Date().toISOString(),
originalPurchaseDate: new Date().toISOString(),
expirationDate: null,
store: 'APP_STORE',
productIdentifier: plan === 'premium-yearly' ? 'tabatafit.premium.yearly' : 'tabatafit.premium.monthly',
isSandbox: true,
}
}
},
activeSubscriptions: [plan === 'premium-yearly' ? 'tabatafit.premium.yearly' : 'tabatafit.premium.monthly'],
allPurchasedProductIdentifiers: [plan === 'premium-yearly' ? 'tabatafit.premium.yearly' : 'tabatafit.premium.monthly'],
firstSeen: new Date().toISOString(),
originalAppUserId: 'dev-user',
originalApplicationVersion: '1.0',
requestDate: new Date().toISOString(),
managementURL: null,
nonSubscriptionTransactions: [],
} as unknown as CustomerInfo
setCustomerInfo(mockCustomerInfo)
syncSubscriptionToStore(mockCustomerInfo)
console.log('[Purchases] DEV: Simulated purchase →', plan)
resolve({ success: true, cancelled: false })
},
@@ -232,7 +267,40 @@ export function usePurchases(): UsePurchasesReturn {
{
text: 'Simulate Restore',
onPress: () => {
setSubscription('premium-yearly')
const plan: SubscriptionPlan = 'premium-yearly'
setSubscription(plan)
// DEV: Create mock customerInfo so isPremium returns true
const mockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
willRenew: true,
periodType: 'NORMAL',
latestPurchaseDate: new Date().toISOString(),
originalPurchaseDate: new Date().toISOString(),
expirationDate: null,
store: 'APP_STORE',
productIdentifier: 'tabatafit.premium.yearly',
isSandbox: true,
}
}
},
activeSubscriptions: ['tabatafit.premium.yearly'],
allPurchasedProductIdentifiers: ['tabatafit.premium.yearly'],
firstSeen: new Date().toISOString(),
originalAppUserId: 'dev-user',
originalApplicationVersion: '1.0',
requestDate: new Date().toISOString(),
managementURL: null,
nonSubscriptionTransactions: [],
} as unknown as CustomerInfo
setCustomerInfo(mockCustomerInfo)
syncSubscriptionToStore(mockCustomerInfo)
console.log('[Purchases] DEV: Simulated restore → premium-yearly')
resolve(true)
},

View File

@@ -8,6 +8,7 @@
"share": "Teilen",
"cancel": "Abbrechen",
"save": "Speichern",
"loading": "Laden...",
"levels": {
"beginner": "Anfänger",

View File

@@ -271,6 +271,18 @@
"fat-burn-protocol": {
"title": "Fettverbrennungs-Protokoll",
"description": "Maximales Kalorienverbrennungsprogramm"
},
"upper-body": {
"title": "Oberk\u00f6rper",
"description": "Schultern, Brust, R\u00fccken und Arme mit physiotherapeutischen Progressionen"
},
"lower-body": {
"title": "Unterk\u00f6rper",
"description": "Kr\u00e4ftige Beine, Ges\u00e4\u00df und H\u00fcften mit gelenkschonenden Bewegungen"
},
"full-body": {
"title": "Ganzk\u00f6rper",
"description": "Komplette K\u00f6rpertransformation nur mit deinem eigenen K\u00f6rpergewicht"
}
},
"achievements": {

View File

@@ -8,6 +8,7 @@
"share": "Share",
"cancel": "Cancel",
"save": "Save",
"loading": "Loading...",
"levels": {
"beginner": "Beginner",

View File

@@ -271,6 +271,18 @@
"fat-burn-protocol": {
"title": "Fat Burn Protocol",
"description": "Maximum calorie burn program"
},
"upper-body": {
"title": "Upper Body",
"description": "Build strong shoulders, chest, back, and arms with physio-designed progressions"
},
"lower-body": {
"title": "Lower Body",
"description": "Develop powerful legs, glutes, and hips with joint-friendly movements"
},
"full-body": {
"title": "Full Body",
"description": "Complete total body transformation using only your bodyweight"
}
},
"achievements": {

View File

@@ -8,6 +8,7 @@
"share": "Compartir",
"cancel": "Cancelar",
"save": "Guardar",
"loading": "Cargando...",
"levels": {
"beginner": "Principiante",

View File

@@ -271,6 +271,18 @@
"fat-burn-protocol": {
"title": "Protocolo quema grasa",
"description": "Programa de m\u00e1xima quema de calor\u00edas"
},
"upper-body": {
"title": "Tren Superior",
"description": "Hombros, pecho, espalda y brazos con progresiones dise\u00f1adas por fisioterapeutas"
},
"lower-body": {
"title": "Tren Inferior",
"description": "Piernas, gl\u00fateos y caderas potentes con movimientos suaves para las articulaciones"
},
"full-body": {
"title": "Cuerpo Completo",
"description": "Transformaci\u00f3n total del cuerpo usando solo tu peso corporal"
}
},
"achievements": {

View File

@@ -8,6 +8,7 @@
"share": "Partager",
"cancel": "Annuler",
"save": "Enregistrer",
"loading": "Chargement...",
"levels": {
"beginner": "D\u00e9butant",

View File

@@ -271,6 +271,18 @@
"fat-burn-protocol": {
"title": "Protocole Br\u00fble-Graisses",
"description": "Programme de br\u00fblure maximale de calories"
},
"upper-body": {
"title": "Haut du Corps",
"description": "\u00c9paules, poitrine, dos et bras avec des progressions con\u00e7ues par des kin\u00e9s"
},
"lower-body": {
"title": "Bas du Corps",
"description": "Jambes, fessiers et hanches puissants avec des mouvements doux pour les articulations"
},
"full-body": {
"title": "Corps Complet",
"description": "Transformation compl\u00e8te du corps avec seulement votre poids de corps"
}
},
"achievements": {

View File

@@ -9,7 +9,7 @@
* - Session replay enabled for onboarding funnel analysis
*/
import PostHog, { PostHogConfig } from 'posthog-react-native'
import PostHog, { PostHogOptions } from 'posthog-react-native'
type EventProperties = Record<string, string | number | boolean | string[]>
@@ -39,24 +39,13 @@ export async function initializeAnalytics(): Promise<PostHog | null> {
}
try {
const config: PostHogConfig = {
const config: PostHogOptions = {
host: POSTHOG_HOST,
// Session Replay - enabled for onboarding funnel analysis
enableSessionReplay: true,
sessionReplayConfig: {
// Mask sensitive inputs (passwords, etc.)
maskAllTextInputs: true,
// Capture screenshots for better replay quality
screenshotMode: 'lazy', // Only capture when needed
// Network capture for API debugging in replays
captureNetworkTelemetry: true,
},
// Autocapture configuration
autocapture: {
captureScreens: true,
captureTouches: true,
},
// Flush events more frequently during onboarding
flushAt: 10,
}
@@ -116,7 +105,7 @@ export function setUserProperties(properties: EventProperties): void {
if (__DEV__) {
console.log('[Analytics] set user properties', properties)
}
posthogClient?.personProperties(properties)
posthogClient?.setPersonProperties(properties)
}
/**
@@ -141,8 +130,9 @@ export function stopSessionRecording(): void {
}
/**
* Get the current session URL for debugging
* Get the current session replay status
* Note: Session replay URL is available via PostHog dashboard
*/
export function getSessionReplayUrl(): string | null {
return posthogClient?.getSessionReplayUrl() ?? null
export async function isSessionReplayActive(): Promise<boolean> {
return posthogClient?.isSessionReplayActive() ?? false
}

View File

@@ -15,7 +15,7 @@ import Purchases, { LOG_LEVEL } from 'react-native-purchases'
export const REVENUECAT_API_KEY = 'test_oIJbIHWISJaUZdgxRMHlwizBHvM'
// Entitlement ID configured in RevenueCat dashboard
export const ENTITLEMENT_ID = 'premium'
export const ENTITLEMENT_ID = '1000 Corp Pro'
// Track initialization state
let isInitialized = false

218
src/shared/services/sync.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Sync Service for Anonymous Auth & Data Sync
* Handles opt-in personalization for premium users
*/
import { supabase } from '@/src/shared/supabase'
import type {
UserProfileData,
WorkoutSessionData,
SyncState,
EnableSyncResult,
SyncWorkoutResult,
DeleteDataResult,
} from '@/src/shared/types'
/**
* Enable sync by creating anonymous user and syncing all data
* Called when user accepts sync after first workout
*/
export async function enableSync(
profileData: UserProfileData,
workoutHistory: WorkoutSessionData[]
): Promise<EnableSyncResult> {
try {
// 1. Create anonymous user
const { data: authData, error: authError } =
await supabase.auth.signInAnonymously()
if (authError) throw authError
if (!authData.user) throw new Error('Failed to create anonymous user')
const userId = authData.user.id
// 2. Create user profile in Supabase
const { error: profileError } = await (supabase as any)
.from('user_profiles')
.insert({
id: userId,
name: profileData.name,
fitness_level: profileData.fitnessLevel,
goal: profileData.goal,
weekly_frequency: profileData.weeklyFrequency,
barriers: profileData.barriers,
is_anonymous: true,
sync_enabled: true,
subscription_plan: 'premium-yearly',
onboarding_completed_at: profileData.onboardingCompletedAt,
})
if (profileError) throw profileError
// 3. Sync all workout history retroactively
if (workoutHistory.length > 0) {
const { error: historyError } = await (supabase as any)
.from('workout_sessions')
.insert(
workoutHistory.map((session) => ({
user_id: userId,
workout_id: session.workoutId,
completed_at: session.completedAt,
duration_seconds: session.durationSeconds,
calories_burned: session.caloriesBurned,
feeling_rating: session.feelingRating,
}))
)
if (historyError) throw historyError
}
// 4. Initialize default preferences
const { error: prefError } = await (supabase as any)
.from('user_preferences')
.insert({
user_id: userId,
preferred_categories: [],
preferred_trainers: [],
preferred_durations: [],
difficulty_preference: 'matched',
})
if (prefError) throw prefError
return { success: true, userId }
} catch (error) {
console.error('Failed to enable sync:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Sync a single workout session (called after each workout completion)
*/
export async function syncWorkoutSession(
session: WorkoutSessionData
): Promise<SyncWorkoutResult> {
try {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { success: false, error: 'No authenticated user' }
const { error } = await supabase.from('workout_sessions').insert({
user_id: user.id,
workout_id: session.workoutId,
completed_at: session.completedAt,
duration_seconds: session.durationSeconds,
calories_burned: session.caloriesBurned,
feeling_rating: session.feelingRating,
})
if (error) throw error
return { success: true }
} catch (error) {
console.error('Failed to sync workout:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Delete all synced data from Supabase (keep local)
* User becomes "unsynced" - can opt-in again later
*/
export async function deleteSyncedData(): Promise<DeleteDataResult> {
try {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { success: false, error: 'No authenticated user' }
// Note: Deleting the user will cascade delete all related data
// due to ON DELETE CASCADE in the schema
// However, supabase.auth.admin.deleteUser requires admin privileges
// Instead, we'll delete from our tables manually
// Delete user's preferences
const { error: prefError } = await supabase
.from('user_preferences')
.delete()
.eq('user_id', user.id)
if (prefError) throw prefError
// Delete user's workout sessions
const { error: sessionsError } = await supabase
.from('workout_sessions')
.delete()
.eq('user_id', user.id)
if (sessionsError) throw sessionsError
// Delete user's profile
const { error: profileError } = await supabase
.from('user_profiles')
.delete()
.eq('id', user.id)
if (profileError) throw profileError
// Sign out locally
await supabase.auth.signOut()
return { success: true }
} catch (error) {
console.error('Failed to delete synced data:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Get current sync state
*/
export async function getSyncState(): Promise<SyncState> {
const { data: session } = await supabase.auth.getSession()
if (!session.session?.user) {
return {
status: 'never-synced',
userId: null,
lastSyncAt: null,
pendingWorkouts: 0,
}
}
const userId = session.session.user.id
// Get latest workout session to determine last sync
const { data: latestSession } = await supabase
.from('workout_sessions')
.select('created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(1)
return {
status: 'synced',
userId: userId,
lastSyncAt: latestSession?.[0]?.created_at || null,
pendingWorkouts: 0,
}
}
/**
* Check if user has synced data (for determining if we can show personalized features)
*/
export async function hasSyncedData(): Promise<boolean> {
const { data: session } = await supabase.auth.getSession()
return !!session.session?.user
}
/**
* Check if user is currently authenticated with Supabase
*/
export async function isAuthenticated(): Promise<boolean> {
const { data: session } = await supabase.auth.getSession()
return !!session.session?.user
}

View File

@@ -1,19 +1,23 @@
/**
* TabataFit Activity Store
* Workout history, streak, stats — persisted via AsyncStorage
* Added: Sync integration for personalized programs
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { WorkoutResult, DayActivity } from '../types'
import { useUserStore } from './userStore'
import { syncWorkoutSession } from '@/src/shared/services/sync'
interface ActivityState {
history: WorkoutResult[]
streak: { current: number; longest: number }
// Actions
addWorkoutResult: (result: WorkoutResult) => void
addWorkoutResult: (result: WorkoutResult) => Promise<void>
getWorkoutHistory: () => WorkoutResult[]
}
function getDateString(timestamp: number) {
@@ -23,7 +27,7 @@ function getDateString(timestamp: number) {
function calculateStreak(history: WorkoutResult[]): { current: number; longest: number } {
if (history.length === 0) return { current: 0, longest: 0 }
const uniqueDays = new Set(history.map(r => getDateString(r.completedAt)))
const uniqueDays = new Set(history.map((r) => getDateString(r.completedAt)))
const sortedDays = Array.from(uniqueDays).sort().reverse()
const today = getDateString(Date.now())
@@ -63,19 +67,44 @@ function calculateLongest(sortedDays: string[]): number {
export const useActivityStore = create<ActivityState>()(
persist(
(set) => ({
(set, get) => ({
history: [],
streak: { current: 0, longest: 0 },
addWorkoutResult: (result) =>
set((state) => {
const newHistory = [result, ...state.history]
const newStreak = calculateStreak(newHistory)
return {
history: newHistory,
streak: newStreak,
}
}),
addWorkoutResult: async (result) => {
const newHistory = [result, ...get().history]
const newStreak = calculateStreak(newHistory)
set({
history: newHistory,
streak: newStreak,
})
// Check if we should show sync prompt (first workout for premium user)
const userStore = useUserStore.getState()
const isPremium = userStore.profile.subscription !== 'free'
const shouldPrompt =
userStore.profile.syncStatus === 'never-synced' && isPremium
if (shouldPrompt) {
// Mark that we should show prompt after this workout
userStore.setPromptPending()
}
// If already synced, sync this workout to Supabase
if (userStore.profile.syncStatus === 'synced') {
await syncWorkoutSession({
workoutId: result.workoutId,
completedAt: new Date(result.completedAt).toISOString(),
durationSeconds: result.durationMinutes * 60,
caloriesBurned: result.calories,
feelingRating: undefined, // Can be added later if needed
})
}
},
getWorkoutHistory: () => {
return get().history
},
}),
{
name: 'tabatafit-activity',
@@ -95,9 +124,7 @@ export function getWeeklyActivity(history: WorkoutResult[]): DayActivity[] {
const date = new Date(startOfWeek)
date.setDate(startOfWeek.getDate() + i)
const dateStr = getDateString(date.getTime())
const workoutsOnDay = history.filter(
r => getDateString(r.completedAt) === dateStr
)
const workoutsOnDay = history.filter((r) => getDateString(r.completedAt) === dateStr)
days.push({
date: String(i),
completed: workoutsOnDay.length > 0,

View File

@@ -5,3 +5,4 @@
export { useUserStore } from './userStore'
export { useActivityStore, getWeeklyActivity } from './activityStore'
export { usePlayerStore } from './playerStore'
export { useProgramStore } from './programStore'

View File

@@ -0,0 +1,358 @@
/**
* TabataFit Program Store
* Handles program selection, progression tracking, and sequential unlocking
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type {
ProgramId,
ProgramWorkout,
ProgramProgress,
AssessmentResult,
} from '../types/program'
import { PROGRAMS } from '../data/programs'
interface ProgramState {
// Program selection
selectedProgramId: ProgramId | null
// Progress tracking for each program
programsProgress: Record<ProgramId, ProgramProgress>
// Assessment
assessment: {
isCompleted: boolean
result: AssessmentResult | null
}
// Actions
selectProgram: (programId: ProgramId) => void
completeWorkout: (programId: ProgramId, workoutId: string) => void
completeAssessment: (result: AssessmentResult) => void
skipAssessment: () => void
resetProgram: (programId: ProgramId) => void
changeProgram: (programId: ProgramId) => void
// Getters
getCurrentWorkout: (programId: ProgramId) => ProgramWorkout | null
getNextWorkout: (programId: ProgramId) => ProgramWorkout | null
isWeekUnlocked: (programId: ProgramId, weekNumber: number) => boolean
isWorkoutUnlocked: (programId: ProgramId, workoutId: string) => boolean
getProgramCompletion: (programId: ProgramId) => number
getTotalWorkoutsCompleted: () => number
getProgramStatus: (programId: ProgramId) => 'not-started' | 'in-progress' | 'completed'
getRecommendedNextWorkout: () => { programId: ProgramId; workout: ProgramWorkout } | null
}
const createInitialProgress = (programId: ProgramId): ProgramProgress => ({
programId,
currentWeek: 1,
currentWorkoutIndex: 0,
completedWorkoutIds: [],
isProgramCompleted: false,
startDate: undefined,
lastWorkoutDate: undefined,
})
export const useProgramStore = create<ProgramState>()(
persist(
(set, get) => ({
// Initial state
selectedProgramId: null,
programsProgress: {
'upper-body': createInitialProgress('upper-body'),
'lower-body': createInitialProgress('lower-body'),
'full-body': createInitialProgress('full-body'),
},
assessment: {
isCompleted: false,
result: null,
},
// Select a program to start
selectProgram: (programId) => {
const state = get()
const progress = state.programsProgress[programId]
// If starting for the first time, set start date
if (progress.completedWorkoutIds.length === 0 && !progress.startDate) {
set((state) => ({
selectedProgramId: programId,
programsProgress: {
...state.programsProgress,
[programId]: {
...progress,
startDate: new Date().toISOString(),
},
},
}))
} else {
set({ selectedProgramId: programId })
}
},
// Complete a workout and advance progress
completeWorkout: (programId, workoutId) => {
const state = get()
const progress = state.programsProgress[programId]
const program = PROGRAMS[programId]
if (!program) return
// Add to completed list
const newCompletedIds = [...progress.completedWorkoutIds, workoutId]
// Find current workout position
let newWeek = progress.currentWeek
let newWorkoutIndex = progress.currentWorkoutIndex
// Check if we completed the last workout of the week
const currentWeekWorkouts = program.weeks[progress.currentWeek - 1]?.workouts || []
const isLastWorkoutOfWeek = progress.currentWorkoutIndex >= currentWeekWorkouts.length - 1
if (isLastWorkoutOfWeek && progress.currentWeek < 4) {
// Move to next week
newWeek = (progress.currentWeek + 1) as 1 | 2 | 3 | 4
newWorkoutIndex = 0
} else if (!isLastWorkoutOfWeek) {
// Move to next workout in same week
newWorkoutIndex = progress.currentWorkoutIndex + 1
}
// Check if program is completed (all 20 workouts)
const isProgramCompleted = newCompletedIds.length >= 20
set((state) => ({
programsProgress: {
...state.programsProgress,
[programId]: {
...progress,
completedWorkoutIds: newCompletedIds,
currentWeek: newWeek,
currentWorkoutIndex: newWorkoutIndex,
isProgramCompleted,
lastWorkoutDate: new Date().toISOString(),
},
},
}))
},
// Complete assessment
completeAssessment: (result) => {
set({
assessment: {
isCompleted: true,
result,
},
})
},
// Skip assessment
skipAssessment: () => {
set({
assessment: {
isCompleted: true,
result: null,
},
})
},
// Reset a program to start over
resetProgram: (programId) => {
set((state) => ({
programsProgress: {
...state.programsProgress,
[programId]: createInitialProgress(programId),
},
selectedProgramId: state.selectedProgramId === programId ? null : state.selectedProgramId,
}))
},
// Change to a different program (allow switching)
changeProgram: (programId) => {
set({ selectedProgramId: programId })
},
// Get the current workout for a program
getCurrentWorkout: (programId) => {
const state = get()
const progress = state.programsProgress[programId]
const program = PROGRAMS[programId]
if (!program || !progress) return null
const week = program.weeks[progress.currentWeek - 1]
if (!week) return null
return week.workouts[progress.currentWorkoutIndex] || null
},
// Get the next workout (for displaying what's coming up)
getNextWorkout: (programId) => {
const state = get()
const progress = state.programsProgress[programId]
const program = PROGRAMS[programId]
if (!program || !progress) return null
const currentWeek = program.weeks[progress.currentWeek - 1]
if (!currentWeek) return null
// Check if there's another workout in the current week
if (progress.currentWorkoutIndex < currentWeek.workouts.length - 1) {
return currentWeek.workouts[progress.currentWorkoutIndex + 1]
}
// Check if there's a next week
if (progress.currentWeek < 4) {
const nextWeek = program.weeks[progress.currentWeek]
return nextWeek?.workouts[0] || null
}
return null
},
// Check if a week is unlocked
isWeekUnlocked: (programId, weekNumber) => {
const state = get()
const progress = state.programsProgress[programId]
// Week 1 is always unlocked
if (weekNumber === 1) return true
// Check if all workouts from previous week are completed
const program = PROGRAMS[programId]
if (!program) return false
const previousWeek = program.weeks[weekNumber - 2]
if (!previousWeek) return false
const previousWeekWorkoutIds = previousWeek.workouts.map((w) => w.id)
return previousWeekWorkoutIds.every((id) =>
progress.completedWorkoutIds.includes(id)
)
},
// Check if a specific workout is unlocked
isWorkoutUnlocked: (programId, workoutId) => {
const state = get()
const progress = state.programsProgress[programId]
const program = PROGRAMS[programId]
if (!program || !progress) return false
// Find the workout in the program structure
for (const week of program.weeks) {
const workoutIndex = week.workouts.findIndex((w) => w.id === workoutId)
if (workoutIndex === -1) continue
// If it's in week 1, it's unlocked
if (week.weekNumber === 1) {
// Check if it's before or at current position
if (week.weekNumber === progress.currentWeek) {
return workoutIndex <= progress.currentWorkoutIndex
}
return true
}
// Check if the week is unlocked
if (!get().isWeekUnlocked(programId, week.weekNumber)) {
return false
}
// Check if it's before or at current position
if (week.weekNumber === progress.currentWeek) {
return workoutIndex <= progress.currentWorkoutIndex
}
// If it's in a previous week, it's unlocked
if (week.weekNumber < progress.currentWeek) {
return true
}
}
return false
},
// Get completion percentage for a program
getProgramCompletion: (programId) => {
const state = get()
const progress = state.programsProgress[programId]
if (!progress) return 0
return Math.round((progress.completedWorkoutIds.length / 20) * 100)
},
// Get total workouts completed across all programs
getTotalWorkoutsCompleted: () => {
const state = get()
return Object.values(state.programsProgress).reduce(
(total, progress) => total + progress.completedWorkoutIds.length,
0
)
},
// Get program status
getProgramStatus: (programId) => {
const state = get()
const progress = state.programsProgress[programId]
if (!progress || progress.completedWorkoutIds.length === 0) {
return 'not-started'
}
if (progress.isProgramCompleted) {
return 'completed'
}
return 'in-progress'
},
// Get recommended next workout (for home screen)
getRecommendedNextWorkout: () => {
const state = get()
// If a program is selected, recommend the next workout in that program
if (state.selectedProgramId) {
const nextWorkout = get().getCurrentWorkout(state.selectedProgramId)
if (nextWorkout) {
return {
programId: state.selectedProgramId,
workout: nextWorkout,
}
}
}
// Find any in-progress program
for (const programId of Object.keys(PROGRAMS) as ProgramId[]) {
const status = get().getProgramStatus(programId)
if (status === 'in-progress') {
const nextWorkout = get().getCurrentWorkout(programId)
if (nextWorkout) {
return { programId, workout: nextWorkout }
}
}
}
return null
},
}),
{
name: 'tabatafit-program-storage',
storage: {
getItem: async (name) => {
const value = await AsyncStorage.getItem(name)
return value ? JSON.parse(value) : null
},
setItem: async (name, value) => {
await AsyncStorage.setItem(name, JSON.stringify(value))
},
removeItem: async (name) => {
await AsyncStorage.removeItem(name)
},
},
}
)
)

View File

@@ -1,13 +1,22 @@
/**
* TabataFit User Store
* Profile, settings, subscription — persisted via AsyncStorage
* Added: Sync status for anonymous auth opt-in
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { getLocales } from 'expo-localization'
import type { UserProfile, UserSettings, SubscriptionPlan, FitnessLevel, FitnessGoal, WeeklyFrequency } from '../types'
import type {
UserProfile,
UserSettings,
SubscriptionPlan,
FitnessLevel,
FitnessGoal,
WeeklyFrequency,
SyncStatus,
} from '../types'
interface OnboardingData {
name: string
@@ -25,6 +34,9 @@ interface UserState {
updateSettings: (updates: Partial<UserSettings>) => void
setSubscription: (plan: SubscriptionPlan) => void
completeOnboarding: (data: OnboardingData) => void
// NEW: Sync-related actions
setSyncStatus: (status: SyncStatus, userId?: string | null) => void
setPromptPending: () => void
}
export const useUserStore = create<UserState>()(
@@ -33,13 +45,19 @@ export const useUserStore = create<UserState>()(
profile: {
name: '',
email: '',
joinDate: new Date().toLocaleDateString(getLocales()[0]?.languageTag ?? 'en-US', { month: 'long', year: 'numeric' }),
joinDate: new Date().toLocaleDateString(
getLocales()[0]?.languageTag ?? 'en-US',
{ month: 'long', year: 'numeric' }
),
subscription: 'free',
onboardingCompleted: false,
fitnessLevel: 'beginner',
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
// NEW: Sync fields
syncStatus: 'never-synced',
supabaseUserId: null,
},
settings: {
haptics: true,
@@ -78,6 +96,25 @@ export const useUserStore = create<UserState>()(
onboardingCompleted: true,
},
})),
// NEW: Sync status management
setSyncStatus: (status, userId = null) =>
set((state) => ({
profile: {
...state.profile,
syncStatus: status,
supabaseUserId: userId,
},
})),
// NEW: Mark that we should show sync prompt after first workout
setPromptPending: () =>
set((state) => ({
profile: {
...state.profile,
syncStatus: 'prompt-pending',
},
})),
}),
{
name: 'tabatafit-user',

View File

@@ -87,6 +87,105 @@ export interface Database {
updated_at?: string
}
}
user_profiles: {
Row: {
id: string
name: string
fitness_level: string
goal: string
weekly_frequency: number
barriers: string[]
is_anonymous: boolean
sync_enabled: boolean
subscription_plan: string
onboarding_completed_at: string
created_at: string
updated_at: string
}
Insert: {
id: string
name: string
fitness_level: string
goal: string
weekly_frequency: number
barriers?: string[]
is_anonymous?: boolean
sync_enabled?: boolean
subscription_plan?: string
onboarding_completed_at: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
name?: string
fitness_level?: string
goal?: string
weekly_frequency?: number
barriers?: string[]
is_anonymous?: boolean
sync_enabled?: boolean
subscription_plan?: string
onboarding_completed_at?: string
updated_at?: string
}
}
user_preferences: {
Row: {
user_id: string
preferred_categories: string[]
preferred_trainers: string[]
preferred_durations: string[]
difficulty_preference: string
created_at: string
updated_at: string
}
Insert: {
user_id: string
preferred_categories?: string[]
preferred_trainers?: string[]
preferred_durations?: string[]
difficulty_preference?: string
created_at?: string
updated_at?: string
}
Update: {
preferred_categories?: string[]
preferred_trainers?: string[]
preferred_durations?: string[]
difficulty_preference?: string
updated_at?: string
}
}
workout_sessions: {
Row: {
id: string
user_id: string
workout_id: string
completed_at: string
duration_seconds: number
calories_burned: number
feeling_rating: number | null
created_at: string
}
Insert: {
id?: string
user_id: string
workout_id: string
completed_at: string
duration_seconds: number
calories_burned: number
feeling_rating?: number | null
created_at?: string
}
Update: {
workout_id?: string
completed_at?: string
duration_seconds?: number
calories_burned?: number
feeling_rating?: number | null
}
}
trainers: {
Row: {
id: string

View File

@@ -6,3 +6,5 @@ export * from './workout'
export * from './trainer'
export * from './user'
export * from './activity'
export * from './sync'
export * from './program'

View File

@@ -0,0 +1,93 @@
/**
* TabataFit Program Types
* Physiotherapist-designed 3-program progressive system
*/
export type ProgramId = 'upper-body' | 'lower-body' | 'full-body'
export type WeekNumber = 1 | 2 | 3 | 4
export type ProgramWorkout = {
id: string
week: WeekNumber
order: number // 1-5 within the week
title: string
description: string
duration: 4 // minutes - all workouts are 4 min
exercises: ProgramExercise[]
equipment: string[]
focus: string[]
tips: string[]
}
export type ProgramExercise = {
name: string
duration: 20 // seconds - fixed for Tabata
reps?: string // optional rep guidance (e.g., "8-10 reps")
modification?: string // easier alternative
progression?: string // harder alternative
}
export interface Week {
weekNumber: WeekNumber
title: string
description: string
focus: string
workouts: ProgramWorkout[]
}
export interface Program {
id: ProgramId
title: string
description: string
durationWeeks: 4
workoutsPerWeek: 5
totalWorkouts: 20
equipment: {
required: string[]
optional: string[]
}
focusAreas: string[]
weeks: Week[]
}
export interface ProgramProgress {
programId: ProgramId
currentWeek: WeekNumber
currentWorkoutIndex: number // 0-4 within current week
completedWorkoutIds: string[]
isProgramCompleted: boolean
startDate?: string
lastWorkoutDate?: string
}
export interface AssessmentExercise {
name: string
duration: 20
purpose: string // what we're checking
}
export interface Assessment {
id: 'initial-assessment'
title: string
description: string
duration: 4
exercises: AssessmentExercise[]
tips: string[]
}
export interface AssessmentResult {
completedAt: string
exercisesCompleted: string[]
notes?: string
recommendedProgram?: ProgramId
}
export type UserProgramState = {
selectedProgram: ProgramId | null
programsProgress: Record<ProgramId, ProgramProgress>
assessment: {
isCompleted: boolean
result: AssessmentResult | null
}
}

57
src/shared/types/sync.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Sync Types for Supabase Integration
* Handles opt-in personalization for premium users
*/
export type SyncStatus =
| 'never-synced' // Never been asked or opted in
| 'prompt-pending' // Waiting to show prompt (after first workout)
| 'synced' // Currently syncing
| 'unsynced' // Previously synced, now disabled
export interface UserProfileData {
name: string
fitnessLevel: string
goal: string
weeklyFrequency: number
barriers: string[]
onboardingCompletedAt: string
}
export interface WorkoutSessionData {
workoutId: string
completedAt: string
durationSeconds: number
caloriesBurned: number
feelingRating?: number
}
export interface UserPreferencesData {
preferredCategories: string[]
preferredTrainers: string[]
preferredDurations: number[]
difficultyPreference: 'easier' | 'matched' | 'harder'
}
export interface SyncState {
status: SyncStatus
userId: string | null
lastSyncAt: string | null
pendingWorkouts: number // Local workouts waiting to sync
}
export interface EnableSyncResult {
success: boolean
userId?: string
error?: string
}
export interface SyncWorkoutResult {
success: boolean
error?: string
}
export interface DeleteDataResult {
success: boolean
error?: string
}

View File

@@ -2,6 +2,8 @@
* TabataFit User Types
*/
import type { SyncStatus } from './sync'
export type SubscriptionPlan = 'free' | 'premium-monthly' | 'premium-yearly'
export interface UserSettings {
@@ -28,6 +30,8 @@ export interface UserProfile {
goal: FitnessGoal
weeklyFrequency: WeeklyFrequency
barriers: string[]
syncStatus: SyncStatus
supabaseUserId: string | null
}
export interface Achievement {

View File

@@ -51,12 +51,4 @@ export interface Collection {
gradient?: [string, string]
}
export interface Program {
id: string
title: string
description: string
weeks: number
workoutsPerWeek: number
level: WorkoutLevel
workoutIds: string[]
}
// Note: Old Program interface removed - replaced by new Program types in program.ts