feat: add mobile shell and unified search
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
This commit is contained in:
parent
f505a03d34
commit
5ca3709b32
98 changed files with 5168 additions and 2093 deletions
321
apps/mobile-rn/__tests__/navigation-shell.test.tsx
Normal file
321
apps/mobile-rn/__tests__/navigation-shell.test.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
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<string, unknown> = {}
|
||||
|
||||
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<unknown>;
|
||||
options?: MockTabOptions;
|
||||
}
|
||||
|
||||
type MockRoute = { key: string; name: string }
|
||||
type MockDescriptor = { options: MockTabOptions }
|
||||
type MockTabBarProps = {
|
||||
state: { index: number; routes: MockRoute[] };
|
||||
descriptors: Record<string, MockDescriptor>;
|
||||
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 (
|
||||
<View testID="mock-bottom-tab-bar">
|
||||
{state.routes.map((route) => {
|
||||
const options = descriptors[route.key]?.options ?? {}
|
||||
const hidden = options.tabBarButton?.() === null
|
||||
return hidden ? null : (
|
||||
<Text key={route.key} testID={`bottom-tab-${route.name}`}>
|
||||
{route.name}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MockNavigator({
|
||||
children,
|
||||
tabBar,
|
||||
}: React.PropsWithChildren<{
|
||||
tabBar: (props: MockTabBarProps) => React.ReactNode;
|
||||
backBehavior?: string;
|
||||
screenOptions?: Record<string, unknown>;
|
||||
}>): React.ReactElement {
|
||||
const screens = ReactRuntime.Children.toArray(children).filter(
|
||||
(child): child is React.ReactElement<MockScreenProps> => 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 (
|
||||
<View testID="mock-tab-navigator">
|
||||
{tabBar({
|
||||
state: { index: activeIndex, routes },
|
||||
descriptors,
|
||||
navigation: { navigate: mockTabNavigate },
|
||||
insets: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, unknown>) => (
|
||||
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<Record<string, unknown>>) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
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<ReactTestRenderer> {
|
||||
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(<TabNavigator />)
|
||||
|
||||
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(<TabNavigator />)
|
||||
|
||||
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(<TabNavigator />)
|
||||
|
||||
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(<MoreScreen />)
|
||||
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(<MoreScreen />)
|
||||
})
|
||||
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(
|
||||
<RecordFab visible bottomOffset={72} onSelect={onSelect} />,
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue