feat: Sprint 2 — test taking + results

Backend:
- Models: TestAttempt, AttemptAnswer (migration 002)
- POST /api/attempts: start attempt, shuffle questions/answers,
  hide is_correct, expose is_multiple for UI hints
- POST /api/attempts/{id}/submit: save answers, calculate score,
  strict matching (selected == correct), return full result
- GET /api/attempts/{id}/result: fetch saved result
- Register attempts router in main.py

Frontend:
- api/attempts.ts: types + API functions
- TestTake page: one question at a time, progress bar, timer
  with auto-submit, back navigation controlled by test setting,
  radio/checkbox based on is_multiple
- AttemptResult page: score, pass/fail, per-question breakdown
  with correct/selected/missed answer highlighting
- App.tsx: add /tests/:testId/take and /attempts/:id/result routes
- TestDetail: add "Пройти тест" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-03-21 12:53:11 +05:00
parent 5551202d6f
commit d5f6abb5ad
12 changed files with 973 additions and 4 deletions
+225
View File
@@ -0,0 +1,225 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Alert,
Button,
Card,
Checkbox,
Progress,
Radio,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons'
import { AttemptStarted, QuestionAnswer, attemptsApi } from '../../api/attempts'
const { Title, Text } = Typography
export default function TestTake() {
const { testId } = useParams<{ testId: string }>()
const navigate = useNavigate()
const [attempt, setAttempt] = useState<AttemptStarted | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
// answers: questionId → выбранные answerId[]
const [answers, setAnswers] = useState<Map<number, number[]>>(new Map())
const [timeLeft, setTimeLeft] = useState<number | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Стартуем попытку при монтировании
useEffect(() => {
attemptsApi
.start(Number(testId))
.then((r) => {
setAttempt(r.data)
if (r.data.time_limit) {
setTimeLeft(r.data.time_limit * 60)
}
})
.catch(() => message.error('Не удалось загрузить тест'))
.finally(() => setLoading(false))
}, [testId])
// Таймер
useEffect(() => {
if (timeLeft === null) return
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev === null || prev <= 1) {
clearInterval(timerRef.current!)
handleSubmit()
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timerRef.current!)
}, [timeLeft !== null]) // запускаем один раз когда timeLeft появился
const handleSubmit = async () => {
if (!attempt || submitting) return
clearInterval(timerRef.current!)
setSubmitting(true)
const payload: QuestionAnswer[] = attempt.questions.map((q) => ({
question_id: q.id,
answer_ids: answers.get(q.id) ?? [],
}))
try {
await attemptsApi.submit(attempt.id, payload)
navigate(`/attempts/${attempt.id}/result`)
} catch {
message.error('Ошибка при отправке теста')
setSubmitting(false)
}
}
const handleAnswer = (questionId: number, answerId: number, isMultiple: boolean) => {
setAnswers((prev) => {
const next = new Map(prev)
if (isMultiple) {
const current = next.get(questionId) ?? []
next.set(
questionId,
current.includes(answerId)
? current.filter((id) => id !== answerId)
: [...current, answerId],
)
} else {
next.set(questionId, [answerId])
}
return next
})
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
if (loading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
if (!attempt) return null
const question = attempt.questions[currentIndex]
const selectedIds = answers.get(question.id) ?? []
const total = attempt.questions.length
const isLast = currentIndex === total - 1
const isTimeCritical = timeLeft !== null && timeLeft < 60
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: 24 }}>
{/* Шапка */}
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
{attempt.test_title}
</Title>
{timeLeft !== null && (
<Tag color={isTimeCritical ? 'red' : 'blue'} style={{ fontSize: 16, padding: '4px 12px' }}>
{formatTime(timeLeft)}
</Tag>
)}
</Space>
{/* Прогресс */}
<Progress
percent={Math.round(((currentIndex + 1) / total) * 100)}
format={() => `${currentIndex + 1} / ${total}`}
style={{ marginBottom: 16 }}
/>
{isTimeCritical && (
<Alert
message="Осталось меньше минуты!"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Вопрос */}
<Card
title={
<Text strong style={{ fontSize: 16 }}>
Вопрос {currentIndex + 1}
</Text>
}
style={{ marginBottom: 24 }}
>
<Text style={{ fontSize: 15, display: 'block', marginBottom: 20 }}>
{question.text}
</Text>
{question.is_multiple && (
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Выберите все правильные варианты
</Text>
)}
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{question.answers.map((answer) =>
question.is_multiple ? (
<Checkbox
key={answer.id}
checked={selectedIds.includes(answer.id)}
onChange={() => handleAnswer(question.id, answer.id, true)}
style={{ fontSize: 14 }}
>
{answer.text}
</Checkbox>
) : (
<Radio
key={answer.id}
checked={selectedIds.includes(answer.id)}
onChange={() => handleAnswer(question.id, answer.id, false)}
style={{ fontSize: 14 }}
>
{answer.text}
</Radio>
),
)}
</Space>
</Card>
{/* Навигация */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => setCurrentIndex((i) => i - 1)}
disabled={currentIndex === 0 || !attempt.allow_navigation_back}
>
Назад
</Button>
{isLast ? (
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmit}
>
Завершить тест
</Button>
) : (
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => setCurrentIndex((i) => i + 1)}
>
Далее
</Button>
)}
</Space>
</div>
)
}