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

@ -7,6 +7,7 @@ import { IdentityManagementService } from '../src/features/auth/identity-linking
const USER_ID = '11111111-1111-4111-8111-111111111111'
const mockCapabilities = jest.fn()
const mockOpenUrl = jest.spyOn(Linking, 'openURL')
let mockProfileTier: 'free' | 'pro' | 'pro_plus' = 'free'
const mockI18n = {
t: (key: string, params?: Record<string, unknown>) => params === undefined ? key : `${key}:${JSON.stringify(params)}`,
formatDate: () => '2026-08-21',
@ -20,7 +21,9 @@ const mockUser = {
identities: [],
}
jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: jest.fn() }) }))
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
@ -40,8 +43,24 @@ jest.mock('../src/lib/preferences-context', () => ({
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', white: '#fff', onAccent: '#000' },
border: { default: '#444', subtle: '#333', strong: '#666' },
accent: { main: '#7cf', dim: '#234' },
accent: {
main: '#7cf',
light: '#7cf',
dim: '#234',
solid: '#37c',
solidHover: '#258',
ink: '#fff',
},
tag: { red: '#f66', green: '#6f6', orange: '#fa6' },
status: {
success: '#6f6',
warning: '#fa6',
danger: '#f66',
info: '#7cf',
dangerBg: '#411',
},
overlay: { soft: '#222' },
led: { off: '#555' },
},
}),
clearAllUserPreferenceCaches: jest.fn(),
@ -53,7 +72,7 @@ jest.mock('../src/lib/supabase', () => ({
select: () => ({
eq: () => ({
single: async () => ({
data: { id: USER_ID, name: 'User', avatar_url: null, locale: 'en', tier: 'free', created_at: '', updated_at: '' },
data: { id: USER_ID, name: 'User', avatar_url: null, locale: 'en', tier: mockProfileTier, created_at: '', updated_at: '' },
error: null,
}),
}),
@ -118,6 +137,7 @@ async function flush(): Promise<void> {
describe('AccountScreen identity management', () => {
beforeEach(() => {
mockProfileTier = 'free'
mockCapabilities.mockResolvedValue({
signUpEnabled: true,
emailEnabled: true,
@ -149,4 +169,17 @@ describe('AccountScreen identity management', () => {
})
expect(mockOpenUrl).toHaveBeenCalledWith('https://provider.example/authorize')
})
it('shows the localized PRO+ label for a pro_plus profile', async () => {
mockProfileTier = 'pro_plus'
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = create(<AccountScreen identityService={createIdentityService()} />)
})
await flush()
expect(renderer!.root.findAllByProps({ children: 'mobile.paywall.proPlus' }).length).toBeGreaterThan(0)
expect(renderer!.root.findAllByProps({ children: 'mobile.account.tierUnknown' })).toHaveLength(0)
})
})

View file

@ -9,18 +9,12 @@ jest.mock('@d3ro/i18n', () => ({
params?.id === undefined ? key : `${key}:${params.id}`
) }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', sidebar: '#111', cardHover: '#222' },
text: { primary: '#fff', secondary: '#ddd', muted: '#999', onAccent: '#000' },
border: { subtle: '#333', default: '#444', strong: '#666' },
accent: { main: '#fc0', dim: '#332b00', pressed: '#db0' },
tag: { red: '#f66', green: '#5d8' },
shadow: '#000',
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/random-id', () => ({
createUuidV4: () => '22222222-2222-4222-8222-222222222222',
}))

View file

@ -16,17 +16,12 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', onAccent: '#000' },
border: { default: '#444', strong: '#666' },
accent: { main: '#fc0', dim: '#221' },
tag: { green: '#6f6', red: '#f66', orange: '#fa6', blue: '#6af' },
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ authError: null }),
}))

View file

