From 679f985adc9b526d62c68ece53d582867c7fd13d Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 20 Aug 2026 14:37:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EB=A9=B4=EC=A0=91=20'?= =?UTF-8?q?=EC=9E=A0=EC=8B=9C=20=EC=A4=91=EB=8B=A8'=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=ED=95=98=EA=B8=B0=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EB=8F=84=EB=8B=AC=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 점검 중 발견: `useSessionLifecycle` 의 `interrupt` 뮤테이션이 **어디서도 호출되지 않는다.** 라이브 화면의 유일한 이탈 경로는 '종료'(COMPLETED, 되돌릴 수 없음, 즉시 피드백)뿐이었다. 그 결과 어제 만든 이어하기(#180)가 사실상 도달 불가였다. INTERRUPTED 는 스위퍼가 **답변이 하나도 없는** 방치 세션에만 붙이기 때문이다(`hasAnswer ? COMPLETED : INTERRUPTED`). 즉 '이어하기' 가 닿는 건 아무것도 답하지 않고 떠난 경우뿐 — 정작 이어하고 싶은 "2개 답하고 자리를 비운" 경우는 COMPLETED 로 굳어 피드백까지 만들어진다. - 헤더에 '잠시 중단' 추가 → 확인 후 `interrupt` → INTERRUPTED - 그 직후 화면이 종료 패널로 바뀌고, 거기 이미 있는 '이어서 진행하기'(#180)로 바로 복귀 가능 - 확인 문구에서 종료와의 차이를 분명히 한다(대화 보존 / 피드백 미생성 / 나중에 재진입) 테스트 `InterviewStage.pause.test.tsx` (3) — 확인 없이는 중단되지 않음, '계속 진행' 시 미실행, 중단과 종료가 서로 다른 콜백을 부름(다이얼로그 확인 버튼과 헤더 버튼의 이름이 같아 다이얼로그 범위로 한정해 조회). --- .../interview/model/useLiveInterview.ts | 4 +- .../ui/live/InterviewStage.pause.test.tsx | 83 +++++++++++++++++++ .../interview/ui/live/InterviewStage.tsx | 21 +++++ .../interview/ui/live/LiveInterview.tsx | 2 + 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/interview/ui/live/InterviewStage.pause.test.tsx 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)} + /> +