import { describe, expect, it } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import { normalizeErrorCode, offersForceRestart, presentError, settingsTabOf, } from '../../src/renderer/navigation/error-recovery' import { buildMeetingErrorInfo, forceRestartLabel } from '../../src/renderer/pages/meeting/meeting-error' import { canDismissOnboarding } from '../../src/renderer/components/onboarding-dismiss' // 회귀: // 1) voice:error 101(STT 모델 없음)이 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다. // 2) 회의 후처리 실패(882)가 '회의를 시작할 수 없습니다'로 보이고, 주 버튼이 새 녹음을 강제로 시작했다. // 3) 회의 오류 분류가 ErrorCode 와 맞지 않았다(1400=충돌, 200-220=마이크). describe('presentError — voice', () => { it('101 은 온보딩이 아니라 설정 → STT 탭 딥링크로 복구한다', () => { const p = presentError({ context: 'voice', code: ErrorCode.STTModelNotFound }) expect(p.category).toBe('sttModelMissing') expect(p.messageKey).toBe('voice.error.modelMissing') expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }]) expect(settingsTabOf(p)).toBe('stt') }) it.each([103, 130, 131, 132])('%i 은 엔진 오류 문구를 유지하고 복구 동작은 없다', (code) => { const p = presentError({ context: 'voice', code }) expect(p.category).toBe('sttEngine') expect(p.messageKey).toBe('voice.error.engine') expect(p.recoveries).toEqual([]) }) it('300 경고는 llmSkipped 문구, 300 오류는 원문 그대로', () => { expect(presentError({ context: 'voice', code: 300, severity: 'warning' }).messageKey).toBe('voice.warning.llmSkipped') const asError = presentError({ context: 'voice', code: 300, severity: 'error' }) expect(asError.category).toBe('general') expect(asError.messageKey).toBeNull() }) it('모르는 코드는 원문을 쓰고 설정 딥링크가 없다', () => { const p = presentError({ context: 'voice', code: 999 }) expect(p.messageKey).toBeNull() expect(settingsTabOf(p)).toBeNull() }) }) describe('presentError — meetingProcessing', () => { it('882 는 후처리 실패이고 강제 재시작(새 녹음)을 절대 제시하지 않는다', () => { const p = presentError({ context: 'meetingProcessing', code: ErrorCode.MeetingProcessingFailed, message: '후처리 실패: SQLITE_FULL' }) expect(p.category).toBe('processing') expect(offersForceRestart(p)).toBe(false) }) it('후처리 문구에 model/already 같은 키워드가 있어도 processing 이다', () => { for (const message of ['후처리 실패: model busy', '후처리 실패: already locked', '후처리 실패: audio']) { const p = presentError({ context: 'meetingProcessing', code: 882, message }) expect(p.category).toBe('processing') expect(offersForceRestart(p)).toBe(false) } }) }) describe('presentError — meetingStart', () => { it.each([ErrorCode.MeetingAlreadyRecording, ErrorCode.CaptionAlreadyActive])('%i 는 충돌 → 강제 재시작', (code) => { const p = presentError({ context: 'meetingStart', code, message: 'x' }) expect(p.category).toBe('collision') expect(p.recoveries).toEqual([{ kind: 'forceRestart' }]) }) it('1400 은 ErrorCode 가 아니므로 충돌로 보지 않는다', () => { expect(presentError({ context: 'meetingStart', code: 1400, message: 'x' }).category).toBe('general') }) it.each([101, 103, 110, 130, 132, 140])('STT 코드 %i 는 STT 설정 + 재시도', (code) => { const p = presentError({ context: 'meetingStart', code, message: 'x' }) expect(p.category).toBe('stt') expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }, { kind: 'forceRestart' }]) }) it.each([400, 401, 410, 420, 431])('오디오 코드 %i 는 오디오 설정 + 재시도', (code) => { const p = presentError({ context: 'meetingStart', code, message: 'x' }) expect(p.category).toBe('audio') expect(settingsTabOf(p)).toBe('audio') }) it.each([200, 210, 220])('TTS 코드 %i 는 마이크 오류가 아니다', (code) => { const p = presentError({ context: 'meetingStart', code, message: 'x' }) expect(p.category).toBe('general') expect(settingsTabOf(p)).toBeNull() }) it('시작 경로에 882 가 와도 강제 재시작을 주지 않는다', () => { expect(offersForceRestart(presentError({ context: 'meetingStart', code: 882 }))).toBe(false) }) it('코드가 없는 catch 경로는 문구 키워드로 폴백한다', () => { expect(presentError({ context: 'meetingStart', message: 'Caption already active' }).category).toBe('collision') expect(presentError({ context: 'meetingStart', message: 'whisper sidecar died' }).category).toBe('stt') expect(presentError({ context: 'meetingStart', message: 'mic not found' }).category).toBe('audio') expect(presentError({ context: 'meetingStart', message: 'ipc timeout' }).category).toBe('general') }) it('숫자 문자열 코드도 정규화한다', () => { expect(normalizeErrorCode('880')).toBe(880) expect(normalizeErrorCode('STT_UNAVAILABLE')).toBeNull() expect(normalizeErrorCode(undefined)).toBeNull() expect(presentError({ context: 'meetingStart', code: '880' }).category).toBe('collision') }) }) describe('buildMeetingErrorInfo', () => { it('후처리 실패는 시작 실패 제목이 아니고 복구 버튼이 없다', () => { const info = buildMeetingErrorInfo('meetingProcessing', '후처리 실패: disk full', 882) expect(info.title).not.toBe('회의를 시작할 수 없습니다') expect(info.category).toBe('processing') expect(info.recoveries).toEqual([]) expect(info.code).toBe(882) expect(info.message).toBe('후처리 실패: disk full') }) it('충돌 라벨과 일반 라벨이 다르다', () => { expect(forceRestartLabel('collision')).not.toBe(forceRestartLabel('general')) }) it('코드·문구가 없으면 카테고리 기본값을 쓴다', () => { const info = buildMeetingErrorInfo('meetingStart') expect(info.category).toBe('general') expect(info.code).toBe('MEETING_START_ERROR') expect(info.message.length).toBeGreaterThan(0) }) }) describe('canDismissOnboarding', () => { it('첫 실행(미완료)은 success 전까지 닫을 수 없다', () => { expect(canDismissOnboarding('select_mode', false)).toBe(false) expect(canDismissOnboarding('local_ollama_setup', false)).toBe(false) expect(canDismissOnboarding('success', false)).toBe(true) }) it('이미 온보딩을 마친 사용자는 어느 단계에서든 닫을 수 있다', () => { for (const phase of ['select_mode', 'local_ollama_setup', 'online_auth', 'failed'] as const) { expect(canDismissOnboarding(phase, true)).toBe(true) } }) })