Shortcuts were defined in four places that drifted apart: per-action IPC channel pairs, a hand-written VK table in the service, a second one in the renderer, and three copies of the keycap styling. Adding an action meant editing all of them, so two shortcuts stayed hardcoded in bootstrap and one had no settings entry at all. packages/core/src/keybinding.ts is now the single source for the binding type, the selectable key catalog, the action catalog, normalization, validation, conflict detection, display labels, search and deserialization. Main, preload and renderer all read from it; nothing redefines keys or rules locally. - Each action holds a list of bindings instead of one. AppConfig's four *Shortcut fields collapse into a single keyBindings map, migrated on launch. - Mouse buttons can be bound. Left click is refused, right/middle need a modifier, side buttons are free. uiohook cannot swallow events, so the original click still fires and the UI says so. - Keys can be picked from a grouped dropdown with a search box, not only by recording a keypress. - HOTKEY's 14 channels become KEYBINDING's 9, taking the action as a parameter, so actions no longer multiply channels. The history and command popups moved out of bootstrap into ordinary actions. - displayLabel is gone; labels derive from the binding and follow the app language and platform. Fixes found on the way: - Double-press hands-free was unreachable: lookup returned only the first matching action, and dictation shares its default binding. - Reserved-combination checks compared joined key names, so a different modifier order let Ctrl+C through. - Disabling shortcuts released every global registration in the process, including the popup ones, and never restored them. - Enabling shortcuts after starting disabled left nothing registered. - The dashboard stored the caption event payload instead of the state in it.
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
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/KeyBindingService', () => ({
|
|
getKeyBindingService: () => ({
|
|
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)
|
|
})
|
|
})
|