refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -0,0 +1,162 @@
// tests/main/windows/popup-navigation.test.ts
// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 히스토리 항목을 삽입하던 버그의 회귀 테스트.
import { describe, it, expect, vi } from 'vitest'
import {
PopupNavigationController,
POPUP_NAV_KEYS,
HISTORY_INSERT_DELAY_MS,
type PopupKind,
type PopupWindowPort,
} from '../../../src/main/windows/popup-navigation'
interface FakeWindow extends PopupWindowPort {
visible: boolean
keys: string[]
}
function fakeWindow(): FakeWindow {
const win: FakeWindow = {
visible: false,
keys: [],
show: () => {
win.visible = true
},
hide: () => {
win.visible = false
},
isVisible: () => win.visible,
sendKey: (key) => {
win.keys.push(key)
},
}
return win
}
/** Electron globalShortcut 처럼 이미 등록된 가속기의 재등록은 false 로 거부한다 */
function fakeShortcuts(): {
registered: Map<string, () => void>
register: (accel: string, cb: () => void) => boolean
unregister: (accel: string) => void
press: (accel: string) => void
} {
const registered = new Map<string, () => void>()
return {
registered,
register: (accel, cb) => {
if (registered.has(accel)) return false
registered.set(accel, cb)
return true
},
unregister: (accel) => {
registered.delete(accel)
},
press: (accel) => registered.get(accel)?.(),
}
}
function setup(): {
controller: PopupNavigationController
windows: Record<PopupKind, FakeWindow>
shortcuts: ReturnType<typeof fakeShortcuts>
inserted: string[]
scheduled: Array<{ fn: () => void; ms: number }>
selections: Array<{ id: string; name: string }>
} {
const windows = { history: fakeWindow(), command: fakeWindow() }
const shortcuts = fakeShortcuts()
const inserted: string[] = []
const scheduled: Array<{ fn: () => void; ms: number }> = []
const selections: Array<{ id: string; name: string }> = []
const controller = new PopupNavigationController({
windows,
shortcuts,
insertText: async (text) => {
inserted.push(text)
},
applyCommandSelection: (sel) => selections.push(sel),
schedule: (fn, ms) => scheduled.push({ fn, ms }),
})
return { controller, windows, shortcuts, inserted, scheduled, selections }
}
describe('PopupNavigationController', () => {
it('히스토리 → 명령어 전환 시 히스토리를 닫고 Enter 는 명령어 팝업으로 간다', () => {
const { controller, windows, shortcuts } = setup()
controller.toggle('history')
controller.toggle('command')
expect(windows.history.visible).toBe(false)
expect(windows.command.visible).toBe(true)
expect(controller.owner).toBe('command')
shortcuts.press('Return')
expect(windows.command.keys).toEqual(['Enter'])
expect(windows.history.keys).toEqual([])
})
it('소유자가 아닌 팝업을 닫아도 소유자의 키를 뺏지 않는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('command')
controller.dismiss('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
shortcuts.press('Down')
expect(windows.command.keys).toEqual(['ArrowDown'])
})
it('소유 팝업을 닫으면 전역 내비게이션 키를 모두 해제한다', () => {
const { controller, shortcuts } = setup()
controller.toggle('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
controller.toggle('history')
expect(shortcuts.registered.size).toBe(0)
expect(controller.owner).toBeNull()
})
it('히스토리 선택은 팝업을 닫고 지연 뒤 삽입한다', async () => {
const { controller, windows, inserted, scheduled, shortcuts } = setup()
controller.show('history')
controller.selectHistoryItem('붙여넣을 텍스트')
expect(windows.history.visible).toBe(false)
expect(shortcuts.registered.size).toBe(0)
expect(scheduled).toHaveLength(1)
expect(scheduled[0].ms).toBe(HISTORY_INSERT_DELAY_MS)
expect(inserted).toEqual([])
scheduled[0].fn()
await Promise.resolve()
expect(inserted).toEqual(['붙여넣을 텍스트'])
})
it('명령어 선택은 팝업을 닫고 선택을 반영한다', () => {
const { controller, windows, selections } = setup()
controller.show('command')
controller.selectCommand({ id: 'cmd-1', name: '번역' })
expect(windows.command.visible).toBe(false)
expect(selections).toEqual([{ id: 'cmd-1', name: '번역' }])
})
it('팝업이 다른 경로로 사라졌으면 다음 키에서 전역 키를 놓는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('history')
windows.history.visible = false
shortcuts.press('Return')
expect(shortcuts.registered.size).toBe(0)
expect(windows.history.keys).toEqual([])
})
it('등록 실패(다른 앱이 점유)를 경고한다', () => {
const warn = vi.fn()
const windows = { history: fakeWindow(), command: fakeWindow() }
const controller = new PopupNavigationController({
windows,
shortcuts: { register: () => false, unregister: () => undefined },
insertText: async () => undefined,
applyCommandSelection: () => undefined,
warn,
})
controller.show('history')
expect(warn).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,94 @@
// tests/main/windows/recording-tip-presenter.test.ts
// RecordingTip 표시 정책 — 에러 숨김 타이머가 다음 세션의 녹음 팁을 숨기던 버그의 회귀 테스트.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
RecordingTipPresenter,
type RecordingTipView,
} from '../../../src/main/windows/recording-tip-presenter'
function fakeView(): RecordingTipView & { calls: string[] } {
const calls: string[] = []
return {
calls,
showRecording: () => calls.push('show'),
hide: () => calls.push('hide'),
showThinking: () => calls.push('thinking'),
showError: (message) => calls.push(`error:${message}`),
sendAudioLevel: (level) => calls.push(`level:${level}`),
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
showResult: (text, ms) => calls.push(`result:${text}:${ms}`),
}
}
describe('RecordingTipPresenter', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('에러 직후 3초 안에 시작한 새 세션의 팁은 숨겨지지 않는다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('No speech detected')
vi.advanceTimersByTime(1000)
presenter.onSessionStarted()
presenter.onPhase('recording')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:No speech detected', 'show'])
})
it('에러는 3초 뒤 한 번 숨긴다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('boom')
vi.advanceTimersByTime(2999)
expect(view.calls).toEqual(['error:boom'])
vi.advanceTimersByTime(1)
expect(view.calls).toEqual(['error:boom', 'hide'])
})
it("'warning' 은 팁을 건드리지 않는다 (Ollama 미가용 알림 등)", () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onError('Ollama unavailable', 'warning')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['show'])
})
it('연속 에러는 숨김 예약을 하나로 유지한다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('a')
vi.advanceTimersByTime(2000)
presenter.onError('b')
vi.advanceTimersByTime(2000)
expect(view.calls).toEqual(['error:a', 'error:b'])
vi.advanceTimersByTime(1000)
expect(view.calls).toEqual(['error:a', 'error:b', 'hide'])
})
it('완료·취소는 즉시 숨기고, 삽입 실패는 결과 팝업을 띄운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onPhase('thinking')
presenter.onSessionEnded()
presenter.onInsertFailed('text')
expect(view.calls).toEqual(['show', 'thinking', 'hide', 'result:text:10000'])
})
it('dispose 는 남은 숨김 예약을 지운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('x')
presenter.dispose()
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:x'])
})
})