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
+153
View File
@@ -0,0 +1,153 @@
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
MinusCircleOutlined,
TrophyOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Col, Divider, List, Result, Row, Space, Spin, Tag, Typography } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
import { AnswerResult, attemptsApi } from '../../api/attempts'
const { Title, Text } = Typography
export default function AttemptResult() {
const { attemptId } = useParams<{ attemptId: string }>()
const navigate = useNavigate()
const { data: result, isLoading } = useQuery({
queryKey: ['attempts', attemptId, 'result'],
queryFn: () => attemptsApi.getResult(Number(attemptId)).then((r) => r.data),
})
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
if (!result) return null
const duration = Math.round(
(new Date(result.finished_at).getTime() - new Date(result.started_at).getTime()) / 1000,
)
const minutes = Math.floor(duration / 60)
const seconds = duration % 60
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
{/* Итог */}
<Result
icon={
result.passed ? (
<TrophyOutlined style={{ color: '#52c41a' }} />
) : (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
)
}
status={result.passed ? 'success' : 'error'}
title={result.passed ? 'Тест сдан!' : 'Тест не сдан'}
subTitle={result.test_title}
/>
{/* Статистика */}
<Card style={{ marginBottom: 24 }}>
<Row gutter={32} justify="center" style={{ textAlign: 'center' }}>
<Col>
<Title level={1} style={{ margin: 0, color: result.passed ? '#52c41a' : '#ff4d4f' }}>
{result.score}%
</Title>
<Text type="secondary">Результат</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{result.correct_count}/{result.total_count}
</Title>
<Text type="secondary">Правильных ответов</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{result.passing_score}%
</Title>
<Text type="secondary">Порог зачёта</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{minutes > 0 ? `${minutes}м ` : ''}{seconds}с
</Title>
<Text type="secondary">Время</Text>
</Col>
</Row>
</Card>
{/* Разбор ошибок */}
<Title level={3}>Разбор ответов</Title>
{result.questions.map((question, index) => (
<Card
key={question.id}
style={{
marginBottom: 12,
borderColor: question.is_answered_correctly ? '#b7eb8f' : '#ffccc7',
}}
title={
<Space>
{question.is_answered_correctly ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
)}
<Text strong>
{index + 1}. {question.text}
</Text>
</Space>
}
>
<List
dataSource={question.answers}
renderItem={(answer: AnswerResult) => {
const icon = answer.is_correct ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : answer.is_selected ? (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
) : (
<MinusCircleOutlined style={{ color: '#d9d9d9' }} />
)
return (
<List.Item style={{ padding: '4px 0' }}>
<Space>
{icon}
<Text
style={{
fontWeight: answer.is_correct ? 600 : 400,
color: answer.is_selected && !answer.is_correct ? '#ff4d4f' : undefined,
}}
>
{answer.text}
</Text>
{answer.is_selected && answer.is_correct && (
<Tag color="green">ваш ответ </Tag>
)}
{answer.is_selected && !answer.is_correct && (
<Tag color="red">ваш ответ </Tag>
)}
{!answer.is_selected && answer.is_correct && (
<Tag color="green">правильный ответ</Tag>
)}
</Space>
</List.Item>
)
}}
/>
</Card>
))}
<Divider />
<Space>
<Button onClick={() => navigate('/')}>К списку тестов</Button>
<Button type="primary" onClick={() => navigate(`/tests/${result.test_id}`)}>
Страница теста
</Button>
</Space>
</div>
)
}
+9 -2
View File
@@ -1,4 +1,4 @@
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone, PlayCircleOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
@@ -24,10 +24,17 @@ export default function TestDetail() {
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ marginBottom: 16 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
К списку тестов
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => navigate(`/tests/${test.id}/take`)}
>
Пройти тест
</Button>
</Space>
<Title level={2}>{test.title}</Title>
+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>
)
}