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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
48
apps/mobile-rn/__tests__/screen-header.test.tsx
Normal file
48
apps/mobile-rn/__tests__/screen-header.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
48
apps/mobile-rn/__tests__/theme-parity.test.ts
Normal file
48
apps/mobile-rn/__tests__/theme-parity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
47
apps/mobile-rn/__tests__/unified-search-result-row.test.tsx
Normal file
47
apps/mobile-rn/__tests__/unified-search-result-row.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal file
215
apps/mobile-rn/__tests__/unified-search-service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue