diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts index 9414ce75..0f281813 100644 --- a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts @@ -19,6 +19,9 @@ export function useVoiceRecorder() { : 'idle', ) + // 권한 요청은 사용자가 프롬프트를 방치하면 영원히 pending 이다. 취소 시 이 값을 올려 + // 뒤늦게 도착한 스트림을 무효화한다 — 안 그러면 취소 후에도 녹음이 시작되고 마이크가 켜진다. + const requestIdRef = useRef(0) const recorderRef = useRef(null) const streamRef = useRef(null) const chunksRef = useRef([]) @@ -37,12 +40,19 @@ export function useVoiceRecorder() { const start = useCallback(async (): Promise => { if (status === 'unsupported' || status === 'recording') return false + const requestId = ++requestIdRef.current setStatus('requesting') let stream: MediaStream try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch { - setStatus('denied') + // 취소 뒤 늦게 거부가 오면 이미 idle 인 상태를 denied 로 되돌리면 안 된다. + if (requestIdRef.current === requestId) setStatus('denied') + return false + } + // 대기 중에 취소했으면 방금 열린 트랙을 바로 닫는다(마이크 표시등이 계속 켜진 채 남는다). + if (requestIdRef.current !== requestId) { + stream.getTracks().forEach((t) => t.stop()) return false } const mimeType = pickMimeType() @@ -79,8 +89,10 @@ export function useVoiceRecorder() { }) }, [cleanup]) - // 녹음 폐기(업로드 안 함). + // 녹음 폐기(업로드 안 함). 아직 권한 대기 중이어도 호출할 수 있다 — 그 경우 진행 중인 + // getUserMedia 를 무효화해 사용자가 텍스트 입력으로 돌아갈 수 있게 한다. const cancel = useCallback(() => { + requestIdRef.current += 1 const recorder = recorderRef.current if (recorder && recorder.state !== 'inactive') { recorder.onstop = null diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.tsx index 53251cc2..ffa71eb9 100644 --- a/frontend/src/features/interview/ui/live/AnswerComposer.tsx +++ b/frontend/src/features/interview/ui/live/AnswerComposer.tsx @@ -32,6 +32,7 @@ function MicIcon() { export function AnswerComposer({ disabled = false, + disabledReason = 'awaiting-question', submitLocked = false, onSubmit, restoreDraft, @@ -39,6 +40,8 @@ export function AnswerComposer({ voiceUploading = false, }: { disabled?: boolean + /** 입력이 막힌 이유 — 안내 문구가 달라진다. 연결 끊김을 '질문 대기'로 적으면 오도한다. */ + disabledReason?: 'awaiting-question' | 'disconnected' submitLocked?: boolean onSubmit: (content: string) => void restoreDraft?: { content: string; nonce: number } | null @@ -110,7 +113,9 @@ export function AnswerComposer({ {voiceUploading ? ( 음성 답변 업로드 중… ) : requesting ? ( - 마이크 준비 중… + + 마이크 권한을 기다리는 중… 브라우저 알림에서 허용해 주세요. + ) : ( @@ -125,12 +130,14 @@ export function AnswerComposer({ )} - {!voiceUploading && !requesting && ( + {/* 권한 대기 중에도 나갈 수 있어야 한다 — 프롬프트를 놓치면 getUserMedia 가 영원히 + 응답하지 않고, 그동안 텍스트 입력창이 사라져 답변 자체가 막힌다. */} + {!voiceUploading && (
- + {!requesting && }
)} @@ -150,7 +157,9 @@ export function AnswerComposer({ aria-label="답변 입력" placeholder={ disabled - ? '질문을 기다리는 중…' + ? disabledReason === 'disconnected' + ? '연결이 끊겨 지금은 보낼 수 없어요. 재연결되면 이어서 답변할 수 있습니다.' + : '질문을 기다리는 중…' : submitLocked ? '질문이 끝나면 전송할 수 있어요' : '답변을 입력하세요 (Enter 전송, Shift+Enter 줄바꿈)' diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.voice.test.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.voice.test.tsx new file mode 100644 index 00000000..c8164e6b --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerComposer.voice.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AnswerComposer } from './AnswerComposer' + +// 녹음 훅은 실제 마이크를 잡으므로 상태만 흉내낸다. +const recorder = { + status: 'idle' as string, + stream: null, + start: vi.fn(async () => true), + stop: vi.fn(async () => null), + cancel: vi.fn(), +} +vi.mock('../../lib/media/useVoiceRecorder', () => ({ + useVoiceRecorder: () => recorder, +})) +vi.mock('./MicLevelMeter', () => ({ MicLevelMeter: () =>
})) + +beforeEach(() => { + recorder.status = 'idle' + recorder.cancel.mockClear() + recorder.start.mockClear() +}) + +function renderComposer(props: Partial[0]> = {}) { + return render( + , + ) +} + +describe('AnswerComposer 음성 답변', () => { + // 권한 프롬프트를 놓치면 getUserMedia 가 영원히 pending 이다. 그동안 텍스트 입력창이 + // 사라지므로, 빠져나갈 길이 없으면 답변 자체가 막힌다. + it('마이크 권한 대기 중에도 텍스트로 돌아갈 수 있다', async () => { + recorder.status = 'requesting' + renderComposer() + + const escape = screen.getByRole('button', { name: '텍스트로 답변' }) + await userEvent.click(escape) + + expect(recorder.cancel).toHaveBeenCalled() + }) + + it('권한 대기 중에는 전송 버튼을 노출하지 않는다', () => { + recorder.status = 'requesting' + renderComposer() + + expect(screen.queryByRole('button', { name: '전송' })).not.toBeInTheDocument() + }) + + it('녹음 중에는 취소와 전송을 모두 제공한다', () => { + recorder.status = 'recording' + renderComposer() + + expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '전송' })).toBeInTheDocument() + }) + + // 연결이 끊겨 막힌 것을 '질문을 기다리는 중' 으로 적으면 AI 가 느린 줄 안다. + it('연결 끊김과 질문 대기를 다른 문구로 안내한다', () => { + const { unmount } = renderComposer({ disabled: true, disabledReason: 'disconnected' }) + expect(screen.getByLabelText('답변 입력')).toHaveAttribute( + 'placeholder', + expect.stringContaining('연결이 끊겨'), + ) + unmount() + + renderComposer({ disabled: true, disabledReason: 'awaiting-question' }) + expect(screen.getByLabelText('답변 입력')).toHaveAttribute( + 'placeholder', + '질문을 기다리는 중…', + ) + }) + + it('마이크 권한이 거부되면 텍스트 전용 안내를 보여준다', () => { + recorder.status = 'denied' + renderComposer() + + expect(screen.getByText(/마이크 권한이 거부되어/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 85d3f994..dba17174 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -168,6 +168,9 @@ export function InterviewStage({