Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OptimisticAnswer[]>([])
// 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거).
Expand Down Expand Up @@ -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` 분기가 에러 시에도 스피너를 영원히 돌린다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div /> }))
vi.mock('./StageQuestion', () => ({ StageQuestion: () => <div /> }))
vi.mock('./WebcamSelfView', () => ({ WebcamSelfView: () => <div /> }))
vi.mock('./TranscriptDrawer', () => ({ TranscriptDrawer: () => <div /> }))
vi.mock('./AnswerComposer', () => ({ AnswerComposer: () => <div /> }))
vi.mock('./DeliveryModeToggle', () => ({ DeliveryModeToggle: () => <div /> }))

const session: Session = {
id: 7,
title: '백엔드 모의면접',
status: 'IN_PROGRESS',
maxQuestions: 5,
generalQuestionCount: 5,
totalQuestionCount: 2,
}

function renderStage(onInterrupt = vi.fn(), onEnd = vi.fn()) {
render(
<InterviewStage
session={session}
connection="open"
items={[]}
awaitingQuestion={false}
questionStreaming={false}
onSubmit={vi.fn()}
onSubmitVoice={vi.fn()}
voiceUploading={false}
onEnd={onEnd}
onInterrupt={onInterrupt}
deliveryMode="text"
onDeliveryModeChange={vi.fn()}
wasSegmented={() => 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()
})
})
21 changes: 21 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function InterviewStage({
onSubmitVoice,
voiceUploading,
onEnd,
onInterrupt,
wasSegmented,
isSpeaking,
deliveryMode,
Expand All @@ -74,13 +75,16 @@ export function InterviewStage({
onSubmitVoice: (audio: Blob) => void
voiceUploading: boolean
onEnd: () => void
/** 잠시 중단 — 대화를 남긴 채 나중에 이어서 진행한다. */
onInterrupt: () => void
wasSegmented: (id: number) => boolean
isSpeaking: (id: number) => boolean
deliveryMode: DeliveryMode
onDeliveryModeChange: (mode: DeliveryMode) => void
}) {
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)
Expand Down Expand Up @@ -135,6 +139,10 @@ export function InterviewStage({
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
기록
</Button>
{/* 종료(되돌릴 수 없음, 즉시 피드백)만 있으면 잠깐 자리를 비워야 할 때 방법이 없다. */}
<Button variant="ghost" size="sm" onClick={() => setPauseConfirmOpen(true)}>
잠시 중단
</Button>
<Button variant="danger" size="sm" onClick={() => setEndConfirmOpen(true)}>
종료
</Button>
Expand Down Expand Up @@ -194,6 +202,19 @@ export function InterviewStage({
/>
)}

<ConfirmDialog
open={pauseConfirmOpen}
title="면접을 잠시 중단할까요?"
description="지금까지 주고받은 대화는 그대로 남습니다. 바로 다음 화면에서 이어서 진행할 수 있고, 나중에 히스토리에서도 다시 들어올 수 있어요. 피드백은 아직 만들어지지 않습니다."
confirmLabel="중단하기"
cancelLabel="계속 진행"
onConfirm={() => {
setPauseConfirmOpen(false)
onInterrupt()
}}
onCancel={() => setPauseConfirmOpen(false)}
/>

<ConfirmDialog
open={endConfirmOpen}
title="면접을 종료하시겠습니까?"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/features/interview/ui/live/LiveInterview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
submitVoice,
voiceUploading,
endSession,
interruptSession,
isLoading,
questionStreaming,
wasSegmented,
Expand Down Expand Up @@ -85,6 +86,7 @@ export function LiveInterview({ sessionId }: { sessionId: number }) {
onSubmitVoice={submitVoice}
voiceUploading={voiceUploading}
onEnd={endSession}
onInterrupt={interruptSession}
wasSegmented={wasSegmented}
isSpeaking={isSpeaking}
deliveryMode={deliveryMode}
Expand Down
Loading