feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -0,0 +1,232 @@
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { getOnlineLLMService } from '../../src/main/services/OnlineLLMService'
import { getCloudSyncService } from '../../src/main/services/CloudSyncService'
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
import { registerCloudSyncHandlers } from '../../src/main/ipc/cloud-sync-handlers'
import { invokeIpc, useRedHarness } from './harness'
import { FX } from './fixtures'
useRedHarness()
describe('유스케이스: 온라인 로그인/가입/로그아웃 + 클라우드 로그인 실패', () => {
it('로그인 전에는 GET_USER 가 비인증이다', async () => {
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.GET_USER)
expect(res.success).toBe(true)
if (res.success) {
expect(res.data.isAuthenticated).toBe(false)
expect(res.data.email).toBeNull()
}
})
it('로그인 성공 시 토큰이 저장되고 인증 상태가 true 다', async () => {
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ token: FX.AUTH_TOKEN, email: FX.AUTH_EMAIL }),
})),
)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGIN, {
email: FX.AUTH_EMAIL,
password: 'fx-not-the-asserted-secret',
})
expect(res.success).toBe(true)
expect(configGet('authToken')).toBe(FX.AUTH_TOKEN)
expect(configGet('userEmail')).toBe(FX.AUTH_EMAIL)
expect(configGet('llmBackend')).toBe('online')
const user = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.GET_USER)
if (user.success) expect(user.data.isAuthenticated).toBe(true)
})
it('로그인 실패(4xx)는 토큰을 남기지 않고 에러를 반환한다', async () => {
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
json: async () => ({ message: FX.AUTH_FAIL_MSG }),
})),
)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGIN, {
email: FX.AUTH_EMAIL,
password: 'wrong',
})
expect(res.success).toBe(false)
if (!res.success) {
expect(res.error.code).toBe(ErrorCode.LLMProcessingFailed)
expect(res.error.message).toContain(FX.AUTH_FAIL_MSG)
}
expect(configGet('authToken')).toBeNull()
})
it('서버 연결 실패는 LLMServerUnreachable 이다', async () => {
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('ECONNREFUSED')
}),
)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGIN, {
email: FX.AUTH_EMAIL,
password: 'x',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.LLMServerUnreachable)
})
it('로그인 실패 후 재시도 성공이 토큰을 저장한다', async () => {
registerLLMHandlers()
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
json: async () => ({ message: FX.AUTH_FAIL_MSG }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: FX.AUTH_TOKEN, email: FX.AUTH_EMAIL }),
})
vi.stubGlobal('fetch', fetchMock)
const fail = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGIN, { email: 'a', password: 'b' })
expect(fail.success).toBe(false)
const ok = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGIN, { email: 'a', password: 'b' })
expect(ok.success).toBe(true)
expect(configGet('authToken')).toBe(FX.AUTH_TOKEN)
})
it('가입 성공은 로그인과 같이 토큰을 저장한다', async () => {
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ token: FX.AUTH_TOKEN, email: FX.AUTH_EMAIL }),
})),
)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.REGISTER, {
email: FX.AUTH_EMAIL,
password: 'pw',
})
expect(res.success).toBe(true)
expect(configGet('authToken')).toBe(FX.AUTH_TOKEN)
})
it('가입 실패는 토큰을 쓰지 않는다', async () => {
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
json: async () => ({ message: 'email taken' }),
})),
)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.REGISTER, {
email: FX.AUTH_EMAIL,
password: 'pw',
})
expect(res.success).toBe(false)
expect(configGet('authToken')).toBeNull()
})
it('로그아웃은 토큰과 이메일을 지운다', async () => {
registerLLMHandlers()
configSet('authToken', FX.AUTH_TOKEN)
configSet('userEmail', FX.AUTH_EMAIL)
const res = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.LOGOUT)
expect(res.success).toBe(true)
expect(configGet('authToken')).toBeNull()
expect(configGet('userEmail')).toBeNull()
const user = await invokeIpc(IPC_CHANNELS.ONLINE_AUTH.GET_USER)
if (user.success) expect(user.data.isAuthenticated).toBe(false)
})
it('토큰 없이 OnlineLLM.processText 는 로그인 필요 에러다', async () => {
await expect(
getOnlineLLMService().processText('hello', 'refine'),
).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable })
})
it('isAvailable 은 토큰이 있을 때만 true 다', () => {
expect(getOnlineLLMService().isAvailable()).toBe(false)
configSet('authToken', FX.AUTH_TOKEN)
expect(getOnlineLLMService().isAvailable()).toBe(true)
})
it('OnlineLLM 이 401 을 받으면 토큰을 지우고 에러를 던진다', async () => {
configSet('authToken', FX.AUTH_TOKEN)
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 401,
json: async () => ({}),
})),
)
await expect(getOnlineLLMService().processText('hi', 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMServerUnreachable,
})
expect(configGet('authToken')).toBeNull()
})
it('OnlineLLM 이 빈 텍스트를 반환하면 성공으로 위장하지 않는다', async () => {
configSet('authToken', FX.AUTH_TOKEN)
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ text: '' }),
})),
)
await expect(getOnlineLLMService().processText('hi', 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMProcessingFailed,
})
})
it('CloudSync 미초기화 상태에서 startSignIn 은 설정 안 됨 에러다', async () => {
await expect(getCloudSyncService().startSignIn('google')).rejects.toMatchObject({
code: ErrorCode.LLMServerUnreachable,
})
})
it('CloudSync handleAuthCallback 빈 코드는 실패한다', async () => {
await expect(getCloudSyncService().handleAuthCallback('')).rejects.toBeTruthy()
})
it('CloudSync 미인증 getState 는 authenticated=false', () => {
const state = getCloudSyncService().getState()
expect(state.authenticated).toBe(false)
expect(state.userEmail).toBeNull()
})
it('미인증 pushAll 은 로그인 필요 에러다', async () => {
await expect(getCloudSyncService().pushAll()).rejects.toMatchObject({
code: ErrorCode.LLMServerUnreachable,
})
})
it('미인증 pullAll 은 로그인 필요 에러다', async () => {
await expect(getCloudSyncService().pullAll()).rejects.toMatchObject({
code: ErrorCode.LLMServerUnreachable,
})
})
it('IPC cloudSync:signIn 은 미설정에서 실패를 숨기지 않는다', async () => {
registerCloudSyncHandlers()
const res = await invokeIpc(IPC_CHANNELS.CLOUD_SYNC.SIGN_IN, { provider: 'google' })
expect(res.success).toBe(false)
})
it('IPC cloudSync:getState 는 비인증이다', async () => {
registerCloudSyncHandlers()
const res = await invokeIpc(IPC_CHANNELS.CLOUD_SYNC.GET_STATE)
expect(res.success).toBe(true)
if (res.success) expect(res.data.authenticated).toBe(false)
})
})

View file

@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getChainService } from '../../src/main/services/ChainService'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { registerChainHandlers } from '../../src/main/ipc/chain-handlers'
import { invokeIpc, useRedHarness } from './harness'
import { FX, USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: LLM 체인 CRUD / 실행 실패 표면화', () => {
it('초기 체인 목록은 비어 있다', () => {
const svc = getChainService()
svc.initialize()
expect(svc.getAll()).toEqual([])
})
it('체인을 만들면 목록에 나타난다', () => {
const svc = getChainService()
svc.initialize()
const chain = svc.create({
name: '요약→번역',
steps: [{ instructionId: 'builtin-summarize', inputSource: 'original' }],
})
expect(chain.name).toBe('요약→번역')
expect(svc.getById(chain.id)?.steps).toHaveLength(1)
})
it('빈 이름 체인은 거부된다', () => {
const svc = getChainService()
svc.initialize()
expect(() => svc.create({ name: ' ', steps: [] })).toThrow()
})
it('없는 체인 수정은 ChainNotFound 다', () => {
const svc = getChainService()
svc.initialize()
expect(() => svc.update({ id: 'missing', name: 'x' })).toThrow(D3ROError)
try {
svc.update({ id: 'missing', name: 'x' })
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.ChainNotFound)
}
})
it('체인 이름 수정이 반영된다', () => {
const svc = getChainService()
svc.initialize()
const chain = svc.create({ name: 'old', steps: [] })
const updated = svc.update({ id: chain.id, name: 'new' })
expect(updated.name).toBe('new')
})
it('체인 삭제 후 조회는 null 이다', () => {
const svc = getChainService()
svc.initialize()
const chain = svc.create({ name: 'del', steps: [] })
svc.delete(chain.id)
expect(svc.getById(chain.id)).toBeNull()
})
it('없는 체인 삭제는 ChainNotFound 다', () => {
const svc = getChainService()
svc.initialize()
expect(() => svc.delete('missing')).toThrow(D3ROError)
})
it('스텝 없는 체인 실행은 ChainExecutionFailed 다', async () => {
const svc = getChainService()
svc.initialize()
const chain = svc.create({ name: 'empty', steps: [] })
await expect(svc.execute(chain.id, USER_TEXT.KO)).rejects.toMatchObject({
code: ErrorCode.ChainExecutionFailed,
})
})
it('없는 체인 실행은 ChainNotFound 다', async () => {
const svc = getChainService()
svc.initialize()
await expect(svc.execute('missing', 'x')).rejects.toMatchObject({
code: ErrorCode.ChainNotFound,
})
})
it('없는 instruction 스텝은 ChainStepFailed 다', async () => {
const svc = getChainService()
svc.initialize()
const chain = svc.create({
name: 'bad-step',
steps: [{ instructionId: 'no-such-instruction', inputSource: 'previous' }],
})
await expect(svc.execute(chain.id, USER_TEXT.KO)).rejects.toMatchObject({
code: ErrorCode.ChainStepFailed,
})
})
it('LLM 실패는 빈 성공이 아니라 ChainStepFailed 다', async () => {
const svc = getChainService()
svc.initialize()
getCustomInstructionService().initialize()
const chain = svc.create({
name: 'real-step',
steps: [{ instructionId: 'builtin-summarize', inputSource: 'original' }],
})
await expect(svc.execute(chain.id, USER_TEXT.KO)).rejects.toMatchObject({
code: ErrorCode.ChainStepFailed,
})
})
it('IPC chain:create / getAll', async () => {
getChainService().initialize()
registerChainHandlers()
const created = await invokeIpc(IPC_CHANNELS.CHAIN.CREATE, {
name: 'ipc-chain',
steps: [],
})
expect(created.success).toBe(true)
const all = await invokeIpc(IPC_CHANNELS.CHAIN.GET_ALL)
expect(all.success).toBe(true)
if (all.success) expect((all.data as unknown[]).length).toBe(1)
})
it('IPC 없는 체인 삭제는 ChainNotFound 다', async () => {
getChainService().initialize()
registerChainHandlers()
const res = await invokeIpc(IPC_CHANNELS.CHAIN.DELETE, { id: 'missing' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.ChainNotFound)
})
it('IPC 실행 실패는 success:false 다', async () => {
getChainService().initialize()
registerChainHandlers()
const res = await invokeIpc(IPC_CHANNELS.CHAIN.EXECUTE, {
chainId: 'missing',
text: USER_TEXT.KO,
})
expect(res.success).toBe(false)
})
it('픽스처 토큰을 체인 입력으로 넣어도 실패 시 그 토큰을 결과로 위장하지 않는다', async () => {
const svc = getChainService()
svc.initialize()
getCustomInstructionService().initialize()
const chain = svc.create({
name: 'no-induce',
steps: [{ instructionId: 'builtin-formal', inputSource: 'original' }],
})
await expect(svc.execute(chain.id, FX.LLM_OK)).rejects.toBeTruthy()
})
})

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
configGet,
configSet,
configGetAll,
configReset,
onConfigChanged,
} from '../../src/main/services/ConfigService'
import { registerConfigHandlers } from '../../src/main/ipc/config-handlers'
import { invokeIpc, useRedHarness } from './harness'
useRedHarness()
describe('유스케이스: 설정 토글 / 테마 / 언어 / 핫키 플래그 / IPC', () => {
it('기본 테마를 읽는다', () => {
expect(configGet('theme')).toBe('auto')
})
it('테마를 dark 로 바꾸면 다시 읽힌다', () => {
configSet('theme', 'dark')
expect(configGet('theme')).toBe('dark')
})
it('테마를 light 로 바꾼다', () => {
configSet('theme', 'light')
expect(configGet('theme')).toBe('light')
})
it('언어를 en 으로 바꾼다', () => {
configSet('language', 'en')
expect(configGet('language')).toBe('en')
})
it('언어를 ko 로 되돌린다', () => {
configSet('language', 'en')
configSet('language', 'ko')
expect(configGet('language')).toBe('ko')
})
it('사운드 토글이 저장된다', () => {
configSet('soundEnabled', false)
expect(configGet('soundEnabled')).toBe(false)
configSet('soundEnabled', true)
expect(configGet('soundEnabled')).toBe(true)
})
it('트레이로 닫기 토글이 저장된다', () => {
configSet('closeToTray', false)
expect(configGet('closeToTray')).toBe(false)
})
it('자동 삽입 토글이 저장된다', () => {
configSet('autoInsert', false)
expect(configGet('autoInsert')).toBe(false)
})
it('핫키 활성 토글이 저장된다', () => {
configSet('hotkeyEnabled', false)
expect(configGet('hotkeyEnabled')).toBe(false)
})
it('온보딩 완료 플래그가 저장된다', () => {
configSet('onboardingCompleted', true)
expect(configGet('onboardingCompleted')).toBe(true)
})
it('STT 언어를 en 으로 바꾼다', () => {
configSet('sttLanguage', 'en')
expect(configGet('sttLanguage')).toBe('en')
})
it('기본 LLM 액션을 none 으로 바꾼다', () => {
configSet('defaultLLMAction', 'none')
expect(configGet('defaultLLMAction')).toBe('none')
})
it('설정 변경 이벤트가 이전값과 새값을 담는다', () => {
const seen: Array<{ key: string; value: unknown; previousValue: unknown }> = []
const off = onConfigChanged((e) => seen.push(e))
configSet('theme', 'dark')
off()
expect(seen.some((e) => e.key === 'theme' && e.value === 'dark')).toBe(true)
})
it('configGetAll 은 방금 쓴 키를 포함한다', () => {
configSet('userEmail', 'a@b.c')
expect(configGetAll().userEmail).toBe('a@b.c')
})
it('단일 키 reset 은 기본값으로 되돌린다', () => {
configSet('theme', 'dark')
configReset('theme')
expect(configGet('theme')).toBe('auto')
})
it('전체 reset 은 커스텀 이메일도 지운다', () => {
configSet('userEmail', 'a@b.c')
configReset()
expect(configGet('userEmail')).toBeNull()
})
it('IPC config:get / set 왕복', async () => {
registerConfigHandlers()
const set = await invokeIpc(IPC_CHANNELS.CONFIG.SET, { key: 'theme', value: 'dark' })
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.CONFIG.GET, { key: 'theme' })
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe('dark')
})
it('IPC config:getTheme / setTheme', async () => {
registerConfigHandlers()
const set = await invokeIpc(IPC_CHANNELS.CONFIG.SET_THEME, { theme: 'light' })
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.CONFIG.GET_THEME)
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe('light')
})
it('IPC config:setLanguage / getLanguage', async () => {
registerConfigHandlers()
await invokeIpc(IPC_CHANNELS.CONFIG.SET_LANGUAGE, { language: 'en' })
const get = await invokeIpc(IPC_CHANNELS.CONFIG.GET_LANGUAGE)
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe('en')
})
it('IPC config:setCloseToTray / getCloseToTray', async () => {
registerConfigHandlers()
await invokeIpc(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, { enabled: false })
const get = await invokeIpc(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY)
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe(false)
})
it('IPC config:getAll 은 객체다', async () => {
registerConfigHandlers()
const res = await invokeIpc(IPC_CHANNELS.CONFIG.GET_ALL)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toHaveProperty('theme')
})
})

View file

@ -0,0 +1,194 @@
import { describe, it, expect, vi } from 'vitest'
import { EventEmitter } from 'events'
import { ErrorCode } from '@d3ro/core/errors'
import { getVoiceConversationService } from '../../src/main/services/VoiceConversationService'
import { getVoiceActionService } from '../../src/main/services/VoiceActionService'
import { useRedHarness } from './harness'
import { FX, USER_TEXT } from './fixtures'
const audioBus = new EventEmitter()
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
on: (ev: string, fn: (...args: unknown[]) => void) => audioBus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => audioBus.off(ev, fn),
}),
calculateRMS: () => 0,
}))
vi.mock('../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({
initialize: vi.fn(async () => undefined),
transcribe: vi.fn(async () => ({
text: FX.STT_OK,
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})),
getModels: () => [],
}),
resetLocalSTTServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
processText: vi.fn(async () => FX.LLM_OK),
generate: vi.fn(async () => ({ text: FX.LLM_OK })),
chatStream: async function* () {
yield FX.LLM_OK
return FX.LLM_OK
},
cancelGeneration: vi.fn(),
isAvailable: () => false,
}),
resetPremiumLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
processText: vi.fn(async () => FX.LLM_OK),
chatStream: async function* () {
yield FX.LLM_OK
return FX.LLM_OK
},
cancelGeneration: vi.fn(),
getStatus: () => ({
connectionState: 'connected',
serverUrl: 'http://localhost:11434',
activeModel: 'x',
serverVersion: null,
}),
}),
resetLocalLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/TTSPlaybackService', () => ({
getTTSPlaybackService: () => ({
speak: vi.fn(async (text: string) => {
if (!text.trim()) {
const { D3ROError, ErrorCode } = await import('@d3ro/core/errors')
throw new D3ROError(ErrorCode.TTSTextEmpty, 'Empty TTS text')
}
}),
speakSentences: vi.fn(async () => undefined),
stop: vi.fn(),
isSpeaking: false,
on: vi.fn(),
off: vi.fn(),
removeAllListeners: vi.fn(),
}),
resetTTSPlaybackServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({
canUse: () => ({ allowed: true, reason: 'ok' }),
promptUpgrade: vi.fn(),
}),
resetLicenseServiceForTests: () => undefined,
}))
useRedHarness()
describe('유스케이스: 음성 대화 세션 / 텍스트 전송 / 히스토리', () => {
it('초기 대화 상태는 idle 이다', () => {
const svc = getVoiceConversationService()
expect(svc.state).toBe('idle')
expect(svc.isActive).toBe(false)
expect(svc.getHistory()).toEqual([])
})
it('세션을 시작하면 listening 이다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
expect(svc.isActive).toBe(true)
expect(svc.state).toBe('listening')
svc.stopSession()
})
it('중복 시작은 ConversationSessionAlreadyActive 다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
await expect(svc.startSession()).rejects.toMatchObject({
code: ErrorCode.ConversationSessionAlreadyActive,
})
svc.stopSession()
})
it('세션 없이 텍스트 전송은 ConversationNoActiveSession 다', async () => {
await expect(getVoiceConversationService().sendTextMessage('hi')).rejects.toMatchObject({
code: ErrorCode.ConversationNoActiveSession,
})
})
it('텍스트 메시지를 보내면 히스토리에 user 가 남는다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
await svc.sendTextMessage(USER_TEXT.KO)
const hist = svc.getHistory()
expect(hist.some((m) => m.role === 'user' && m.content === USER_TEXT.KO)).toBe(true)
svc.stopSession()
})
it('히스토리를 비운다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
await svc.sendTextMessage(USER_TEXT.EN)
svc.clearHistory()
expect(svc.getHistory()).toEqual([])
svc.stopSession()
})
it('세션을 중지하면 idle 이다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
svc.stopSession()
expect(svc.isActive).toBe(false)
expect(svc.state).toBe('idle')
})
it('비활성 중지/취소는 던지지 않는다', () => {
expect(() => getVoiceConversationService().stopSession()).not.toThrow()
expect(() => getVoiceConversationService().cancelResponse()).not.toThrow()
})
it('프리미엄 불가 시 로컬 LLM 경로로 텍스트가 히스토리에 남는다', async () => {
const svc = getVoiceConversationService()
await svc.startSession()
await svc.sendTextMessage(USER_TEXT.KO)
const hist = svc.getHistory()
expect(hist.some((m) => m.role === 'user' && m.content === USER_TEXT.KO)).toBe(true)
expect(hist.some((m) => m.role === 'assistant' && m.content === FX.LLM_OK)).toBe(true)
svc.stopSession()
})
})
describe('유스케이스: 보이스 액션', () => {
it('초기 액션은 비활성이다', () => {
expect(getVoiceActionService().isEnabled).toBe(false)
})
it('활성 토글이 동작한다', () => {
getVoiceActionService().setEnabled(true)
expect(getVoiceActionService().isEnabled).toBe(true)
getVoiceActionService().setEnabled(false)
expect(getVoiceActionService().isEnabled).toBe(false)
})
it('프리셋 목록이 비어 있지 않다', () => {
expect(getVoiceActionService().getPresets().length).toBeGreaterThan(0)
})
it('히스토리를 비운다', () => {
getVoiceActionService().clearHistory()
expect(getVoiceActionService().getHistory()).toEqual([])
})
})

View file

@ -0,0 +1,114 @@
// apps/desktop/tests/red/crypto-license.usecase.test.ts
// Phase 11+: Ed25519 비대칭 암호화 라이센스 검증 & Reverse Trial 유스케이스 테스트
import { describe, it, expect } from 'vitest'
import {
generateLicenseKeyPair,
issueSignedLicenseKey,
verifySignedLicenseKey,
createDefaultTrialPayload,
DEFAULT_LICENSE_PUBLIC_KEY,
} from '@d3ro/core/utils/crypto-license'
import { getLicenseService } from '../../src/main/services/LicenseService'
import { useRedHarness } from './harness'
useRedHarness()
describe('Ed25519 암호화 라이센스 & Reverse Trial 유스케이스', () => {
it('Ed25519 키쌍을 생성하고 유효한 라이센스를 서명/검증할 수 있다', () => {
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
expect(publicKeyPem).toContain('BEGIN PUBLIC KEY')
expect(privateKeyPem).toContain('BEGIN PRIVATE KEY')
const payload = {
licenseId: 'lic-1001',
tier: 'pro_plus' as const,
customerEmail: 'alice@enterprise.com',
issuedAt: Date.now(),
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
machineId: 'test-machine-id-1234',
}
const key = issueSignedLicenseKey(payload, privateKeyPem)
expect(key.startsWith('D3RO-LIC-')).toBe(true)
// 검증 성공
const verification = verifySignedLicenseKey(key, 'test-machine-id-1234', publicKeyPem)
expect(verification.valid).toBe(true)
expect(verification.tier).toBe('pro_plus')
expect(verification.reason).toBe('valid')
expect(verification.payload?.customerEmail).toBe('alice@enterprise.com')
})
it('머신 ID가 일치하지 않으면 machine_mismatch 로 실패한다', () => {
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
const payload = {
licenseId: 'lic-1002',
tier: 'pro' as const,
customerEmail: 'bob@company.com',
issuedAt: Date.now(),
expiresAt: null,
machineId: 'machine-A',
}
const key = issueSignedLicenseKey(payload, privateKeyPem)
const verification = verifySignedLicenseKey(key, 'machine-B', publicKeyPem)
expect(verification.valid).toBe(false)
expect(verification.reason).toBe('machine_mismatch')
})
it('만료된 라이센스는 expired 로 실패한다', () => {
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
const payload = {
licenseId: 'lic-1003',
tier: 'pro' as const,
customerEmail: 'charlie@past.com',
issuedAt: Date.now() - 100000,
expiresAt: Date.now() - 1000, // 이미 만료됨
machineId: null,
}
const key = issueSignedLicenseKey(payload, privateKeyPem)
const verification = verifySignedLicenseKey(key, undefined, publicKeyPem)
expect(verification.valid).toBe(false)
expect(verification.reason).toBe('expired')
})
it('서명이 위조된 라이센스는 invalid_signature 로 실패한다', () => {
const { publicKeyPem } = generateLicenseKeyPair()
const otherKeyPair = generateLicenseKeyPair()
const payload = {
licenseId: 'lic-forged',
tier: 'pro_plus' as const,
customerEmail: 'attacker@evil.com',
issuedAt: Date.now(),
expiresAt: null,
machineId: null,
}
// 다른 키로 서명 (위조 시도)
const forgedKey = issueSignedLicenseKey(payload, otherKeyPair.privateKeyPem)
const verification = verifySignedLicenseKey(forgedKey, undefined, publicKeyPem)
expect(verification.valid).toBe(false)
expect(verification.reason).toBe('invalid_signature')
})
it('LicenseService.startTrial() 호출 시 14일 Pro+ Reverse Trial 이 활성화된다', () => {
const svc = getLicenseService()
svc.initialize()
const result = svc.startTrial('trial-tester@local')
expect(result.success).toBe(true)
expect(result.tier).toBe('pro_plus')
expect(svc.tier).toBe('pro_plus')
const info = svc.getInfo()
expect(info.isTrial).toBe(true)
expect(info.trialExpiresAt).toBeGreaterThan(Date.now())
// 중복 체험 시작은 방지된다
const retry = svc.startTrial('again@local')
expect(retry.success).toBe(false)
})
})

View file

@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getDictionaryService } from '../../src/main/services/DictionaryService'
import { registerDictionaryHandlers } from '../../src/main/ipc/dictionary-handlers'
import { invokeIpc, useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 사용자 사전 CRUD / 검색 / 힌트 / IPC', () => {
it('빈 사전 목록은 0건이다', () => {
const page = getDictionaryService().list({ page: 0, pageSize: 20 })
expect(page.total).toBe(0)
expect(page.entries).toEqual([])
})
it('단어를 추가하면 목록에 나타난다', () => {
const entry = getDictionaryService().add({ word: 'D3RO' })
expect(entry.word).toBe('D3RO')
expect(entry.category).toBe('user')
expect(entry.usageCount).toBe(0)
expect(getDictionaryService().list({ page: 0, pageSize: 10 }).total).toBe(1)
})
it('발음과 기술 카테고리를 지정해 추가한다', () => {
const entry = getDictionaryService().add({
word: 'Whisper',
pronunciation: '위스퍼',
category: 'technical',
})
expect(entry.pronunciation).toBe('위스퍼')
expect(entry.category).toBe('technical')
})
it('유니코드 단어도 추가된다', () => {
const entry = getDictionaryService().add({ word: USER_TEXT.UNICODE })
expect(entry.word).toBe(USER_TEXT.UNICODE)
})
it('빈 단어 추가는 거부된다', () => {
expect(() => getDictionaryService().add({ word: ' ' })).toThrow(D3ROError)
try {
getDictionaryService().add({ word: '' })
expect.unreachable()
} catch (err) {
expect(err).toBeInstanceOf(D3ROError)
expect((err as D3ROError).code).toBe(ErrorCode.DictionaryImportInvalidFormat)
}
})
it('같은 단어+카테고리 중복 추가는 DictionaryDuplicate 이다', () => {
getDictionaryService().add({ word: USER_TEXT.DUP })
expect(() => getDictionaryService().add({ word: USER_TEXT.DUP })).toThrow(D3ROError)
try {
getDictionaryService().add({ word: USER_TEXT.DUP })
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.DictionaryDuplicate)
}
})
it('같은 단어를 다른 카테고리로 추가할 수 있다', () => {
getDictionaryService().add({ word: 'API', category: 'user' })
const tech = getDictionaryService().add({ word: 'API', category: 'technical' })
expect(tech.category).toBe('technical')
expect(getDictionaryService().list({ page: 0, pageSize: 10 }).total).toBe(2)
})
it('단어 수정이 반영된다', () => {
const created = getDictionaryService().add({ word: 'old' })
const updated = getDictionaryService().update({ id: created.id, word: 'new' })
expect(updated?.word).toBe('new')
expect(getDictionaryService().list({ page: 0, pageSize: 10 }).entries[0].word).toBe('new')
})
it('없는 id 수정은 null 이다', () => {
expect(getDictionaryService().update({ id: 'missing', word: 'x' })).toBeNull()
})
it('단어 삭제 후 목록에서 사라진다', () => {
const created = getDictionaryService().add({ word: 'gone' })
expect(getDictionaryService().delete(created.id)).toBe(true)
expect(getDictionaryService().list({ page: 0, pageSize: 10 }).total).toBe(0)
})
it('없는 id 삭제는 false 이다', () => {
expect(getDictionaryService().delete('missing')).toBe(false)
})
it('검색은 부분 일치한다', () => {
getDictionaryService().add({ word: '인공지능' })
getDictionaryService().add({ word: '자연어' })
const found = getDictionaryService().search({ query: '인공', page: 0, pageSize: 10 })
expect(found.total).toBe(1)
expect(found.entries[0].word).toBe('인공지능')
})
it('검색 무결과는 빈 페이지이다', () => {
getDictionaryService().add({ word: 'foo' })
expect(getDictionaryService().search({ query: 'zzz', page: 0, pageSize: 10 }).total).toBe(0)
})
it('사용 횟수 증가가 저장된다', () => {
const created = getDictionaryService().add({ word: 'hint' })
getDictionaryService().incrementUsage(created.id)
getDictionaryService().incrementUsage(created.id)
const listed = getDictionaryService().list({ page: 0, pageSize: 10 }).entries[0]
expect(listed.usageCount).toBe(2)
expect(listed.lastUsedAt).toBeGreaterThan(0)
})
it('getPromptHints 는 사용 빈도 순 단어 목록이다', () => {
const a = getDictionaryService().add({ word: 'alpha' })
getDictionaryService().add({ word: 'beta' })
getDictionaryService().incrementUsage(a.id)
const hints = getDictionaryService().getPromptHints()
expect(hints.startsWith('alpha')).toBe(true)
expect(hints).toContain('beta')
})
it('빈 사전의 prompt hint 는 빈 문자열이다', () => {
expect(getDictionaryService().getPromptHints()).toBe('')
})
it('IPC dictionary:add / getAll 왕복', async () => {
registerDictionaryHandlers()
const add = await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: 'IPCWORD' })
expect(add.success).toBe(true)
const all = await invokeIpc(IPC_CHANNELS.DICTIONARY.GET_ALL, { page: 0, pageSize: 10 })
expect(all.success).toBe(true)
if (all.success) expect(all.data.total).toBe(1)
})
it('IPC dictionary:add 빈 단어는 실패한다', async () => {
registerDictionaryHandlers()
const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: ' ' })
expect(res.success).toBe(false)
})
it('IPC dictionary:add 중복은 DictionaryDuplicate 이다', async () => {
registerDictionaryHandlers()
await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: 'dup' })
const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.ADD, { word: 'dup' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.DictionaryDuplicate)
})
it('IPC dictionary:update 없는 id 는 DictionaryNotFound 이다', async () => {
registerDictionaryHandlers()
const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.UPDATE, { id: 'missing', word: 'x' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.DictionaryNotFound)
})
it('IPC dictionary:delete 없는 id 는 DictionaryNotFound 이다', async () => {
registerDictionaryHandlers()
const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.DELETE, { id: 'missing' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.DictionaryNotFound)
})
it('IPC dictionary:search 는 서비스 검색을 노출한다', async () => {
registerDictionaryHandlers()
getDictionaryService().add({ word: '검색대상' })
const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.SEARCH, { query: '검색', page: 0, pageSize: 10 })
expect(res.success).toBe(true)
if (res.success) expect(res.data.total).toBe(1)
})
})

