From 01ab8cb23b0450ab9b3e129c161df49258939bc3 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 11:30:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B5=EC=8A=B5=20=EB=93=9C=EB=A6=B4=20+=20?= =?UTF-8?q?=EC=97=B0=EC=8A=B5=20=EB=8B=B5=EB=B3=80=20=EC=9C=A0=EC=8B=A4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(A-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 드릴 상태 기계 추출 (#178 에서 미뤘던 것) `usePracticeSession` 안에 섞여 있던 "한 문제씩 넘기며 답을 적고 정답을 확인하는" 상태를 `useQuestionRunner(questionIds, storageKey?)` 로 뽑았다. 질문 id 목록만 받고 질문을 어디서 얻는지는 모른다. **`shared/hooks` 에 뒀다.** 처음엔 `features/practice` 에 두고 오답노트에서 가져다 썼는데, features 끼리 import 는 FSD 위반이다(§3). 도메인 타입에 의존하지 않으므로 shared 가 맞다. `PracticeRunner` 는 손대지 않았다 — 정적 질문 은행을 쓰는 전체 화면 레이아웃 그대로고, 내부 상태만 공용 훅으로 바뀌었다. ## A-7 — 연습 모드 답변이 새로고침하면 사라지던 문제 `storageKey` 를 주면 답변 메모를 localStorage 에 남긴다. 사용자가 직접 쓴 것이라 잃으면 손실이 크다(질문 목록은 다시 뽑으면 그만). 트랙별로 키를 나눈다. - 저장 실패(용량 초과·프라이빗 모드)는 무시한다 — 드릴이 멈추면 안 된다 - 읽을 때 형식을 검사한다. 다른 코드가 같은 키를 썼거나 형식이 바뀌어도 빈 상태로 시작할 뿐 `reset()` 에서 `removeItem` 을 호출했다가 걷어냈다 — answers 변경으로 저장 이펙트가 곧바로 `"{}"` 를 다시 써서 무의미했다(테스트가 잡았다). 저장은 이펙트 하나가 책임진다. ## 오답노트 복습 드릴 목록 위 '복습 시작' 으로 진입. 한 문제씩 다시 답해 본 뒤 **정답 확인**을 눌러야 그때 내 답변· 모범 답안·코칭이 보인다. 여기 적는 답도 localStorage 에 남는다. 기록이 없는 질문(답변 전에 담았거나 피드백 전)도 드릴에 포함하고, 펼쳤을 때 이유를 알려준다. ## 테스트 - `useQuestionRunner.test.ts` 신설 (7) — 순서·마지막·완료, 다음 질문에서 정답 재차단, **저장/복원**, storageKey 없으면 미저장, reset, **망가진 저장값 방어 3종**, 질문 목록이 짧아질 때 범위 접힘(오답노트에서 항목을 빼는 경우) - `BookmarkDrill.test.tsx` 신설 (6) — 정답 확인 전 미노출, 다음 질문에서 재차단, 기록 없는 질문 안내, 완료 화면, 나가기, 재진입 후 답변 유지 vitest 111/111 · eslint 0 · tsc · build 통과. --- frontend/src/features/CLAUDE.md | 2 +- .../interview/ui/BookmarkDrill.test.tsx | 95 ++++++++++++ .../features/interview/ui/BookmarkDrill.tsx | 145 ++++++++++++++++++ .../features/interview/ui/BookmarkList.tsx | 25 ++- .../practice/model/usePracticeSession.ts | 50 +++--- frontend/src/shared/CLAUDE.md | 9 +- frontend/src/shared/hooks/index.ts | 1 + .../shared/hooks/useQuestionRunner.test.ts | 96 ++++++++++++ .../src/shared/hooks/useQuestionRunner.ts | 89 +++++++++++ 9 files changed, 473 insertions(+), 39 deletions(-) create mode 100644 frontend/src/features/interview/ui/BookmarkDrill.test.tsx create mode 100644 frontend/src/features/interview/ui/BookmarkDrill.tsx create mode 100644 frontend/src/shared/hooks/useQuestionRunner.test.ts create mode 100644 frontend/src/shared/hooks/useQuestionRunner.ts 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}

+ )} + +
+ +