diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md index 35269fab..5514c845 100644 --- a/frontend/src/features/CLAUDE.md +++ b/frontend/src/features/CLAUDE.md @@ -80,7 +80,7 @@ features/{X} → pages/*, app/* ✗ | `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 | | `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 | | `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 | -| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 | +| `interview` | 세션 생성·진행·종료, 메시지, 음성, 재도전, **오답노트**(질문 북마크 + 복습 드릴 — 북마크는 면접 질문이라 별도 슬라이스로 쪼개지 않았다) | US-13~22 | | `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 | | `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 | diff --git a/frontend/src/features/interview/ui/BookmarkDrill.test.tsx b/frontend/src/features/interview/ui/BookmarkDrill.test.tsx new file mode 100644 index 00000000..d8e97b09 --- /dev/null +++ b/frontend/src/features/interview/ui/BookmarkDrill.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BookmarkDrill } from './BookmarkDrill' +import type { BookmarkedQuestion } from '../api/bookmarkApi' + +const items: BookmarkedQuestion[] = [ + { + messageId: 100, + sessionId: 7, + sessionTitle: '백엔드 모의면접', + category: 'CS_FUNDAMENTAL', + question: 'ACID 를 설명해 주세요.', + expectedSignal: '4대 속성을 예시와 함께', + myAnswer: '원자성만 말했습니다', + modelAnswer: '원자성·일관성·격리성·지속성', + coachingComment: '나머지도 짚어보세요', + createdAt: '2026-08-18T00:00:00Z', + }, + { + messageId: 101, + sessionId: 7, + sessionTitle: '백엔드 모의면접', + category: 'TECH_CHOICE', + question: '왜 Kafka 를 골랐나요?', + createdAt: '2026-08-18T00:00:00Z', + }, +] + +beforeEach(() => window.localStorage.clear()) + +describe('BookmarkDrill', () => { + // 드릴의 핵심 — 먼저 답해 보고 그 다음에 정답을 본다. + it('정답 확인 전에는 모범 답안이 보이지 않는다', async () => { + render() + + expect(screen.getByText('ACID 를 설명해 주세요.')).toBeInTheDocument() + expect(screen.queryByText('원자성·일관성·격리성·지속성')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '정답 확인' })) + + expect(screen.getByText('원자성·일관성·격리성·지속성')).toBeInTheDocument() + expect(screen.getByText('원자성만 말했습니다')).toBeInTheDocument() + }) + + it('다음 질문으로 넘어가면 정답이 다시 가려진다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '정답 확인' })) + await userEvent.click(screen.getByRole('button', { name: '다음 질문' })) + + expect(screen.getByText('왜 Kafka 를 골랐나요?')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '정답 확인' })).toBeInTheDocument() + }) + + // 기록이 없는 질문도 드릴에 포함된다 — 펼쳤을 때 이유를 알려준다. + it('복습 재료가 없는 질문은 이유를 보여준다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '정답 확인' })) + + expect( + screen.getByText('이 질문에는 아직 답변·피드백 기록이 없어요.'), + ).toBeInTheDocument() + }) + + it('마지막 질문을 마치면 완료 화면을 보여준다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '정답 확인' })) + await userEvent.click(screen.getByRole('button', { name: '복습 마치기' })) + + expect(screen.getByText('복습을 마쳤습니다')).toBeInTheDocument() + }) + + it('목록으로 나갈 수 있다', async () => { + const onExit = vi.fn() + render() + + await userEvent.click(screen.getByRole('button', { name: '목록으로' })) + + expect(onExit).toHaveBeenCalled() + }) + + // 적어둔 답은 다시 들어와도 남아 있어야 한다. + it('적은 답변이 재진입 후에도 남는다', async () => { + const { unmount } = render() + await userEvent.type(screen.getByLabelText(/다시 답해 보기/), '지금이라면 이렇게') + unmount() + + render() + + expect(screen.getByLabelText(/다시 답해 보기/)).toHaveValue('지금이라면 이렇게') + }) +}) diff --git a/frontend/src/features/interview/ui/BookmarkDrill.tsx b/frontend/src/features/interview/ui/BookmarkDrill.tsx new file mode 100644 index 00000000..4563cd85 --- /dev/null +++ b/frontend/src/features/interview/ui/BookmarkDrill.tsx @@ -0,0 +1,145 @@ +import { useMemo } from 'react' +import { Button } from '@/shared/ui/Button' +import { TextArea } from '@/shared/ui/TextArea' +import { Eyebrow, StatusBadge } from '@/shared/ui' +import { useQuestionRunner } from '@/shared/hooks' +import { categoryLabel } from '../lib/categoryLabel' +import type { BookmarkedQuestion } from '../api/bookmarkApi' + +const STORAGE_KEY = 'stackup:bookmark-drill-answers' + +/** + * 오답노트 복습 드릴 — 한 문제씩 다시 답해 보고 모범 답안과 비교한다. + * 연습 면접과 같은 상태 기계(`useQuestionRunner`)를 쓴다. + */ +export function BookmarkDrill({ + items, + onExit, +}: { + items: BookmarkedQuestion[] + onExit: () => void +}) { + const ids = useMemo(() => items.map((i) => String(i.messageId)), [items]) + const { index, total, isLast, done, revealed, answers, reveal, next, setAnswer, reset } = + useQuestionRunner(ids, STORAGE_KEY) + + const current = items[index] + + if (done || !current) { + return ( +
+

+ 복습을 마쳤습니다 +

+

+ 담아둔 질문 {total}개를 모두 다시 풀었어요. +

+
+ + +
+
+ ) + } + + const id = String(current.messageId) + const label = categoryLabel(current.category) + + return ( +
+
+

+ {index + 1} / {total} +

+ +
+ +
+ {label && {label}} + {current.sessionTitle && ( + + {current.sessionTitle} + + )} +
+ +

+ {current.question} +

+ {current.expectedSignal && ( +

평가 관점: {current.expectedSignal}

+ )} + +
+ +