diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 344436a..34ed1f0 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -33,7 +33,7 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = // 끊겼거나 아직 연결 전이면 5초 폴링으로 소실 이벤트를 메운다(최후 방어선). status === 'IN_PROGRESS' && connection !== 'open' ? 5_000 : false, ) - const { end } = useSessionLifecycle(sessionId) + const { end, interrupt } = useSessionLifecycle(sessionId) const [optimistic, setOptimistic] = useState([]) // 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거). @@ -302,6 +302,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = voiceUploading: voiceMutation.isPending, voiceError: voiceMutation.isError, endSession: () => end.mutate(), + // 잠시 중단 — 대화를 남긴 채 INTERRUPTED 로. 나중에 '이어서 진행하기' 로 돌아온다. + interruptSession: () => interrupt.mutate(), isLoading: sessionQuery.isLoading, // 세션 조회 실패를 화면에 알리기 위한 것 — 없으면 LiveInterview 의 // `isLoading || !session` 분기가 에러 시에도 스피너를 영원히 돌린다. diff --git a/frontend/src/features/interview/ui/live/InterviewStage.pause.test.tsx b/frontend/src/features/interview/ui/live/InterviewStage.pause.test.tsx new file mode 100644 index 0000000..a69a291 --- /dev/null +++ b/frontend/src/features/interview/ui/live/InterviewStage.pause.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InterviewStage } from './InterviewStage' +import type { Session } from '@/domain/session' + +// 스테이지는 미디어·자식 위젯이 무거워 헤더 동작만 보도록 가볍게 대체한다. +vi.mock('./InterviewerAvatar', () => ({ InterviewerAvatar: () =>
})) +vi.mock('./StageQuestion', () => ({ StageQuestion: () =>
})) +vi.mock('./WebcamSelfView', () => ({ WebcamSelfView: () =>
})) +vi.mock('./TranscriptDrawer', () => ({ TranscriptDrawer: () =>
})) +vi.mock('./AnswerComposer', () => ({ AnswerComposer: () =>
})) +vi.mock('./DeliveryModeToggle', () => ({ DeliveryModeToggle: () =>
})) + +const session: Session = { + id: 7, + title: '백엔드 모의면접', + status: 'IN_PROGRESS', + maxQuestions: 5, + generalQuestionCount: 5, + totalQuestionCount: 2, +} + +function renderStage(onInterrupt = vi.fn(), onEnd = vi.fn()) { + render( + false} + isSpeaking={() => false} + />, + ) + return { onInterrupt, onEnd } +} + +describe('InterviewStage 잠시 중단', () => { + // 종료(되돌릴 수 없음)만 있으면 잠깐 자리를 비워야 할 때 방법이 없다. + it('중단은 확인을 거쳐야 실행된다', async () => { + const onInterrupt = vi.fn() + renderStage(onInterrupt) + + await userEvent.click(screen.getByRole('button', { name: '잠시 중단' })) + expect(onInterrupt).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: '중단하기' })) + expect(onInterrupt).toHaveBeenCalledTimes(1) + }) + + it('계속 진행을 고르면 중단하지 않는다', async () => { + const onInterrupt = vi.fn() + renderStage(onInterrupt) + + await userEvent.click(screen.getByRole('button', { name: '잠시 중단' })) + await userEvent.click(screen.getByRole('button', { name: '계속 진행' })) + + expect(onInterrupt).not.toHaveBeenCalled() + }) + + // 중단과 종료는 결과가 다르다(이어하기 가능 vs 피드백 생성). 섞이면 안 된다. + it('중단과 종료는 서로 다른 동작을 부른다', async () => { + const onInterrupt = vi.fn() + const onEnd = vi.fn() + renderStage(onInterrupt, onEnd) + + // 헤더 버튼과 다이얼로그 확인 버튼의 이름이 같다 — 다이얼로그 안에서 다시 찾는다. + await userEvent.click(screen.getByRole('button', { name: '종료' })) + const dialog = screen.getByRole('dialog') + await userEvent.click(within(dialog).getByRole('button', { name: '종료' })) + + expect(onEnd).toHaveBeenCalledTimes(1) + expect(onInterrupt).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 043bfc3..3779948 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -59,6 +59,7 @@ export function InterviewStage({ onSubmitVoice, voiceUploading, onEnd, + onInterrupt, wasSegmented, isSpeaking, deliveryMode, @@ -74,6 +75,8 @@ export function InterviewStage({ onSubmitVoice: (audio: Blob) => void voiceUploading: boolean onEnd: () => void + /** 잠시 중단 — 대화를 남긴 채 나중에 이어서 진행한다. */ + onInterrupt: () => void wasSegmented: (id: number) => boolean isSpeaking: (id: number) => boolean deliveryMode: DeliveryMode @@ -81,6 +84,7 @@ export function InterviewStage({ }) { const [transcriptOpen, setTranscriptOpen] = useState(false) const [endConfirmOpen, setEndConfirmOpen] = useState(false) + const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false) const progress = sessionProgress(session) const focusAreas = session.focusAreas ?? [] const currentQuestion = [...items].reverse().find(isQuestion) @@ -135,6 +139,10 @@ export function InterviewStage({ + {/* 종료(되돌릴 수 없음, 즉시 피드백)만 있으면 잠깐 자리를 비워야 할 때 방법이 없다. */} + @@ -194,6 +202,19 @@ export function InterviewStage({ /> )} + { + setPauseConfirmOpen(false) + onInterrupt() + }} + onCancel={() => setPauseConfirmOpen(false)} + /> +