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
2 changes: 2 additions & 0 deletions frontend/src/features/feedback/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { FeedbackReport } from './ui/FeedbackReport'
export { FeedbackReportSkeleton } from './ui/FeedbackReportSkeleton'
export { useFeedbackLive } from './model/useFeedbackLive'
export {
useFeedback,
isFeedbackPending,
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/features/feedback/model/useFeedbackLive.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ReactNode } from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ApiError } from '@/shared/api'
import { useFeedbackLive } from './useFeedbackLive'
import { getFeedback } from '../api/feedbackApi'

vi.mock('../api/feedbackApi', () => ({
getFeedback: vi.fn(),
}))

// SSE 를 흉내 내는 EventSource — 테스트가 FEEDBACK_READY 를 직접 발화한다.
class FakeES {
static last: FakeES | null = null
handlers: Record<string, (e: { data: string }) => void> = {}
onopen: (() => void) | null = null
onerror: (() => void) | null = null
closed = false
url: string
constructor(url: string) {
this.url = url
FakeES.last = this
}
addEventListener(name: string, fn: (e: { data: string }) => void) {
this.handlers[name] = fn
}
close() {
this.closed = true
}
emit(name: string, payload: unknown) {
this.handlers[name]?.({ data: JSON.stringify(payload) })
}
}

vi.stubGlobal('EventSource', FakeES)

const notReady = () => new ApiError(404, { code: 'FEEDBACK_NOT_READY', message: '생성 중' })

function setup() {
const qc = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
})
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
return renderHook(() => useFeedbackLive(99, async () => 'tok'), { wrapper })
}

beforeEach(() => {
FakeES.last = null
vi.mocked(getFeedback).mockReset()
})

describe('useFeedbackLive', () => {
it('FEEDBACK_READY 수신 즉시 재조회한다 — 3s 폴링 간격을 기다리지 않는다', async () => {
// 첫 조회는 생성 중(404) → 재시도 대기(3s) 상태로 들어간다.
vi.mocked(getFeedback)
.mockRejectedValueOnce(notReady())
.mockResolvedValue({ sessionId: 99, overallScore: 80 } as never)

const { result } = setup()
await waitFor(() => expect(FakeES.last).not.toBeNull())
await waitFor(() => expect(getFeedback).toHaveBeenCalledTimes(1))
expect(result.current.data).toBeUndefined()

act(() => FakeES.last?.emit('FEEDBACK_READY', { data: { sessionId: 99 } }))

// 3s 재시도 타이머보다 훨씬 이른 시점에 데이터가 도착해야 한다.
await waitFor(() => expect(result.current.data).toBeDefined(), { timeout: 1_500 })
expect(result.current.data?.overallScore).toBe(80)
})

it('피드백이 도착하면 스트림을 닫는다', async () => {
vi.mocked(getFeedback).mockResolvedValue({ sessionId: 99, overallScore: 80 } as never)

const { result } = setup()
await waitFor(() => expect(result.current.data).toBeDefined())
// path 가 null 이 되어 연결 effect 가 정리된다.
await waitFor(() => expect(FakeES.last?.closed ?? true).toBe(true))
})

it('데이터가 이미 표시 중일 때 늦게 온 이벤트는 무시한다 — 스켈레톤으로 되돌아가지 않는다', async () => {
vi.mocked(getFeedback).mockResolvedValue({ sessionId: 99, overallScore: 80 } as never)

const { result } = setup()
await waitFor(() => expect(result.current.data).toBeDefined())
const callsAfterLoad = vi.mocked(getFeedback).mock.calls.length

// 스트림 close 전 좁은 레이스 창에서 지연 도착한 이벤트를 재현.
act(() => FakeES.last?.emit('FEEDBACK_READY', { data: { sessionId: 99 } }))

expect(result.current.data?.overallScore).toBe(80)
expect(vi.mocked(getFeedback).mock.calls.length).toBe(callsAfterLoad)
})
})
49 changes: 49 additions & 0 deletions frontend/src/features/feedback/model/useFeedbackLive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEventStream } from '@/shared/hooks'
import type { StreamConnectionStatus } from '@/shared/hooks'
import { getFeedback } from '../api/feedbackApi'
import { feedbackKeys, isFeedbackPending } from './useFeedback'