@ -10,6 +10,7 @@ const mockNavigate = jest.fn()
jest.mock('@react-navigation/native', () => {
const ReactValue = require('react') as typeof React
return {
useNavigation: () => ({ goBack: jest.fn() }),
useFocusEffect: (callback: () => void | (() => void)) => {
ReactValue.useEffect(callback, [])
},
@ -18,6 +19,9 @@ jest.mock('@react-navigation/native', () => {
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('../src/navigation/tab-layout', () => ({
useTabScreenBottomPadding: () => 88,
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => params === undefined
@ -94,7 +98,7 @@ jest.mock('../src/features/templates', () => ({
loadTemplateLibrary: (...args: unknown[]) => mockLoadTemplateLibrary(...args),
}))
import { TextInput } from 'react-native'
import { FlatList, StyleSheet, TextInput } from 'react-native'
import MeetingsScreen from '../src/screens/MeetingsScreen'
const USER_ID = '11111111-1111-4111-8111-111111111111'
@ -175,6 +179,23 @@ describe('meeting metadata creation screen', () => {
mockCreateMeetingWorkspaceWithMetadata.mockResolvedValue(createdMeeting())
})
test('uses the shared tab and FAB clearance for list content', async () => {
const navigation = {
getParent: () => null,
navigate: mockNavigate,
}
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<MeetingsScreen navigation={navigation as never} />)
})
await flush()
const list = renderer!.root.findByType(FlatList)
expect(StyleSheet.flatten(list.props.contentContainerStyle).paddingBottom).toBe(88)
expect(list.props.ListFooterComponent).toBeNull()
await act(() => renderer!.unmount())
})
test('submits title, attendees, language, and an explicit template then opens server readback', async () => {
const navigation = {
getParent: () => null,
@ -300,4 +321,37 @@ describe('meeting metadata creation screen', () => {
})
await act(() => renderer!.unmount())
})
test('consumes each FAB create request exactly once', async () => {
const setParams = jest.fn()
const navigation = {
getParent: () => null,
navigate: mockNavigate,
setParams,
}
const route = { params: { createRequest: 1700000000000 } }
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(
<MeetingsScreen navigation={navigation as never} route={route} />,
)
})
await flush()
expect(setParams).toHaveBeenCalledWith({ createRequest: undefined })
expect(mockLoadTemplateLibrary).toHaveBeenCalledTimes(1)
expect(
renderer!.root.findAllByProps({ testID: 'meeting-editor-modal' }).length,
).toBeGreaterThan(0)
await act(async () => {
renderer!.update(
<MeetingsScreen navigation={navigation as never} route={route} />,
)
})
await flush()
expect(mockLoadTemplateLibrary).toHaveBeenCalledTimes(1)
await act(() => renderer!.unmount())
})
})

View file

@ -1,26 +1,17 @@
import React from 'react'
import ReactTestRenderer, { act } from 'react-test-renderer'
import { I18nProvider } from '@d3ro/i18n'
jest.mock('../src/lib/auth-context', () => ({ useAuth: jest.fn() }))
jest.mock('../src/lib/entitlement-context', () => ({ useEntitlement: jest.fn() }))
jest.mock('../src/lib/native-config', () => ({ getMobileRuntimeConfig: jest.fn() }))
jest.mock('../src/lib/supabase', () => ({ supabase: { from: jest.fn() } }))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: jest.fn(() => ({
palette: {
accent: { main: '#3366ff' },
bg: { inset: '#111111' },
border: { subtle: '#222222' },
tag: { green: '#00aa66', red: '#cc3344' },
text: {
primary: '#ffffff',
secondary: '#dddddd',
muted: '#999999',
onAccent: '#ffffff',
},
},
})),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: jest.fn(() => ({ palette: d3roNativeDarkPalette })),
}
})
const mockedAuth = jest.requireMock('../src/lib/auth-context') as { useAuth: jest.Mock }
const mockedEntitlement = jest.requireMock('../src/lib/entitlement-context') as {
@ -149,7 +140,9 @@ describe('mobile rewarded ad lifecycle', () => {
let renderer: ReactTestRenderer.ReactTestRenderer
await act(async () => {
renderer = ReactTestRenderer.create(
<MobileAdsProvider><FreeTierBanner /></MobileAdsProvider>,
<I18nProvider initialLocale="en">
<MobileAdsProvider><FreeTierBanner /></MobileAdsProvider>
</I18nProvider>,
)
await Promise.resolve()
await Promise.resolve()
@ -157,7 +150,7 @@ describe('mobile rewarded ad lifecycle', () => {
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdFailedToLoad())
expect(JSON.stringify(renderer!.toJSON())).toContain('AD UNAVAILABLE')
expect(JSON.stringify(renderer!.toJSON())).toContain('Ad unavailable')
await act(async () => {
jest.advanceTimersByTime(30_000)
@ -167,7 +160,7 @@ describe('mobile rewarded ad lifecycle', () => {
expect(latestBannerProps?.unitId).toBe('ca-app-pub-3940256099942544/6300978111')
act(() => latestBannerProps?.onAdLoaded())
expect(JSON.stringify(renderer!.toJSON())).not.toContain('AD UNAVAILABLE')
expect(JSON.stringify(renderer!.toJSON())).not.toContain('Ad unavailable')
act(() => renderer!.unmount())
jest.useRealTimers()
})

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

View file

@ -15,10 +15,15 @@ const mockValidateLocalAudio = jest.fn()
const mockCleanup = jest.fn(async () => undefined)
const mockStart = jest.fn(async () => undefined)
const mockStop = jest.fn()
const mockPickAudioFile = jest.fn()
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('../src/components/ScreenHeader', () => ({ ScreenHeader: () => null }))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key, locale: 'ko' }),
}))
@ -28,7 +33,10 @@ jest.mock('../src/lib/auth-context', () => ({
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: 'app', card: 'card', inset: 'inset' },
bg: { app: 'app', sidebar: 'sidebar', card: 'card', inset: 'inset' },
text: { primary: 'primary', muted: 'muted' },
border: { default: 'default' },
overlay: { soft: 'soft' },
accent: { main: 'accent' },
tag: { red: 'red' },
},
@ -56,7 +64,9 @@ jest.mock('@d3ro/ui-native', () => {
<Text>{label}</Text>
</Pressable>
),
d3roNativeFonts: { mono: 'mono' },
d3roNativeFonts: { sans: 'sans' },
d3roNativeControl: { h: 48 },
d3roNativeRadius: { button: 8 },
}
})
jest.mock('../src/lib/audio-recorder', () => {
@ -81,7 +91,9 @@ jest.mock('../src/lib/audio-recorder', () => {
jest.mock('../src/features/import/audio-validation', () => ({
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
}))
jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() }))
jest.mock('../src/features/import/audio-file-picker', () => ({
pickAudioFile: (...args: unknown[]) => mockPickAudioFile(...args),
}))
jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() }))
jest.mock('../src/features/notifications/notification-native', () => ({
showLocalTranscriptionCompleted: jest.fn(async () => true),
@ -143,7 +155,12 @@ function meetingResult(): AudioPipelineResult {
}
}
type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string }
type Params = {
meetingId?: string
meetingTitle?: string
meetingLanguage?: string
importRequest?: number
}
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | undefined
@ -238,4 +255,33 @@ describe('RecordScreen meeting durability (redteam r2-27)', () => {
expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1)
expect(mockStart).toHaveBeenCalledTimes(2)
})
test('consumes each FAB import request exactly once', async () => {
mockPickAudioFile.mockRejectedValue(
new AudioPipelineError('cancelled', 'Picker cancelled'),
)
const setParams = jest.fn()
const params = { importRequest: 1700000000000 }
const renderer = await render(params, setParams)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(setParams).toHaveBeenCalledWith({ importRequest: undefined })
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
await act(async () => {
renderer.update(
<RecordScreen
route={{ params }}
navigation={{ getParent: () => undefined, setParams }}
/>,
)
await Promise.resolve()
})
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,48 @@
import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { StyleSheet } from 'react-native'
const mockGoBack = jest.fn()
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ goBack: mockGoBack }),
}))
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 10, bottom: 0, left: 0, right: 0 }),
}))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { sidebar: '#111827' },
text: { primary: '#ffffff', secondary: '#d1d5db', tertiary: '#9ca3af' },
border: { default: '#374151' },
accent: { light: '#60a5fa', ink: '#ffffff' },
tag: { green: '#34d399', red: '#f87171' },
status: { success: '#34d399', danger: '#f87171' },
overlay: { soft: 'rgba(255,255,255,0.06)' },
},
}),
}))
import { ScreenHeader } from '../src/components/ScreenHeader'
describe('ScreenHeader', () => {
test('renders a 48dp back action for stack screens and omits it for tab roots', () => {
let renderer: ReactTestRenderer
act(() => {
renderer = create(<ScreenHeader title="Title" testID="header" />)
})
const back = renderer!.root.findByProps({ accessibilityLabel: 'common.back' })
expect(StyleSheet.flatten(back.props.style({ pressed: false })).width).toBe(48)
act(() => back.props.onPress())
expect(mockGoBack).toHaveBeenCalledTimes(1)
act(() => renderer!.update(<ScreenHeader title="Title" showBack={false} />))
expect(renderer!.root.findAllByProps({ accessibilityLabel: 'common.back' })).toHaveLength(0)
act(() => renderer!.unmount())
})
})

