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

@ -30,6 +30,7 @@ import { registerAdsHandlers } from './ads-handlers'
import { registerSupportHandlers } from './support-handlers'
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
import { registerSuggestionHandlers } from './suggestion-handlers'
import { registerUnifiedSearchHandlers } from './unified-search-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -44,6 +45,7 @@ export function registerAllIpcHandlers(): void {
registerKeyBindingHandlers()
registerLLMHandlers()
registerHistoryHandlers()
registerUnifiedSearchHandlers()
registerDictionaryHandlers()
registerInstructionHandlers()
registerMemoHandlers()

View file

@ -0,0 +1,19 @@
import { ipcMain } from 'electron'
import { D3ROError, ErrorCode, ipcError, ipcSuccess } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
getUnifiedSearchService,
validateUnifiedSearchParams,
} from '../services/UnifiedSearchService'
export function registerUnifiedSearchHandlers(): void {
ipcMain.handle(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, (_event, params: unknown) => {
try {
const validatedParams = validateUnifiedSearchParams(params)
return ipcSuccess(getUnifiedSearchService().search(validatedParams))
} catch (error) {
if (error instanceof D3ROError) return ipcError(error.code, error.message, error.details)
return ipcError(ErrorCode.DBQueryFailed, 'Unified search failed')
}
})
}

View file

@ -0,0 +1,182 @@
// 기록과 회의를 스키마 변경 없이 함께 찾는 로컬 통합 검색.
import { EventEmitter } from 'events'
import { count, desc, or } from 'drizzle-orm'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
UnifiedSearchParams,
UnifiedSearchResponse,
UnifiedSearchResult,
} from '@d3ro/core/types'
import { getDatabase } from '../db'
import { containsPattern } from '../db/like-pattern'
import { history, meetingSessions, type History, type MeetingSessionRow } from '../db/schema'
import { getLogger } from './LoggerService'
const logger = getLogger('UnifiedSearchService')
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 50
const MAX_QUERY_LENGTH = 200
const EXCERPT_LENGTH = 180
export function validateUnifiedSearchParams(value: unknown): UnifiedSearchParams {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search parameters are required')
}
const candidate = value as Record<string, unknown>
if (typeof candidate.query !== 'string') {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search query must be a string', {
field: 'query',
})
}
if (
candidate.limit !== undefined
&& (typeof candidate.limit !== 'number' || !Number.isFinite(candidate.limit))
) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Unified search limit must be a finite number', {
field: 'limit',
})
}
return candidate.limit === undefined
? { query: candidate.query }
: { query: candidate.query, limit: candidate.limit as number }
}
export function normalizeUnifiedSearchQuery(value: string): string {
return value.slice(0, MAX_QUERY_LENGTH).trim().replace(/\s+/g, ' ')
}
function includesQuery(value: string | null | undefined, query: string): boolean {
return value?.toLocaleLowerCase().includes(query.toLocaleLowerCase()) === true
}
export function createUnifiedSearchExcerpt(
value: string | null | undefined,
query: string,
maxLength = EXCERPT_LENGTH,
): string {
const text = (value ?? '').replace(/\s+/g, ' ').trim()
if (text.length <= maxLength) return text
const matchAt = text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase())
if (matchAt < 0) return `${text.slice(0, maxLength - 1).trimEnd()}…`
const context = Math.max(20, Math.floor((maxLength - query.length) / 2))
const start = Math.max(0, matchAt - context)
const end = Math.min(text.length, start + maxLength)
const body = text.slice(start, end).trim()
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`
}
function recordResult(row: History, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingContent = [row.polishedText, row.originalText, row.summaryText]
.find((value) => includesQuery(value, query))
const displayContent = matchingContent ?? row.polishedText ?? row.originalText ?? row.summaryText ?? row.title
return {
source: 'record',
id: row.id,
title: row.title,
excerpt: createUnifiedSearchExcerpt(displayContent, query),
matchedField: titleMatched ? 'title' : 'content',
occurredAt: row.createdAt,
}
}
function meetingResult(row: MeetingSessionRow, query: string): UnifiedSearchResult {
const titleMatched = includesQuery(row.title, query)
const matchingTranscript = [row.editedTranscript, row.rawTranscript]
.find((value) => includesQuery(value, query))
const displayContent = matchingTranscript ?? row.editedTranscript ?? row.rawTranscript ?? row.title
return {
source: 'meeting',
id: row.id,
title: row.title,
excerpt: createUnifiedSearchExcerpt(displayContent, query),
matchedField: titleMatched ? 'title' : 'transcript',
occurredAt: row.startedAt,
}
}
export function mergeUnifiedSearchResults(
records: readonly UnifiedSearchResult[],
meetings: readonly UnifiedSearchResult[],
limit: number,
): UnifiedSearchResult[] {
return [...records, ...meetings]
.sort((left, right) => right.occurredAt - left.occurredAt || left.id.localeCompare(right.id))
.slice(0, limit)
}
class UnifiedSearchService extends EventEmitter {
search(value: unknown): UnifiedSearchResponse {
const params = validateUnifiedSearchParams(value)
const query = normalizeUnifiedSearchQuery(params.query)
const limit = Math.max(1, Math.min(Math.floor(params.limit ?? DEFAULT_LIMIT), MAX_LIMIT))
if (query.length === 0) return { query, results: [], total: 0 }
try {
const db = getDatabase()
const pattern = containsPattern(query)
const recordConditions = or(
pattern.match(history.title),
pattern.match(history.originalText),
pattern.match(history.polishedText),
pattern.match(history.summaryText),
)
const meetingConditions = or(
pattern.match(meetingSessions.title),
pattern.match(meetingSessions.editedTranscript),
pattern.match(meetingSessions.rawTranscript),
)
const recordTotal = db.select({ count: count() }).from(history).where(recordConditions).get()?.count ?? 0
const recordRows = db
.select()
.from(history)
.where(recordConditions)
.orderBy(desc(history.createdAt))
.limit(limit)
.all()
const meetingTotal = db.select({ count: count() }).from(meetingSessions).where(meetingConditions).get()?.count ?? 0
const meetingRows = db
.select()
.from(meetingSessions)
.where(meetingConditions)
.orderBy(desc(meetingSessions.startedAt))
.limit(limit)
.all()
const results = mergeUnifiedSearchResults(
recordRows.map((row) => recordResult(row, query)),
meetingRows.map((row) => meetingResult(row, query)),
limit,
)
return { query, results, total: recordTotal + meetingTotal }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`Unified search failed: ${message}`)
throw new D3ROError(ErrorCode.DBQueryFailed, 'Unified search failed', { cause: message })
}
}
dispose(): void {
this.removeAllListeners()
}
}
let instance: UnifiedSearchService | null = null
export function getUnifiedSearchService(): UnifiedSearchService {
if (instance === null) instance = new UnifiedSearchService()
return instance
}
export function resetUnifiedSearchServiceForTests(): void {
instance?.dispose()
instance = null
}

View file

@ -68,6 +68,8 @@ import type {
HistorySearchParams,
HistorySetFavoriteParams,
HistoryAudioSource,
UnifiedSearchParams,
UnifiedSearchResponse,
DictionaryQueryParams,
DictionaryPage,
DictionaryEntry,
@ -426,6 +428,12 @@ const electronAPI = {
on(IPC_CHANNELS.HISTORY.ADDED, cb)
},
// ── Unified search (records + meetings) ───────────────
unifiedSearch: {
query: (params: UnifiedSearchParams) =>
invoke<UnifiedSearchResponse>(IPC_CHANNELS.UNIFIED_SEARCH.QUERY, params),
},
// ── Dictionary ─────────────────────────────────────────
dictionary: {
getAll: (params: DictionaryQueryParams) =>

View file

@ -19,6 +19,7 @@ import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage'
import { UnifiedSearchPage } from '../pages/UnifiedSearchPage'
import { SettingsModal } from './SettingsModal'
import { OPEN_SETTINGS_EVENT, readOpenSettingsTab, DEFAULT_SETTINGS_TAB } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
@ -132,10 +133,16 @@ function Shell(): React.ReactElement {
}
}, [t, navigate])
// 키보드: Ctrl+1..3 = 1급 화면, Ctrl+, = 설정. 입력 중에는 무시.
// 키보드: Ctrl+K = 통합 검색, Ctrl+1..3 = 1급 화면, Ctrl+, = 설정.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || isTypingTarget(e.target)) return
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return
if (e.key.toLocaleLowerCase() === 'k') {
e.preventDefault()
if (route !== 'search') navigate('search')
return
}
if (isTypingTarget(e.target)) return
const index = ['1', '2', '3'].indexOf(e.key)
if (index >= 0) {
e.preventDefault()
@ -147,7 +154,7 @@ function Shell(): React.ReactElement {
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [navigate])
}, [navigate, route])
const openSettingsTab = useCallback(
(tab: SettingsTabId) => {
@ -186,6 +193,7 @@ function Shell(): React.ReactElement {
style={{ height: '100%', width: '100%' }}
>
{route === 'home' && <HomePage />}
{route === 'search' && <UnifiedSearchPage />}
{route === 'records' && <HistoryPage />}
{route === 'meetings' && <MeetingModePage />}
{route === 'conversation' && <VoiceConversationPage />}

View file

@ -12,8 +12,9 @@ export interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder: string
/** 포커스 단축키. '/' 만 지원. 빈 문자열이면 끈다 */
/** 포커스 단축키. '/' 또는 'Ctrl K'. 빈 문자열이면 끈다 */
shortcut?: string
autoFocus?: boolean
}
function isTypingTarget(target: EventTarget | null): boolean {
@ -21,17 +22,34 @@ function isTypingTarget(target: EventTarget | null): boolean {
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
}
export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: SearchInputProps): React.ReactElement {
export function SearchInput({
value,
onChange,
placeholder,
shortcut = '/',
autoFocus = false,
}: SearchInputProps): React.ReactElement {
const { t } = useI18n()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (shortcut !== '/') return undefined
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return
const slash = shortcut === '/'
&& e.key === '/'
&& !e.ctrlKey
&& !e.metaKey
&& !e.altKey
&& !isTypingTarget(e.target)
const commandK = shortcut === 'Ctrl K'
&& (e.ctrlKey || e.metaKey)
&& !e.altKey
&& !e.shiftKey
&& e.key.toLocaleLowerCase() === 'k'
if (!slash && !commandK) return
e.preventDefault()
inputRef.current?.focus()
}
if (shortcut !== '/' && shortcut !== 'Ctrl K') return undefined
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [shortcut])
@ -39,6 +57,7 @@ export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: Se
return (
<TextField
inputRef={inputRef}
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}

View file

@ -14,6 +14,7 @@ import {
Home,
Library,
MessagesSquare,
Search,
Settings,
SquareTerminal,
Users,
@ -31,6 +32,7 @@ const ICON = 18
const ROUTE_META: Record<AppRoute, { labelKey: TranslationKey; icon: React.ReactElement }> = {
home: { labelKey: 'nav.home', icon: <Home size={ICON} /> },
search: { labelKey: 'unifiedSearch.title', icon: <Search size={ICON} /> },
records: { labelKey: 'nav.records', icon: <HistoryIcon size={ICON} /> },
meetings: { labelKey: 'nav.meeting', icon: <Users size={ICON} /> },
conversation: { labelKey: 'nav.aiChat', icon: <MessagesSquare size={ICON} /> },

View file

@ -11,6 +11,7 @@ import type { SettingsTabId } from './settingsTabs'
export type AppRoute =
| 'home'
| 'search'
| 'records'
| 'meetings'
| 'conversation'

View file

@ -0,0 +1,251 @@
// 기록과 회의 제목·전사를 함께 찾고, 원본 상세로 돌려보내는 읽기 전용 검색 화면.
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box, ButtonBase, IconButton, Skeleton, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, FileText, Search as SearchIcon, SearchX, Users } from 'lucide-react'
import type { UnifiedSearchMatchedField, UnifiedSearchResponse, UnifiedSearchResult } from '@d3ro/core/types'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import {
d3roFocusRing,
d3roPalette,
d3roRadius,
d3roTransition,
typoSx,
} from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { EmptyStateCard, PageHeader, SearchInput } from '../components/shared'
import { useAppNavigation, useRouteRequest } from '../navigation/app-navigation'
const SEARCH_DEBOUNCE_MS = 180
type SearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; response: UnifiedSearchResponse }
| { status: 'error' }
function resultIcon(source: UnifiedSearchResult['source']): React.ReactElement {
return source === 'record' ? <FileText size={18} aria-hidden /> : <Users size={18} aria-hidden />
}
export function UnifiedSearchPage(): React.ReactElement {
const { t, locale } = useI18n()
const { navigate, back } = useAppNavigation()
const [query, setQuery] = useState('')
const [state, setState] = useState<SearchState>({ status: 'idle' })
const [retryNonce, setRetryNonce] = useState(0)
const requestSequence = useRef(0)
useRouteRequest('search', (params) => {
setQuery(params.query ?? '')
})
useEffect(() => {
const normalized = query.trim()
if (!normalized) {
requestSequence.current += 1
setState({ status: 'idle' })
return undefined
}
const sequence = ++requestSequence.current
const timer = window.setTimeout(() => {
setState({ status: 'loading' })
void window.electronAPI.unifiedSearch
.query({ query: normalized })
.then((result) => {
if (requestSequence.current !== sequence) return
setState(result.success ? { status: 'ready', response: result.data } : { status: 'error' })
})
.catch(() => {
if (requestSequence.current === sequence) setState({ status: 'error' })
})
}, SEARCH_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
if (requestSequence.current === sequence) requestSequence.current += 1
}
}, [query, retryNonce])
const openResult = useCallback(
(result: UnifiedSearchResult) => {
if (result.source === 'record') navigate('records', { recordId: result.id })
else navigate('meetings', { meetingSessionId: result.id })
},
[navigate],
)
const matchLabel = useCallback(
(field: UnifiedSearchMatchedField): string => {
switch (field) {
case 'title':
return t('unifiedSearch.matchedTitle')
case 'content':
return t('unifiedSearch.matchedContent')
case 'transcript':
return t('unifiedSearch.matchedTranscript')
}
},
[t],
)
const resultCount = state.status === 'ready'
? t('unifiedSearch.resultCount', { count: state.response.total })
: undefined
let body: React.ReactNode
if (state.status === 'idle') {
body = (
<EmptyStateCard
icon={<SearchIcon />}
message={t('unifiedSearch.promptTitle')}
description={t('unifiedSearch.promptDescription')}
/>
)
} else if (state.status === 'loading') {
body = (
<Box aria-busy="true" aria-label={t('unifiedSearch.loading')} sx={{ pt: 1 }}>
{Array.from({ length: 5 }, (_, index) => (
<Box key={index} sx={{ display: 'flex', gap: 2, px: 1.5, py: 1.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
<Skeleton variant="circular" width={18} height={18} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width={`${52 + index * 7}%`} />
<Skeleton variant="text" width={`${78 - index * 5}%`} />
</Box>
</Box>
))}
</Box>
)
} else if (state.status === 'error') {
body = (
<EmptyStateCard
icon={<AlertTriangle />}
message={t('unifiedSearch.loadErrorTitle')}
description={t('unifiedSearch.loadErrorDescription')}
action={
<PhysicalButton tone="glass" size="small" onClick={() => setRetryNonce((value) => value + 1)}>
{t('unifiedSearch.retry')}
</PhysicalButton>
}
/>
)
} else if (state.response.results.length === 0) {
body = (
<EmptyStateCard
icon={<SearchX />}
message={t('unifiedSearch.noResultsTitle')}
description={t('unifiedSearch.noResultsDescription', { query: state.response.query })}
/>
)
} else {
body = (
<Box component="ul" aria-label={t('unifiedSearch.resultsLabel')} sx={{ m: 0, p: 0, listStyle: 'none' }}>
{state.response.results.map((result) => {
const sourceLabel = result.source === 'record'
? t('unifiedSearch.sourceRecord')
: t('unifiedSearch.sourceMeeting')
const title = result.title?.trim() || (result.source === 'record'
? t('unifiedSearch.untitledRecord')
: t('unifiedSearch.untitledMeeting'))
return (
<Box component="li" key={`${result.source}:${result.id}`}>
<ButtonBase
onClick={() => openResult(result)}
aria-label={t('unifiedSearch.openResult', { source: sourceLabel, title })}
sx={{
width: '100%',
minHeight: 72,
display: 'grid',
gridTemplateColumns: '24px minmax(0, 1fr) auto',
alignItems: 'start',
gap: 1.5,
px: 1.5,
py: 1.5,
textAlign: 'left',
color: d3roPalette.text.secondary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.button,
transition: d3roTransition('background-color', 'color'),
'&:hover': { bgcolor: d3roPalette.surface.overlaySoft },
'&:focus-visible': d3roFocusRing,
}}
>
<Box aria-hidden sx={{ color: d3roPalette.text.tertiary, pt: 0.25 }}>
{resultIcon(result.source)}
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Typography component="h2" sx={{ ...typoSx('compact'), fontWeight: 500, color: d3roPalette.text.primary }}>
{title}
</Typography>
<Typography component="span" sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
{sourceLabel} · {matchLabel(result.matchedField)}
</Typography>
</Box>
<Typography
sx={{
...typoSx('compact'),
color: d3roPalette.text.secondary,
mt: 0.5,
overflowWrap: 'anywhere',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{result.excerpt || t('unifiedSearch.noPreview')}
</Typography>
</Box>
<Typography
component="time"
dateTime={new Date(result.occurredAt).toISOString()}
sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary, whiteSpace: 'nowrap', pt: 0.25 }}
>
{new Date(result.occurredAt).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' })}
</Typography>
</ButtonBase>
</Box>
)
})}
</Box>
)
}
return (
<Box
onKeyDown={(event) => {
if (event.key === 'Escape' && query.trim().length === 0) back()
}}
sx={{ maxWidth: 1000, mx: 'auto', px: 5, py: 4, pb: 10 }}
>
<PageHeader
title={t('unifiedSearch.title')}
count={resultCount}
description={t('unifiedSearch.description')}
action={
<IconButton aria-label={t('unifiedSearch.back')} onClick={back} sx={{ color: d3roPalette.text.secondary }}>
<ArrowLeft size={18} aria-hidden />
</IconButton>
}
/>
<SearchInput
value={query}
onChange={setQuery}
placeholder={t('unifiedSearch.placeholder')}
shortcut="Ctrl K"
autoFocus
/>
<Box role="status" aria-live="polite" sx={{ minHeight: 18, mb: 1 }}>
{resultCount && (
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.tertiary }}>
{resultCount}
</Typography>
)}
</Box>
{body}
</Box>
)
}

View file

@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(
() => new Map<string, (event: unknown, params: unknown) => unknown>()
)
const search = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params: unknown) => unknown) => {
handlers.set(channel, handler)
}),
},
}))
vi.mock('../../../src/main/services/UnifiedSearchService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/main/services/UnifiedSearchService')>()
return {
...actual,
getUnifiedSearchService: () => ({ search }),
}
})
beforeEach(async () => {
vi.clearAllMocks()
handlers.clear()
const mod = await import('../../../src/main/ipc/unified-search-handlers')
mod.registerUnifiedSearchHandlers()
})
describe('UNIFIED_SEARCH.QUERY', () => {
it.each([
null,
{},
{ query: 42 },
{ query: '계획', limit: Number.NaN },
{ query: '계획', limit: Number.NEGATIVE_INFINITY },
])('returns ConfigInvalidValue for malformed renderer input: %o', async (params) => {
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
if (!handler) throw new Error('unified search handler was not registered')
const result = await handler({}, params)
expect(result).toMatchObject({
success: false,
error: { code: ErrorCode.ConfigInvalidValue },
})
expect(search).not.toHaveBeenCalled()
})
it('passes validated input to the service and returns IPC success', async () => {
const handler = handlers.get(IPC_CHANNELS.UNIFIED_SEARCH.QUERY)
if (!handler) throw new Error('unified search handler was not registered')
search.mockReturnValue({ query: '계획', results: [], total: 0 })
const result = await handler({}, { query: '계획', limit: 10 })
expect(search).toHaveBeenCalledWith({ query: '계획', limit: 10 })
expect(result).toEqual({
success: true,
data: { query: '계획', results: [], total: 0 },
})
})
})

View file

@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import type { History, MeetingSessionRow } from '../../../src/main/db/schema'
interface MockBuilderOptions {
get?: unknown
all?: unknown[]
error?: Error
}
function createMockBuilder(options: MockBuilderOptions = {}) {
const builder = {
from: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
get: vi.fn(),
all: vi.fn(),
}
builder.from.mockImplementation(() => {
if (options.error !== undefined) throw options.error
return builder
})
builder.where.mockReturnValue(builder)
builder.orderBy.mockReturnValue(builder)
builder.limit.mockReturnValue(builder)
builder.get.mockReturnValue(options.get ?? null)
builder.all.mockReturnValue(options.all ?? [])
return builder
}
const selectQueue: ReturnType<typeof createMockBuilder>[] = []
const mockDb = {
select: vi.fn(() => selectQueue.shift() ?? createMockBuilder()),
}
vi.mock('../../../src/main/db', () => ({
getDatabase: () => mockDb,
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
const record: History = {
id: 'record-1',
title: '제품 메모',
originalText: '초기 원문',
polishedText: '다음 분기 출시 계획을 정리했습니다.',
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: 'dictation',
status: 'completed',
errorCode: null,
audioLocalPath: null,
duration: 10,
detectedLanguage: 'ko',
micDevice: null,
wordCount: 6,
sttModel: null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
createdAt: 100,
updatedAt: 100,
appVersion: '1.9.0',
summaryText: null,
isFavorite: false,
}
const meeting: MeetingSessionRow = {
id: 'meeting-1',
title: '주간 회의',
status: 'completed',
startedAt: 200,
endedAt: 300,
durationMs: 100,
rawTranscript: '출시 계획을 다음 주에 검토합니다.',
editedTranscript: null,
minutesMarkdown: null,
minutesJson: null,
sttModel: null,
llmModel: null,
sttLatencyMs: null,
llmLatencyMs: null,
errorMessage: null,
createdAt: 200,
updatedAt: 300,
}
describe('UnifiedSearchService', () => {
beforeEach(() => {
selectQueue.length = 0
vi.clearAllMocks()
})
it('normalizes input and creates a bounded excerpt around the match', async () => {
const { createUnifiedSearchExcerpt, normalizeUnifiedSearchQuery } = await import(
'../../../src/main/services/UnifiedSearchService'
)
expect(normalizeUnifiedSearchQuery(' 출시\n 계획 ')).toBe('출시 계획')
expect(normalizeUnifiedSearchQuery('a'.repeat(250))).toHaveLength(200)
expect(normalizeUnifiedSearchQuery(` ${'a'.repeat(199)} b`)).toBe('a'.repeat(199))
const excerpt = createUnifiedSearchExcerpt(`앞부분 ${'x'.repeat(80)} 출시 계획 ${'y'.repeat(80)}`, '출시 계획', 60)
expect(excerpt).toContain('출시 계획')
expect(excerpt.startsWith('…')).toBe(true)
expect(excerpt.endsWith('…')).toBe(true)
})
it('merges records and meeting transcripts by occurrence time and preserves source ids', async () => {
selectQueue.push(
createMockBuilder({ get: { count: 1 } }),
createMockBuilder({ all: [record] }),
createMockBuilder({ get: { count: 1 } }),
createMockBuilder({ all: [meeting] }),
)
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
const response = getUnifiedSearchService().search({ query: '계획', limit: 10 })
expect(response.total).toBe(2)
expect(response.results.map(({ source, id }) => ({ source, id }))).toEqual([
{ source: 'meeting', id: 'meeting-1' },
{ source: 'record', id: 'record-1' },
])
expect(response.results[0]).toMatchObject({
matchedField: 'transcript',
excerpt: '출시 계획을 다음 주에 검토합니다.',
})
expect(response.results[1]).toMatchObject({ matchedField: 'content' })
})
it('returns immediately for an empty query', async () => {
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(getUnifiedSearchService().search({ query: ' ' })).toEqual({
query: '',
results: [],
total: 0,
})
expect(mockDb.select).not.toHaveBeenCalled()
})
it.each([
null,
undefined,
{},
{ query: 42 },
{ query: '계획', limit: Number.NaN },
{ query: '계획', limit: Number.POSITIVE_INFINITY },
{ query: '계획', limit: '10' },
])('rejects malformed runtime parameters with ConfigInvalidValue: %o', async (params) => {
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(() => getUnifiedSearchService().search(params)).toThrowError(
expect.objectContaining({ code: ErrorCode.ConfigInvalidValue }),
)
expect(mockDb.select).not.toHaveBeenCalled()
})
it('maps database failures to the IPC error contract', async () => {
selectQueue.push(createMockBuilder({ error: new Error('database unavailable') }))
const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import(
'../../../src/main/services/UnifiedSearchService'
)
resetUnifiedSearchServiceForTests()
expect(() => getUnifiedSearchService().search({ query: '계획' })).toThrowError(
expect.objectContaining({ code: ErrorCode.DBQueryFailed }),
)
})
})