diff --git a/src/App.jsx b/src/App.jsx
index 483d766..0a69750 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -34,6 +34,7 @@ import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage'
import ChatRoomModal from './domains/freetalk/components/ChatRoomModal'
import VocabDashboard from './domains/vocab/pages/VocabDashboard'
import DailyLearning from './domains/vocab/pages/DailyLearning'
+import TestPage from './domains/vocab/pages/TestPage'
import { useChat } from './contexts/ChatContext'
import { useSettings } from './contexts/SettingsContext'
@@ -364,6 +365,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx
new file mode 100644
index 0000000..880d42f
--- /dev/null
+++ b/src/domains/vocab/components/TestQuestion.jsx
@@ -0,0 +1,124 @@
+import { Box, Typography, Paper, Radio, RadioGroup, FormControlLabel } from '@mui/material'
+
+export default function TestQuestion({
+ question,
+ selectedAnswer,
+ onSelect,
+ showResult = false,
+ disabled = false,
+}) {
+ if (!question) return null
+
+ const getOptionStyle = (option) => {
+ if (!showResult) {
+ return {
+ border: selectedAnswer === option ? '2px solid' : '1px solid',
+ borderColor: selectedAnswer === option ? 'primary.main' : 'divider',
+ backgroundColor: selectedAnswer === option ? 'primary.50' : 'background.paper',
+ }
+ }
+
+ // 결과 표시 모드
+ const isCorrect = option === question.correctAnswer
+ const isSelected = option === selectedAnswer
+
+ if (isCorrect) {
+ return {
+ border: '2px solid',
+ borderColor: 'success.main',
+ backgroundColor: 'success.50',
+ }
+ }
+ if (isSelected && !isCorrect) {
+ return {
+ border: '2px solid',
+ borderColor: 'error.main',
+ backgroundColor: 'error.50',
+ }
+ }
+ return {
+ border: '1px solid',
+ borderColor: 'divider',
+ backgroundColor: 'background.paper',
+ opacity: 0.6,
+ }
+ }
+
+ return (
+
+ {/* 문제 */}
+
+
+ {question.question}
+
+
+ {question.type === 'KOREAN_TO_ENGLISH'
+ ? '다음 중 올바른 영어 단어는?'
+ : '다음 중 올바른 한국어 뜻은?'}
+
+
+
+ {/* 선택지 */}
+ !disabled && onSelect(e.target.value)}
+ >
+
+ {question.options.map((option, index) => (
+ !disabled && onSelect(option)}
+ sx={{
+ p: 2,
+ cursor: disabled ? 'default' : 'pointer',
+ transition: 'all 0.2s',
+ ...getOptionStyle(option),
+ '&:hover': disabled
+ ? {}
+ : {
+ borderColor: 'primary.main',
+ transform: 'translateX(4px)',
+ },
+ }}
+ >
+
+ }
+ label={
+
+ {`${['①', '②', '③', '④'][index]} ${option}`}
+
+ }
+ sx={{ m: 0, width: '100%' }}
+ />
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx
new file mode 100644
index 0000000..2ff9f6a
--- /dev/null
+++ b/src/domains/vocab/pages/TestPage.jsx
@@ -0,0 +1,488 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ Container,
+ Box,
+ Typography,
+ Button,
+ Paper,
+ ToggleButton,
+ ToggleButtonGroup,
+ RadioGroup,
+ Radio,
+ FormControlLabel,
+ FormControl,
+ FormLabel,
+ LinearProgress,
+ IconButton,
+ Chip,
+ Card,
+ CardContent,
+ CardActionArea,
+ CircularProgress,
+ Alert,
+} from '@mui/material'
+import {
+ ArrowBack as BackIcon,
+ PlayArrow as PlayIcon,
+ Timer as TimerIcon,
+ NavigateNext as NextIcon,
+ NavigateBefore as PrevIcon,
+} from '@mui/icons-material'
+import TestQuestion from '../components/TestQuestion'
+import { testService } from '../services/vocabService'
+import { LEVELS, LEVEL_LABELS, TEST_TYPES, TEST_TYPE_LABELS } from '../constants/vocabConstants'
+
+const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
+
+// 시험 설정 화면
+function TestSetup({ onStart, recentResults, loading }) {
+ const [wordCount, setWordCount] = useState(20)
+ const [level, setLevel] = useState(null)
+ const [type, setType] = useState(TEST_TYPES.ENGLISH_TO_KOREAN)
+
+ const handleStart = () => {
+ onStart({ wordCount, level, type })
+ }
+
+ return (
+
+
+
+ 시험 설정
+
+
+ {/* 문제 수 */}
+
+
+ 문제 수
+
+ val && setWordCount(val)}
+ fullWidth
+ >
+ 10개
+ 20개
+ 30개
+
+
+
+ {/* 레벨 */}
+
+
+ 레벨
+
+ setLevel(val)}
+ fullWidth
+ >
+ 전체
+ {Object.entries(LEVEL_LABELS).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 출제 유형 */}
+
+
+
+ 출제 유형
+
+ setType(e.target.value)}
+ >
+ {Object.entries(TEST_TYPE_LABELS).map(([key, label]) => (
+ }
+ label={label}
+ />
+ ))}
+
+
+
+
+ : }
+ onClick={handleStart}
+ disabled={loading}
+ >
+ 시험 시작하기
+
+
+
+ {/* 최근 시험 기록 */}
+ {recentResults.length > 0 && (
+
+
+ 최근 시험 기록
+
+ {recentResults.map((result, index) => (
+
+
+
+
+
+ {new Date(result.completedAt).toLocaleDateString()}
+
+
+ {result.totalQuestions}문제
+
+
+ = 80 ? 'success' : result.successRate >= 60 ? 'warning' : 'error'}
+ />
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// 시험 진행 화면
+function TestInProgress({
+ questions,
+ currentIndex,
+ answers,
+ timeRemaining,
+ onAnswer,
+ onNext,
+ onPrev,
+ onSubmit,
+}) {
+ const currentQuestion = questions[currentIndex]
+ const progress = ((currentIndex + 1) / questions.length) * 100
+ const minutes = Math.floor(timeRemaining / 60)
+ const seconds = timeRemaining % 60
+
+ return (
+
+ {/* 헤더 */}
+
+
+ 문제 {currentIndex + 1} / {questions.length}
+
+ }
+ label={`${minutes}:${seconds.toString().padStart(2, '0')}`}
+ color={timeRemaining < 60 ? 'error' : 'default'}
+ />
+
+
+ {/* 진행률 */}
+
+
+ {/* 문제 */}
+ onAnswer(currentQuestion.questionId, answer)}
+ />
+
+ {/* 네비게이션 */}
+
+ }
+ onClick={onPrev}
+ disabled={currentIndex === 0}
+ >
+ 이전
+
+
+ {currentIndex === questions.length - 1 ? (
+
+ ) : (
+ }
+ onClick={onNext}
+ >
+ 다음
+
+ )}
+
+
+ {/* 문제 번호 표시 */}
+
+ {questions.map((q, idx) => (
+ onNext(idx - currentIndex)}
+ >
+ {idx + 1}
+
+ ))}
+
+
+ )
+}
+
+// 결과 화면
+function TestResult({ result, onRetry, onHome }) {
+ const navigate = useNavigate()
+
+ return (
+
+
+ {result.successRate?.toFixed(0)}점
+
+
+ {result.correctCount} / {result.totalQuestions} 정답
+
+
+
+
+
+
+ {result.correctCount}
+
+ 정답
+
+
+
+ {result.incorrectCount}
+
+ 오답
+
+
+
+
+ {/* 틀린 문제 */}
+ {result.results?.filter(r => !r.isCorrect).length > 0 && (
+
+
+ 틀린 문제
+
+ {result.results.filter(r => !r.isCorrect).map((r, idx) => (
+
+
+ 내 답: {r.userAnswer}
+
+
+ 정답: {r.correctAnswer}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+export default function TestPage() {
+ const navigate = useNavigate()
+ const [phase, setPhase] = useState('setup') // setup, testing, result
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [recentResults, setRecentResults] = useState([])
+
+ // 시험 상태
+ const [testId, setTestId] = useState(null)
+ const [questions, setQuestions] = useState([])
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [answers, setAnswers] = useState({})
+ const [timeRemaining, setTimeRemaining] = useState(0)
+ const [result, setResult] = useState(null)
+
+ useEffect(() => {
+ fetchRecentResults()
+ }, [])
+
+ // 타이머
+ useEffect(() => {
+ if (phase !== 'testing' || timeRemaining <= 0) return
+
+ const timer = setInterval(() => {
+ setTimeRemaining(prev => {
+ if (prev <= 1) {
+ handleSubmit()
+ return 0
+ }
+ return prev - 1
+ })
+ }, 1000)
+
+ return () => clearInterval(timer)
+ }, [phase, timeRemaining])
+
+ const fetchRecentResults = async () => {
+ try {
+ const response = await testService.getResults(TEMP_USER_ID, { limit: 5 })
+ setRecentResults(response?.data?.testResults || [])
+ } catch (err) {
+ console.error('Fetch results error:', err)
+ }
+ }
+
+ const handleStart = async (options) => {
+ try {
+ setLoading(true)
+ setError(null)
+ const response = await testService.start(TEMP_USER_ID, options)
+
+ if (response?.data) {
+ setTestId(response.data.testId)
+ setQuestions(response.data.questions || [])
+ setTimeRemaining(options.wordCount * 30) // 문제당 30초
+ setAnswers({})
+ setCurrentIndex(0)
+ setPhase('testing')
+ }
+ } catch (err) {
+ console.error('Start test error:', err)
+ setError('시험을 시작할 수 없습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleAnswer = (questionId, answer) => {
+ setAnswers(prev => ({ ...prev, [questionId]: answer }))
+ }
+
+ const handleNext = (offset = 1) => {
+ const newIndex = currentIndex + offset
+ if (newIndex >= 0 && newIndex < questions.length) {
+ setCurrentIndex(newIndex)
+ }
+ }
+
+ const handlePrev = () => {
+ if (currentIndex > 0) {
+ setCurrentIndex(currentIndex - 1)
+ }
+ }
+
+ const handleSubmit = async () => {
+ try {
+ setLoading(true)
+ const answersArray = questions.map(q => ({
+ questionId: q.questionId,
+ wordId: q.wordId,
+ answer: answers[q.questionId] || '',
+ }))
+
+ const response = await testService.submit(TEMP_USER_ID, testId, answersArray)
+
+ if (response?.data) {
+ setResult(response.data)
+ setPhase('result')
+ }
+ } catch (err) {
+ console.error('Submit error:', err)
+ setError('제출에 실패했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleRetry = () => {
+ setPhase('setup')
+ setTestId(null)
+ setQuestions([])
+ setAnswers({})
+ setResult(null)
+ fetchRecentResults()
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+ navigate('/vocab')}>
+
+
+
+ 단어 시험
+
+
+
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {phase === 'setup' && (
+
+ )}
+
+ {phase === 'testing' && (
+ handleNext(1)}
+ onPrev={handlePrev}
+ onSubmit={handleSubmit}
+ />
+ )}
+
+ {phase === 'result' && result && (
+ navigate('/vocab')}
+ />
+ )}
+
+ )
+}