// 피드백 조회 + 세션 채널 SSE(FEEDBACK_READY) 구독.
// - 준비 완료 이벤트 수신 즉시 재조회 — 3s 폴링 간격을 기다리지 않는다.
// - 기존 3s×40 재시도는 백스톱으로 축소: 스트림이 살아 있으면 15s 간격(이벤트가 주 신호),
// 죽어 있으면 기존대로 3s (api-conventions §9 — SSE 우선, 끊기면 폴링).
// 어느 모드든 총 대기 예산은 ≈2분 — 소진 시 페이지가 재생성 복구 UI 를 연다.
// - getToken 은 호출부(페이지)가 주입한다 — 세션 stream token API 는 interview 슬라이스
// 소유라 feedback 이 직접 import 할 수 없다 (FSD 동일 레이어 금지).
export function useFeedbackLive(sessionId: number, getToken: () => Promise<string | null>) {
const queryClient = useQueryClient()

// retry 콜백은 비동기 시점에 실행되므로 ref 로 최신 스트림 상태를 읽는다.
const statusRef = useRef<StreamConnectionStatus>('connecting')

const query = useQuery({
queryKey: feedbackKeys.detail(sessionId),
queryFn: () => getFeedback(sessionId),
retry: (count, err) =>
isFeedbackPending(err) && count < (statusRef.current === 'open' ? 8 : 40),
retryDelay: () => (statusRef.current === 'open' ? 15_000 : 3_000),
})

const onReady = useCallback(() => {
// 데이터가 이미 도착한 뒤 늦게 온 이벤트는 무시 — reset 하면 표시 중인 리포트가
// 스켈레톤으로 되돌아간다 (스트림 close 는 다음 커밋에서야 일어나는 좁은 레이스 창).
if (queryClient.getQueryData(feedbackKeys.detail(sessionId)) !== undefined) return
// 재시도 대기 중인 쿼리를 리셋해 즉시 다시 조회한다 (useRegenerateFeedback 과 동일 패턴).
void queryClient.resetQueries({ queryKey: feedbackKeys.detail(sessionId) })
}, [queryClient, sessionId])

// 피드백이 도착하면 스트림을 닫는다 (path: null).
const streamStatus = useEventStream({
path: query.data ? null : `/realtime/stream/sessions/${sessionId}`,
getToken,
handlers: { FEEDBACK_READY: onReady },
})

useEffect(() => {
statusRef.current = streamStatus
}, [streamStatus])

return { ...query, streamStatus }
}
25 changes: 25 additions & 0 deletions frontend/src/features/feedback/ui/FeedbackReportSkeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { FeedbackReportSkeleton } from './FeedbackReportSkeleton'

afterEach(() => {
vi.useRealTimers()
})

describe('FeedbackReportSkeleton', () => {
it('생성 중 안내와 자동 표시 예고를 보여준다', () => {
render(<FeedbackReportSkeleton />)
expect(screen.getByRole('status')).toHaveTextContent('피드백을 생성하는 중입니다')
expect(screen.getByRole('status')).toHaveTextContent('완성되면 자동으로 표시됩니다')
})

it('경과 시간이 흐른다 — 멈춘 화면으로 보이지 않게', () => {
vi.useFakeTimers()
render(<FeedbackReportSkeleton />)
expect(screen.getByRole('status')).toHaveTextContent('(0s 경과)')
act(() => {
vi.advanceTimersByTime(12_000)
})
expect(screen.getByRole('status')).toHaveTextContent('(12s 경과)')
})
})
47 changes: 47 additions & 0 deletions frontend/src/features/feedback/ui/FeedbackReportSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'

function Pulse({ className }: { className: string }) {
return <div aria-hidden className={`animate-pulse rounded-lg bg-surface ${className}`} />
}

// 피드백 생성 대기 화면 — 리포트가 올 자리의 형태를 미리 보여주고 경과 시간을 알린다.
// 정적 텍스트만 있으면 "멈췄나?" 로 읽힌다 (A4). 완료는 FEEDBACK_READY SSE 로 즉시 반영된다.
export function FeedbackReportSkeleton() {
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
const id = setInterval(() => setElapsedSec((s) => s + 1), 1_000)
return () => clearInterval(id)
}, [])

