diff --git a/__tests__/app/buddy.test.tsx b/__tests__/app/buddy.test.tsx new file mode 100644 index 00000000..da6db4a1 --- /dev/null +++ b/__tests__/app/buddy.test.tsx @@ -0,0 +1,114 @@ +/** + * @fileoverview Tests for the Buddy tab screen + * + * Tests the Sobers Buddy placeholder screen including: + * - Rendering the coming soon message + * - Displaying feature list + * - Personalized greeting with display name + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import BuddyScreen from '@/app/(app)/(tabs)/buddy'; + +// ============================================================================= +// Mocks +// ============================================================================= + +const mockProfile = { + id: 'user-123', + email: 'test@example.com', + display_name: 'Test User', + sobriety_date: '2024-01-01', + ai_buddy_enabled: true, +}; + +jest.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ + profile: mockProfile, + }), +})); + +jest.mock('@/contexts/ThemeContext', () => ({ + useTheme: () => ({ + theme: { + primary: '#007AFF', + primaryLight: '#E5F1FF', + text: '#111827', + textSecondary: '#6b7280', + background: '#ffffff', + surface: '#ffffff', + card: '#ffffff', + border: '#e5e7eb', + fontRegular: 'JetBrainsMono-Regular', + fontMedium: 'JetBrainsMono-Medium', + fontSemiBold: 'JetBrainsMono-SemiBold', + fontBold: 'JetBrainsMono-Bold', + }, + isDark: false, + }), +})); + +jest.mock('@/hooks/useTabBarPadding', () => ({ + useTabBarPadding: () => 0, +})); + +jest.mock('@/components/navigation/SettingsButton', () => { + const React = require('react'); + return { + __esModule: true, + default: () => React.createElement('View', { testID: 'settings-button' }), + }; +}); + +jest.mock('lucide-react-native', () => ({ + Bot: () => null, +})); + +jest.mock('@/lib/format', () => ({ + hexWithAlpha: (hex: string, _alpha: number) => hex, +})); + +// ============================================================================= +// Test Suite +// ============================================================================= + +describe('BuddyScreen', () => { + it('renders the title', () => { + render(); + + expect(screen.getByText('Sobers Buddy')).toBeTruthy(); + }); + + it('renders the subtitle', () => { + render(); + + expect(screen.getByText('Your AI-powered accountability partner')).toBeTruthy(); + }); + + it('renders the coming soon card', () => { + render(); + + expect(screen.getByText('Coming Soon')).toBeTruthy(); + }); + + it('includes personalized greeting with display name', () => { + render(); + + expect(screen.getByText(/Hey Test User!/)).toBeTruthy(); + }); + + it('renders feature list items', () => { + render(); + + expect(screen.getByText('💬 Real-time chat conversations')).toBeTruthy(); + expect(screen.getByText('🎯 Personalized recovery support')).toBeTruthy(); + expect(screen.getByText('🔒 Private and secure')).toBeTruthy(); + expect(screen.getByText('🤝 Non-judgmental, always supportive')).toBeTruthy(); + }); + + it('renders without crashing', () => { + const { toJSON } = render(); + expect(toJSON()).toBeTruthy(); + }); +}); diff --git a/__tests__/app/settings.test.tsx b/__tests__/app/settings.test.tsx index 16b0c4f4..c3c560c8 100644 --- a/__tests__/app/settings.test.tsx +++ b/__tests__/app/settings.test.tsx @@ -121,6 +121,7 @@ jest.mock('lucide-react-native', () => ({ RotateCcw: () => null, Zap: () => null, BookOpen: () => null, + Bot: () => null, })); jest.mock('@react-native-community/datetimepicker', () => { diff --git a/__tests__/app/tabs-layout-native.test.tsx b/__tests__/app/tabs-layout-native.test.tsx index 33a91015..dc653faa 100644 --- a/__tests__/app/tabs-layout-native.test.tsx +++ b/__tests__/app/tabs-layout-native.test.tsx @@ -91,7 +91,9 @@ jest.mock('expo-router/unstable-native-tabs', () => { MockTrigger.Label = MockLabel; MockTrigger.Icon = MockIcon; MockTrigger.Badge = MockBadge; - MockTrigger.VectorIcon = () => null; + const MockVectorIcon = () => null; + MockVectorIcon.displayName = 'MockVectorIcon'; + MockTrigger.VectorIcon = MockVectorIcon; const MockBottomAccessory = ({ children }: { children?: React.ReactNode }) => React.createElement(View, { testID: 'bottom-accessory' }, children); @@ -165,6 +167,7 @@ jest.mock('lucide-react-native', () => ({ TrendingUp: () => null, CheckSquare: () => null, User: () => null, + Bot: () => null, })); // Store original Platform.OS @@ -220,6 +223,7 @@ describe('TabsLayout', () => { renderWithProviders(); expect(screen.getByTestId('trigger-index')).toBeTruthy(); + expect(screen.getByTestId('trigger-buddy')).toBeTruthy(); expect(screen.getByTestId('trigger-program')).toBeTruthy(); expect(screen.getByTestId('trigger-journey')).toBeTruthy(); expect(screen.getByTestId('trigger-tasks')).toBeTruthy(); @@ -231,6 +235,7 @@ describe('TabsLayout', () => { renderWithProviders(); expect(screen.getByTestId('trigger-label-Home')).toBeTruthy(); + expect(screen.getByTestId('trigger-label-Buddy')).toBeTruthy(); expect(screen.getByTestId('trigger-label-Program')).toBeTruthy(); expect(screen.getByTestId('trigger-label-Journey')).toBeTruthy(); expect(screen.getByTestId('trigger-label-Tasks')).toBeTruthy(); @@ -242,11 +247,12 @@ describe('TabsLayout', () => { renderWithProviders(); const icons = screen.getAllByTestId('trigger-icon'); - // 5 visible tabs have icons (manage-tasks is hidden and has no icon) - expect(icons.length).toBe(5); + // 6 visible tabs have icons (manage-tasks is hidden and has no icon) + expect(icons.length).toBe(6); // Verify icon data via captured triggers expect(mockCapturedTriggers['index']).toBeDefined(); + expect(mockCapturedTriggers['buddy']).toBeDefined(); expect(mockCapturedTriggers['program']).toBeDefined(); expect(mockCapturedTriggers['journey']).toBeDefined(); expect(mockCapturedTriggers['tasks']).toBeDefined(); @@ -283,6 +289,7 @@ describe('TabsLayout', () => { renderWithProviders(); expect(screen.getByTestId('expo-tab-screen-index')).toBeTruthy(); + expect(screen.getByTestId('expo-tab-screen-buddy')).toBeTruthy(); expect(screen.getByTestId('expo-tab-screen-program')).toBeTruthy(); expect(screen.getByTestId('expo-tab-screen-journey')).toBeTruthy(); expect(screen.getByTestId('expo-tab-screen-tasks')).toBeTruthy(); @@ -402,12 +409,74 @@ describe('TabsLayout', () => { // All other tabs should not be hidden expect(screen.getByTestId('trigger-index').props['data-hidden']).toBeFalsy(); + expect(screen.getByTestId('trigger-buddy').props['data-hidden']).toBeFalsy(); expect(screen.getByTestId('trigger-journey').props['data-hidden']).toBeFalsy(); expect(screen.getByTestId('trigger-tasks').props['data-hidden']).toBeFalsy(); expect(screen.getByTestId('trigger-profile').props['data-hidden']).toBeFalsy(); }); }); + describe('conditional Buddy tab visibility', () => { + beforeEach(() => { + setPlatform('ios'); + }); + + it('shows Buddy tab when ai_buddy_enabled is true', () => { + (useAuth as jest.Mock).mockReturnValue({ + profile: { ...mockProfile, ai_buddy_enabled: true }, + }); + + renderWithProviders(); + + const trigger = screen.getByTestId('trigger-buddy'); + expect(trigger.props['data-hidden']).toBeFalsy(); + }); + + it('hides Buddy tab on native when ai_buddy_enabled is false', () => { + (useAuth as jest.Mock).mockReturnValue({ + profile: { ...mockProfile, ai_buddy_enabled: false }, + }); + + renderWithProviders(); + + const trigger = screen.getByTestId('trigger-buddy'); + expect(trigger.props['data-hidden']).toBe(true); + }); + + it('shows Buddy tab when ai_buddy_enabled is undefined (default on)', () => { + (useAuth as jest.Mock).mockReturnValue({ + profile: { ...mockProfile, ai_buddy_enabled: undefined }, + }); + + renderWithProviders(); + + const trigger = screen.getByTestId('trigger-buddy'); + expect(trigger.props['data-hidden']).toBeFalsy(); + }); + + it('hides Buddy tab on web when ai_buddy_enabled is false', () => { + setPlatform('web'); + (useAuth as jest.Mock).mockReturnValue({ + profile: { ...mockProfile, ai_buddy_enabled: false }, + }); + + renderWithProviders(); + + expect(screen.queryByTestId('expo-tab-screen-buddy')).toBeNull(); + }); + + it('shows Buddy tab on web when ai_buddy_enabled is true', () => { + setPlatform('web'); + (useAuth as jest.Mock).mockReturnValue({ + profile: { ...mockProfile, ai_buddy_enabled: true }, + }); + + renderWithProviders(); + + expect(screen.getByTestId('expo-tab-screen-buddy')).toBeTruthy(); + }); + }); + describe('rendering', () => { it('renders without errors', () => { const { toJSON } = renderWithProviders(); diff --git a/__tests__/components/SettingsSheet.test.tsx b/__tests__/components/SettingsSheet.test.tsx index 3d01b286..044c04aa 100644 --- a/__tests__/components/SettingsSheet.test.tsx +++ b/__tests__/components/SettingsSheet.test.tsx @@ -209,6 +209,7 @@ jest.mock('lucide-react-native', () => ({ RotateCcw: () => null, Zap: () => null, BookOpen: () => null, + Bot: () => null, })); jest.mock('@react-native-community/datetimepicker', () => { diff --git a/__tests__/components/settings/SettingsContent.analytics.test.tsx b/__tests__/components/settings/SettingsContent.analytics.test.tsx index 9acab166..15c32eec 100644 --- a/__tests__/components/settings/SettingsContent.analytics.test.tsx +++ b/__tests__/components/settings/SettingsContent.analytics.test.tsx @@ -75,6 +75,7 @@ jest.mock('lucide-react-native', () => ({ ChevronUp: () => null, Calendar: () => null, BookOpen: () => null, + Bot: () => null, })); jest.mock('@react-native-community/datetimepicker', () => { diff --git a/__tests__/components/settings/SettingsContent.dev.test.tsx b/__tests__/components/settings/SettingsContent.dev.test.tsx index 30ef798b..66910900 100644 --- a/__tests__/components/settings/SettingsContent.dev.test.tsx +++ b/__tests__/components/settings/SettingsContent.dev.test.tsx @@ -51,6 +51,7 @@ jest.mock('lucide-react-native', () => ({ Bell: () => null, Calendar: () => null, BookOpen: () => null, + Bot: () => null, })); jest.mock('@react-native-community/datetimepicker', () => { diff --git a/__tests__/components/settings/SettingsContent.sections.test.tsx b/__tests__/components/settings/SettingsContent.sections.test.tsx index 244eab72..80e3b531 100644 --- a/__tests__/components/settings/SettingsContent.sections.test.tsx +++ b/__tests__/components/settings/SettingsContent.sections.test.tsx @@ -48,6 +48,7 @@ jest.mock('lucide-react-native', () => ({ Bell: () => null, Calendar: () => null, BookOpen: () => null, + Bot: () => null, })); jest.mock('@react-native-community/datetimepicker', () => { @@ -337,5 +338,25 @@ describe('SettingsContent - Section Structure', () => { expect(screen.getByTestId('settings-show-savings-toggle')).toBeTruthy(); expect(screen.getByText('Show savings card')).toBeTruthy(); }); + + it('renders AI Buddy toggle in Features section', () => { + render(); + + expect(screen.getByTestId('settings-ai-buddy-toggle')).toBeTruthy(); + expect(screen.getByText('Sobers Buddy')).toBeTruthy(); + expect( + screen.getByText(/AI-powered accountability partner for your recovery journey/) + ).toBeTruthy(); + }); + + it('displays AI Buddy toggle as ON when ai_buddy_enabled is true or undefined', () => { + render(); + + // Default profile has ai_buddy_enabled undefined, treated as true + const toggle = screen.getByTestId('settings-ai-buddy-toggle'); + expect(toggle).toBeTruthy(); + + expect(within(toggle).getByText('ON')).toBeTruthy(); + }); }); }); diff --git a/__tests__/types/analytics.test.ts b/__tests__/types/analytics.test.ts index fdcf166d..111f4ab2 100644 --- a/__tests__/types/analytics.test.ts +++ b/__tests__/types/analytics.test.ts @@ -66,6 +66,12 @@ describe('types/analytics', () => { // Savings events expect(AnalyticsEvents.SAVINGS_GOAL_SET).toBe('Savings Goal Set'); expect(AnalyticsEvents.SAVINGS_UPDATED).toBe('Savings Updated'); + + // AI Buddy events + expect(AnalyticsEvents.AI_BUDDY_ENABLED).toBe('AI Buddy Enabled'); + expect(AnalyticsEvents.AI_BUDDY_DISABLED).toBe('AI Buddy Disabled'); + expect(AnalyticsEvents.AI_BUDDY_MESSAGE_SENT).toBe('AI Buddy Message Sent'); + expect(AnalyticsEvents.AI_BUDDY_CONVERSATION_STARTED).toBe('AI Buddy Conversation Started'); }); it('should use Title Case naming convention', () => { @@ -109,6 +115,7 @@ describe('types/analytics', () => { task_streak_current: 7, steps_completed_count: '4-6', savings_goal_set: true, + ai_buddy_enabled: true, }; expect(props).toBeDefined(); }); @@ -138,9 +145,9 @@ describe('types/analytics', () => { }); describe('AnalyticsEvents constant integrity', () => { - it('should have exactly 37 events as documented', () => { + it('should have exactly 41 events as documented', () => { const eventCount = Object.keys(AnalyticsEvents).length; - expect(eventCount).toBe(37); + expect(eventCount).toBe(41); }); it('should not have duplicate event values', () => { @@ -232,6 +239,13 @@ describe('AnalyticsEvents constant integrity', () => { expect(AnalyticsEvents.SAVINGS_UPDATED).toBeDefined(); }); + it('should have all AI Buddy events', () => { + expect(AnalyticsEvents.AI_BUDDY_ENABLED).toBeDefined(); + expect(AnalyticsEvents.AI_BUDDY_DISABLED).toBeDefined(); + expect(AnalyticsEvents.AI_BUDDY_MESSAGE_SENT).toBeDefined(); + expect(AnalyticsEvents.AI_BUDDY_CONVERSATION_STARTED).toBeDefined(); + }); + it('should be a const object (frozen)', () => { // TypeScript const assertions make the object readonly // Attempting to modify should fail in TypeScript (compile-time check) @@ -242,11 +256,11 @@ describe('AnalyticsEvents constant integrity', () => { it('should have event values that work as Amplitude event names', () => { // Amplitude recommends Title Case with spaces - // Each word should start with capital letter + // Each word starts with uppercase. Allows all-caps acronyms like "AI". const eventValues = Object.values(AnalyticsEvents); eventValues.forEach((value) => { - // Should contain only letters and spaces - expect(value).toMatch(/^[A-Z][a-z]+(?: [A-Z][a-z]+)*$/); + // Each word is either Title Case (e.g. "Buddy") or all-caps acronym (e.g. "AI") + expect(value).toMatch(/^(?:[A-Z][a-z]+|[A-Z]+)(?: (?:[A-Z][a-z]+|[A-Z]+))*$/); }); }); }); @@ -280,7 +294,7 @@ describe('Type safety', () => { }); it('should allow all valid theme preferences', () => { - const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; + const themes: ('light' | 'dark' | 'system')[] = ['light', 'dark', 'system']; themes.forEach((theme) => { const prop: UserProperties = { theme_preference: theme }; @@ -289,7 +303,7 @@ describe('Type safety', () => { }); it('should allow all valid sign-in methods', () => { - const methods: Array<'email' | 'google' | 'apple'> = ['email', 'google', 'apple']; + const methods: ('email' | 'google' | 'apple')[] = ['email', 'google', 'apple']; methods.forEach((method) => { const prop: UserProperties = { sign_in_method: method }; @@ -359,6 +373,10 @@ describe('Documentation and discoverability', () => { const savingsEvents = Object.keys(AnalyticsEvents).filter((k) => k.startsWith('SAVINGS_')); expect(savingsEvents).toHaveLength(2); + // AI Buddy events: 4 + const aiBuddyEvents = Object.keys(AnalyticsEvents).filter((k) => k.startsWith('AI_BUDDY_')); + expect(aiBuddyEvents).toHaveLength(4); + // Screen viewed: 1 expect(AnalyticsEvents.SCREEN_VIEWED).toBeDefined(); }); diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 1adc5ffd..4d5d82a5 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -5,7 +5,7 @@ import React, { useMemo } from 'react'; import { Platform } from 'react-native'; import { Tabs } from 'expo-router'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import { Home, Compass, TrendingUp, CheckSquare, User } from 'lucide-react-native'; +import { Home, Compass, TrendingUp, CheckSquare, User, Bot } from 'lucide-react-native'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/contexts/AuthContext'; import type { SFSymbol } from 'sf-symbols-typescript'; @@ -38,6 +38,13 @@ const TAB_ROUTES: TabRoute[] = [ mdIcon: 'home', icon: Home, }, + { + name: 'buddy', + title: 'Buddy', + sfSymbol: 'bubble.left.and.text.bubble.right.fill', + mdIcon: 'smart_toy', + icon: Bot, + }, { name: 'program', title: 'Program', @@ -86,10 +93,18 @@ export default function TabLayout(): React.ReactElement { // Show Program tab unless explicitly set to false (treats null/undefined as true for backwards compatibility) const shouldShowProgram = profile?.show_program_content !== false; + // Determine if Buddy tab should be visible + // Show Buddy tab unless explicitly set to false (treats null/undefined as true for new users) + const shouldShowBuddy = profile?.ai_buddy_enabled !== false; + // Filter tab routes for web navigation items only const visibleTabRoutes = useMemo(() => { - return shouldShowProgram ? TAB_ROUTES : TAB_ROUTES.filter((route) => route.name !== 'program'); - }, [shouldShowProgram]); + return TAB_ROUTES.filter((route) => { + if (route.name === 'program' && !shouldShowProgram) return false; + if (route.name === 'buddy' && !shouldShowBuddy) return false; + return true; + }); + }, [shouldShowProgram, shouldShowBuddy]); // Web: Use top navigation instead of bottom tabs if (Platform.OS === 'web') { @@ -135,7 +150,10 @@ export default function TabLayout(): React.ReactElement {