View file

@ -0,0 +1,41 @@
// 독립 선언 픽스처. 목업 반환값과 expect 리터럴이 같은 "정답 유도"를 금지한다.
// 모델/로그인 I/O 더블은 여기 토큰만 반환하고, 테스트는 이 상수를 import 해서 비교한다.
export const FX = {
STT_OK: 'fx.stt.opaque.token.A7K2',
STT_OK_B: 'fx.stt.opaque.token.B9Q1',
STT_EMPTY: '',
STT_WS: ' \n\t ',
STT_MALFORMED_SHAPE: { notText: true },
LLM_OK: 'fx.llm.opaque.token.C3M8',
LLM_OK_B: 'fx.llm.opaque.token.D4N7',
LLM_EMPTY: '',
LLM_TITLE: 'fx.llm.title.token.E1P0',
LLM_SUMMARY_MD: [
'## 요약',
'fx.summary.opaque.body',
'',
'## 핵심 결정사항',
'- fx.decision.1',
'',
'## 할 일 목록',
'- [ ] fx.action.1',
].join('\n'),
AUTH_TOKEN: 'fx.auth.jwt.opaque.T0K3N',
AUTH_EMAIL: 'fx.user@example.test',
AUTH_FAIL_MSG: 'fx.auth.rejected.R9',
LICENSE_PRO: 'D3RO-PRO-TEST-KEY1',
LICENSE_PLUS: 'D3RO-PLUS-TEST-KEY1',
LICENSE_BAD: 'NOT-A-KEY',
} as const
export const USER_TEXT = {
SHORT: 'hi',
KO: '오늘 회의는 오후 세 시에 시작합니다',
EN: 'please schedule the quarterly review',
UNICODE: '한글 English 日本語 🎉 café',
LONG: '가'.repeat(4000),
EMPTY: '',
WS: ' ',
DUP: '중복단어',
} as const

View file

@ -0,0 +1,181 @@
// RED 유스케이스 공통 하네스: 실제 서비스 + in-memory DB + IPC 캡처
// 네트워크/모델 I/O 만 더블로 교체한다.
import { afterEach, beforeEach, vi } from 'vitest'
import { ipcMain } from 'electron'
import { createTestDb } from '../helpers/createTestDb'
import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db'
import {
initInMemoryConfig,
resetInMemoryConfig,
} from '../../src/main/services/ConfigService'
import { resetHistoryServiceForTests } from '../../src/main/services/HistoryService'
import { resetDictionaryServiceForTests } from '../../src/main/services/DictionaryService'
import { resetCustomInstructionServiceForTests } from '../../src/main/services/CustomInstructionService'
import { resetChainServiceForTests } from '../../src/main/services/ChainService'
import { resetVoiceCommandServiceForTests } from '../../src/main/services/VoiceCommandService'
import { resetLicenseServiceForTests } from '../../src/main/services/LicenseService'
import { resetVoiceModeServiceForTests } from '../../src/main/services/VoiceModeService'
import { resetMemoServiceForTests } from '../../src/main/services/MemoService'
import { resetMeetingModeServiceForTests } from '../../src/main/services/MeetingModeService'
import { resetMeetingDocTemplateServiceForTests } from '../../src/main/services/MeetingDocTemplateService'
import { resetDictationTemplateServiceForTests } from '../../src/main/services/DictationTemplateService'
import { resetVoiceConversationServiceForTests } from '../../src/main/services/VoiceConversationService'
import { resetRAGServiceForTests } from '../../src/main/services/RAGService'
import { resetVoiceActionServiceForTests } from '../../src/main/services/VoiceActionService'
import { resetCaptionServiceForTests } from '../../src/main/services/CaptionService'
import { resetFileTranscriptionServiceForTests } from '../../src/main/services/FileTranscriptionService'
import { resetCloudSTTServiceForTests } from '../../src/main/services/CloudSTTService'
import { resetPremiumLLMServiceForTests } from '../../src/main/services/PremiumLLMService'
import { resetOnlineLLMServiceForTests } from '../../src/main/services/OnlineLLMService'
import { resetCloudSyncServiceForTests } from '../../src/main/services/CloudSyncService'
import { resetTTSPlaybackServiceForTests } from '../../src/main/services/TTSPlaybackService'
import { resetMeetingSummaryServiceForTests } from '../../src/main/services/MeetingSummaryService'
import { resetLocalLLMServiceForTests } from '../../src/main/services/LocalLLMService'
import { resetLocalSTTServiceForTests } from '../../src/main/services/LocalSTTService'
import type { IPCResult } from '@d3ro/core/errors'
import type { NewHistory } from '../../src/main/db/schema'
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>()
export function resetIpcHandlers(): void {
ipcHandlers.clear()
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
ipcHandlers.set(channel, handler)
})
}
export async function invokeIpc<T = unknown>(
channel: string,
...args: unknown[]
): Promise<IPCResult<T>> {
const handler = ipcHandlers.get(channel)
if (!handler) {
throw new Error(`IPC handler not registered: ${channel}`)
}
return (await handler({}, ...args)) as IPCResult<T>
}
export function hasIpc(channel: string): boolean {
return ipcHandlers.has(channel)
}
export function historyInput(
overrides: Partial<Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>> = {},
): Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'> {
return {
originalText: '오늘 회의는 오후 세 시에 시작합니다',
duration: 3.5,
wordCount: 8,
mode: 'dictation',
status: 'completed',
appVersion: '1.0.0',
...overrides,
}
}
export function resetAllSingletons(): void {
resetHistoryServiceForTests()
resetDictionaryServiceForTests()
resetCustomInstructionServiceForTests()
resetChainServiceForTests()
resetVoiceCommandServiceForTests()
resetLicenseServiceForTests()
resetVoiceModeServiceForTests()
resetMemoServiceForTests()
resetMeetingModeServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetDictationTemplateServiceForTests()
resetVoiceConversationServiceForTests()
resetRAGServiceForTests()
resetVoiceActionServiceForTests()
resetCaptionServiceForTests()
resetFileTranscriptionServiceForTests()
resetCloudSTTServiceForTests()
resetPremiumLLMServiceForTests()
resetOnlineLLMServiceForTests()
resetCloudSyncServiceForTests()
resetTTSPlaybackServiceForTests()
resetMeetingSummaryServiceForTests()
resetLocalLLMServiceForTests()
resetLocalSTTServiceForTests()
}
export function useRedHarness(): void {
let testdb: ReturnType<typeof createTestDb> | null = null
beforeEach(() => {
testdb = createTestDb()
bindTestDatabase(testdb.db)
initInMemoryConfig()
resetAllSingletons()
resetIpcHandlers()
})
afterEach(() => {
resetAllSingletons()
unbindTestDatabase()
testdb?.close()
testdb = null
resetInMemoryConfig()
vi.unstubAllGlobals()
})
}
export type InvokeFnResult = { data: unknown; error: { message: string } | null }
export function makeCloudSyncFake(opts?: {
authenticated?: boolean
invoke?: (name: string, body: Record<string, unknown>) => Promise<InvokeFnResult>
}): {
isEnabled: () => boolean
isAuthenticated: () => boolean
getState: () => {
authenticated: boolean
userEmail: string | null
lastSyncAt: number | null
syncing: boolean
}
getUser: () => { id: string; email: string } | null
invokeFunction: (name: string, body: Record<string, unknown>) => Promise<InvokeFnResult>
pushOne: () => Promise<void>
pushAll: () => Promise<{ pushed: number; errors: string[] }>
pullAll: () => Promise<{ pushed: number; errors: string[] }>
startSignIn: (provider: string) => Promise<void>
handleAuthCallback: (code: string) => Promise<void>
signOut: () => Promise<void>
signInAnonymously: () => Promise<void>
on: () => void
off: () => void
} {
const authenticated = opts?.authenticated ?? false
return {
isEnabled: () => true,
isAuthenticated: () => authenticated,
getState: () => ({
authenticated,
userEmail: authenticated ? 'fx.user@example.test' : null,
lastSyncAt: null,
syncing: false,
}),
getUser: () =>
authenticated ? { id: 'fx-user-id', email: 'fx.user@example.test' } : null,
invokeFunction: opts?.invoke
?? (async () => ({ data: null, error: { message: 'fx.cloud.unconfigured' } })),
pushOne: async () => undefined,
pushAll: async () => ({ pushed: 0, errors: [] }),
pullAll: async () => ({ pushed: 0, errors: [] }),
startSignIn: async () => {
throw new Error('fx.oauth.not-started')
},
handleAuthCallback: async () => {
throw new Error('fx.oauth.callback-rejected')
},
signOut: async () => undefined,
signInAnonymously: async () => {
throw new Error('fx.anon.not-supported')
},
on: () => undefined,
off: () => undefined,
}
}

View file

@ -0,0 +1,208 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { registerHistoryHandlers } from '../../src/main/ipc/history-handlers'
import { historyInput, invokeIpc, useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 히스토리 CRUD / 검색 / 통계 / IPC', () => {
it('대시보드에서 빈 히스토리를 열면 0건이다', () => {
const page = getHistoryService().list({ page: 0, pageSize: 20 })
expect(page.total).toBe(0)
expect(page.entries).toEqual([])
expect(page.totalPages).toBe(0)
})
it('받아쓰기 완료 후 히스토리에 원문이 저장된다', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i)
expect(entry.originalText).toBe(USER_TEXT.KO)
expect(entry.status).toBe('completed')
expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.KO)
})
it('영문 받아쓰기도 같은 경로로 저장된다', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.EN }))
expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.EN)
})
it('유니코드/이모지 원문이 그대로 저장된다', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.UNICODE }))
expect(getHistoryService().getById(entry.id)?.originalText).toBe(USER_TEXT.UNICODE)
})
it('긴 원문(4000자)이 잘리지 않고 저장된다', () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.LONG, wordCount: 4000 }))
expect(getHistoryService().getById(entry.id)?.originalText.length).toBe(4000)
})
it('빈 원문도 오류 상태 엔트리로 저장할 수 있다', () => {
const entry = getHistoryService().create(
historyInput({ originalText: USER_TEXT.EMPTY, status: 'error', errorCode: 'STTNoAudioData', wordCount: 0 }),
)
expect(entry.status).toBe('error')
expect(entry.errorCode).toBe('STTNoAudioData')
expect(getHistoryService().getById(entry.id)?.errorCode).toBe('STTNoAudioData')
})
it('취소된 세션은 cancelled 상태로 남는다', () => {
const entry = getHistoryService().create(historyInput({ status: 'cancelled', originalText: '' }))
expect(getHistoryService().getById(entry.id)?.status).toBe('cancelled')
})
it('같은 원문을 두 번 저장하면 서로 다른 id의 두 건이 된다', () => {
const a = getHistoryService().create(historyInput({ originalText: USER_TEXT.DUP }))
const b = getHistoryService().create(historyInput({ originalText: USER_TEXT.DUP }))
expect(a.id).not.toBe(b.id)
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(2)
})
it('존재하지 않는 id 조회는 null이다', () => {
expect(getHistoryService().getById('missing-id')).toBeNull()
})
it('한 건 삭제 후 목록에서 사라진다', () => {
const a = getHistoryService().create(historyInput())
const b = getHistoryService().create(historyInput({ originalText: USER_TEXT.EN }))
expect(getHistoryService().delete(a.id)).toBe(true)
expect(getHistoryService().getById(a.id)).toBeNull()
expect(getHistoryService().getById(b.id)).not.toBeNull()
})
it('없는 id 삭제는 false를 반환한다', () => {
expect(getHistoryService().delete('no-such')).toBe(false)
})
it('전체 삭제는 목록과 통계 조회가 가능한 빈 상태를 만든다', () => {
getHistoryService().create(historyInput())
getHistoryService().create(historyInput())
getHistoryService().deleteAll()
expect(getHistoryService().list({ page: 0, pageSize: 20 }).total).toBe(0)
const stats = getHistoryService().getStats()
expect(stats.todaySessionCount).toBe(0)
})
it('원문 검색은 부분 일치한다', () => {
getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
getHistoryService().create(historyInput({ originalText: USER_TEXT.EN }))
const found = getHistoryService().search({ query: '회의', page: 0, pageSize: 10 })
expect(found.total).toBe(1)
expect(found.entries[0].originalText).toBe(USER_TEXT.KO)
})
it('검색어가 없으면 매칭 0건이다', () => {
getHistoryService().create(historyInput())
const found = getHistoryService().search({ query: 'zzzz-no-hit', page: 0, pageSize: 10 })
expect(found.total).toBe(0)
expect(found.entries).toEqual([])
})
it('페이지네이션 두 번째 페이지는 첫 페이지와 겹치지 않는다', () => {
for (let i = 0; i < 5; i++) {
getHistoryService().create(historyInput({ originalText: `item-${i}` }))
}
const p0 = getHistoryService().list({ page: 0, pageSize: 2 })
const p1 = getHistoryService().list({ page: 1, pageSize: 2 })
expect(p0.entries).toHaveLength(2)
expect(p1.entries).toHaveLength(2)
const ids = new Set([...p0.entries, ...p1.entries].map((e) => e.id))
expect(ids.size).toBe(4)
expect(p0.totalPages).toBe(3)
})
it('완료 세션은 오늘 통계에 반영된다', () => {
getHistoryService().create(historyInput({ duration: 2, wordCount: 4 }))
getHistoryService().create(historyInput({ duration: 3, wordCount: 6 }))
const stats = getHistoryService().getStats()
expect(stats.todaySessionCount).toBe(2)
expect(stats.todayWordCount).toBe(10)
expect(stats.totalSessionCount).toBe(2)
expect(stats.totalWordCount).toBe(10)
})
it('error 상태 세션은 오늘 완료 통계에 포함되지 않는다', () => {
getHistoryService().create(historyInput({ status: 'error', duration: 10, wordCount: 99 }))
const stats = getHistoryService().getStats()
expect(stats.todaySessionCount).toBe(0)
})
it('30일 이내 항목은 보존 정리에서 삭제되지 않는다', () => {
const entry = getHistoryService().create(historyInput())
expect(getHistoryService().runRetentionCleanup()).toBe(0)
expect(getHistoryService().getById(entry.id)).not.toBeNull()
})
it('IPC history:getAll 은 서비스 list 결과를 감싼다', async () => {
registerHistoryHandlers()
getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
const res = await invokeIpc(IPC_CHANNELS.HISTORY.GET_ALL, { page: 0, pageSize: 10 })
expect(res.success).toBe(true)
if (res.success) {
expect(res.data.total).toBe(1)
expect(res.data.entries[0].originalText).toBe(USER_TEXT.KO)
}
})
it('IPC history:getById 없는 id 는 성공+null 이 아니라 조회 결과를 그대로 전달한다', async () => {
registerHistoryHandlers()
const res = await invokeIpc(IPC_CHANNELS.HISTORY.GET_BY_ID, { id: 'missing' })
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBeNull()
})
it('IPC history:delete 없는 id 는 HistoryNotFound 로 실패한다', async () => {
registerHistoryHandlers()
const res = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: 'missing' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.HistoryNotFound)
})
it('IPC history:delete 있는 id 는 성공하고 목록에서 사라진다', async () => {
registerHistoryHandlers()
const entry = getHistoryService().create(historyInput())
const res = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: entry.id })
expect(res.success).toBe(true)
expect(getHistoryService().getById(entry.id)).toBeNull()
})
it('IPC history:search 는 사용자 검색어로 필터한다', async () => {
registerHistoryHandlers()
getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
const res = await invokeIpc(IPC_CHANNELS.HISTORY.SEARCH, { query: '오후', page: 0, pageSize: 10 })
expect(res.success).toBe(true)
if (res.success) expect(res.data.total).toBe(1)
})
it('IPC history:deleteAll 후 목록이 비고 stats:getSummary 가 동작한다', async () => {
registerHistoryHandlers()
getHistoryService().create(historyInput())
const del = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE_ALL)
expect(del.success).toBe(true)
const stats = await invokeIpc(IPC_CHANNELS.STATS.GET_SUMMARY)
expect(stats.success).toBe(true)
if (stats.success) expect(stats.data.todaySessionCount).toBe(0)
})
it('짧은 원문(<10자)은 자동 제목을 만들지 않고 title 이 null 이다', async () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.SHORT }))
const title = await getHistoryService().generateTitle(entry.id)
expect(title).toBeNull()
expect(getHistoryService().getById(entry.id)?.title ?? null).toBeNull()
})
it('없는 엔트리 generateTitle 은 null 이다', async () => {
expect(await getHistoryService().generateTitle('missing')).toBeNull()
})
it('LLM 제목 생성 실패 시 create 는 성공하고 title 은 비어 있다', async () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
expect(entry.id).toBeTruthy()
await new Promise((r) => setTimeout(r, 20))
const stored = getHistoryService().getById(entry.id)
expect(stored).not.toBeNull()
expect(stored?.title ?? null).toBeNull()
})
})

View file

@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { registerInstructionHandlers } from '../../src/main/ipc/instruction-handlers'
import { invokeIpc, useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 커스텀 명령어 CRUD / 프리셋 보호 / IPC', () => {
it('첫 실행 시 빌트인 5개가 있다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.getAll()).toHaveLength(5)
expect(svc.getAll().every((i) => i.isBuiltin)).toBe(true)
})
it('빌트인 번역 명령어를 id 로 연다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.getById('builtin-translate')?.id).toBe('builtin-translate')
})
it('없는 명령어 조회는 null 이다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.getById('nope')).toBeNull()
})
it('사용자 명령어를 추가하면 목록이 6개가 된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({
name: '내 명령',
description: '설명',
prompt: '다음을 고쳐라: {{text}}',
icon: 'Edit',
})
expect(created.isBuiltin).toBe(false)
expect(svc.getAll()).toHaveLength(6)
})
it('빈 이름 명령어 추가는 거부된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(() =>
svc.create({ name: ' ', description: 'd', prompt: 'p', icon: 'Edit' }),
).toThrow()
})
it('빈 프롬프트 추가는 거부된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(() =>
svc.create({ name: 'n', description: 'd', prompt: '', icon: 'Edit' }),
).toThrow()
})
it('사용자 명령어 이름을 수정한다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({ name: 'old', description: 'd', prompt: 'p', icon: 'Edit' })
const updated = svc.update(created.id, { name: 'new' })
expect(updated?.name).toBe('new')
})
it('빌트인은 이름 수정이 적용되지 않고 프롬프트만 바뀐다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const updated = svc.update('builtin-summarize', { name: '해킹', prompt: '새 프롬프트' })
expect(updated?.name).not.toBe('해킹')
expect(updated?.prompt).toBe('새 프롬프트')
})
it('없는 명령어 수정은 null 이다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.update('missing', { name: 'x' })).toBeNull()
})
it('사용자 명령어는 삭제된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({ name: 'del', description: 'd', prompt: 'p', icon: 'Edit' })
expect(svc.delete(created.id)).toBe(true)
expect(svc.getById(created.id)).toBeNull()
})
it('빌트인 삭제는 거부된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.delete('builtin-translate')).toBe(false)
expect(svc.getById('builtin-translate')).not.toBeNull()
})
it('없는 명령어 삭제는 false 이다', () => {
const svc = getCustomInstructionService()
svc.initialize()
expect(svc.delete('missing')).toBe(false)
})
it('순서를 바꾸면 getAll 순서가 바뀐다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const ids = svc.getAll().map((i) => i.id).reverse()
svc.reorder(ids)
expect(svc.getAll().map((i) => i.id)).toEqual(ids)
})
it('resetBuiltins 후 빌트인이 다시 5개다', () => {
const svc = getCustomInstructionService()
svc.initialize()
svc.update('builtin-translate', { prompt: 'changed' })
svc.create({ name: 'keep', description: 'd', prompt: 'p', icon: 'Edit' })
svc.resetBuiltins()
expect(svc.getById('builtin-translate')?.prompt).not.toBe('changed')
expect(svc.getAll().filter((i) => !i.isBuiltin)).toHaveLength(1)
})
it('유니코드 이름/프롬프트가 저장된다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({
name: USER_TEXT.UNICODE,
description: USER_TEXT.UNICODE,
prompt: USER_TEXT.UNICODE,
icon: 'Edit',
})
expect(svc.getById(created.id)?.name).toBe(USER_TEXT.UNICODE)
})
it('IPC instruction:getAll 은 초기화 후 5개 이상이다', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const res = await invokeIpc(IPC_CHANNELS.INSTRUCTION.GET_ALL)
expect(res.success).toBe(true)
if (res.success) expect((res.data as unknown[]).length).toBeGreaterThanOrEqual(5)
})
it('IPC instruction:create 왕복', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const res = await invokeIpc(IPC_CHANNELS.INSTRUCTION.CREATE, {
name: 'ipc',
description: 'd',
prompt: 'p',
})
expect(res.success).toBe(true)
if (res.success) expect(res.data.name).toBe('ipc')
})
it('IPC instruction:delete 빌트인은 실패한다', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const res = await invokeIpc(IPC_CHANNELS.INSTRUCTION.DELETE, { id: 'builtin-formal' })
expect(res.success).toBe(false)
})
it('IPC instruction:delete 없는 id 는 빌트인 삭제와 다른 코드다', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const missing = await invokeIpc(IPC_CHANNELS.INSTRUCTION.DELETE, { id: 'missing' })
const builtin = await invokeIpc(IPC_CHANNELS.INSTRUCTION.DELETE, { id: 'builtin-explain-code' })
expect(missing.success).toBe(false)
expect(builtin.success).toBe(false)
if (!missing.success && !builtin.success) {
expect(missing.error.code).not.toBe(builtin.error.code)
}
})
it('IPC instruction:getById 없는 id 는 null 이다', async () => {
getCustomInstructionService().initialize()
registerInstructionHandlers()
const res = await invokeIpc(IPC_CHANNELS.INSTRUCTION.GET_BY_ID, { id: 'missing' })
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBeNull()
})
})

View file

@ -0,0 +1,214 @@
import { describe, it, expect, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import { configGet } from '../../src/main/services/ConfigService'
import { registerHotkeyHandlers } from '../../src/main/ipc/hotkey-handlers'
import { registerCaptionHandlers } from '../../src/main/ipc/caption-handlers'
import { registerRAGHandlers } from '../../src/main/ipc/rag-handlers'
import { registerVoiceConversationHandlers } from '../../src/main/ipc/voice-conversation-handlers'
import { registerVoiceActionHandlers } from '../../src/main/ipc/voice-action-handlers'
import { registerFileTranscriptionHandlers } from '../../src/main/ipc/file-transcription-handlers'
import { registerMeetingModeHandlers } from '../../src/main/ipc/meeting-mode-handlers'
import { registerTemplateHandlers } from '../../src/main/ipc/template-handlers'
import { registerMeetingDocTemplateHandlers } from '../../src/main/ipc/meeting-doc-template-handlers'
import { invokeIpc, useRedHarness } from './harness'
vi.mock('../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => ({
loadFromConfig: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}),
}))
useRedHarness()
const SAMPLE_BINDING = {
keyCode: 65,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl+A',
}
describe('유스케이스: 핫키 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => {
it('핫키 받아쓰기 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, { binding: SAMPLE_BINDING })
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT)
expect(get.success).toBe(true)
if (get.success) expect(get.data.displayLabel).toBe('Ctrl+A')
})
it('핫키 핸즈프리 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT)
expect(get.success).toBe(true)
})
it('핫키 명령 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT)
expect(get.success).toBe(true)
})
it('핫키 자막 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT)
expect(get.success).toBe(true)
})
it('핫키 활성 토글을 끈다', async () => {
registerHotkeyHandlers()
const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: false })
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED)
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe(false)
expect(configGet('hotkeyEnabled')).toBe(false)
})
it('핫키 활성 토글을 켠다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: true })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED)
if (get.success) expect(get.data).toBe(true)
})
it('캡션 초기 상태를 읽는다', async () => {
registerCaptionHandlers()
const res = await invokeIpc(IPC_CHANNELS.CAPTION.GET_STATE)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBe('inactive')
})
it('캡션 설정을 바꾼다', async () => {
registerCaptionHandlers()
const res = await invokeIpc(IPC_CHANNELS.CAPTION.SET_CONFIG, { fontSize: 20, opacity: 0.5 })
expect(res.success).toBe(true)
})
it('캡션 시작 실패는 success:false 로 나온다', async () => {
registerCaptionHandlers()
const res = await invokeIpc(IPC_CHANNELS.CAPTION.START)
if (res.success) {
const stop = await invokeIpc(IPC_CHANNELS.CAPTION.STOP)
expect(stop.success).toBe(true)
} else {
expect(res.error.code).toBeTruthy()
}
})
it('RAG 문서 목록 IPC', async () => {
registerRAGHandlers()
const res = await invokeIpc(IPC_CHANNELS.RAG.GET_DOCUMENTS)
expect(res.success).toBe(true)
if (res.success) expect(Array.isArray(res.data)).toBe(true)
})
it('RAG 질의 실패는 에러다', async () => {
registerRAGHandlers()
const res = await invokeIpc(IPC_CHANNELS.RAG.QUERY, { query: 'hello', topK: 3 })
expect(res.success).toBe(false)
})
it('대화 상태를 읽는다', async () => {
registerVoiceConversationHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.GET_STATE)
expect(res.success).toBe(true)
})
it('대화 히스토리를 읽는다', async () => {
registerVoiceConversationHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.GET_HISTORY)
expect(res.success).toBe(true)
if (res.success) expect(Array.isArray(res.data)).toBe(true)
})
it('세션 없이 대화 메시지는 실패한다', async () => {
registerVoiceConversationHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.SEND_MESSAGE, { text: 'hi' })
expect(res.success).toBe(false)
})
it('보이스 액션 프리셋을 읽는다', async () => {
registerVoiceActionHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE_ACTION.GET_PRESETS)
expect(res.success).toBe(true)
if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0)
})
it('보이스 액션 활성 토글', async () => {
registerVoiceActionHandlers()
await invokeIpc(IPC_CHANNELS.VOICE_ACTION.SET_ENABLED, { enabled: true })
const res = await invokeIpc(IPC_CHANNELS.VOICE_ACTION.IS_ENABLED)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBe(true)
})
it('파일 전사 상태를 읽는다', async () => {
registerFileTranscriptionHandlers()
const res = await invokeIpc(IPC_CHANNELS.FILE_TRANSCRIPTION.GET_STATE)
expect(res.success).toBe(true)
if (res.success) expect(res.data.state).toBe('idle')
})
it('파일 전사 잘못된 경로는 실패한다', async () => {
registerFileTranscriptionHandlers()
const res = await invokeIpc(IPC_CHANNELS.FILE_TRANSCRIPTION.START, { filePath: 'nope.txt' })
expect(res.success).toBe(false)
})
it('회의 상태를 읽는다', async () => {
registerMeetingModeHandlers()
const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_STATE)
expect(res.success).toBe(true)
})
it('회의 세션 목록을 읽는다', async () => {
registerMeetingModeHandlers()
const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, { page: 1, pageSize: 10 })
expect(res.success).toBe(true)
})
it('없는 회의 세션 조회는 실패한다', async () => {
registerMeetingModeHandlers()
const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_SESSION, { sessionId: 'missing' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingSessionNotFound)
})
it('딕테이션 템플릿 목록 IPC', async () => {
registerTemplateHandlers()
const res = await invokeIpc(IPC_CHANNELS.DICTATION_TEMPLATE.GET_ALL)
expect(res.success).toBe(true)
if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0)
})
it('없는 딕테이션 템플릿 세션 시작은 실패한다', async () => {
registerTemplateHandlers()
const res = await invokeIpc(IPC_CHANNELS.DICTATION_TEMPLATE.START_SESSION, { templateId: 'missing' })
expect(res.success).toBe(false)
})
it('회의 문서 템플릿 목록 IPC', async () => {
registerMeetingDocTemplateHandlers()
const res = await invokeIpc(IPC_CHANNELS.MEETING_DOC_TEMPLATE.GET_ALL)
expect(res.success).toBe(true)
if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0)
})
it('빌트인 회의 템플릿 삭제는 실패한다', async () => {
registerMeetingDocTemplateHandlers()
const res = await invokeIpc(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, { id: 'builtin-minutes' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingDocTemplateBuiltinDelete)
})
})

View file