View file

@ -22,17 +22,12 @@ jest.mock('@d3ro/i18n', () => ({
t: (key: string) => key,
}),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', card: '#181818', inset: '#202020', cardHover: '#222' },
text: { primary: '#fff', muted: '#999', onAccent: '#000' },
border: { default: '#444', strong: '#666' },
accent: { main: '#fc0' },
tag: { red: '#f66' },
},
}),
}))
jest.mock('../src/lib/preferences-context', () => {
const { d3roNativeDarkPalette } = jest.requireActual('@d3ro/ui-native') as typeof import('@d3ro/ui-native')
return {
useMobilePreferences: () => ({ palette: d3roNativeDarkPalette }),
}
})
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ authError: null }),
}))

View file

@ -18,6 +18,7 @@ const mockStreamTalkResponse = jest.fn()
jest.mock('@react-navigation/native', () => {
const ReactValue = require('react') as typeof React
return {
useNavigation: () => ({ goBack: jest.fn() }),
useFocusEffect: (effect: () => void | (() => void)) => {
ReactValue.useEffect(effect, [effect])
},

View file

@ -7,7 +7,10 @@ const mockSelectTemplate = jest.fn()
const mockSearchMemos = jest.fn()
const mockAddMemoTag = jest.fn()
jest.mock('@react-navigation/native', () => ({ useIsFocused: () => true }))
jest.mock('@react-navigation/native', () => ({
useIsFocused: () => true,
useNavigation: () => ({ goBack: jest.fn() }),
}))
jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({

View file

@ -0,0 +1,48 @@
import { RAW, STATUS } from '../../../packages/ui/src/theme-raw'
import {
D3RO_NATIVE_RAW,
D3RO_NATIVE_STATUS,
getD3roNativePalette,
} from '@d3ro/ui-native'
type Rgb = readonly [number, number, number]
function rgb(hex: string): Rgb {
const normalized = hex.replace('#', '')
if (!/^[0-9a-f]{6}$/i.test(normalized)) throw new Error(`Expected 6-digit hex, got ${hex}`)
return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as unknown as Rgb
}
function luminance(hex: string): number {
const channels = rgb(hex).map((value) => {
const channel = value / 255
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
})
return (0.2126 * channels[0]!) + (0.7152 * channels[1]!) + (0.0722 * channels[2]!)
}
function contrast(foreground: string, background: string): number {
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
return (lighter! + 0.05) / (darker! + 0.05)
}
describe('mobile v4 theme contract', () => {
test.each(['dark', 'light'] as const)('%s raw tokens stay in desktop SSOT parity', (mode) => {
for (const group of ['bg', 'text', 'border', 'accent'] as const) {
for (const [key, value] of Object.entries(D3RO_NATIVE_RAW[mode][group])) {
expect(RAW[mode][group][key as keyof typeof RAW[typeof mode][typeof group]]).toBe(value)
}
}
expect(D3RO_NATIVE_STATUS[mode]).toEqual(STATUS[mode])
})
test.each(['dark', 'light'] as const)('%s readable roles meet 4.5:1 contrast', (mode) => {
const palette = getD3roNativePalette(mode)
for (const background of [palette.bg.app, palette.bg.card, palette.bg.elevated, palette.bg.sidebar]) {
for (const foreground of [palette.text.primary, palette.text.secondary, palette.text.tertiary]) {
expect(contrast(foreground, background)).toBeGreaterThanOrEqual(4.5)
}
}
expect(contrast(palette.accent.ink, palette.accent.solid)).toBeGreaterThanOrEqual(4.5)
})
})

View file

@ -75,7 +75,10 @@ describe('ui-native runtime theme boundary', () => {
const darkButton = hostByTestId(renderer!.root, 'theme-button')
expect(StyleSheet.flatten(darkText.props.style).color).toBe(dark.text.primary)
expect(StyleSheet.flatten(darkCard.props.style).backgroundColor).toBe(dark.bg.card)
expect(StyleSheet.flatten(darkButton.props.style).backgroundColor).toBe(dark.accent.main)
expect(StyleSheet.flatten(darkButton.props.style).backgroundColor).toBe(dark.accent.solid)
expect(StyleSheet.flatten(darkButton.props.style).minHeight).toBe(48)
expect(StyleSheet.flatten(darkCard.props.style).borderWidth).toBe(1)
expect(StyleSheet.flatten(darkCard.props.style).shadowOpacity).toBeUndefined()
act(() => {
renderer!.update(<ThemeFixture mode="light" />)
@ -87,7 +90,7 @@ describe('ui-native runtime theme boundary', () => {
const lightButton = hostByTestId(renderer!.root, 'theme-button')
expect(StyleSheet.flatten(lightText.props.style).color).toBe(light.text.primary)
expect(StyleSheet.flatten(lightCard.props.style).backgroundColor).toBe(light.bg.card)
expect(StyleSheet.flatten(lightButton.props.style).backgroundColor).toBe(light.accent.main)
expect(StyleSheet.flatten(lightButton.props.style).backgroundColor).toBe(light.accent.solid)
act(() => renderer!.unmount())
})

View file

@ -0,0 +1,47 @@
import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { UnifiedSearchResult } from '@d3ro/core/types'
import { NativeThemeProvider, getD3roNativePalette } from '@d3ro/ui-native'
import { UnifiedSearchResultRow } from '../src/components/UnifiedSearchResultRow'
const result: UnifiedSearchResult = {
source: 'meeting',
id: '33333333-3333-4333-8333-333333333333',
title: '주간 회의',
excerpt: '로드맵을 다음 주에 검토합니다.',
matchedField: 'transcript',
occurredAt: Date.parse('2026-09-28T00:00:00.000Z'),
}
describe('UnifiedSearchResultRow', () => {
test('exposes a named button and forwards the source result', () => {
const onPress = jest.fn()
let renderer: ReactTestRenderer | undefined
act(() => {
renderer = create(
<NativeThemeProvider palette={getD3roNativePalette('dark')}>
<UnifiedSearchResultRow
result={result}
title="주간 회의"
sourceLabel="회의"
matchLabel="전사 일치"
dateLabel="2026. 9. 28."
previewFallback="미리 볼 내용이 없습니다."
accessibilityLabel="회의 ‘주간 회의’ 열기"
onPress={onPress}
/>
</NativeThemeProvider>,
)
})
const button = renderer!.root.findByProps({
testID: `unified-search-open-${result.source}-${result.id}`,
})
expect(button.props.accessibilityRole).toBe('button')
expect(button.props.accessibilityLabel).toBe('회의 ‘주간 회의’ 열기')
act(() => button.props.onPress())
expect(onPress).toHaveBeenCalledWith(result)
act(() => renderer!.unmount())
})
})

View file

@ -0,0 +1,215 @@
jest.mock('../src/lib/supabase', () => ({ supabase: {} }))
import type { D3roSupabaseClient, HistoryEntry, Meeting } from '@d3ro/api-client'
import { normalizeUnifiedSearchInitialQuery } from '../src/features/search/unified-search-route'
import {
createMobileSearchExcerpt,
createPostgrestLikePattern,
createSupabaseUnifiedSearchSource,
sanitizeUnifiedSearchQuery,
searchUnifiedContent,
type MobileUnifiedSearchOptions,
type UnifiedSearchSourcePort,
} from '../src/features/search/unified-search-service'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const record: HistoryEntry = {
id: '22222222-2222-4222-8222-222222222222',
user_id: USER_ID,
title: '메모',
original_text: '초기 로드맵을 기록했습니다.',
polished_text: '다음 분기 로드맵을 기록했습니다.',
focused_app: null,
focused_app_name: null,
focused_app_window_title: null,
mode: 'dictation',
status: 'completed',
error_code: null,
audio_storage_key: null,
duration: 12,
detected_language: 'ko',
mic_device: null,
word_count: 5,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
app_version: '1.9.0',
summary_text: null,
is_favorite: false,
revision: 1,
created_at: '2026-09-27T00:00:00.000Z',
updated_at: '2026-09-27T00:00:00.000Z',
}
const meeting: Meeting = {
id: '33333333-3333-4333-8333-333333333333',
user_id: USER_ID,
team_id: null,
title: '주간 회의',
status: 'completed',
started_at: '2026-09-28T00:00:00.000Z',
ended_at: '2026-09-28T00:30:00.000Z',
duration_ms: 1_800_000,
raw_transcript: '로드맵을 다음 주에 검토합니다.',
edited_transcript: null,
minutes_markdown: null,
minutes_json: null,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
error_message: null,
audio_storage_key: null,
created_at: '2026-09-28T00:00:00.000Z',
updated_at: '2026-09-28T00:30:00.000Z',
}
describe('mobile unified search service', () => {
test('sanitizes PostgREST control characters and bounds excerpts', () => {
expect(sanitizeUnifiedSearchQuery(' Q3.1, foo_bar 50% ("초안") O\'Reilly ')).toBe(
"Q3.1 foo_bar 50% 초안 O'Reilly",
)
expect(createPostgrestLikePattern('Q3.1')).toBe('*Q3.1*')
expect(createPostgrestLikePattern('foo_bar')).toBe('*foo\\_bar*')
expect(createPostgrestLikePattern('50%')).toBe('*50\\%*')
expect(createPostgrestLikePattern('literal*star')).toBe('*literal\\*star*')
const excerpt = createMobileSearchExcerpt(
`앞 ${'가'.repeat(80)} 로드맵 ${'나'.repeat(80)}`,
'로드맵',
60,
)
expect(excerpt).toContain('로드맵')
expect(excerpt.startsWith('…')).toBe(true)
expect(excerpt.endsWith('…')).toBe(true)
})
test('searches both sources, reports full totals, and opens newest source first', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(async () => ({ rows: [record], total: 4 })),
searchMeetings: jest.fn(async () => ({ rows: [meeting], total: 3 })),
}
const response = await searchUnifiedContent({
userId: USER_ID,
query: ' 로드맵 ',
limit: 10,
}, source)
expect(source.searchRecords).toHaveBeenCalledWith(USER_ID, '로드맵', 10)
expect(source.searchMeetings).toHaveBeenCalledWith('로드맵', 10)
expect(response.total).toBe(7)
expect(response.results.map(({ source: kind, id }) => ({ kind, id }))).toEqual([
{ kind: 'meeting', id: meeting.id },
{ kind: 'record', id: record.id },
])
expect(response.results[0]).toMatchObject({ matchedField: 'transcript' })
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
})
test('does not query either table for blank input', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
await expect(searchUnifiedContent({ userId: USER_ID, query: ' ' }, source)).resolves.toEqual({
query: '',
results: [],
total: 0,
})
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects a missing authentication boundary before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const invalidUserIds = ['not-a-user', 42, null]
for (const userId of invalidUserIds) {
const options = { userId, query: '로드맵' } as unknown as MobileUnifiedSearchOptions
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({
code: 'auth',
})
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('rejects malformed options, queries, and non-finite limits before querying', async () => {
const source: UnifiedSearchSourcePort = {
searchRecords: jest.fn(),
searchMeetings: jest.fn(),
}
const malformedOptions: MobileUnifiedSearchOptions[] = [
null as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: 42 } as unknown as MobileUnifiedSearchOptions,
{ userId: USER_ID, query: '로드맵', limit: Number.NaN },
{ userId: USER_ID, query: '로드맵', limit: Number.POSITIVE_INFINITY },
]
for (const options of malformedOptions) {
await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({ code: 'invalid' })
}
expect(source.searchRecords).not.toHaveBeenCalled()
expect(source.searchMeetings).not.toHaveBeenCalled()
})
test('escapes a raw PostgREST wildcard instead of creating a broad match', () => {
expect(createPostgrestLikePattern('*')).toBe('*\\**')
expect(createPostgrestLikePattern('*')).not.toBe('***')
})
test('uses minimal Supabase projections and leaves team meeting visibility to RLS', async () => {
const recordBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
const meetingBuilder = {
select: jest.fn(),
eq: jest.fn(),
or: jest.fn(),
order: jest.fn(),
limit: jest.fn(),
}
for (const builder of [recordBuilder, meetingBuilder]) {
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.or.mockReturnValue(builder)
builder.order.mockReturnValue(builder)
builder.limit.mockResolvedValue({ data: [], error: null, count: 0 })
}
const from = jest.fn((table: string) => (
table === 'history' ? recordBuilder : meetingBuilder
))
const source = createSupabaseUnifiedSearchSource({ from } as unknown as D3roSupabaseClient)
await source.searchRecords(USER_ID, '로드맵', 10)
await source.searchMeetings('*', 10)
expect(recordBuilder.select).toHaveBeenCalledWith(
'id,title,original_text,polished_text,summary_text,created_at',
{ count: 'exact' },
)
expect(recordBuilder.eq).toHaveBeenCalledWith('user_id', USER_ID)
expect(meetingBuilder.select).toHaveBeenCalledWith(
'id,title,raw_transcript,edited_transcript,started_at',
{ count: 'exact' },
)
expect(meetingBuilder.eq).not.toHaveBeenCalled()
expect(meetingBuilder.or).toHaveBeenCalledWith(
'title.ilike.*\\**,raw_transcript.ilike.*\\**,edited_transcript.ilike.*\\**',
)
})
test('guards restored navigation initialQuery values at runtime', () => {
expect(normalizeUnifiedSearchInitialQuery({ unexpected: true })).toBe('')
expect(normalizeUnifiedSearchInitialQuery(123)).toBe('')
expect(normalizeUnifiedSearchInitialQuery('가'.repeat(120))).toHaveLength(100)
})
})