return (
<div className="flex flex-col gap-8" role="status" aria-label="피드백 생성 중">
<div className="flex flex-col items-center gap-2 py-4 text-center">
<p className="text-body font-medium text-fg">피드백을 생성하는 중입니다…</p>
<p className="text-caption text-fg-muted">
답변을 종합 분석하고 있어요. 보통 1분 내외, 길면 2분 이상 걸릴 수 있습니다.
완성되면 자동으로 표시됩니다. <span className="font-mono">({elapsedSec}s 경과)</span>
</p>
</div>

{/* 종합 점수 + 축별 점수 바 자리 */}
<div className="rounded-2xl border border-border bg-surface-raised/60 p-6">
<Pulse className="mb-6 h-8 w-40" />
<div className="flex flex-col gap-4">
<Pulse className="h-4 w-full" />
<Pulse className="h-4 w-5/6" />
<Pulse className="h-4 w-4/6" />
</div>
</div>

{/* 강점/개선 본문 자리 */}
<div className="rounded-2xl border border-border bg-surface-raised/60 p-6">
<Pulse className="mb-4 h-6 w-28" />
<div className="flex flex-col gap-3">
<Pulse className="h-3.5 w-full" />
<Pulse className="h-3.5 w-full" />
<Pulse className="h-3.5 w-3/4" />
</div>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/features/interview/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { LiveInterview } from './ui/live/LiveInterview'
export { fetchSessionStreamToken } from './api/streamToken'
export { InterviewTranscript } from './ui/InterviewTranscript'
export { BookmarkList } from './ui/BookmarkList'
export { InterviewSetupForm } from './ui/setup/InterviewSetupForm'
Expand Down
29 changes: 16 additions & 13 deletions frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import { SiteNav } from '@/widgets/site-nav'
import { SiteFooter } from '@/widgets/site-footer'
import { Button } from '@/shared/ui/Button'
import { PageHeader } from '@/shared/ui'
import {
FeedbackReport,
FeedbackReportSkeleton,
isFeedbackPending,
useFeedback,
useFeedbackLive,
useRegenerateFeedback,
} from '@/features/feedback'
import { InterviewTranscript, useRetrySession, useSession } from '@/features/interview'
import {
InterviewTranscript,
fetchSessionStreamToken,
useRetrySession,
useSession,
} from '@/features/interview'

export default function SessionFeedbackPage() {
const { id } = useParams<{ id: string }>()
const sessionId = Number(id)
const { data, isLoading, isError, error, refetch } = useFeedback(sessionId)
// 피드백 조회 + FEEDBACK_READY SSE — 준비 완료 즉시 표시(폴링은 백스톱).
// 세션 stream token 은 interview 슬라이스 소유라 페이지가 주입한다.
const getToken = useCallback(() => fetchSessionStreamToken(sessionId), [sessionId])
const { data, isLoading, isError, error, refetch } = useFeedbackLive(sessionId, getToken)
const regenerate = useRegenerateFeedback(sessionId)
// 재도전은 원본 세션의 자료 수를 알아야 "몇 개가 빠졌는지" 안내할 수 있다.
const { data: session } = useSession(sessionId)
Expand Down Expand Up @@ -51,19 +61,12 @@ export default function SessionFeedbackPage() {
}
/>

{isLoading && (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-center">
<p className="text-body text-fg">피드백을 생성하는 중입니다…</p>
<p className="text-caption text-fg-muted">
답변을 종합 분석하고 있어요. 최대 1분가량 걸릴 수 있습니다.
</p>
</div>
)}
{isLoading && <FeedbackReportSkeleton />}

{isError &&
(isFeedbackPending(error) ? (
// polling 40회(약 2분)를 다 써도 피드백이 없으면 생성 요청이 유실됐을 가능성이
// 높다(브로커 다운·AI 실패). 무한 대기 대신 재생성 복구 경로를 연다.
// 백스톱 재시도 예산(모드 무관 ≈2분, useFeedbackLive)을 다 써도 피드백이 없으면
// 생성 요청이 유실됐을 가능성이 높다(브로커 다운·AI 실패). 무한 대기 대신 복구 경로를 연다.
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<p className="text-body text-fg">피드백 생성이 예상보다 오래 걸리고 있어요.</p>
<p className="text-caption text-fg-muted">
Expand Down
Loading