import { describe, it, expect } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { Feature } from '@d3ro/core/types' import { getHistoryService } from '../../src/main/services/HistoryService' import { getDictionaryService } from '../../src/main/services/DictionaryService' import { getMemoService } from '../../src/main/services/MemoService' import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService' import { getChainService } from '../../src/main/services/ChainService' import { getVoiceCommandService } from '../../src/main/services/VoiceCommandService' import { getLicenseService } from '../../src/main/services/LicenseService' import { getMeetingModeService } from '../../src/main/services/MeetingModeService' import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService' import { configGet, configSet } from '../../src/main/services/ConfigService' import { getDatabase } from '../../src/main/db' import { meetingSessions } from '../../src/main/db/schema' import { registerHistoryHandlers } from '../../src/main/ipc/history-handlers' import { registerDictionaryHandlers } from '../../src/main/ipc/dictionary-handlers' import { registerInstructionHandlers } from '../../src/main/ipc/instruction-handlers' import { historyInput, invokeIpc, useRedHarness } from './harness' import { FX, USER_TEXT } from './fixtures' useRedHarness() describe('유스케이스: 연계 플로우 (사전→힌트, 히스토리→태그, 명령→체인, 라이선스→설정)', () => { it('사전 추가 후 prompt hint 가 받아쓰기 힌트에 쓰일 단어를 담는다', () => { getDictionaryService().add({ word: 'D3ROVoice' }) const hints = getDictionaryService().getPromptHints() expect(hints).toContain('D3ROVoice') }) it('히스토리 저장 → 태그 → 태그 검색이 같은 엔트리를 찾는다', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO })) getMemoService().addTag(entry.id, 'meeting') const found = getMemoService().searchByTag({ tag: 'meeting', page: 0, pageSize: 5 }) expect(found.entries[0].originalText).toBe(USER_TEXT.KO) }) it('히스토리 삭제 후 태그는 검색에서 빠진다', () => { const entry = getHistoryService().create(historyInput()) getMemoService().addTag(entry.id, 'orphan') getHistoryService().delete(entry.id) const found = getMemoService().searchByTag({ tag: 'orphan', page: 0, pageSize: 5 }) expect(found.total).toBe(0) }) it('명령어 생성 → 체인 스텝 → 실행 시 해당 명령을 찾는다 (LLM 실패는 표면화)', async () => { const inst = getCustomInstructionService() inst.initialize() const created = inst.create({ name: '스텝용', description: '', prompt: 'do {{text}}', icon: 'Edit' }) const chain = getChainService() chain.initialize() const c = chain.create({ name: 'one', steps: [{ instructionId: created.id, inputSource: 'original' }], }) await expect(chain.execute(c.id, USER_TEXT.EN)).rejects.toMatchObject({ code: ErrorCode.ChainStepFailed, }) }) it('음성 단축키 활성 + 키워드 후 전사 텍스트가 명령으로 갈린다', () => { const vc = getVoiceCommandService() vc.initialize() vc.setEnabled(true) vc.setKeywordsForInstruction('builtin-summarize', [ { keyword: '짧게', matchMode: 'prefix' }, ]) const match = vc.match('짧게 오늘 회의 내용') expect(match.matched).toBe(true) expect(match.cleanedText).toBe('오늘 회의 내용') }) it('라이선스 pro 활성화 후 로컬 기능은 계속 허용된다', async () => { const lic = getLicenseService() lic.initialize() await lic.activate(FX.LICENSE_PRO) expect(lic.canUse(Feature.DICTATION).allowed).toBe(true) expect(lic.canUse(Feature.LLM_PROCESS).allowed).toBe(true) }) it('설정에서 LLM 액션을 none 으로 바꾸면 config 가 유지된다', () => { configSet('defaultLLMAction', 'none') expect(configGet('defaultLLMAction')).toBe('none') configSet('defaultLLMAction', 'refine') expect(configGet('defaultLLMAction')).toBe('refine') }) it('회의 세션 생성 → 제목 수정 → 전사 수정 → 상세에 둘 다 남는다', () => { const now = Date.now() getDatabase() .insert(meetingSessions) .values({ id: 'chain-meet', title: 'old', status: 'completed', startedAt: now, createdAt: now, updatedAt: now, rawTranscript: USER_TEXT.KO, }) .run() getMeetingModeService().updateTitle('chain-meet', 'new') getMeetingModeService().updateTranscript('chain-meet', USER_TEXT.EN) const detail = getMeetingModeService().getSession('chain-meet') expect(detail.title).toBe('new') expect(detail.editedTranscript).toBe(USER_TEXT.EN) }) it('이메일 템플릿 세션을 취소한 뒤 다시 시작할 수 있다', () => { const svc = getDictationTemplateService() svc.startSession('builtin-email') svc.cancelSession() svc.startSession('builtin-email') expect(svc.getSessionState()?.templateId).toBe('builtin-email') svc.cancelSession() }) it('IPC 히스토리 추가 후 검색 → 삭제 연계', async () => { registerHistoryHandlers() const entry = getHistoryService().create(historyInput({ originalText: '연계검색어XYZ' })) const found = await invokeIpc(IPC_CHANNELS.HISTORY.SEARCH, { query: '연계검색어XYZ', page: 0, pageSize: 10, }) expect(found.success).toBe(true) const del = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: entry.id }) expect(del.success).toBe(true) const again = getHistoryService().search({ query: '연계검색어XYZ', page: 0, pageSize: 10 }) expect(again.total).toBe(0) }) it('IPC 사전 추가 → 검색 → 삭제 연계', async () => { registerDictionaryHandlers() const add = await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: '연계단어Q' }) expect(add.success).toBe(true) const search = await invokeIpc(IPC_CHANNELS.DICTIONARY.SEARCH, { query: '연계단어', page: 0, pageSize: 10, }) expect(search.success).toBe(true) if (add.success) { const del = await invokeIpc(IPC_CHANNELS.DICTIONARY.DELETE, { id: add.data.id }) expect(del.success).toBe(true) } }) it('사용자 명령 생성 후 IPC 로 조회한다', async () => { getCustomInstructionService().initialize() registerInstructionHandlers() const created = await invokeIpc(IPC_CHANNELS.INSTRUCTION.CREATE, { name: '연계명령', description: 'd', prompt: 'p', }) expect(created.success).toBe(true) if (created.success) { const got = await invokeIpc(IPC_CHANNELS.INSTRUCTION.GET_BY_ID, { id: created.data.id }) expect(got.success).toBe(true) if (got.success) expect(got.data.name).toBe('연계명령') } }) it('히스토리 여러 모드가 목록에 함께 보인다', () => { getHistoryService().create(historyInput({ mode: 'dictation', originalText: 'd' })) getHistoryService().create(historyInput({ mode: 'caption', originalText: 'c' })) getHistoryService().create(historyInput({ mode: 'file-transcription', originalText: 'f' })) expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(3) }) it('같은 태그를 두 히스토리에 달면 카운트가 2 다', () => { const a = getHistoryService().create(historyInput({ originalText: 'a-text' })) const b = getHistoryService().create(historyInput({ originalText: 'b-text' })) getMemoService().addTag(a.id, 'shared-tag') getMemoService().addTag(b.id, 'shared-tag') expect(getMemoService().getAllTags().find((t) => t.tag === 'shared-tag')?.count).toBe(2) }) it('명령어 순서 변경 후 첫 항목이 바뀐다', () => { const svc = getCustomInstructionService() svc.initialize() const ids = svc.getAll().map((i) => i.id) const reversed = [...ids].reverse() svc.reorder(reversed) expect(svc.getAll()[0].id).toBe(reversed[0]) }) it('온보딩 완료 플래그와 테마를 같이 저장한다', () => { configSet('onboardingCompleted', true) configSet('theme', 'dark') expect(configGet('onboardingCompleted')).toBe(true) expect(configGet('theme')).toBe('dark') }) it('STT 언어와 LLM 액션을 함께 바꾼다', () => { configSet('sttLanguage', 'en') configSet('defaultLLMAction', 'translate') expect(configGet('sttLanguage')).toBe('en') expect(configGet('defaultLLMAction')).toBe('translate') }) it('라이선스 활성화 실패 후 재시도 성공', async () => { const lic = getLicenseService() lic.initialize() const fail = await lic.activate(FX.LICENSE_BAD) expect(fail.success).toBe(false) const ok = await lic.activate(FX.LICENSE_PRO) expect(ok.success).toBe(true) expect(lic.tier).toBe('pro') }) it('회의 세션 두 건 중 하나만 삭제한다', () => { const now = Date.now() for (const id of ['m-keep', 'm-drop']) { getDatabase() .insert(meetingSessions) .values({ id, title: id, status: 'completed', startedAt: now, createdAt: now, updatedAt: now, }) .run() } getMeetingModeService().deleteSession('m-drop') expect(() => getMeetingModeService().getSession('m-drop')).toThrow() expect(getMeetingModeService().getSession('m-keep').id).toBe('m-keep') }) it('사전 사용횟수 증가 후 hint 순서가 바뀐다', () => { const low = getDictionaryService().add({ word: 'lowuse' }) const high = getDictionaryService().add({ word: 'highuse' }) getDictionaryService().incrementUsage(high.id) getDictionaryService().incrementUsage(high.id) getDictionaryService().incrementUsage(low.id) const hints = getDictionaryService().getPromptHints() expect(hints.indexOf('highuse')).toBeLessThan(hints.indexOf('lowuse')) }) it('히스토리 통계는 여러 건 누적된다', () => { getHistoryService().create(historyInput({ duration: 1, wordCount: 2 })) getHistoryService().create(historyInput({ duration: 2, wordCount: 3 })) getHistoryService().create(historyInput({ duration: 3, wordCount: 5 })) const st = getHistoryService().getStats() expect(st.todaySessionCount).toBe(3) expect(st.todayWordCount).toBe(10) }) it('빌트인 명령 프롬프트 수정 후 reset 하면 원래 프롬프트로 돌아간다', () => { const svc = getCustomInstructionService() svc.initialize() const before = svc.getById('builtin-summarize')!.prompt svc.update('builtin-summarize', { prompt: 'CHANGED-PROMPT' }) expect(svc.getById('builtin-summarize')!.prompt).toBe('CHANGED-PROMPT') svc.resetBuiltins() expect(svc.getById('builtin-summarize')!.prompt).toBe(before) }) it('체인 수정 후 실행해도 없는 instruction 이면 실패한다', async () => { const svc = getChainService() svc.initialize() const c = svc.create({ name: 'x', steps: [] }) svc.update({ id: c.id, steps: [{ instructionId: 'still-missing', inputSource: 'original' }], }) await expect(svc.execute(c.id, 'hi')).rejects.toMatchObject({ code: ErrorCode.ChainStepFailed }) }) it('유니코드 히스토리 + 유니코드 태그 검색', () => { const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.UNICODE })) getMemoService().addTag(entry.id, '아이디어🎉') const found = getMemoService().searchByTag({ tag: '아이디어🎉', page: 0, pageSize: 5 }) expect(found.total).toBe(1) }) it('긴 히스토리 원문이 검색된다', () => { getHistoryService().create(historyInput({ originalText: `${USER_TEXT.LONG} UNIQUE_TAIL` })) expect(getHistoryService().search({ query: 'UNIQUE_TAIL', page: 0, pageSize: 5 }).total).toBe(1) }) })