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

This commit is contained in:
Yun Chan 2026-09-28 23:23:36 +09:00
parent f505a03d34
commit 5ca3709b32
98 changed files with 5168 additions and 2093 deletions

View 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)
})
})