@ -0,0 +1,192 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { Feature } from '@d3ro/core/types'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getLicenseService } from '../../src/main/services/LicenseService'
import { registerLicenseHandlers } from '../../src/main/ipc/license-handlers'
import { invokeIpc, useRedHarness } from './harness'
import { FX } from './fixtures'
useRedHarness()
describe('유스케이스: 라이선스 활성화 / 기능 게이트 / 쿼터 / IPC', () => {
it('초기 티어는 free 이다', () => {
const svc = getLicenseService()
svc.initialize()
expect(svc.getInfo().tier).toBe('free')
expect(svc.tier).toBe('free')
})
it('빈 키 활성화는 실패하고 free 를 유지한다', async () => {
const svc = getLicenseService()
svc.initialize()
const result = await svc.activate(' ')
expect(result.success).toBe(false)
expect(svc.tier).toBe('free')
})
it('잘못된 키 활성화는 실패한다', async () => {
const svc = getLicenseService()
svc.initialize()
const result = await svc.activate(FX.LICENSE_BAD)
expect(result.success).toBe(false)
expect(svc.getInfo().licenseKey).toBeNull()
})
it('프로 키 활성화는 성공하고 tier 가 pro 가 된다', async () => {
const svc = getLicenseService()
svc.initialize()
const result = await svc.activate(FX.LICENSE_PRO)
expect(result.success).toBe(true)
expect(result.tier).toBe('pro')
expect(svc.tier).toBe('pro')
expect(svc.getInfo().licenseKey).toBe(FX.LICENSE_PRO)
expect(svc.getInfo().activatedAt).toBeGreaterThan(0)
})
it('플러스 키 활성화는 pro_plus 가 된다', async () => {
const svc = getLicenseService()
svc.initialize()
const result = await svc.activate(FX.LICENSE_PLUS)
expect(result.success).toBe(true)
expect(svc.tier).toBe('pro_plus')
})
it('비활성화하면 free 로 돌아간다', async () => {
const svc = getLicenseService()
svc.initialize()
await svc.activate(FX.LICENSE_PRO)
await svc.deactivate()
expect(svc.tier).toBe('free')
expect(svc.getInfo().licenseKey).toBeNull()
})
it('로컬 받아쓰기는 익명 free 에서도 허용된다', () => {
const svc = getLicenseService()
svc.initialize()
expect(svc.canUse(Feature.DICTATION).allowed).toBe(true)
expect(svc.canUse(Feature.DICTATION).reason).toBe('ok')
})
it('클라우드 PREMIUM_LLM 은 로컬 모드에서 login_required 이다', () => {
const svc = getLicenseService()
svc.initialize()
const access = svc.canUse(Feature.PREMIUM_LLM)
expect(access.allowed).toBe(false)
expect(access.reason).toBe('login_required')
})
it('클라우드 CLOUD_SYNC 도 로컬 모드에서 login_required 이다', () => {
const svc = getLicenseService()
svc.initialize()
expect(svc.canUse(Feature.CLOUD_SYNC).reason).toBe('login_required')
})
it('login_required 기능을 consumeQuota 하면 에러가 나고 upgrade-prompt 가 뜬다', () => {
const svc = getLicenseService()
svc.initialize()
const prompts: unknown[] = []
svc.on('upgrade-prompt', (e) => prompts.push(e))
expect(() => svc.consumeQuota(Feature.PREMIUM_LLM)).toThrow()
try {
svc.consumeQuota(Feature.CLOUD_SYNC)
} catch (err) {
expect((err as { code: number }).code).toBe(ErrorCode.TierRequired)
}
expect(prompts.length).toBeGreaterThan(0)
})
it('로컬 기능 consumeQuota 는 성공한다', () => {
const svc = getLicenseService()
svc.initialize()
expect(() => svc.consumeQuota(Feature.DICTATION)).not.toThrow()
})
it('쿼터 없는 기능 getUsage 는 무제한(-1) 이다', () => {
const svc = getLicenseService()
svc.initialize()
const usage = svc.getUsage(Feature.DICTATION)
expect(usage.limit).toBe(-1)
expect(usage.remaining).toBe(-1)
})
it('syncFromCloud 는 티어를 바꾸고 tier-changed 를 낸다', () => {
const svc = getLicenseService()
svc.initialize()
const events: string[] = []
svc.on('tier-changed', (info) => events.push(info.tier))
svc.syncFromCloud('pro')
expect(svc.tier).toBe('pro')
expect(events).toContain('pro')
})
it('같은 티어 syncFromCloud 는 이벤트를 내지 않는다', () => {
const svc = getLicenseService()
svc.initialize()
let count = 0
svc.on('tier-changed', () => {
count += 1
})
svc.syncFromCloud('free')
expect(count).toBe(0)
})
it('resetToFree 는 pro 를 free 로 내린다', () => {
const svc = getLicenseService()
svc.initialize()
svc.syncFromCloud('pro')
svc.resetToFree()
expect(svc.tier).toBe('free')
})
it('getTierComparison 은 비어 있지 않다', () => {
const svc = getLicenseService()
svc.initialize()
const rows = svc.getTierComparison()
expect(rows.length).toBeGreaterThan(5)
expect(rows.every((r) => r.featureLabel.length > 0)).toBe(true)
})
it('IPC license:getInfo 는 free 를 돌려준다', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
const res = await invokeIpc(IPC_CHANNELS.LICENSE.GET_INFO)
expect(res.success).toBe(true)
if (res.success) expect(res.data.tier).toBe('free')
})
it('IPC license:activate 잘못된 키는 성공 플래그가 false 다', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
const res = await invokeIpc(IPC_CHANNELS.LICENSE.ACTIVATE, { licenseKey: FX.LICENSE_BAD })
expect(res.success).toBe(true)
if (res.success) expect(res.data.success).toBe(false)
})
it('IPC license:activate 프로 키 후 checkFeature dictation 은 허용', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
const act = await invokeIpc(IPC_CHANNELS.LICENSE.ACTIVATE, { licenseKey: FX.LICENSE_PRO })
expect(act.success).toBe(true)
const check = await invokeIpc(IPC_CHANNELS.LICENSE.CHECK_FEATURE, { feature: Feature.DICTATION })
expect(check.success).toBe(true)
if (check.success) expect(check.data.allowed).toBe(true)
})
it('IPC license:deactivate 후 티어는 free', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
await invokeIpc(IPC_CHANNELS.LICENSE.ACTIVATE, { licenseKey: FX.LICENSE_PRO })
const res = await invokeIpc(IPC_CHANNELS.LICENSE.DEACTIVATE)
expect(res.success).toBe(true)
expect(getLicenseService().tier).toBe('free')
})
it('IPC license:getTierComparison 는 배열이다', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
const res = await invokeIpc(IPC_CHANNELS.LICENSE.GET_TIER_COMPARISON)
expect(res.success).toBe(true)
if (res.success) expect(Array.isArray(res.data)).toBe(true)
})
})

View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest'
import fs from 'fs'
import path from 'path'
import { getLocalLLMService, startLocalLLMAvailability } from '../../src/main/services/LocalLLMService'
import { useRedHarness } from './harness'
useRedHarness()
describe('유스케이스: 앱 시작 시 로컬 LLM 폴링', () => {
it('startLocalLLMAvailability 는 ensureRunning 과 startPolling 을 호출한다', async () => {
const llm = getLocalLLMService()
const ensure = vi.spyOn(llm, 'ensureRunning').mockResolvedValue('not-installed')
const poll = vi.spyOn(llm, 'startPolling')
await startLocalLLMAvailability()
expect(ensure).toHaveBeenCalledTimes(1)
expect(poll).toHaveBeenCalledTimes(1)
llm.stopPolling()
})
it('bootstrap llm-polling 스텝은 startLocalLLMAvailability 를 호출하고 no-op 주석이 아니다', () => {
const src = fs.readFileSync(
path.join(__dirname, '../../src/main/bootstrap.ts'),
'utf-8',
)
expect(src).toContain('startLocalLLMAvailability')
expect(src).not.toContain('LocalLLMService replaced by PremiumLLMService')
})
it('폴링을 건너뛰면 isAvailable 이 계속 false 인 채 남는 경로를 허용하지 않는다', async () => {
const llm = getLocalLLMService()
expect(llm.isAvailable()).toBe(false)
const poll = vi.spyOn(llm, 'startPolling')
await startLocalLLMAvailability()
expect(poll).toHaveBeenCalled()
llm.stopPolling()
})
})

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { configSet } from '../../src/main/services/ConfigService'
import { useRedHarness } from './harness'
import { FX, USER_TEXT } from './fixtures'
const localGenerate = vi.fn(async () => ({ text: FX.LLM_OK, model: 'fx-local-model' }))
const premiumGenerate = vi.fn(async () => {
throw new Error('fx.premium.should-not-run-on-local-backend')
})
vi.mock('../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
generate: (...args: unknown[]) => localGenerate(...args),
chatStream: async function* () {
yield FX.LLM_OK
return FX.LLM_OK
},
}),
resetLocalLLMServiceForTests: () => undefined,
startLocalLLMAvailability: vi.fn(),
}))
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
isAvailable: () => false,
generate: (...args: unknown[]) => premiumGenerate(...args),
chatStream: async function* () {
throw new Error('fx.premium.chat.should-not-run')
},
}),
resetPremiumLLMServiceForTests: () => undefined,
}))
useRedHarness()
function insertSession(id: string): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id,
title: 'llm-session',
status: 'completed',
startedAt: now,
createdAt: now,
updatedAt: now,
rawTranscript: USER_TEXT.KO,
})
.run()
}
describe('유스케이스: 회의 문서/폴리시가 로컬 LLM 을 탄다', () => {
it('llmBackend=local 이면 polish 가 LocalLLM generate 를 쓰고 Premium 을 부르지 않는다', async () => {
configSet('llmBackend', 'local')
insertSession('meet-local-polish')
localGenerate.mockClear()
premiumGenerate.mockClear()
const polished = await getMeetingModeService().polishTranscript('meet-local-polish')
expect(polished).toBe(FX.LLM_OK)
expect(localGenerate).toHaveBeenCalled()
expect(premiumGenerate).not.toHaveBeenCalled()
expect(getMeetingModeService().getSession('meet-local-polish').editedTranscript).toBe(FX.LLM_OK)
})
it('llmBackend=local 이면 generateDocument 도 LocalLLM 을 탄다', async () => {
configSet('llmBackend', 'local')
insertSession('meet-local-doc')
localGenerate.mockClear()
premiumGenerate.mockClear()
const doc = await getMeetingModeService().generateDocument({
sessionId: 'meet-local-doc',
templateId: 'builtin-minutes',
})
expect(doc.content).toBe(FX.LLM_OK)
expect(localGenerate).toHaveBeenCalled()
expect(premiumGenerate).not.toHaveBeenCalled()
})
it('없는 세션 polish 는 MeetingSessionNotFound 다', async () => {
configSet('llmBackend', 'local')
await expect(getMeetingModeService().polishTranscript('missing')).rejects.toMatchObject({
code: ErrorCode.MeetingSessionNotFound,
})
})
})

View file

@ -0,0 +1,172 @@
import { describe, it, expect, vi } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { historyInput, useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
function insertSession(id: string, overrides: Record<string, unknown> = {}): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id,
title: '세션',
status: 'completed',
startedAt: now,
endedAt: now + 1000,
durationMs: 1000,
rawTranscript: USER_TEXT.KO,
createdAt: now,
updatedAt: now,
...overrides,
})
.run()
}
describe('유스케이스: 회의 세션 CRUD / 메모 / 전사 수정 / 요약 실패', () => {
it('초기 회의 상태는 idle 이다', () => {
expect(getMeetingModeService().getState()).toBe('idle')
expect(getMeetingModeService().getStateInfo().sessionId).toBeNull()
})
it('녹음 중이 아닐 때 메모 추가는 MeetingNotRecording 이다', async () => {
await expect(getMeetingModeService().addMemo('메모')).rejects.toMatchObject({
code: ErrorCode.MeetingNotRecording,
})
})
it('녹음 중이 아닐 때 중지는 MeetingNotRecording 이다', async () => {
await expect(getMeetingModeService().stopRecording()).rejects.toMatchObject({
code: ErrorCode.MeetingNotRecording,
})
})
it('세션 목록은 처음에 비어 있다', () => {
const page = getMeetingModeService().getSessions(1, 10)
expect(page.total).toBe(0)
expect(page.sessions).toEqual([])
})
it('저장된 세션을 목록에서 본다', () => {
insertSession('sess-1', { title: '주간회의' })
const page = getMeetingModeService().getSessions(1, 10)
expect(page.total).toBe(1)
expect(page.sessions[0].title).toBe('주간회의')
})
it('세션 상세를 연다', () => {
insertSession('sess-2', { title: '상세' })
const detail = getMeetingModeService().getSession('sess-2')
expect(detail.id).toBe('sess-2')
expect(detail.title).toBe('상세')
})
it('없는 세션 조회는 MeetingSessionNotFound 다', () => {
expect(() => getMeetingModeService().getSession('missing')).toThrow(D3ROError)
try {
getMeetingModeService().getSession('missing')
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.MeetingSessionNotFound)
}
})
it('세션 제목을 바꾼다', () => {
insertSession('sess-3', { title: 'old' })
getMeetingModeService().updateTitle('sess-3', 'new-title')
expect(getMeetingModeService().getSession('sess-3').title).toBe('new-title')
})
it('없는 세션 제목 변경은 MeetingSessionNotFound 다', () => {
expect(() => getMeetingModeService().updateTitle('missing', 'x')).toThrow(D3ROError)
})
it('세션을 삭제하면 목록에서 사라진다', () => {
insertSession('sess-4')
getMeetingModeService().deleteSession('sess-4')
expect(getMeetingModeService().getSessions(1, 10).total).toBe(0)
})
it('없는 세션 삭제는 MeetingSessionNotFound 다', () => {
expect(() => getMeetingModeService().deleteSession('missing')).toThrow(D3ROError)
})
it('전사를 수정하면 edited 텍스트가 남는다', () => {
insertSession('sess-5')
getMeetingModeService().updateTranscript('sess-5', USER_TEXT.EN)
const detail = getMeetingModeService().getSession('sess-5')
expect(detail.editedTranscript ?? detail.rawTranscript).toBeTruthy()
})
it('없는 세션 전사 수정은 MeetingSessionNotFound 다', () => {
expect(() => getMeetingModeService().updateTranscript('missing', 'x')).toThrow(D3ROError)
})
it('빈 제목으로 바꾸면 빈 문자열이 저장되거나 거부된다', () => {
insertSession('sess-6', { title: 'keep' })
try {
getMeetingModeService().updateTitle('sess-6', ' ')
const title = getMeetingModeService().getSession('sess-6').title
expect(title === 'keep' || title?.trim() === '').toBe(true)
} catch (err) {
expect(err).toBeInstanceOf(D3ROError)
}
})
it('없는 세션의 문서 목록은 빈 배열이다', () => {
expect(getMeetingModeService().getDocuments('missing')).toEqual([])
})
it('없는 문서 수정은 MeetingDocumentNotFound 다', () => {
expect(() => getMeetingModeService().updateDocument('missing', 'x')).toThrow(D3ROError)
})
it('없는 문서 삭제는 MeetingDocumentNotFound 다', () => {
expect(() => getMeetingModeService().deleteDocument('missing')).toThrow(D3ROError)
})
it('페이지네이션으로 세션을 나눈다', () => {
insertSession('p-a', { title: 'A' })
insertSession('p-b', { title: 'B' })
insertSession('p-c', { title: 'C' })
const p0 = getMeetingModeService().getSessions(1, 2)
expect(p0.sessions.length).toBe(2)
expect(p0.totalPages).toBe(2)
})
it('요약할 히스토리가 없으면 HistoryNotFound 다', async () => {
await expect(getMeetingSummaryService().summarize('missing')).rejects.toMatchObject({
code: ErrorCode.HistoryNotFound,
})
})
it('빈 전사는 MeetingSummaryNoTranscript 다', async () => {
const entry = getHistoryService().create(historyInput({ originalText: '' }))
await expect(getMeetingSummaryService().summarize(entry.id)).rejects.toMatchObject({
code: ErrorCode.MeetingSummaryNoTranscript,
})
})
it('LLM 이 없으면 요약 실패가 표면화된다 (빈 성공 금지)', async () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
await expect(getMeetingSummaryService().summarize(entry.id)).rejects.toBeTruthy()
expect(getHistoryService().getById(entry.id)?.summaryText ?? null).toBeNull()
})
it('저장된 요약이 없으면 getSummary 는 null 이다', () => {
const entry = getHistoryService().create(historyInput())
expect(getMeetingSummaryService().getSummary(entry.id)).toBeNull()
})
it('내보내기할 요약이 없으면 MeetingSummaryExportFailed 다', async () => {
const entry = getHistoryService().create(historyInput())
await expect(getMeetingSummaryService().exportMarkdown(entry.id)).rejects.toMatchObject({
code: ErrorCode.MeetingSummaryExportFailed,
})
})
})

View file

