refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
162
apps/desktop/tests/main/windows/popup-navigation.test.ts
Normal file
162
apps/desktop/tests/main/windows/popup-navigation.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue