diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_007.md b/DOC/ШАГИ/ШАГ_2026-03-21_007.md new file mode 100644 index 0000000..66b7bf2 --- /dev/null +++ b/DOC/ШАГИ/ШАГ_2026-03-21_007.md @@ -0,0 +1,97 @@ +# ШАГ 007 — Спринт 2: Прохождение теста + результат + +**Дата:** 2026-03-21 +**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. + +--- + +## Запрос + +> запускай реализацию спринта 2 + +--- + +## Ответ + +Реализован Спринт 2: прохождение теста с таймером и навигацией, результат с разбором ошибок. + +--- + +## Новые файлы + +``` +backend/app/models/attempt.py ← TestAttempt, AttemptAnswer +backend/app/schemas/attempt.py ← схемы для старта, сдачи и результата +backend/app/api/attempts.py ← 3 эндпоинта +backend/alembic/versions/002_attempts.py ← миграция + +frontend/src/api/attempts.ts ← типы и запросы +frontend/src/pages/TestTake/ ← страница прохождения теста +frontend/src/pages/AttemptResult/ ← страница результата +``` + +--- + +## API эндпоинты (новые) + +| Метод | URL | Описание | +|-------|-----|----------| +| POST | `/api/attempts` | Начать попытку → возвращает вопросы перемешанные, без правильных ответов | +| POST | `/api/attempts/{id}/submit` | Сдать тест → подсчитать и вернуть результат | +| GET | `/api/attempts/{id}/result` | Получить результат сохранённой попытки | + +--- + +## Схема БД (добавлено) + +``` +test_attempts + id, test_id → tests.id, started_at, finished_at, + score, passed, correct_count, total_count, status + +attempt_answers + id, attempt_id → test_attempts.id, + question_id → questions.id, answer_id → answers.id +``` + +Одна строка `attempt_answers` = один выбранный вариант ответа. +Для вопросов с несколькими правильными ответами — несколько строк. + +--- + +## Логика прохождения теста + +**Старт попытки:** +- Создаётся запись `TestAttempt` со статусом `in_progress` +- Вопросы и ответы внутри каждого вопроса перемешиваются случайно +- Поле `is_correct` **не передаётся** на фронт — нельзя смошенничать через DevTools +- Поле `is_multiple: bool` говорит фронту: показывать радио-кнопки или чекбоксы + +**Сдача теста:** +- Фронт отправляет `[{ question_id, answer_ids[] }]` для каждого вопроса +- Вопрос засчитывается правильным только если `selected_ids == correct_ids` (точное совпадение) +- Балл = (правильных / всего) × 100 +- Зачёт: балл ≥ порогу из теста + +**Разбор ошибок:** +- Для каждого вопроса: `is_answered_correctly` +- Для каждого варианта: `is_correct` + `is_selected` → фронт показывает что выбрал и что было правильно + +--- + +## UX прохождения теста + +- Вопросы по одному, прогресс-бар сверху +- Таймер: если задан — обратный отсчёт, при `< 60 сек` — предупреждение, при `0` — автосабмит +- Кнопка «Назад» заблокирована если `allow_navigation_back = false` +- Чекбоксы для `is_multiple`, радио-кнопки для одиночного ответа + +--- + +## Следующие шаги + +- [x] Спринт 1: Инфраструктура + Создание тестов +- [x] Спринт 2: Прохождение теста + результат +- [ ] Спринт 3: Трекер результатов +- [ ] Спринт 4: Авторизация и роли +- [ ] Спринт 5: Уведомления в MAX diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3cfaac7..8d4701a 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -15,7 +15,7 @@ if config.config_file_name is not None: # Берём DATABASE_URL из настроек приложения from app.config import settings from app.database import Base -from app.models import test # noqa: F401 — импортируем модели, чтобы Alembic их видел +from app.models import attempt, test # noqa: F401 — импортируем модели, чтобы Alembic их видел config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/backend/alembic/versions/002_attempts.py b/backend/alembic/versions/002_attempts.py new file mode 100644 index 0000000..2d9d9dc --- /dev/null +++ b/backend/alembic/versions/002_attempts.py @@ -0,0 +1,53 @@ +"""attempts + +Revision ID: 002 +Revises: 001 +Create Date: 2026-03-21 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "test_attempts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False), + sa.Column( + "started_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("score", sa.Float(), nullable=True), + sa.Column("passed", sa.Boolean(), nullable=True), + sa.Column("correct_count", sa.Integer(), nullable=True), + sa.Column("total_count", sa.Integer(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="in_progress"), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "attempt_answers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "attempt_id", sa.Integer(), sa.ForeignKey("test_attempts.id"), nullable=False + ), + sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False), + sa.Column("answer_id", sa.Integer(), sa.ForeignKey("answers.id"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("attempt_answers") + op.drop_table("test_attempts") diff --git a/backend/app/api/attempts.py b/backend/app/api/attempts.py new file mode 100644 index 0000000..ee5ea54 --- /dev/null +++ b/backend/app/api/attempts.py @@ -0,0 +1,223 @@ +import random +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.attempt import AttemptAnswer, TestAttempt +from app.models.test import Question, Test +from app.schemas.attempt import ( + AttemptResult, + AttemptStart, + AttemptStarted, + AttemptSubmitDto, + AnswerForTest, + AnswerResult, + QuestionForTest, + QuestionResult, +) + +router = APIRouter(prefix="/api/attempts", tags=["attempts"]) + + +@router.post("", response_model=AttemptStarted, status_code=201) +async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)): + """Начать попытку прохождения теста. Возвращает вопросы в случайном порядке без правильных ответов.""" + + result = await db.execute( + select(Test) + .options(selectinload(Test.questions).selectinload(Question.answers)) + .where(Test.id == data.test_id, Test.is_active == True) + ) + test = result.scalar_one_or_none() + if not test: + raise HTTPException(status_code=404, detail="Тест не найден") + + attempt = TestAttempt(test_id=test.id, status="in_progress") + db.add(attempt) + await db.commit() + await db.refresh(attempt) + + # Перемешиваем вопросы и ответы случайно + questions_shuffled = list(test.questions) + random.shuffle(questions_shuffled) + + questions_for_test = [] + for q in questions_shuffled: + correct_count = sum(1 for a in q.answers if a.is_correct) + answers_shuffled = list(q.answers) + random.shuffle(answers_shuffled) + questions_for_test.append( + QuestionForTest( + id=q.id, + text=q.text, + is_multiple=correct_count > 1, + answers=[AnswerForTest(id=a.id, text=a.text) for a in answers_shuffled], + ) + ) + + return AttemptStarted( + id=attempt.id, + test_id=test.id, + test_title=test.title, + test_description=test.description, + started_at=attempt.started_at, + time_limit=test.time_limit, + allow_navigation_back=test.allow_navigation_back, + questions=questions_for_test, + ) + + +@router.post("/{attempt_id}/submit", response_model=AttemptResult) +async def submit_attempt( + attempt_id: int, data: AttemptSubmitDto, db: AsyncSession = Depends(get_db) +): + """Завершить попытку: сохранить ответы, подсчитать результат.""" + + result = await db.execute( + select(TestAttempt) + .where(TestAttempt.id == attempt_id, TestAttempt.status == "in_progress") + ) + attempt = result.scalar_one_or_none() + if not attempt: + raise HTTPException(status_code=404, detail="Попытка не найдена или уже завершена") + + # Загружаем тест с вопросами и ответами + test_result = await db.execute( + select(Test) + .options(selectinload(Test.questions).selectinload(Question.answers)) + .where(Test.id == attempt.test_id) + ) + test = test_result.scalar_one() + + # Сохраняем выбранные ответы + submitted = {qa.question_id: set(qa.answer_ids) for qa in data.answers} + + for qa in data.answers: + for answer_id in qa.answer_ids: + db.add(AttemptAnswer( + attempt_id=attempt.id, + question_id=qa.question_id, + answer_id=answer_id, + )) + + # Подсчёт результата + correct_count = 0 + question_results = [] + + for question in test.questions: + correct_ids = {a.id for a in question.answers if a.is_correct} + selected_ids = submitted.get(question.id, set()) + is_correct = selected_ids == correct_ids + + if is_correct: + correct_count += 1 + + question_results.append( + QuestionResult( + id=question.id, + text=question.text, + is_answered_correctly=is_correct, + answers=[ + AnswerResult( + id=a.id, + text=a.text, + is_correct=a.is_correct, + is_selected=a.id in selected_ids, + ) + for a in question.answers + ], + ) + ) + + total = len(test.questions) + score = round((correct_count / total) * 100, 1) if total > 0 else 0.0 + passed = score >= test.passing_score + finished_at = datetime.now(timezone.utc) + + attempt.finished_at = finished_at + attempt.score = score + attempt.passed = passed + attempt.correct_count = correct_count + attempt.total_count = total + attempt.status = "finished" + + await db.commit() + + return AttemptResult( + id=attempt.id, + test_id=test.id, + test_title=test.title, + started_at=attempt.started_at, + finished_at=finished_at, + score=score, + passed=passed, + passing_score=test.passing_score, + correct_count=correct_count, + total_count=total, + questions=question_results, + ) + + +@router.get("/{attempt_id}/result", response_model=AttemptResult) +async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)): + """Получить результат завершённой попытки.""" + + result = await db.execute( + select(TestAttempt) + .options(selectinload(TestAttempt.answers).selectinload(AttemptAnswer.answer)) + .where(TestAttempt.id == attempt_id, TestAttempt.status == "finished") + ) + attempt = result.scalar_one_or_none() + if not attempt: + raise HTTPException(status_code=404, detail="Результат не найден") + + test_result = await db.execute( + select(Test) + .options(selectinload(Test.questions).selectinload(Question.answers)) + .where(Test.id == attempt.test_id) + ) + test = test_result.scalar_one() + + # Восстанавливаем выбранные ответы из БД + selected_by_question: dict[int, set[int]] = {} + for aa in attempt.answers: + selected_by_question.setdefault(aa.question_id, set()).add(aa.answer_id) + + question_results = [] + for question in test.questions: + correct_ids = {a.id for a in question.answers if a.is_correct} + selected_ids = selected_by_question.get(question.id, set()) + question_results.append( + QuestionResult( + id=question.id, + text=question.text, + is_answered_correctly=selected_ids == correct_ids, + answers=[ + AnswerResult( + id=a.id, + text=a.text, + is_correct=a.is_correct, + is_selected=a.id in selected_ids, + ) + for a in question.answers + ], + ) + ) + + return AttemptResult( + id=attempt.id, + test_id=test.id, + test_title=test.title, + started_at=attempt.started_at, + finished_at=attempt.finished_at, + score=attempt.score, + passed=attempt.passed, + passing_score=test.passing_score, + correct_count=attempt.correct_count, + total_count=attempt.total_count, + questions=question_results, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 95b0407..b15efa2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import tests +from app.api import attempts, tests app = FastAPI( title="QA Test App API", @@ -19,6 +19,7 @@ app.add_middleware( ) app.include_router(tests.router) +app.include_router(attempts.router) @app.get("/api/health") diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py new file mode 100644 index 0000000..7222203 --- /dev/null +++ b/backend/app/models/attempt.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class TestAttempt(Base): + __tablename__ = "test_attempts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + score: Mapped[float | None] = mapped_column(Float, nullable=True) # процент 0–100 + passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + correct_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # in_progress | finished + status: Mapped[str] = mapped_column(String(20), default="in_progress", nullable=False) + + test: Mapped["Test"] = relationship("Test") # type: ignore[name-defined] + answers: Mapped[list["AttemptAnswer"]] = relationship( + "AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan" + ) + + +class AttemptAnswer(Base): + """Одна запись = один выбранный вариант ответа сотрудника.""" + + __tablename__ = "attempt_answers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + attempt_id: Mapped[int] = mapped_column( + Integer, ForeignKey("test_attempts.id"), nullable=False + ) + question_id: Mapped[int] = mapped_column( + Integer, ForeignKey("questions.id"), nullable=False + ) + answer_id: Mapped[int] = mapped_column( + Integer, ForeignKey("answers.id"), nullable=False + ) + + attempt: Mapped["TestAttempt"] = relationship("TestAttempt", back_populates="answers") + answer: Mapped["Answer"] = relationship("Answer") # type: ignore[name-defined] diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py new file mode 100644 index 0000000..cc1cf88 --- /dev/null +++ b/backend/app/schemas/attempt.py @@ -0,0 +1,89 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# ── Начало попытки ────────────────────────────────────────── + +class AttemptStart(BaseModel): + test_id: int + + +class AnswerForTest(BaseModel): + """Вариант ответа без поля is_correct — не раскрываем правильные ответы.""" + id: int + text: str + + model_config = {"from_attributes": True} + + +class QuestionForTest(BaseModel): + id: int + text: str + is_multiple: bool # True = несколько правильных ответов → показываем чекбоксы + answers: list[AnswerForTest] # перемешаны случайно + + model_config = {"from_attributes": True} + + +class AttemptStarted(BaseModel): + """Возвращается после старта попытки.""" + id: int + test_id: int + test_title: str + test_description: Optional[str] + started_at: datetime + time_limit: Optional[int] # минуты, из теста + allow_navigation_back: bool # из теста + questions: list[QuestionForTest] # перемешаны случайно + + model_config = {"from_attributes": True} + + +# ── Сдача попытки ──────────────────────────────────────────── + +class QuestionAnswer(BaseModel): + """Ответы сотрудника на один вопрос.""" + question_id: int + answer_ids: list[int] # выбранные варианты (может быть пустым) + + +class AttemptSubmitDto(BaseModel): + answers: list[QuestionAnswer] + + +# ── Результат ──────────────────────────────────────────────── + +class AnswerResult(BaseModel): + id: int + text: str + is_correct: bool # правильный ли ответ вообще + is_selected: bool # выбрал ли его сотрудник + + model_config = {"from_attributes": True} + + +class QuestionResult(BaseModel): + id: int + text: str + is_answered_correctly: bool # вся комбинация ответов верна + answers: list[AnswerResult] + + model_config = {"from_attributes": True} + + +class AttemptResult(BaseModel): + id: int + test_id: int + test_title: str + started_at: datetime + finished_at: datetime + score: float # процент правильных ответов + passed: bool # преодолён ли порог зачёта + passing_score: int # порог из теста + correct_count: int + total_count: int + questions: list[QuestionResult] + + model_config = {"from_attributes": True} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93c0af4..5ebe479 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,11 @@ import { ConfigProvider } from 'antd' import ruRU from 'antd/locale/ru_RU' import { BrowserRouter, Route, Routes } from 'react-router-dom' +import AttemptResult from './pages/AttemptResult' import TestCreate from './pages/TestCreate' import TestDetail from './pages/TestDetail' import TestList from './pages/TestList' +import TestTake from './pages/TestTake' const queryClient = new QueryClient() @@ -18,6 +20,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/api/attempts.ts b/frontend/src/api/attempts.ts new file mode 100644 index 0000000..ff69e32 --- /dev/null +++ b/frontend/src/api/attempts.ts @@ -0,0 +1,68 @@ +import client from './client' + +export interface AnswerForTest { + id: number + text: string +} + +export interface QuestionForTest { + id: number + text: string + is_multiple: boolean // true → показываем чекбоксы, false → радио-кнопки + answers: AnswerForTest[] +} + +export interface AttemptStarted { + id: number + test_id: number + test_title: string + test_description: string | null + started_at: string + time_limit: number | null // минуты + allow_navigation_back: boolean + questions: QuestionForTest[] +} + +export interface QuestionAnswer { + question_id: number + answer_ids: number[] +} + +export interface AnswerResult { + id: number + text: string + is_correct: boolean + is_selected: boolean +} + +export interface QuestionResult { + id: number + text: string + is_answered_correctly: boolean + answers: AnswerResult[] +} + +export interface AttemptResult { + id: number + test_id: number + test_title: string + started_at: string + finished_at: string + score: number + passed: boolean + passing_score: number + correct_count: number + total_count: number + questions: QuestionResult[] +} + +export const attemptsApi = { + start: (test_id: number) => + client.post('/attempts', { test_id }), + + submit: (attempt_id: number, answers: QuestionAnswer[]) => + client.post(`/attempts/${attempt_id}/submit`, { answers }), + + getResult: (attempt_id: number) => + client.get(`/attempts/${attempt_id}/result`), +} diff --git a/frontend/src/pages/AttemptResult/index.tsx b/frontend/src/pages/AttemptResult/index.tsx new file mode 100644 index 0000000..7027e3b --- /dev/null +++ b/frontend/src/pages/AttemptResult/index.tsx @@ -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 + 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 ( +
+ + {/* Итог */} + + ) : ( + + ) + } + status={result.passed ? 'success' : 'error'} + title={result.passed ? 'Тест сдан!' : 'Тест не сдан'} + subTitle={result.test_title} + /> + + {/* Статистика */} + + + + + {result.score}% + + Результат + + + + {result.correct_count}/{result.total_count} + + Правильных ответов + + + + {result.passing_score}% + + Порог зачёта + + + + {minutes > 0 ? `${minutes}м ` : ''}{seconds}с + + Время + + + + + {/* Разбор ошибок */} + Разбор ответов + + {result.questions.map((question, index) => ( + + {question.is_answered_correctly ? ( + + ) : ( + + )} + + {index + 1}. {question.text} + + + } + > + { + const icon = answer.is_correct ? ( + + ) : answer.is_selected ? ( + + ) : ( + + ) + + return ( + + + {icon} + + {answer.text} + + {answer.is_selected && answer.is_correct && ( + ваш ответ ✓ + )} + {answer.is_selected && !answer.is_correct && ( + ваш ответ ✗ + )} + {!answer.is_selected && answer.is_correct && ( + правильный ответ + )} + + + ) + }} + /> + + ))} + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/TestDetail/index.tsx b/frontend/src/pages/TestDetail/index.tsx index e69aae6..61dde1a 100644 --- a/frontend/src/pages/TestDetail/index.tsx +++ b/frontend/src/pages/TestDetail/index.tsx @@ -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 (
- + + {test.title} diff --git a/frontend/src/pages/TestTake/index.tsx b/frontend/src/pages/TestTake/index.tsx new file mode 100644 index 0000000..214972c --- /dev/null +++ b/frontend/src/pages/TestTake/index.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [currentIndex, setCurrentIndex] = useState(0) + // answers: questionId → выбранные answerId[] + const [answers, setAnswers] = useState>(new Map()) + const [timeLeft, setTimeLeft] = useState(null) + const timerRef = useRef | 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 + 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 ( +
+ + {/* Шапка */} + + + {attempt.test_title} + + {timeLeft !== null && ( + + ⏱ {formatTime(timeLeft)} + + )} + + + {/* Прогресс */} + `${currentIndex + 1} / ${total}`} + style={{ marginBottom: 16 }} + /> + + {isTimeCritical && ( + + )} + + {/* Вопрос */} + + Вопрос {currentIndex + 1} + + } + style={{ marginBottom: 24 }} + > + + {question.text} + + + {question.is_multiple && ( + + Выберите все правильные варианты + + )} + + + {question.answers.map((answer) => + question.is_multiple ? ( + handleAnswer(question.id, answer.id, true)} + style={{ fontSize: 14 }} + > + {answer.text} + + ) : ( + handleAnswer(question.id, answer.id, false)} + style={{ fontSize: 14 }} + > + {answer.text} + + ), + )} + + + + {/* Навигация */} + + + + {isLast ? ( + + ) : ( + + )} + +
+ ) +}