@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getMemoService } from '../../src/main/services/MemoService'
import { registerMemoHandlers } from '../../src/main/ipc/memo-handlers'
import { historyInput, invokeIpc, useRedHarness } from './harness'
useRedHarness()
describe('유스케이스: 메모 태그 추가/삭제/검색/내보내기', () => {
function seedHistory(): string {
return getHistoryService().create(historyInput()).id
}
it('새 히스토리에는 태그가 없다', () => {
const id = seedHistory()
expect(getMemoService().getTagsForEntry(id)).toEqual([])
})
it('태그를 달면 소문자로 정규화된다', () => {
const id = seedHistory()
const tag = getMemoService().addTag(id, ' IDEA ')
expect(tag.tag).toBe('idea')
expect(getMemoService().getTagsForEntry(id)).toHaveLength(1)
})
it('같은 태그 중복은 MemoTagDuplicate 다', () => {
const id = seedHistory()
getMemoService().addTag(id, 'work')
expect(() => getMemoService().addTag(id, 'WORK')).toThrow(D3ROError)
try {
getMemoService().addTag(id, 'work')
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.MemoTagDuplicate)
}
})
it('빈 태그는 거부된다', () => {
const id = seedHistory()
expect(() => getMemoService().addTag(id, ' ')).toThrow()
})
it('태그 제거 후 목록이 비다', () => {
const id = seedHistory()
getMemoService().addTag(id, 'tmp')
getMemoService().removeTag(id, 'tmp')
expect(getMemoService().getTagsForEntry(id)).toEqual([])
})
it('없는 태그 제거는 MemoTagNotFound 다', () => {
const id = seedHistory()
expect(() => getMemoService().removeTag(id, 'ghost')).toThrow(D3ROError)
try {
getMemoService().removeTag(id, 'ghost')
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.MemoTagNotFound)
}
})
it('전체 태그 카운트가 사용 횟수를 반영한다', () => {
const a = seedHistory()
const b = seedHistory()
getMemoService().addTag(a, 'shared')
getMemoService().addTag(b, 'shared')
getMemoService().addTag(a, 'solo')
const all = getMemoService().getAllTags()
const shared = all.find((t) => t.tag === 'shared')
expect(shared?.count).toBe(2)
})
it('태그로 히스토리를 검색한다', () => {
const a = seedHistory()
seedHistory()
getMemoService().addTag(a, 'findme')
const page = getMemoService().searchByTag({ tag: 'findme', page: 0, pageSize: 10 })
expect(page.total).toBe(1)
expect(page.entries[0].id).toBe(a)
})
it('없는 태그 검색은 0건이다', () => {
seedHistory()
expect(getMemoService().searchByTag({ tag: 'none', page: 0, pageSize: 10 }).total).toBe(0)
})
it('마크다운 내보내기는 파일을 만들고 내용을 담는다', () => {
const id = seedHistory()
getMemoService().addTag(id, 'export')
const filePath = getMemoService().exportMarkdown({ tag: 'export' })
expect(filePath.endsWith('.md')).toBe(true)
const fs = require('fs') as typeof import('fs')
const body = fs.readFileSync(filePath, 'utf-8')
expect(body).toContain('#export')
expect(body.length).toBeGreaterThan(10)
})
it('매칭 없는 내보내기도 파일을 만들되 비었음을 표시한다', () => {
const filePath = getMemoService().exportMarkdown({ tag: 'absent' })
const fs = require('fs') as typeof import('fs')
const body = fs.readFileSync(filePath, 'utf-8')
expect(body).toMatch(/No memo entries found/i)
})
it('IPC memo:addTag / getTags 왕복', async () => {
registerMemoHandlers()
const id = seedHistory()
const add = await invokeIpc(IPC_CHANNELS.MEMO.ADD_TAG, { historyId: id, tag: 'ipc' })
expect(add.success).toBe(true)
const tags = await invokeIpc(IPC_CHANNELS.MEMO.GET_TAGS, { historyId: id })
expect(tags.success).toBe(true)
if (tags.success) expect(tags.data[0].tag).toBe('ipc')
})
it('IPC 중복 태그는 MemoTagDuplicate 다', async () => {
registerMemoHandlers()
const id = seedHistory()
await invokeIpc(IPC_CHANNELS.MEMO.ADD_TAG, { historyId: id, tag: 'dup' })
const res = await invokeIpc(IPC_CHANNELS.MEMO.ADD_TAG, { historyId: id, tag: 'dup' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MemoTagDuplicate)
})
it('IPC 없는 태그 제거는 MemoTagNotFound 다', async () => {
registerMemoHandlers()
const id = seedHistory()
const res = await invokeIpc(IPC_CHANNELS.MEMO.REMOVE_TAG, { historyId: id, tag: 'nope' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MemoTagNotFound)
})
it('IPC getAllTags / searchByTag', async () => {
registerMemoHandlers()
const id = seedHistory()
getMemoService().addTag(id, 'listed')
const all = await invokeIpc(IPC_CHANNELS.MEMO.GET_ALL_TAGS)
expect(all.success).toBe(true)
const search = await invokeIpc(IPC_CHANNELS.MEMO.SEARCH_BY_TAG, {
tag: 'listed',
page: 0,
pageSize: 10,
})
expect(search.success).toBe(true)
if (search.success) expect(search.data.total).toBe(1)
})
})

View file

@ -0,0 +1,68 @@
// apps/desktop/tests/red/pii-redactor.usecase.test.ts
// Phase 11+/CCPA: PII 마스킹 / 가명화 및 Zero Data Retention (ZDR) 오디오 메모리 와이프 테스트
import { describe, it, expect } from 'vitest'
import {
redactPII,
rehydrateText,
secureZeroBuffer,
secureWipeAudioChunks,
} from '@d3ro/core'
describe('CCPA/CPRA & GDPR 규제 준수: PII/SPI 마스킹 및 메모리 안전성', () => {
it('주민등록번호를 감지하여 마스킹한다', () => {
const input = '고객님의 주민번호는 950101-1234567 입니다.'
const res = redactPII(input, 'mask')
expect(res.redactedText).toBe('고객님의 주민번호는 950101-******* 입니다.')
expect(res.redactionCount).toBe(1)
expect(res.matchedCategories).toContain('주민등록번호')
})
it('신용카드번호를 마스킹한다', () => {
const input = '결제 카드: 1234-5678-9012-3456 승인 요청'
const res = redactPII(input, 'mask')
expect(res.redactedText).toContain('1234-****-****-3456')
expect(res.matchedCategories).toContain('신용카드번호')
})
it('전화번호를 마스킹한다', () => {
const input = '담당자 연락처: 010-9876-5432 로 연락 바랍니다.'
const res = redactPII(input, 'mask')
expect(res.redactedText).toBe('담당자 연락처: 010-****-5432 로 연락 바랍니다.')
expect(res.matchedCategories).toContain('전화번호')
})
it('이메일 주소를 마스킹한다', () => {
const input = '문의 이메일: contact@d3ro.ai 입니다.'
const res = redactPII(input, 'mask')
expect(res.redactedText).toBe('문의 이메일: co***@d3ro.ai 입니다.')
})
it('tokenize 모드로 변환 후 rehydrateText 로 안전하게 원문 복원이 가능하다', () => {
const input = '대표 번호 010-1111-2222 및 이메일 test@company.com'
const res = redactPII(input, 'tokenize')
expect(res.redactedText).toContain('[REDACTED_PHONE_NUMBER_')
expect(res.redactedText).toContain('[REDACTED_EMAIL_')
const rehydrated = rehydrateText(res.redactedText, res.tokens)
expect(rehydrated).toBe(input)
})
it('Zero Data Retention (ZDR): secureZeroBuffer 는 버퍼 메모리를 0으로 즉시 초기화한다', () => {
const buf = Buffer.from([1, 2, 3, 4, 5, 255, 128])
expect(buf[0]).toBe(1)
secureZeroBuffer(buf)
expect(buf.every((byte) => byte === 0)).toBe(true)
})
it('secureWipeAudioChunks 는 여러 오디오 청크를 0으로 채우고 배열을 비운다', () => {
const chunk1 = Buffer.from([10, 20, 30])
const chunk2 = new Float32Array([0.5, -0.5, 0.8])
const chunks = [chunk1, chunk2]
secureWipeAudioChunks(chunks)
expect(chunk1.every((b) => b === 0)).toBe(true)
expect(chunk2.every((v) => v === 0)).toBe(true)
expect(chunks.length).toBe(0)
})
})

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getRAGService } from '../../src/main/services/RAGService'
import { getFileTranscriptionService } from '../../src/main/services/FileTranscriptionService'
import { useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
function writeTemp(name: string, body: string): string {
const p = path.join(os.tmpdir(), `d3ro-red-${Date.now()}-${name}`)
fs.writeFileSync(p, body, 'utf-8')
return p
}
describe('유스케이스: 지식베이스 문서 추가/삭제/질의 실패', () => {
it('초기 문서 목록은 비어 있다', () => {
expect(getRAGService().getDocuments()).toEqual([])
expect(getRAGService().getStateInfo().documentCount).toBe(0)
})
it('지원하지 않는 확장자는 RAGUnsupportedFormat 이다', async () => {
const p = writeTemp('x.bin', 'aaaa')
await expect(getRAGService().addDocument(p)).rejects.toMatchObject({
code: ErrorCode.RAGUnsupportedFormat,
})
})
it('너무 짧은 텍스트는 RAGIndexingFailed 다', async () => {
const p = writeTemp('short.txt', 'hi')
await expect(getRAGService().addDocument(p)).rejects.toMatchObject({
code: ErrorCode.RAGIndexingFailed,
})
})
it('충분한 txt 문서를 추가하면 목록에 나타난다', async () => {
const p = writeTemp('doc.txt', `${USER_TEXT.KO}\n`.repeat(5))
const doc = await getRAGService().addDocument(p)
expect(doc.fileType).toBe('txt')
expect(getRAGService().getDocuments().some((d) => d.id === doc.id)).toBe(true)
})
it('문서를 제거하면 목록에서 사라진다', async () => {
const p = writeTemp('rm.txt', `${USER_TEXT.EN}\n`.repeat(8))
const doc = await getRAGService().addDocument(p)
getRAGService().removeDocument(doc.id)
expect(getRAGService().getDocuments().some((d) => d.id === doc.id)).toBe(false)
})
it('없는 문서 재인덱스는 RAGDocumentNotFound 다', async () => {
await expect(getRAGService().reindex('missing')).rejects.toMatchObject({
code: ErrorCode.RAGDocumentNotFound,
})
})
it('청크가 없을 때 query 는 빈 답을 성공으로 위장하지 않는다', async () => {
await expect(getRAGService().query('무엇이든')).rejects.toMatchObject({
code: ErrorCode.RAGQueryFailed,
})
})
it('임베딩 서버가 없으면 인덱싱이 indexed=true + 0 chunks 로 성공 위장하지 않는다', async () => {
const p = writeTemp('emb.txt', `${USER_TEXT.KO}\n`.repeat(6))
const doc = await getRAGService().addDocument(p)
await new Promise((r) => setTimeout(r, 80))
const stored = getRAGService().getDocuments().find((d) => d.id === doc.id)
if (stored?.indexed) {
expect(stored.chunkCount).toBeGreaterThan(0)
} else {
expect(stored?.indexed).toBe(false)
}
})
})
describe('유스케이스: 파일 전사 입력 검증 / 취소', () => {
it('초기 상태는 idle 이다', () => {
expect(getFileTranscriptionService().state).toBe('idle')
expect(getFileTranscriptionService().getStateInfo().jobId).toBeNull()
})
it('지원하지 않는 포맷은 FileTranscriptionInvalidFormat 이다', async () => {
const p = writeTemp('x.txt', 'not audio')
await expect(getFileTranscriptionService().startTranscription(p)).rejects.toMatchObject({
code: ErrorCode.FileTranscriptionInvalidFormat,
})
})
it('없는 파일은 숨기지 않고 던진다', async () => {
await expect(
getFileTranscriptionService().startTranscription(path.join(os.tmpdir(), 'nope.wav')),
).rejects.toBeTruthy()
})
it('취소는 idle 이 아니어도 안전하다', () => {
expect(() => getFileTranscriptionService().cancel()).not.toThrow()
})
})

View file

@ -0,0 +1,132 @@
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { Feature } from '@d3ro/core/types'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getLicenseService } from '../../src/main/services/LicenseService'
import { getAutoLaunchService } from '../../src/main/services/AutoLaunchService'
import { getCaptionService } from '../../src/main/services/CaptionService'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
import { registerHistoryHandlers } from '../../src/main/ipc/history-handlers'
import { registerLicenseHandlers } from '../../src/main/ipc/license-handlers'
import { historyInput, invokeIpc, useRedHarness } from './harness'
import { FX, USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 사일런트 에러 / 폴백 성공 위장 탐지', () => {
it('자동 제목 LLM 실패 후 히스토리는 completed 로 남되 title 공백을 성공 제목으로 쓰지 않는다', async () => {
const entry = getHistoryService().create(historyInput({ originalText: USER_TEXT.KO }))
const title = await getHistoryService().generateTitle(entry.id)
expect(title === null || title.length > 0).toBe(true)
if (title === null) {
expect(getHistoryService().getById(entry.id)?.title ?? null).toBeNull()
}
})
it('라이선스 사용량 DB 가 열려 있을 때 getUsage 가 실패를 0으로 숨기지 않는다', () => {
const svc = getLicenseService()
svc.initialize()
const usage = svc.getUsage(Feature.DICTATION)
expect(usage.used).toBe(0)
expect(usage.limit).toBe(-1)
})
it('자동 시작 OS API 실패 시 config 와 OS 가 어긋난 채 성공으로 끝나지 않는다', () => {
const errors: string[] = []
const orig = console.error
try {
getAutoLaunchService().setEnabled(true)
expect(configGet('autoLaunch')).toBe(true)
} catch (err) {
errors.push(String(err))
expect(configGet('autoLaunch')).not.toBe(true)
}
void orig
})
it('캡션 초기 상태는 inactive 이며 start 실패가 active+빈세그먼트로 위장되지 않는다', async () => {
expect(getCaptionService().getState()).toBe('inactive')
try {
await getCaptionService().start()
expect(['starting', 'active', 'inactive']).toContain(getCaptionService().getState())
} catch (err) {
expect(getCaptionService().getState()).toBe('inactive')
expect(err).toBeTruthy()
}
})
it('IPC llm:process 가 원문을 processedText 로 그대로 돌려주며 success 하지 않는다 (미인증)', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, {
text: USER_TEXT.KO,
action: 'refine',
})
expect(res.success).toBe(false)
if (res.success) {
expect(res.data.processedText).not.toBe(USER_TEXT.KO)
}
})
it('IPC 히스토리 삭제가 없는 id 를 success 로 위장하지 않는다', async () => {
registerHistoryHandlers()
const res = await invokeIpc(IPC_CHANNELS.HISTORY.DELETE, { id: 'ghost' })
expect(res.success).toBe(false)
})
it('프리미엄 상태 IPC 가 로그인 없이 isAvailable:true 로 위장하지 않는다', async () => {
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PREMIUM_GET_STATUS)
expect(res.success).toBe(true)
if (res.success) expect(res.data.isAvailable).toBe(false)
})
it('라이선스 빈 키 IPC 활성화가 tier=pro 로 위장하지 않는다', async () => {
getLicenseService().initialize()
registerLicenseHandlers()
const res = await invokeIpc(IPC_CHANNELS.LICENSE.ACTIVATE, { licenseKey: '' })
expect(res.success).toBe(true)
if (res.success) {
expect(res.data.success).toBe(false)
expect(res.data.tier).toBe('free')
}
expect(getLicenseService().tier).toBe('free')
})
it('잘못된 키 활성화 메시지가 비어 있지 않다', async () => {
const result = await getLicenseService().activate(FX.LICENSE_BAD)
expect(result.success).toBe(false)
expect(result.message.length).toBeGreaterThan(0)
})
it('configSet 후 같은 키 get 이 이전 기본값을 반환하지 않는다', () => {
configSet('soundEnabled', false)
expect(configGet('soundEnabled')).toBe(false)
})
it('authToken 만료 클리어 후 Online 경로가 캐시된 성공을 내지 않는다', async () => {
configSet('authToken', FX.AUTH_TOKEN)
configSet('llmBackend', 'online')
registerLLMHandlers()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 401, json: async () => ({}) })),
)
const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, { text: 'x', action: 'refine' })
expect(res.success).toBe(false)
expect(configGet('authToken')).toBeNull()
})
it('캡션 setConfig 후 getConfig 가 반영된다', () => {
getCaptionService().setConfig({ fontSize: 22 })
expect(getCaptionService().getConfig().fontSize).toBe(22)
})
it('캡션 stop 은 inactive 에서 던져도 상태가 깨지지 않는다', async () => {
await getCaptionService().stop()
expect(getCaptionService().getState()).toBe('inactive')
})
})

View file

@ -0,0 +1,252 @@
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getCloudSTTService } from '../../src/main/services/CloudSTTService'
import { getPremiumLLMService } from '../../src/main/services/PremiumLLMService'
import { getOnlineLLMService } from '../../src/main/services/OnlineLLMService'
import { getLocalLLMService } from '../../src/main/services/LocalLLMService'
import { configSet } from '../../src/main/services/ConfigService'
import { registerSTTHandlers } from '../../src/main/ipc/stt-handlers'
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
import { FX, USER_TEXT } from './fixtures'
import { invokeIpc, useRedHarness } from './harness'
const cloudPorts = {
authenticated: true,
invoke: async (
_name: string,
_body: Record<string, unknown>,
): Promise<{ data: unknown; error: { message: string } | null }> => ({
data: null,
error: { message: 'fx.default.unconfigured' },
}),
}
vi.mock('../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => ({
isEnabled: () => true,
isAuthenticated: () => cloudPorts.authenticated,
invokeFunction: (name: string, body: Record<string, unknown>) => cloudPorts.invoke(name, body),
signInAnonymously: async () => {
throw new Error('fx.anon.not-supported')
},
pushOne: async () => undefined,
getState: () => ({
authenticated: cloudPorts.authenticated,
userEmail: null,
lastSyncAt: null,
syncing: false,
}),
}),
resetCloudSyncServiceForTests: () => undefined,
}))
useRedHarness()
describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', () => {
it('미인증 STT initialize 는 조용히 ready 가 되지 않고 이후 transcribe 가 실패한다', async () => {
cloudPorts.authenticated = false
await getCloudSTTService().initialize()
await expect(
getCloudSTTService().transcribe(Buffer.alloc(100)),
).rejects.toMatchObject({ code: ErrorCode.STTTranscriptionFailed })
})
it('STT 프록시 에러는 STTTranscriptionFailed 다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: null, error: { message: 'fx.stt.proxy-down' } })
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTTranscriptionFailed,
})
})
it('STT 빈 텍스트는 성공 객체가 아니라 에러다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { text: FX.STT_EMPTY, segments: [], language: 'ko' },
error: null,
})
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTNoAudioData,
})
})
it('STT 기형 페이로드(text 없음)는 파싱 실패다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: FX.STT_MALFORMED_SHAPE, error: null })
await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({
code: ErrorCode.STTTranscriptionFailed,
})
})
it('STT 정상 픽스처는 text 가 픽스처 토큰이다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { text: FX.STT_OK, segments: [], language: 'ko', duration: 1, processingTime: 1 },
error: null,
})
const result = await getCloudSTTService().transcribe(Buffer.from('pcm'))
expect(result.text).toBe(FX.STT_OK)
expect(result.text).not.toBe(USER_TEXT.KO)
})
it('미인증 PremiumLLM.processText 는 로그인 에러 + upgrade-required 다', async () => {
cloudPorts.authenticated = false
const llm = getPremiumLLMService()
const events: unknown[] = []
llm.on('upgrade-required', (e) => events.push(e))
await expect(llm.processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMServerUnreachable,
})
expect(events.length).toBeGreaterThan(0)
})
it('PremiumLLM 프록시 에러는 원문을 반환하지 않는다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: null, error: { message: 'fx.llm.500' } })
await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMProcessingFailed,
})
})
it('PremiumLLM 빈 content 는 원문 폴백이 아니라 에러다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { content: [{ type: 'text', text: FX.LLM_EMPTY }] },
error: null,
})
await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMProcessingFailed,
})
})
it('PremiumLLM 기형 content 는 에러다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: { content: [] }, error: null })
await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toBeTruthy()
})
it('PremiumLLM 정상 픽스처는 입력과 다른 토큰이다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { content: [{ type: 'text', text: FX.LLM_OK }] },
error: null,
})
const out = await getPremiumLLMService().processText(USER_TEXT.KO, 'refine')
expect(out).toBe(FX.LLM_OK)
expect(out).not.toBe(USER_TEXT.KO)
})
it('쿼터 초과 메시지는 upgrade-required 를 낸다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({ data: null, error: { message: 'quota_exceeded 429' } })
const llm = getPremiumLLMService()
const ev: unknown[] = []
llm.on('upgrade-required', (e) => ev.push(e))
await expect(llm.processText('x', 'refine')).rejects.toBeTruthy()
expect(ev.length).toBeGreaterThan(0)
})
it('generate() 가 공개 API 로 존재하고 픽스처를 반환한다', async () => {
cloudPorts.authenticated = true
cloudPorts.invoke = async () => ({
data: { content: [{ type: 'text', text: FX.LLM_TITLE }] },
error: null,
})
const llm = getPremiumLLMService() as { generate?: (t: string, o?: unknown) => Promise<{ text: string }> }
expect(typeof llm.generate).toBe('function')
const result = await llm.generate!(USER_TEXT.KO, { systemPrompt: 'title' })
expect(result.text).toBe(FX.LLM_TITLE)
})
it('IPC llm:process 실패는 success:false 다', async () => {
cloudPorts.authenticated = false
configSet('llmBackend', 'premium')
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, {
text: USER_TEXT.KO,
action: 'refine',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.LLMProcessingFailed)
})
it('IPC llm:process online + 미로그인은 실패다', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, {
text: USER_TEXT.KO,
action: 'refine',
})
expect(res.success).toBe(false)
})
it('IPC premium getStatus 는 실제 가용 여부를 반영한다 (항상 true 금지)', async () => {
cloudPorts.authenticated = false
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PREMIUM_GET_STATUS)
expect(res.success).toBe(true)
if (res.success) expect(res.data.isAvailable).toBe(false)
})
it('IPC premium getQuota 는 하드코딩 999999 이 아니다', async () => {
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PREMIUM_GET_QUOTA)
expect(res.success).toBe(true)
if (res.success) {
expect(res.data.remainingTokens).not.toBe(999999)
}
})
it('IPC stt:setLanguage / getLanguage 왕복', async () => {
registerSTTHandlers()
await invokeIpc(IPC_CHANNELS.STT.SET_LANGUAGE, { language: 'en' })
const res = await invokeIpc(IPC_CHANNELS.STT.GET_LANGUAGE)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBe('en')
})
it('IPC llm:setModel / getActiveModel 왕복', async () => {
registerLLMHandlers()
await invokeIpc(IPC_CHANNELS.LLM.SET_MODEL, { modelId: 'fx-model-id' })
const res = await invokeIpc(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBe('fx-model-id')
})
it('IPC llm:getStatus 는 LocalLLM.getStatus() 와 같고 하드코딩 ready 가 아니다', async () => {
registerLLMHandlers()
const live = getLocalLLMService().getStatus()
const res = await invokeIpc(IPC_CHANNELS.LLM.GET_STATUS)
expect(res.success).toBe(true)
if (res.success) {
expect(res.data).toEqual(live)
expect(res.data).not.toMatchObject({
isInstalled: true,
isRunning: true,
isReady: true,
currentModel: 'premium',
})
expect(live.connectionState === 'disconnected' || live.connectionState === 'connected').toBe(true)
}
})
it('IPC llm:pullModel 실패는 성공으로 위장하지 않는다', async () => {
registerLLMHandlers()
const res = await invokeIpc(IPC_CHANNELS.LLM.PULL_MODEL, { modelId: 'fx-missing-model' })
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.LLMServerUnreachable)
})
it('OnlineLLM 5xx 는 LLMProcessingFailed 다', async () => {
configSet('authToken', FX.AUTH_TOKEN)
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })),
)
await expect(getOnlineLLMService().processText('x', 'refine')).rejects.toMatchObject({
code: ErrorCode.LLMProcessingFailed,
})
})
})

View file

@ -0,0 +1,180 @@
import { describe, it, expect } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService'
import { getMeetingDocTemplateService } from '../../src/main/services/MeetingDocTemplateService'
import { useRedHarness } from './harness'
import { USER_TEXT } from './fixtures'
useRedHarness()
describe('유스케이스: 딕테이션 템플릿 CRUD / 필드 세션', () => {
it('빌트인 템플릿이 있다', () => {
const all = getDictationTemplateService().getAll()
expect(all.length).toBeGreaterThanOrEqual(3)
expect(all.some((t) => t.id === 'builtin-email')).toBe(true)
})
it('사용자 템플릿을 만든다', () => {
const created = getDictationTemplateService().create({
name: '내 템플릿',
description: 'd',
fields: [{ id: 'f1', name: 'f1', label: 'F', promptText: 'say', required: true, maxDurationSec: 10 }],
outputFormat: '{{f1}}',
})
expect(created.isBuiltin).toBe(false)
expect(getDictationTemplateService().getById(created.id)?.name).toBe('내 템플릿')
})
it('없는 템플릿 수정은 TemplateNotFound 다', () => {
expect(() => getDictationTemplateService().update({ id: 'missing', name: 'x' })).toThrow(
D3ROError,
)
try {
getDictationTemplateService().update({ id: 'missing', name: 'x' })
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.TemplateNotFound)
}
})
it('빌트인 삭제는 거부된다', () => {
expect(() => getDictationTemplateService().delete('builtin-email')).toThrow(D3ROError)
expect(getDictationTemplateService().getById('builtin-email')).not.toBeNull()
})
it('사용자 템플릿은 삭제된다', () => {
const created = getDictationTemplateService().create({
name: 'del',
description: '',
fields: [{ id: 'f1', name: 'f1', label: 'F', promptText: 'p', required: false, maxDurationSec: 5 }],
outputFormat: '{{f1}}',
})
getDictationTemplateService().delete(created.id)
expect(getDictationTemplateService().getById(created.id)).toBeNull()
})
it('없는 템플릿 세션 시작은 TemplateNotFound 다', () => {
expect(() => getDictationTemplateService().startSession('missing')).toThrow(D3ROError)
})
it('이메일 템플릿 세션을 시작하고 필드를 채운다', () => {
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
const state = svc.getSessionState()
expect(state?.templateId).toBe('builtin-email')
expect(state?.currentField?.id).toBe('recipient')
svc.setFieldValue('recipient', 'a@b.c')
expect(svc.getSessionState()?.currentField?.id).toBe('subject')
})
it('이미 세션이 있으면 재시작은 TemplateSessionAlreadyActive 다', () => {
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
expect(() => svc.startSession('builtin-report')).toThrow(D3ROError)
try {
svc.startSession('builtin-report')
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.TemplateSessionAlreadyActive)
}
})
it('세션 없이 필드 입력은 TemplateSessionNotActive 다', () => {
expect(() => getDictationTemplateService().setFieldValue('x', 'y')).toThrow(D3ROError)
})
it('세션을 취소하면 상태가 null 이다', () => {
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
svc.cancelSession()
expect(svc.getSessionState()).toBeNull()
})
it('모든 필드를 채우면 출력이 치환되고 세션이 끝난다', () => {
const svc = getDictationTemplateService()
let output: string | null = null
svc.on('session-completed', (e: { outputText: string }) => {
output = e.outputText
})
svc.startSession('builtin-email')
svc.setFieldValue('recipient', 'to@x.test')
svc.setFieldValue('subject', 'hello')
svc.setFieldValue('body', USER_TEXT.KO)
expect(svc.getSessionState()).toBeNull()
expect(output).toContain('to@x.test')
expect(output).toContain('hello')
expect(output).toContain(USER_TEXT.KO)
})
it('빈 필드 템플릿은 시작할 수 없다', () => {
const created = getDictationTemplateService().create({
name: 'nofield',
description: '',
fields: [],
outputFormat: 'x',
})
expect(() => getDictationTemplateService().startSession(created.id)).toThrow(D3ROError)
})
})
describe('유스케이스: 회의 문서 템플릿 CRUD', () => {
it('빌트인 회의록/보고서/아이디어/마인드맵이 있다', () => {
const all = getMeetingDocTemplateService().getAll()
expect(all.some((t) => t.id === 'builtin-minutes')).toBe(true)
expect(all.some((t) => t.id === 'builtin-report')).toBe(true)
expect(all.some((t) => t.id === 'builtin-idea-note')).toBe(true)
expect(all.some((t) => t.id === 'builtin-mindmap')).toBe(true)
})
it('커스텀 문서 템플릿을 만든다', () => {
const created = getMeetingDocTemplateService().create({
name: '내 양식',
description: 'd',
systemPrompt: '요약만',
})
expect(created.isBuiltin).toBe(false)
expect(getMeetingDocTemplateService().getById(created.id)?.systemPrompt).toBe('요약만')
})
it('없는 템플릿 수정은 MeetingDocTemplateNotFound 다', () => {
expect(() => getMeetingDocTemplateService().update({ id: 'missing', name: 'x' })).toThrow(
D3ROError,
)
try {
getMeetingDocTemplateService().update({ id: 'missing', name: 'x' })
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.MeetingDocTemplateNotFound)
}
})
it('빌트인 삭제는 MeetingDocTemplateBuiltinDelete 다', () => {
expect(() => getMeetingDocTemplateService().delete('builtin-minutes')).toThrow(D3ROError)
try {
getMeetingDocTemplateService().delete('builtin-minutes')
} catch (err) {
expect((err as D3ROError).code).toBe(ErrorCode.MeetingDocTemplateBuiltinDelete)
}
})
it('커스텀 템플릿은 삭제된다', () => {
const created = getMeetingDocTemplateService().create({
name: 'del',
description: '',
systemPrompt: 'p',
})
getMeetingDocTemplateService().delete(created.id)
expect(getMeetingDocTemplateService().getById(created.id)).toBeNull()
})
it('없는 템플릿 삭제는 MeetingDocTemplateNotFound 다', () => {
expect(() => getMeetingDocTemplateService().delete('missing')).toThrow(D3ROError)
})
it('템플릿 이름 수정이 반영된다', () => {
const created = getMeetingDocTemplateService().create({
name: 'old',
description: '',
systemPrompt: 'p',
})
const updated = getMeetingDocTemplateService().update({ id: created.id, name: 'new' })
expect(updated.name).toBe('new')
})
})

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { getTTSPlaybackService } from '../../src/main/services/TTSPlaybackService'
import { useRedHarness } from './harness'
useRedHarness()
describe('유스케이스: TTS 입력 가드 (네이티브 재생 전)', () => {
it('빈 텍스트는 TTSTextEmpty 다', async () => {
await expect(getTTSPlaybackService().speak('')).rejects.toMatchObject({
code: ErrorCode.TTSTextEmpty,
})
})
it('공백만 있는 텍스트도 TTSTextEmpty 다', async () => {
await expect(getTTSPlaybackService().speak(' \n')).rejects.toMatchObject({
code: ErrorCode.TTSTextEmpty,
})
})
it('stop 은 재생 중이 아니어도 안전하다', () => {
expect(() => getTTSPlaybackService().stop()).not.toThrow()
expect(getTTSPlaybackService().isSpeaking).toBe(false)
})
})

