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
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|