import React from 'react' import { AccessibilityInfo, BackHandler, Keyboard, StyleSheet, } from 'react-native' import { act, create, type ReactTestRenderer } from 'react-test-renderer' const mockNavigate = jest.fn() const mockTabNavigate = jest.fn() let mockActiveTab = 'Home' let mockAppMetadata: Record = {} type MockTabOptions = { tabBarButton?: () => React.ReactNode; tabBarItemStyle?: unknown; } type MockTabScreen = { name: string; options: MockTabOptions; } let mockTabScreens: MockTabScreen[] = [] jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })) jest.mock('@react-navigation/bottom-tabs', () => { const ReactRuntime = require('react') as typeof import('react') const { Text, View } = require('react-native') as typeof import('react-native') type MockScreenProps = { name: string; component: React.ComponentType; options?: MockTabOptions; } type MockRoute = { key: string; name: string } type MockDescriptor = { options: MockTabOptions } type MockTabBarProps = { state: { index: number; routes: MockRoute[] }; descriptors: Record; navigation: { navigate: typeof mockTabNavigate }; insets: { top: number; bottom: number; left: number; right: number }; } function MockScreen(_props: MockScreenProps): null { return null } function MockBottomTabBar({ state, descriptors }: MockTabBarProps): React.ReactElement { return ( {state.routes.map((route) => { const options = descriptors[route.key]?.options ?? {} const hidden = options.tabBarButton?.() === null return hidden ? null : ( {route.name} ) })} ) } function MockNavigator({ children, tabBar, }: React.PropsWithChildren<{ tabBar: (props: MockTabBarProps) => React.ReactNode; backBehavior?: string; screenOptions?: Record; }>): React.ReactElement { const screens = ReactRuntime.Children.toArray(children).filter( (child): child is React.ReactElement => ReactRuntime.isValidElement(child), ) mockTabScreens = screens.map((screen) => ({ name: screen.props.name, options: screen.props.options ?? {}, })) const routes = mockTabScreens.map((screen, index) => ({ key: `${screen.name}-${index}`, name: screen.name, })) const activeIndex = routes.findIndex((route) => route.name === mockActiveTab) const descriptors = Object.fromEntries(routes.map((route, index) => [ route.key, { options: mockTabScreens[index]?.options ?? {} }, ])) return ( {tabBar({ state: { index: activeIndex, routes }, descriptors, navigation: { navigate: mockTabNavigate }, insets: { top: 0, bottom: 0, left: 0, right: 0 }, })} ) } return { BottomTabBar: MockBottomTabBar, createBottomTabNavigator: () => ({ Navigator: MockNavigator, Screen: MockScreen }), } }) jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), })) jest.mock('../src/navigation/tab-layout', () => ({ useTabScreenBottomPadding: () => 24, })) jest.mock('@d3ro/i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => ( params === undefined ? key : `${key}:${JSON.stringify(params)}` ), }), })) jest.mock('../src/lib/auth-context', () => ({ useAuth: () => ({ user: { app_metadata: mockAppMetadata } }), })) jest.mock('../src/lib/entitlement-context', () => ({ useEntitlement: () => ({ snapshot: { tier: 'free' } }), })) jest.mock('../src/components/FreeTierBanner', () => () => null) jest.mock('../src/lib/preferences-context', () => ({ useMobilePreferences: () => ({ palette: { bg: { app: 'app', cardHover: 'hover', elevated: 'elevated' }, text: { primary: 'primary', muted: 'muted', onAccent: 'on-accent' }, border: { default: 'default', subtle: 'subtle' }, accent: { main: 'accent', pressed: 'pressed', solid: 'solid', solidHover: 'solid-hover', ink: 'accent-ink', }, scrim: 'scrim', shadow: 'shadow', }, }), })) jest.mock('../src/theme/themed-components', () => { const { Text } = require('react-native') as typeof import('react-native') return { ThemeText: ({ children, ...props }: React.PropsWithChildren>) => ( {children} ), } }) import MoreScreen from '../src/screens/MoreScreen' import RecordFab from '../src/components/RecordFab' import TabNavigator from '../src/navigation/TabNavigator' async function render(element: React.ReactElement): Promise { let renderer: ReactTestRenderer | undefined await act(async () => { renderer = create(element) await Promise.resolve() }) if (renderer === undefined) throw new Error('Component did not render') return renderer } describe('mobile navigation shell', () => { beforeEach(() => { jest.clearAllMocks() mockActiveTab = 'Home' mockTabScreens = [] mockAppMetadata = {} jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(false) jest.spyOn(AccessibilityInfo, 'addEventListener').mockReturnValue({ remove: jest.fn() }) jest.spyOn(Keyboard, 'addListener').mockReturnValue({ remove: jest.fn() } as never) }) test('TabNavigator exposes four visible destinations and keeps Record hidden', async () => { const renderer = await render() expect(mockTabScreens.map((screen) => screen.name)).toEqual([ 'Home', 'Records', 'Meetings', 'More', 'Record', ]) ;['Home', 'Records', 'Meetings', 'More'].forEach((route) => { expect( renderer.root.findAllByProps({ testID: `bottom-tab-${route}` }).length, ).toBeGreaterThan(0) }) expect(renderer.root.findAllByProps({ testID: 'bottom-tab-Record' })).toHaveLength(0) const record = mockTabScreens.find((screen) => screen.name === 'Record') expect(record?.options.tabBarButton?.()).toBeNull() expect(StyleSheet.flatten(record?.options.tabBarItemStyle)).toMatchObject({ display: 'none' }) }) test.each([ ['Home', true], ['Records', true], ['Meetings', true], ['More', false], ['Record', false], ])('FAB visibility follows the active %s route', async (route, visible) => { mockActiveTab = route const renderer = await render() expect(renderer.root.findAllByProps({ testID: 'record-fab' }).length > 0).toBe(visible) }) test('FAB actions dispatch the exact dictation, meeting, and import routes', async () => { const now = jest.spyOn(Date, 'now') .mockReturnValue(1_700_000_000_001) const renderer = await render() for (const action of ['dictation', 'meeting', 'import'] as const) { await act(async () => { renderer.root.findByProps({ testID: 'record-fab' }).props.onPress() }) await act(async () => { renderer.root.findByProps({ testID: `record-fab-${action}` }).props.onPress() }) } expect(mockTabNavigate).toHaveBeenNthCalledWith(1, 'Record') expect(mockTabNavigate).toHaveBeenNthCalledWith(2, 'Meetings', { createRequest: 1_700_000_000_001, }) expect(mockTabNavigate).toHaveBeenNthCalledWith(3, 'Record', { importRequest: 1_700_000_000_001, }) expect(mockTabNavigate).toHaveBeenCalledTimes(3) now.mockRestore() }) test('More preserves tools and account routes, with admin gated by role', async () => { const renderer = await render() const routeIds = [ 'open-talk', 'open-commands', 'open-dictionary', 'open-knowledge', 'open-templates', 'open-memos', 'open-teams', 'open-actions', 'open-notifications', 'open-data-portability', 'open-account', 'open-paywall', 'open-devices', 'more-open-settings', ] routeIds.forEach((testID) => { expect(renderer.root.findAllByProps({ testID }).length).toBeGreaterThan(0) }) expect(renderer.root.findAllByProps({ testID: 'open-admin' })).toHaveLength(0) await act(async () => { renderer.root.findByProps({ testID: 'open-talk' }).props.onPress() renderer.root.findByProps({ testID: 'more-open-settings' }).props.onPress() }) expect(mockNavigate).toHaveBeenNthCalledWith(1, 'Talk') expect(mockNavigate).toHaveBeenNthCalledWith(2, 'Settings') mockAppMetadata = { role: 'admin' } await act(async () => { renderer.update() }) expect(renderer.root.findAllByProps({ testID: 'open-admin' }).length).toBeGreaterThan(0) }) test('FAB menu closes on Android back, scrim, and selection', async () => { let hardwareBack: (() => boolean) | null = null jest.spyOn(BackHandler, 'addEventListener').mockImplementation((_event, listener) => { hardwareBack = listener return { remove: jest.fn() } }) const onSelect = jest.fn() const renderer = await render( , ) const fab = renderer.root.findByProps({ testID: 'record-fab' }) expect(StyleSheet.flatten(fab.props.style({ pressed: false })).backgroundColor).toBe('solid') expect(StyleSheet.flatten(fab.props.style({ pressed: true })).backgroundColor).toBe('solid-hover') await act(async () => { fab.props.onPress() }) expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' }).length).toBeGreaterThan(0) expect(hardwareBack).not.toBeNull() await act(async () => { expect(hardwareBack?.()).toBe(true) }) expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0) await act(async () => { renderer.root.findByProps({ testID: 'record-fab' }).props.onPress() }) await act(async () => { renderer.root.findByProps({ testID: 'record-fab-scrim' }).props.onPress() }) expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0) await act(async () => { renderer.root.findByProps({ testID: 'record-fab' }).props.onPress() }) await act(async () => { renderer.root.findByProps({ testID: 'record-fab-meeting' }).props.onPress() }) expect(onSelect).toHaveBeenCalledWith('meeting') expect(renderer.root.findAllByProps({ testID: 'record-fab-menu' })).toHaveLength(0) }) })