View file

@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getVoiceCommandService } from '../../src/main/services/VoiceCommandService'
import { registerVoiceCommandHandlers } from '../../src/main/ipc/voice-command-handlers'
import { invokeIpc, useRedHarness } from './harness'
useRedHarness()
describe('유스케이스: 음성 단축키 매칭 / 토글 / 키워드 CRUD', () => {
it('초기에는 비활성이다', () => {
const svc = getVoiceCommandService()
svc.initialize()
expect(svc.isEnabled()).toBe(false)
})
it('비활성 상태에서는 키워드가 있어도 매칭하지 않는다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
const match = svc.match('번역해줘 이 문장')
expect(match.matched).toBe(false)
expect(match.cleanedText).toBe('번역해줘 이 문장')
})
it('활성 + 기본 키워드면 prefix 매칭한다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
svc.setEnabled(true)
const match = svc.match('번역해줘 이 문장')
expect(match.matched).toBe(true)
expect(match.instructionId).toBe('builtin-translate')
expect(match.cleanedText).toBe('이 문장')
expect(match.matchedKeyword).toBe('번역해줘')
})
it('요약 키워드도 매칭한다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
svc.setEnabled(true)
expect(svc.match('요약해줘 긴 글').instructionId).toBe('builtin-summarize')
})
it('매칭 실패 시 cleanedText 는 원문이다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
svc.setEnabled(true)
const match = svc.match('그냥 받아쓰기')
expect(match.matched).toBe(false)
expect(match.cleanedText).toBe('그냥 받아쓰기')
})
it('빈 텍스트는 매칭하지 않는다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
svc.setEnabled(true)
expect(svc.match(' ').matched).toBe(false)
})
it('사용자 키워드를 추가하면 contains 로 잡힌다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.setEnabled(true)
svc.setKeywordsForInstruction('builtin-formal', [
{ keyword: '정중하게', matchMode: 'contains' },
])
const match = svc.match('이 메일 정중하게 써줘')
expect(match.matched).toBe(true)
expect(match.instructionId).toBe('builtin-formal')
})
it('같은 instruction 키워드를 덮어쓴다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.setEnabled(true)
svc.setKeywordsForInstruction('builtin-formal', [
{ keyword: 'oldkw', matchMode: 'prefix' },
])
svc.setKeywordsForInstruction('builtin-formal', [
{ keyword: 'newkw', matchMode: 'prefix' },
])
expect(svc.match('oldkw x').matched).toBe(false)
expect(svc.match('newkw x').matched).toBe(true)
})
it('initDefaultKeywords 두 번은 규칙을 복제하지 않는다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
const n = svc.getAllRules().length
svc.initDefaultKeywords()
expect(svc.getAllRules().length).toBe(n)
})
it('비활성화하면 다시 매칭하지 않는다', () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
svc.setEnabled(true)
svc.setEnabled(false)
expect(svc.match('번역해줘 x').matched).toBe(false)
})
it('IPC setEnabled / isEnabled 왕복', async () => {
getVoiceCommandService().initialize()
registerVoiceCommandHandlers()
await invokeIpc(IPC_CHANNELS.VOICE_COMMAND.SET_ENABLED, { enabled: true })
const res = await invokeIpc(IPC_CHANNELS.VOICE_COMMAND.IS_ENABLED)
expect(res.success).toBe(true)
if (res.success) expect(res.data).toBe(true)
})
it('IPC getAll 은 배열이다', async () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.initDefaultKeywords()
registerVoiceCommandHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE_COMMAND.GET_ALL)
expect(res.success).toBe(true)
if (res.success) expect(Array.isArray(res.data)).toBe(true)
})
it('IPC setKeywords 후 매칭된다', async () => {
const svc = getVoiceCommandService()
svc.initialize()
svc.setEnabled(true)
registerVoiceCommandHandlers()
const set = await invokeIpc(IPC_CHANNELS.VOICE_COMMAND.SET_KEYWORDS, {
instructionId: 'builtin-translate',
keywords: [{ keyword: '영작', matchMode: 'prefix' }],
})
expect(set.success).toBe(true)
expect(svc.match('영작 hello').matched).toBe(true)
})
})

View file

@ -0,0 +1,392 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import { RecognitionState } from '@d3ro/core/types'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { FX } from './fixtures'
import { invokeIpc, useRedHarness } from './harness'
const audioBus = new EventEmitter()
const mockAudio = {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
on: (ev: string, fn: (...args: unknown[]) => void) => audioBus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => audioBus.off(ev, fn),
}
const mockSTT = {
initialize: vi.fn(async () => undefined),
transcribe: vi.fn(async () => ({
text: FX.STT_OK,
segments: [],
language: 'ko',
duration: 1,
processingTime: 10,
})),
getModels: vi.fn(() => [
{ id: 'large-v3-turbo', name: 'Turbo', sizeBytes: 0, downloaded: true, languages: [] },
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [] },
]),
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'large-v3-turbo', engineVersion: null, gpuAccelerated: false })),
}
const mockLLM = {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => FX.LLM_OK),
generate: vi.fn(async () => ({ text: FX.LLM_OK })),
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}
const mockLicense = {
canUse: vi.fn(() => ({ allowed: true, reason: 'ok' })),
consumeQuota: vi.fn(),
promptUpgrade: vi.fn(),
initialize: vi.fn(),
getInfo: vi.fn(() => ({ tier: 'free' })),
on: vi.fn(),
}
const mockCaption = {
getState: vi.fn(() => 'inactive'),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
}
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => mockAudio,
calculateRMS: () => 0.1,
}))
vi.mock('../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => mockSTT,
resetLocalSTTServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: vi.fn(() => true),
processText: (...args: unknown[]) => mockLLM.processText(...args),
chatStream: mockLLM.chatStream,
cancelGeneration: vi.fn(),
getStatus: () => ({
connectionState: 'connected',
serverUrl: 'http://localhost:11434',
activeModel: 'gemma4:e4b',
serverVersion: null,
}),
}),
resetLocalLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => mockLLM,
resetPremiumLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({
insertText: vi.fn(async () => ({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })),
}),
}))
vi.mock('../../src/main/services/LicenseService', () => ({
getLicenseService: () => mockLicense,
resetLicenseServiceForTests: () => undefined,
initLicenseService: () => undefined,
}))
vi.mock('../../src/main/services/CaptionService', () => ({
getCaptionService: () => mockCaption,
resetCaptionServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => ({
on: vi.fn(),
off: vi.fn(),
}),
}))
useRedHarness()
import { getVoiceModeService } from '../../src/main/services/VoiceModeService'
import { registerVoiceHandlers } from '../../src/main/ipc/voice-handlers'
import { configSet } from '../../src/main/services/ConfigService'
import { getHistoryService } from '../../src/main/services/HistoryService'
import {
persistCompletedVoiceSession,
persistCompletedVoiceSessionSafe,
} from '../../src/main/voice-session-persist'
import { unbindTestDatabase } from '../../src/main/db'
function feedAudio(): void {
audioBus.emit('audio-data', { buffer: Buffer.alloc(16000 * 2) })
}
async function startAndRecord(): Promise<ReturnType<typeof getVoiceModeService>> {
const svc = getVoiceModeService()
await svc.startSession('dictation')
feedAudio()
await new Promise((r) => setTimeout(r, 750))
feedAudio()
return svc
}
describe('유스케이스: 받아쓰기 시작/정지/취소 / STT·LLM 실패 표면화', () => {
beforeEach(() => {
mockSTT.initialize.mockReset()
mockSTT.transcribe.mockReset()
mockLLM.processText.mockReset()
mockSTT.initialize.mockResolvedValue(undefined)
mockSTT.transcribe.mockResolvedValue({
text: FX.STT_OK,
segments: [],
language: 'ko',
duration: 1,
processingTime: 10,
})
mockLLM.processText.mockResolvedValue(FX.LLM_OK)
mockLicense.canUse.mockReturnValue({ allowed: true, reason: 'ok' })
mockCaption.getState.mockReturnValue('inactive')
mockAudio.start.mockClear()
audioBus.removeAllListeners()
configSet('defaultLLMAction', 'refine')
configSet('autoInsert', false)
})
it('초기 음성 상태는 IDLE 이다', () => {
const state = getVoiceModeService().getState()
expect(state.recognitionState).toBe(RecognitionState.IDLE)
expect(state.sessionId).toBeNull()
expect(getVoiceModeService().isActive).toBe(false)
})
it('녹음 시작 시 세션이 활성화된다', async () => {
const svc = getVoiceModeService()
await svc.startSession('dictation')
expect(svc.isActive).toBe(true)
expect(svc.getState().sessionId).toBeTruthy()
})
it('사용자가 취소하면 session-cancelled(user) 가 난다', async () => {
const svc = getVoiceModeService()
let reason: string | null = null
svc.on('session-cancelled', (p: { reason: string }) => {
reason = p.reason
})
await svc.startSession('dictation')
svc.cancelSession()
expect(reason).toBe('user')
expect(svc.getState().recognitionState).toBe(RecognitionState.IDLE)
})
it('너무 짧은 녹음은 too-short 로 취소된다', async () => {
const svc = getVoiceModeService()
let reason: string | null = null
svc.on('session-cancelled', (p: { reason: string }) => {
reason = p.reason
})
await svc.startSession('dictation')
await svc.stopSession()
expect(reason).toBe('too-short')
})
it('정상 녹음 후 STT 픽스처가 전사로 올라온다', async () => {
const svc = await startAndRecord()
let finalText: string | null = null
svc.on('session-completed', (p: { finalText: string }) => {
finalText = p.finalText
})
await svc.stopSession()
expect(mockSTT.transcribe).toHaveBeenCalled()
expect(finalText === FX.LLM_OK || finalText === FX.STT_OK).toBe(true)
})
it('STT 가 빈 문자열이면 성공 완료가 아니라 error 이벤트다', async () => {
mockSTT.transcribe.mockResolvedValue({
text: FX.STT_EMPTY,
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
const svc = await startAndRecord()
const errors: Array<{ code: number }> = []
let completed = false
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error))
svc.on('session-completed', () => {
completed = true
})
await svc.stopSession()
expect(completed).toBe(false)
expect(errors.some((e) => e.code === ErrorCode.STTNoAudioData)).toBe(true)
})
it('STT 가 공백만 반환해도 error 다', async () => {
mockSTT.transcribe.mockResolvedValue({
text: ' ',
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
const svc = await startAndRecord()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.stopSession()
expect(errors).toContain(ErrorCode.STTNoAudioData)
})
it('STT throw 는 STTTranscriptionFailed 로 표면화된다', async () => {
mockSTT.transcribe.mockRejectedValue(new Error('fx.stt.down'))
const svc = await startAndRecord()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.stopSession()
expect(errors).toContain(ErrorCode.STTTranscriptionFailed)
})
it('LLM 실패 시 원문으로 조용히 완료하지 않고 error 를 낸다', async () => {
mockLLM.processText.mockRejectedValue(new Error('fx.llm.timeout'))
const svc = await startAndRecord()
const errors: number[] = []
let completed = false
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
svc.on('session-completed', () => {
completed = true
})
await svc.stopSession()
expect(completed).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('LLM 액션 none 이면 STT 픽스처가 최종 텍스트다', async () => {
configSet('defaultLLMAction', 'none')
const svc = await startAndRecord()
let finalText: string | null = null
svc.on('session-completed', (p: { finalText: string }) => {
finalText = p.finalText
})
await svc.stopSession()
expect(finalText).toBe(FX.STT_OK)
expect(mockLLM.processText).not.toHaveBeenCalled()
})
it('라이선스가 막으면 세션이 시작되지 않고 error 가 난다', async () => {
mockLicense.canUse.mockReturnValue({ allowed: false, reason: 'quota_exceeded' })
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
await svc.startSession('dictation')
expect(svc.isActive).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('자막 모드가 켜져 있으면 받아쓰기를 시작하지 않고 error 다', async () => {
mockCaption.getState.mockReturnValue('active')
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
await svc.startSession('dictation')
expect(svc.isActive).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('오디오 시작 실패는 AudioCaptureStartFailed 다', async () => {
mockAudio.start.mockRejectedValueOnce(new Error('fx.mic.denied'))
const svc = getVoiceModeService()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.startSession('dictation')
expect(errors).toContain(ErrorCode.AudioCaptureStartFailed)
})
it('중복 startSession 은 두 번째를 무시하고 기존 세션을 유지한다', async () => {
const svc = getVoiceModeService()
await svc.startSession('dictation')
const first = svc.getState().sessionId
await svc.startSession('dictation')
expect(svc.getState().sessionId).toBe(first)
})
it('IPC voice:getState 는 현재 상태를 돌려준다', async () => {
registerVoiceHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE.GET_STATE)
expect(res.success).toBe(true)
if (res.success) expect(res.data.recognitionState).toBe(RecognitionState.IDLE)
})
it('IPC voice:startRecording 성공 시 sessionId 가 있다', async () => {
registerVoiceHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE.START_RECORDING, { sessionId: 'ui' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.sessionId).toBeTruthy()
})
it('IPC voice:cancelRecording 은 활성 세션을 종료한다', async () => {
registerVoiceHandlers()
await invokeIpc(IPC_CHANNELS.VOICE.START_RECORDING, { sessionId: 'ui' })
const res = await invokeIpc(IPC_CHANNELS.VOICE.CANCEL_RECORDING, { sessionId: 'ui' })
expect(res.success).toBe(true)
expect(getVoiceModeService().isActive).toBe(false)
})
it('hands-free 모드로도 세션을 시작할 수 있다', async () => {
const svc = getVoiceModeService()
await svc.startSession('hands-free')
expect(svc.currentSession?.mode).toBe('hands-free')
svc.cancelSession()
})
it('녹음 완료 → persistCompletedVoiceSession 이 히스토리 행을 만든다', async () => {
const svc = await startAndRecord()
svc.on('session-completed', ({ session, finalText }) => {
persistCompletedVoiceSession(session, finalText)
})
await svc.stopSession()
const page = getHistoryService().list({ page: 0, pageSize: 10 })
expect(page.total).toBe(1)
expect(page.entries[0].originalText === FX.STT_OK || page.entries[0].polishedText === FX.LLM_OK).toBe(
true,
)
expect(page.entries[0].status).toBe('completed')
})
it('히스토리 저장 실패는 삼키지 않고 error 로 표면화된다', async () => {
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
persistCompletedVoiceSessionSafe(
(error, session) => {
svc.emit('error', { error, session })
},
{
id: 'fx-session',
transcription: FX.STT_OK,
processedText: null,
mode: 'dictation',
startedAt: Date.now(),
},
FX.STT_OK,
)
unbindTestDatabase()
persistCompletedVoiceSessionSafe(
(error, session) => {
svc.emit('error', { error, session })
},
{
id: 'fx-session-fail',
transcription: FX.STT_OK,
processedText: null,
mode: 'dictation',
startedAt: Date.now(),
},
FX.STT_OK,
)
expect(errors.length).toBeGreaterThan(0)
})
})