feat(desktop): honest, unified dictation popups with Esc to cancel
Wave 3 of the 2026-09-28 design overhaul. - Esc (no modifiers) cancels an in-progress dictation, recording or processing: VoiceModeService.requestEscape() queues the dead 'escape' action. Clicking the capsule cancels only while recording, so a stray click during processing no longer throws the text away. - Errors show in the user's language: bootstrap now passes error.code to the tip (the translated branch never ran), and one core classifier (voice-error-message) feeds both the tip and the main-window alert. - Capsule: bars stay flat without input (no fake "alive" wobble before the mic opens), an indeterminate bar replaces the made-up percentage, the partial transcript stays visible while processing, an Esc hint, and a brief "Inserted" state so success is visible. - All six popups share popups/_shared (Pretendard, one panel radius, selection, kbd hints, reduced motion). History/command popups get titles, listbox roles and window sizes from popup-list-geometry (empty states were clipped); the command popup's "0" key is registered; result popup explains why it appeared and can be closed; captions keep a theme-independent dark backing on purpose. - Popup themes follow the OS when set to system and the document lang follows the app language. - Site hero demo mirrors the new capsule (done state, indeterminate bar, Esc hint; 10 locales).
This commit is contained in:
parent
f064c93197
commit
7b1210b5d1
41 changed files with 1565 additions and 1143 deletions
|
|
@ -15,6 +15,7 @@ interface FakeWebContents {
|
|||
send: (channel: string, data: unknown) => void
|
||||
isDestroyed: () => boolean
|
||||
insertCSS: () => Promise<string>
|
||||
executeJavaScript: () => Promise<unknown>
|
||||
invalidate: () => void
|
||||
setWindowOpenHandler: () => void
|
||||
}
|
||||
|
|
@ -56,6 +57,7 @@ vi.mock('electron', () => {
|
|||
},
|
||||
isDestroyed: () => false,
|
||||
insertCSS: async () => '',
|
||||
executeJavaScript: async () => undefined,
|
||||
invalidate: () => undefined,
|
||||
setWindowOpenHandler: () => undefined
|
||||
}
|
||||
|
|
@ -91,6 +93,8 @@ vi.mock('electron', () => {
|
|||
|
||||
const scaled = (value: number): number => Math.round(value / state.scale)
|
||||
return {
|
||||
// 팝업 테마: 시스템 테마 해석(2026-09-28)
|
||||
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
|
||||
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
|
||||
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
|
||||
screen: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
// redteam r3-4 회귀
|
||||
// 1) 녹음 phase 전에 끝난 세션의 오류(STT 모델 미설치·쿼터·자막 사용 중)도 RecordingTip 을 띄워 보인다.
|
||||
// 예전엔 showError 가 상태 IPC 만 보내 숨겨진 창에 그려졌고 사용자에겐 오류음만 남았다.
|
||||
// 2) macOS 에서 애플리케이션 메뉴를 null 로 지우지 않는다(Cmd+C/V/X/A/Z/Q 는 role 메뉴로만 동작).
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { getI18n } from '@d3ro/i18n'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
type Listener = (...args: unknown[]) => void
|
||||
|
||||
interface FakeWindow {
|
||||
options: Record<string, unknown>
|
||||
visible: boolean
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }>
|
||||
sent: Array<{ channel: string; data: unknown }>
|
||||
contentsListeners: Map<string, Listener[]>
|
||||
showInactive: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
windows: [] as FakeWindow[],
|
||||
setApplicationMenu: vi.fn(),
|
||||
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
|
||||
}))
|
||||
|
||||
vi.unmock('../../../src/main/windows/WindowManager')
|
||||
|
||||
vi.mock('electron', () => {
|
||||
class FakeBrowserWindow {
|
||||
options: Record<string, unknown>
|
||||
visible = false
|
||||
destroyed = false
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
|
||||
sent: Array<{ channel: string; data: unknown }> = []
|
||||
listeners = new Map<string, Listener>()
|
||||
contentsListeners = new Map<string, Listener[]>()
|
||||
webContents = {
|
||||
on: (event: string, listener: Listener) => {
|
||||
const list = this.contentsListeners.get(event) ?? []
|
||||
list.push(listener)
|
||||
this.contentsListeners.set(event, list)
|
||||
},
|
||||
send: (channel: string, data: unknown) => {
|
||||
this.sent.push({ channel, data })
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
isLoading: () => false,
|
||||
insertCSS: vi.fn(async () => ''),
|
||||
executeJavaScript: async () => undefined,
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
}
|
||||
showInactive = vi.fn(() => {
|
||||
this.visible = true
|
||||
})
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
state.windows.push(this as unknown as FakeWindow)
|
||||
}
|
||||
on(event: string, listener: Listener): this {
|
||||
this.listeners.set(event, listener)
|
||||
return this
|
||||
}
|
||||
once(event: string, listener: Listener): this {
|
||||
return this.on(event, listener)
|
||||
}
|
||||
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
|
||||
this.bounds.push(bounds)
|
||||
}
|
||||
isVisible(): boolean {
|
||||
return this.visible
|
||||
}
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
hide(): void {
|
||||
this.visible = false
|
||||
}
|
||||
show(): void {
|
||||
this.visible = true
|
||||
}
|
||||
setAlwaysOnTop = vi.fn()
|
||||
moveTop = vi.fn()
|
||||
setSkipTaskbar = vi.fn()
|
||||
setMenu = vi.fn()
|
||||
center = vi.fn()
|
||||
restore = vi.fn()
|
||||
focus = vi.fn()
|
||||
flashFrame = vi.fn()
|
||||
isMinimized = vi.fn(() => false)
|
||||
loadURL = vi.fn(async () => undefined)
|
||||
loadFile = vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
// 팝업 테마: 시스템 테마 해석(2026-09-28)
|
||||
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
|
||||
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
|
||||
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
|
||||
screen: {
|
||||
getPrimaryDisplay: vi.fn(() => ({
|
||||
workAreaSize: { width: 1920, height: 1080 },
|
||||
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
})),
|
||||
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]),
|
||||
getCursorScreenPoint: vi.fn(() => ({ x: 500, y: 500 })),
|
||||
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
|
||||
screenToDipRect: vi.fn((_win: unknown, rect: unknown) => rect),
|
||||
},
|
||||
ipcMain: { on: vi.fn(), once: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
|
||||
Menu: {
|
||||
setApplicationMenu: (...args: unknown[]) => state.setApplicationMenu(...args),
|
||||
buildFromTemplate: (...args: unknown[]) => state.buildFromTemplate(...args),
|
||||
},
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: vi.fn(async () => undefined) },
|
||||
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
|
||||
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
|
||||
Tray: vi.fn(),
|
||||
dialog: { showErrorBox: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
|
||||
|
||||
const config: Record<string, unknown> = { theme: 'dark', language: 'ko', closeToTray: true }
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => config[key],
|
||||
configSet: (key: string, value: unknown) => {
|
||||
config[key] = value
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/lifecycle', () => ({
|
||||
getIsQuitting: () => false,
|
||||
setIsQuitting: () => undefined,
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
state.windows.length = 0
|
||||
state.setApplicationMenu.mockClear()
|
||||
state.buildFromTemplate.mockClear()
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
function tipStates(win: FakeWindow): Array<{ state?: string; errorMessage?: string }> {
|
||||
for (const listener of win.contentsListeners.get('did-finish-load') ?? []) listener()
|
||||
return win.sent
|
||||
.filter((m) => m.channel === IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED)
|
||||
.map((m) => m.data as { state?: string; errorMessage?: string })
|
||||
}
|
||||
|
||||
describe('RecordingTip 오류 표시 — recording phase 전에 난 오류도 보인다', () => {
|
||||
it('phase 없이 onError → 숨겨진 팁 창을 띄우고 오류 문구를 보낸다', async () => {
|
||||
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
|
||||
'../../../src/main/windows/recording-tip-presenter'
|
||||
)
|
||||
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
|
||||
|
||||
presenter.onSessionStarted()
|
||||
presenter.onError('Cannot start dictation: caption mode active')
|
||||
|
||||
expect(state.windows).toHaveLength(1)
|
||||
const tip = state.windows[0]
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tip.showInactive).toHaveBeenCalledTimes(1)
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({
|
||||
state: 'error',
|
||||
errorMessage: 'Cannot start dictation: caption mode active',
|
||||
})
|
||||
presenter.dispose()
|
||||
})
|
||||
|
||||
it('STT 모델 미설치 코드는 사용자 언어 문구로 바꿔 그린다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
|
||||
'../../../src/main/windows/recording-tip-presenter'
|
||||
)
|
||||
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
|
||||
presenter.onError('STT model not installed: base', 'error', ErrorCode.STTModelNotFound)
|
||||
|
||||
const tip = wm.getRecordingTipWindow() as unknown as FakeWindow
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({
|
||||
state: 'error',
|
||||
errorMessage: getI18n('ko').t('voice.error.modelMissing'),
|
||||
})
|
||||
presenter.dispose()
|
||||
})
|
||||
|
||||
it('녹음 도중 난 오류는 떠 있는 팁 자리를 옮기지 않는다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showRecordingTip('recording')
|
||||
const tip = state.windows[0]
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
|
||||
wm.showRecordingTipError('boom')
|
||||
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tipStates(tip).map((m) => m.state)).toEqual(['recording', 'error'])
|
||||
})
|
||||
|
||||
it('숨겨진 팁은 오류 때 다시 뜬다(이전 세션이 숨긴 뒤)', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showRecordingTip('recording')
|
||||
wm.hideRecordingTip()
|
||||
const tip = state.windows[0]
|
||||
expect(tip.visible).toBe(false)
|
||||
|
||||
wm.showRecordingTipError('boom')
|
||||
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tip.showInactive).toHaveBeenCalledTimes(2)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({ state: 'error', errorMessage: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRecordingTipErrorMessage', () => {
|
||||
it('코드별로 번역 문구를 고르고, 모르는 코드는 원문을 그린다', async () => {
|
||||
const { resolveRecordingTipErrorMessage } = await import('../../../src/main/windows/recording-tip-error-message')
|
||||
const { t } = getI18n('ko')
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTModelNotFound, 'raw', t)).toBe(t('voice.error.modelMissing'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.QuotaExceeded, 'raw', t)).toBe(
|
||||
t('license.quotaExceeded.title', { feature: t('license.feature.dictation') }),
|
||||
)
|
||||
// 2026-09-28: 받아쓰기가 실제로 내보내는 코드(자막 충돌·마이크·무음·전사 실패·다듬기 실패)는 전부 사용자 문구로
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.CaptionAlreadyActive, 'raw', t)).toBe(t('voice.error.captionActive'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureStartFailed, 'raw', t)).toBe(t('voice.error.micOpen'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureFailed, 'raw', t)).toBe(t('voice.error.micLost'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTNoAudioData, 'raw', t)).toBe(t('voice.error.noSpeech'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTTranscriptionFailed, 'raw', t)).toBe(t('voice.error.transcription'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.LLMProcessingFailed, 'raw', t)).toBe(t('voice.error.llmFailed'))
|
||||
// 분류 밖의 코드와 코드 없음은 원문
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.TTSVoiceNotFound, 'raw', t)).toBe('raw')
|
||||
expect(resolveRecordingTipErrorMessage(undefined, 'raw', t)).toBe('raw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('애플리케이션 메뉴 — macOS 는 편집/종료 단축키용 role 메뉴를 유지한다', () => {
|
||||
it('darwin 에서 메인 창을 만들면 editMenu 가 든 메뉴를 설치한다(null 아님)', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.createMainWindow()
|
||||
|
||||
expect(state.setApplicationMenu).toHaveBeenCalledTimes(1)
|
||||
const installed = state.setApplicationMenu.mock.calls[0][0] as { template: Array<{ role: string }> } | null
|
||||
expect(installed).not.toBeNull()
|
||||
expect(installed?.template.map((item) => item.role)).toEqual(['appMenu', 'editMenu', 'windowMenu'])
|
||||
})
|
||||
|
||||
it('win32 에서는 지금처럼 메뉴를 제거한다(Alt 키 메뉴 활성화 방지)', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.createMainWindow()
|
||||
|
||||
expect(state.setApplicationMenu).toHaveBeenCalledWith(null)
|
||||
expect(state.buildFromTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('buildApplicationMenuTemplate 는 darwin 만 템플릿을 준다', async () => {
|
||||
const { buildApplicationMenuTemplate } = await import('../../../src/main/windows/app-menu')
|
||||
expect(buildApplicationMenuTemplate('linux')).toBeNull()
|
||||
expect(buildApplicationMenuTemplate('win32')).toBeNull()
|
||||
expect(buildApplicationMenuTemplate('darwin')?.map((item) => item.role)).toEqual([
|
||||
'appMenu',
|
||||
'editMenu',
|
||||
'windowMenu',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ function fakeView(): RecordingTipView & { calls: string[] } {
|
|||
showRecording: () => calls.push('show'),
|
||||
hide: () => calls.push('hide'),
|
||||
showThinking: () => calls.push('thinking'),
|
||||
showDone: () => calls.push('done'),
|
||||
showError: (message) => calls.push(`error:${message}`),
|
||||
sendAudioLevel: (level) => calls.push(`level:${level}`),
|
||||
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
|
||||
|
|
@ -91,4 +92,24 @@ describe('RecordingTipPresenter', () => {
|
|||
vi.advanceTimersByTime(5000)
|
||||
expect(view.calls).toEqual(['error:x'])
|
||||
})
|
||||
|
||||
it('완료는 "입력함"을 잠깐 보이고 숨기며, 곧바로 시작한 새 세션의 팁은 숨기지 않는다', () => {
|
||||
const view = fakeView()
|
||||
const presenter = new RecordingTipPresenter(view)
|
||||
presenter.onPhase('recording')
|
||||
presenter.onSessionCompleted()
|
||||
vi.advanceTimersByTime(699)
|
||||
expect(view.calls).toEqual(['show', 'done'])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(view.calls).toEqual(['show', 'done', 'hide'])
|
||||
|
||||
const view2 = fakeView()
|
||||
const presenter2 = new RecordingTipPresenter(view2)
|
||||
presenter2.onSessionCompleted()
|
||||
vi.advanceTimersByTime(300)
|
||||
presenter2.onSessionStarted()
|
||||
presenter2.onPhase('recording')
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(view2.calls).toEqual(['done', 'show'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue