feat: add mobile shell and unified search
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s

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

View file

@ -0,0 +1,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 }),
)
})
})