feat: redesign quiz runner and add study tools
Add Orthobullets-inspired numbered-answer UI, explicit study response confirmation, response statistics, review navigation, safe calculator, keyboard controls and sourced educator lab references. Persist attempt mode to prevent query-flag exam disclosure. Combined deployed-image backend suite (22), frontend suite (48), build and synthetic desktop/mobile browser checks pass. PostgreSQL round-trip and independent review remain release gates; no production deployment.
This commit is contained in:
parent
38f3fb8250
commit
a3a6ef7995
24 changed files with 977 additions and 93 deletions
30
backend/alembic/versions/d94a26b8f302_study_tools.py
Normal file
30
backend/alembic/versions/d94a26b8f302_study_tools.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Persist attempt mode and educator-maintained lab references."""
|
||||
from alembic import op
|
||||
|
||||
revision = "d94a26b8f302"
|
||||
down_revision = "c82d19e4a601"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("ALTER TABLE quiz_attempts ADD COLUMN IF NOT EXISTS mode VARCHAR(10)")
|
||||
op.execute('''CREATE TABLE IF NOT EXISTS lab_reference_values (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
"group" VARCHAR(60) NOT NULL,
|
||||
reference_range VARCHAR(250) NOT NULL,
|
||||
units VARCHAR(80) NOT NULL,
|
||||
age_group VARCHAR(120) NOT NULL,
|
||||
specimen VARCHAR(120) NOT NULL,
|
||||
source VARCHAR(500) NOT NULL,
|
||||
source_url VARCHAR(2000),
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)''')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS lab_reference_values")
|
||||
op.execute("ALTER TABLE quiz_attempts DROP COLUMN IF EXISTS mode")
|
||||
|
|
@ -12,6 +12,7 @@ from app.logging_config import setup_logging
|
|||
setup_logging(settings.LOG_LEVEL)
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
|
||||
from app.routers import study_tools
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -633,6 +634,7 @@ app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcard
|
|||
app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
|
||||
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
||||
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
||||
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class QuizAttempt(Base):
|
|||
id = Column(Integer, primary_key=True, index=True)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
mode = Column(String(10), nullable=True) # legacy NULL resumes without exposing answers
|
||||
score = Column(Integer, default=0)
|
||||
total_questions = Column(Integer, default=0)
|
||||
started_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
|
|
|||
20
backend/app/models/lab_reference.py
Normal file
20
backend/app/models/lab_reference.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LabReference(Base):
|
||||
__tablename__ = "lab_reference_values"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(120), nullable=False)
|
||||
group = Column(String(60), nullable=False)
|
||||
reference_range = Column(String(250), nullable=False)
|
||||
units = Column(String(80), nullable=False)
|
||||
age_group = Column(String(120), nullable=False)
|
||||
specimen = Column(String(120), nullable=False)
|
||||
source = Column(String(500), nullable=False)
|
||||
source_url = Column(String(2000), nullable=True)
|
||||
is_published = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
|
|
@ -11,6 +12,8 @@ from sqlalchemy import func
|
|||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.models.question_category import QuestionCategory
|
||||
from app.services.quiz_builder import category_breadcrumbs
|
||||
from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||
from app.models.pdf_document import PDFDocument
|
||||
from app.models.user import User
|
||||
|
|
@ -33,6 +36,7 @@ router = APIRouter()
|
|||
def start_attempt(
|
||||
quiz_id: int,
|
||||
fresh: bool = False,
|
||||
mode: Literal["study", "exam"] | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
|
|
@ -42,6 +46,10 @@ def start_attempt(
|
|||
if not can_access_quiz(db, quiz, current_user):
|
||||
raise HTTPException(status_code=403, detail="This quiz is private")
|
||||
|
||||
chosen_mode = mode or ("study" if quiz.mode == "learning" else "exam")
|
||||
if quiz.course_id is not None:
|
||||
chosen_mode = "study" if quiz.mode == "learning" and quiz.allow_review == 1 else "exam"
|
||||
|
||||
# Enforce max_attempts
|
||||
if quiz.max_attempts:
|
||||
completed_count = db.query(QuizAttempt).filter(
|
||||
|
|
@ -68,6 +76,7 @@ def start_attempt(
|
|||
percentage=0.0,
|
||||
started_at=existing.started_at,
|
||||
completed_at=None,
|
||||
mode=existing.mode or "exam",
|
||||
)
|
||||
|
||||
# Question pool: randomly select N questions if questions_per_attempt is set
|
||||
|
|
@ -84,6 +93,7 @@ def start_attempt(
|
|||
user_id=current_user.id,
|
||||
total_questions=total_q,
|
||||
selected_question_ids=selected_ids,
|
||||
mode=chosen_mode,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.commit()
|
||||
|
|
@ -96,6 +106,7 @@ def start_attempt(
|
|||
percentage=0.0,
|
||||
started_at=attempt.started_at,
|
||||
completed_at=None,
|
||||
mode=attempt.mode,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -118,6 +129,7 @@ def submit_attempt(
|
|||
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
|
||||
require_quiz_access(db, quiz, current_user)
|
||||
|
||||
categories = db.query(QuestionCategory).all()
|
||||
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id),
|
||||
[(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
|
||||
score = sum(correct for _, _, correct in grades)
|
||||
|
|
@ -142,6 +154,9 @@ def submit_attempt(
|
|||
is_correct=is_correct,
|
||||
explanation=q.explanation,
|
||||
explanation_image_path=q.explanation_image_path,
|
||||
image_path=q.image_path,
|
||||
page_reference=q.page_reference,
|
||||
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
|
||||
))
|
||||
|
||||
attempt.score = score
|
||||
|
|
@ -258,7 +273,7 @@ def save_progress(
|
|||
"attempt_id": data.attempt_id,
|
||||
"answers": data.answers,
|
||||
"current_idx": data.current_idx,
|
||||
"mode": data.mode,
|
||||
"mode": attempt.mode or "exam",
|
||||
"voice": data.voice,
|
||||
"time_left": data.time_left,
|
||||
"started_at": data.started_at,
|
||||
|
|
@ -311,6 +326,7 @@ def get_progress(
|
|||
return None
|
||||
|
||||
saved = _json.loads(data)
|
||||
saved["mode"] = attempt.mode or "exam"
|
||||
|
||||
# If the quiz was suspended, timer is paused — re-anchor on resume so
|
||||
# the held time_left becomes the new total_time starting now.
|
||||
|
|
@ -588,6 +604,7 @@ def get_attempt(
|
|||
is_course_quiz = quiz and quiz.course_id is not None
|
||||
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
|
||||
|
||||
categories = db.query(QuestionCategory).all()
|
||||
# Never reveal review content for an unfinished attempt or an unselected pool question.
|
||||
review_allowed = review_allowed and attempt.completed_at is not None
|
||||
submitted_map = {ans.question_id: ans for ans in attempt.answers}
|
||||
|
|
@ -607,6 +624,9 @@ def get_attempt(
|
|||
is_correct=ans.is_correct if ans else False,
|
||||
explanation=q.explanation,
|
||||
explanation_image_path=q.explanation_image_path,
|
||||
image_path=q.image_path,
|
||||
page_reference=q.page_reference,
|
||||
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
|
||||
))
|
||||
|
||||
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from app.models.question import Question as QuestionModel
|
|||
from app.models.section import Section
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.user import User
|
||||
from app.models.question_category import QuestionCategory
|
||||
from app.services.quiz_builder import category_breadcrumbs
|
||||
from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
|
||||
from app.services import quiz_service
|
||||
from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared
|
||||
|
|
@ -286,33 +288,32 @@ def get_quiz(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get quiz for taking. study=true forces answers/explanations to be included.
|
||||
attempt_id filters to the question subset selected for that attempt (question pool)."""
|
||||
"""Return selected questions; only an authorized study/completed attempt can reveal answers."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
if not can_access_quiz(db, quiz, current_user):
|
||||
raise HTTPException(status_code=403, detail="This quiz is private")
|
||||
|
||||
if study or quiz.mode == "learning":
|
||||
if quiz.mode != "learning":
|
||||
require_quiz_access(db, quiz, current_user, review=True)
|
||||
result = QuizLearningDetail.model_validate(quiz)
|
||||
else:
|
||||
result = QuizDetail.model_validate(quiz)
|
||||
|
||||
# Filter questions if attempt has a question pool selection
|
||||
if attempt_id:
|
||||
from app.models.attempt import QuizAttempt
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
).first()
|
||||
if attempt and attempt.selected_question_ids:
|
||||
sel = set(attempt.selected_question_ids)
|
||||
result.questions = [q for q in result.questions if q.id in sel]
|
||||
result.questions_count = len(result.questions)
|
||||
|
||||
attempt = None
|
||||
if attempt_id is not None:
|
||||
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, quiz_id=quiz.id, user_id=current_user.id).first()
|
||||
if not attempt:
|
||||
raise HTTPException(404, "Attempt not found for this quiz")
|
||||
reveal = attempt is not None and (attempt.mode == "study" or attempt.completed_at is not None)
|
||||
if study and not reveal:
|
||||
raise HTTPException(403, "Answers are hidden until the exam is submitted")
|
||||
if reveal:
|
||||
require_quiz_access(db, quiz, current_user, review=True)
|
||||
result = (QuizLearningDetail if reveal else QuizDetail).model_validate(quiz)
|
||||
result.attempt_mode = (attempt.mode or "exam") if attempt else None
|
||||
if attempt and attempt.selected_question_ids is not None:
|
||||
selected = set(attempt.selected_question_ids)
|
||||
result.questions = [q for q in result.questions if q.id in selected]
|
||||
result.questions_count = len(result.questions)
|
||||
categories = db.query(QuestionCategory).all()
|
||||
for question in result.questions:
|
||||
question.category_breadcrumbs = category_breadcrumbs(categories, question.question_category_id)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
129
backend/app/routers/study_tools.py
Normal file
129
backend/app/routers/study_tools.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Educator-maintained lab references and authorized question response statistics."""
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.attempt import AttemptAnswer, QuizAttempt
|
||||
from app.models.lab_reference import LabReference
|
||||
from app.models.question import Question
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
from app.utils.quiz_access import general_quiz_visibility, require_quiz_access
|
||||
from app.utils.quiz_questions import question_in_quiz
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LabInput(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
group: str = Field(min_length=1, max_length=60)
|
||||
reference_range: str = Field(min_length=1, max_length=250)
|
||||
units: str = Field(min_length=1, max_length=80)
|
||||
age_group: str = Field(min_length=1, max_length=120)
|
||||
specimen: str = Field(min_length=1, max_length=120)
|
||||
source: str = Field(min_length=1, max_length=500)
|
||||
source_url: HttpUrl | None = None
|
||||
is_published: bool = False
|
||||
|
||||
@field_validator("source_url")
|
||||
@classmethod
|
||||
def bounded_source_url(cls, value):
|
||||
if value is not None and len(str(value)) > 2000:
|
||||
raise ValueError("Source URL must be at most 2000 characters")
|
||||
return value
|
||||
|
||||
@field_validator("name", "group", "reference_range", "units", "age_group", "specimen", "source")
|
||||
@classmethod
|
||||
def not_blank(cls, value):
|
||||
if not value.strip():
|
||||
raise ValueError("A value is required")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class LabOutput(LabInput):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.get("/lab-values", response_model=list[LabOutput])
|
||||
def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
if include_drafts and not user.is_moderator:
|
||||
raise HTTPException(403, "Educator access required")
|
||||
query = db.query(LabReference)
|
||||
if not include_drafts:
|
||||
query = query.filter(LabReference.is_published.is_(True))
|
||||
# ponytail: bounded personal-project table; add pagination before exceeding 500 entries.
|
||||
return query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all()
|
||||
|
||||
|
||||
def lab_fields(data):
|
||||
fields = data.model_dump()
|
||||
fields["source_url"] = str(data.source_url) if data.source_url else None
|
||||
return fields
|
||||
|
||||
|
||||
@router.post("/lab-values", response_model=LabOutput, status_code=201)
|
||||
def create_lab_value(data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
||||
entry = LabReference(**lab_fields(data), updated_by=user.id)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.put("/lab-values/{entry_id}", response_model=LabOutput)
|
||||
def update_lab_value(entry_id: int, data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
||||
entry = db.get(LabReference, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(404, "Reference not found")
|
||||
for key, value in lab_fields(data).items():
|
||||
setattr(entry, key, value)
|
||||
entry.updated_by = user.id
|
||||
entry.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/lab-values/{entry_id}", status_code=204)
|
||||
def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
||||
entry = db.get(LabReference, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(404, "Reference not found")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/attempts/{attempt_id}/questions/{question_id}/responses")
|
||||
def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()
|
||||
if not attempt:
|
||||
raise HTTPException(404, "Attempt not found")
|
||||
quiz = db.get(Quiz, attempt.quiz_id)
|
||||
require_quiz_access(db, quiz, user, review=True)
|
||||
if attempt.completed_at is None and attempt.mode != "study":
|
||||
raise HTTPException(403, "Responses are hidden until the exam is submitted")
|
||||
if not question_in_quiz(db, quiz.id, question_id) or (attempt.selected_question_ids is not None and question_id not in attempt.selected_question_ids):
|
||||
raise HTTPException(404, "Question not selected for this attempt")
|
||||
question = db.get(Question, question_id)
|
||||
rows = db.query(AttemptAnswer.user_answer, func.count(AttemptAnswer.id)).join(
|
||||
QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).filter(
|
||||
AttemptAnswer.question_id == question_id, QuizAttempt.completed_at.isnot(None),
|
||||
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), general_quiz_visibility(user),
|
||||
).group_by(AttemptAnswer.user_answer).all()
|
||||
counts = Counter()
|
||||
for answer, count in rows:
|
||||
if answer and answer.strip():
|
||||
counts[answer.strip().casefold()] += count
|
||||
options = [{"option": option, "count": counts[option.strip().casefold()]} for option in (question.options or [])]
|
||||
sample_size = sum(option["count"] for option in options)
|
||||
for option in options:
|
||||
option["percentage"] = round(100 * option["count"] / sample_size, 1) if sample_size else 0
|
||||
return {"sample_size": sample_size, "options": options, "basis": "Recorded answers from accessible completed general-bank attempts; skips and obsolete options excluded."}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AnswerSubmission(BaseModel):
|
||||
|
|
@ -22,6 +22,9 @@ class AnswerDetail(BaseModel):
|
|||
is_correct: bool
|
||||
explanation: str | None
|
||||
explanation_image_path: str | None = None
|
||||
image_path: str | None = None
|
||||
page_reference: int | None = None
|
||||
category_breadcrumbs: list[dict] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -35,6 +38,7 @@ class AttemptResponse(BaseModel):
|
|||
percentage: float
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
mode: str | None = None
|
||||
course_id: int | None = None
|
||||
allow_review: bool = True
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QuizCreate(BaseModel):
|
||||
|
|
@ -30,6 +30,8 @@ class QuestionResponse(BaseModel):
|
|||
question_type: str
|
||||
options: list[str] | None
|
||||
image_path: str | None = None
|
||||
question_category_id: int | None = None
|
||||
category_breadcrumbs: list[dict] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -57,6 +59,10 @@ class QuizResponse(BaseModel):
|
|||
is_published: int = 1
|
||||
is_shared: int = 0
|
||||
questions_per_attempt: int | None = None
|
||||
course_id: int | None = None
|
||||
allow_review: int = 1
|
||||
max_attempts: int | None = None
|
||||
attempt_mode: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
107
backend/tests/test_study_tools.py
Normal file
107
backend/tests/test_study_tools.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.quiz import Quiz
|
||||
from app.routers import study_tools
|
||||
|
||||
|
||||
class StudyToolTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.client.app.include_router(study_tools.router, prefix='/study-tools')
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def start(self, quiz_id, mode):
|
||||
response = self.client.post(f'/attempts/start?quiz_id={quiz_id}&mode={mode}&fresh=true')
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()['id']
|
||||
|
||||
def test_attempt_mode_controls_answers_and_stats_not_query_flags(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], mode='learning').json()['id']
|
||||
initial = self.client.get(f'/quizzes/{quiz_id}').json()
|
||||
self.assertNotIn('correct_answer', initial['questions'][0])
|
||||
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?study=true').status_code, 403)
|
||||
exam = self.start(quiz_id, 'exam')
|
||||
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}').json()
|
||||
self.assertEqual(data['attempt_mode'], 'exam')
|
||||
self.assertNotIn('correct_answer', data['questions'][0])
|
||||
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}&study=true').status_code, 403)
|
||||
stats_url = f'/study-tools/attempts/{exam}/questions/1/responses'
|
||||
self.assertEqual(self.client.get(stats_url).status_code, 403)
|
||||
redis = Mock()
|
||||
with patch.dict(sys.modules, {'redis': redis}):
|
||||
self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': exam, 'answers': {}, 'current_idx': 0, 'mode': 'study'})
|
||||
stored = json.loads(redis.from_url.return_value.setex.call_args_list[-1].args[2])
|
||||
self.assertEqual(stored['mode'], 'exam')
|
||||
study = self.start(quiz_id, 'study')
|
||||
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').json()
|
||||
self.assertEqual(data['attempt_mode'], 'study')
|
||||
self.assertEqual(data['questions'][0]['correct_answer'], 'yes')
|
||||
self.assertTrue(data['questions'][0]['category_breadcrumbs'])
|
||||
self.assertEqual(self.client.get('/quizzes/1', params={'attempt_id': study}).status_code, 404)
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').status_code, 404)
|
||||
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
||||
|
||||
def test_real_response_counts_exclude_skips_expiry_course_and_private_attempts(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
||||
study = self.start(quiz_id, 'study')
|
||||
self.bank.answer(1, True)
|
||||
self.bank.answer(1, True, day=1)
|
||||
self.bank.answer(1, False, day=2)
|
||||
self.bank.answer(1, True, expired=1)
|
||||
self.bank.answer(1, True, quiz_id=2)
|
||||
self.bank.answer(1, True, completed=False)
|
||||
other_private = Quiz(title='Private peer test', user_id=2, is_shared=0, is_published=0)
|
||||
self.bank.db.add(other_private)
|
||||
self.bank.db.commit()
|
||||
self.bank.answer(1, True, quiz_id=other_private.id)
|
||||
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses')
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()['sample_size'], 3)
|
||||
self.assertEqual([row['count'] for row in response.json()['options']], [2, 1])
|
||||
self.assertEqual([row['percentage'] for row in response.json()['options']], [66.7, 33.3])
|
||||
attempt = self.bank.db.get(QuizAttempt, study)
|
||||
attempt.selected_question_ids = [2]
|
||||
self.bank.db.commit()
|
||||
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
||||
empty = self.client.get(f'/study-tools/attempts/{study}/questions/2/responses').json()
|
||||
self.assertEqual(empty['sample_size'], 0)
|
||||
self.assertTrue(all(row['percentage'] == 0 for row in empty['options']))
|
||||
|
||||
def test_lab_reference_permissions_validation_and_publication(self):
|
||||
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
|
||||
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')
|
||||
self.assertEqual(self.client.post('/study-tools/lab-values', json=payload).status_code, 403)
|
||||
self.assertEqual(self.client.get('/study-tools/lab-values?include_drafts=true').status_code, 403)
|
||||
self.bank.user = self.bank.mod
|
||||
response = self.client.post('/study-tools/lab-values', json=payload)
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
entry_id = response.json()['id']
|
||||
self.assertFalse(response.json()['is_published'])
|
||||
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
|
||||
self.assertEqual(len(self.client.get('/study-tools/lab-values?include_drafts=true').json()), 1)
|
||||
for bad in ({'source': ' '}, {'source_url': 'javascript:alert(1)'}, {'source_url': 'https://example.com/' + 'a' * 2000}, {'units': ''}):
|
||||
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, **bad}).status_code, 422)
|
||||
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, 'is_published': True}).status_code, 200)
|
||||
self.bank.user = self.bank.peer
|
||||
rows = self.client.get('/study-tools/lab-values').json()
|
||||
self.assertEqual(rows[0]['source'], payload['source'])
|
||||
self.assertEqual(rows[0]['age_group'], payload['age_group'])
|
||||
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json=payload).status_code, 403)
|
||||
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 403)
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 204)
|
||||
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
39
backend/tests/test_study_tools_migration.py
Normal file
39
backend/tests/test_study_tools_migration.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Offline PostgreSQL DDL test for the study tools migration; no database access."""
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
|
||||
from app.database import Base
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
|
||||
class StudyToolsMigrationTests(unittest.TestCase):
|
||||
def test_explicit_offline_upgrade_and_downgrade(self):
|
||||
output = io.StringIO()
|
||||
config = Config(output_buffer=output)
|
||||
config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic'))
|
||||
scripts = ScriptDirectory.from_config(config)
|
||||
self.assertEqual(len(scripts.get_heads()), 1)
|
||||
self.assertEqual(scripts.get_revision('d94a26b8f302').down_revision, 'c82d19e4a601')
|
||||
with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}):
|
||||
command.upgrade(config, 'c82d19e4a601:d94a26b8f302', sql=True)
|
||||
sql = output.getvalue()
|
||||
self.assertIn('ADD COLUMN IF NOT EXISTS mode VARCHAR(10)', sql)
|
||||
self.assertIn('CREATE TABLE IF NOT EXISTS lab_reference_values', sql)
|
||||
self.assertIn('is_published BOOLEAN NOT NULL DEFAULT FALSE', sql)
|
||||
self.assertIn('source VARCHAR(500) NOT NULL', sql)
|
||||
self.assertNotIn('UPDATE quiz_attempts', sql)
|
||||
output.seek(0)
|
||||
output.truncate()
|
||||
command.downgrade(config, 'd94a26b8f302:c82d19e4a601', sql=True)
|
||||
self.assertIn('DROP TABLE IF EXISTS lab_reference_values', output.getvalue())
|
||||
self.assertIn('DROP COLUMN IF EXISTS mode', output.getvalue())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -21,7 +21,7 @@ Initial implementation received two independent read-only reviews (access/correc
|
|||
- PostgreSQL 16: actual migration upgrade, FK enforcement, existing-row/question-assignment preservation, repeated upgrade, downgrade and re-upgrade passed in a disposable network-isolated container. Container/data cleaned up automatically.
|
||||
- Alembic offline SQL: explicit `5f8c1c2a9d40:c82d19e4a601` upgrade and reverse downgrade exercised without mocking inspection.
|
||||
- Changed Python syntax and `git diff --check` passed.
|
||||
- Regression negative-control run against the unfixed `affd717` is tracked separately; do not infer its result from the passing fixed-code suite.
|
||||
- The same regression suite fails against frozen `affd717` as expected: unauthorized revocation/deletion, premature review answers, invalid mobile selections, duplicate scoring (200%) and offline SQL inspection are reproduced. The legacy-Hide test also catches its missing sharing-state response. All tests pass on the fixed code.
|
||||
|
||||
The first Docker validation attempt failed before tests because a read-only mountpoint was absent; setup was corrected. One frontend regression initially used an ambiguous Create Quiz selector; the modal was labeled and the test scoped to it, then all tests passed.
|
||||
|
||||
|
|
@ -39,6 +39,20 @@ Verification: 18 backend tests passed in the exact deployed image, including log
|
|||
|
||||
This source change is committed/pushed with the feature work; it has not been deployed to production.
|
||||
|
||||
## Milestone 2 — runner and study tools (under verification)
|
||||
|
||||
Implemented a wide white/blue question layout with category breadcrumbs, numbered answers, provisional study selections, explicit response confirmation, real response-distribution bars, question navigation and Review & Complete. The results screen reviews one question at a time. Full question/explanation text, images, notes, highlighting and tutor access are retained.
|
||||
|
||||
Added a safe arithmetic calculator (no eval), keyboard shortcuts that do not interfere with text entry/dialogs, native focus-trapping tool dialogs, and educator-managed lab references with required population/specimen/units/source and explicit publication. No clinical reference ranges are fabricated or seeded without verification.
|
||||
|
||||
Attempt mode is persisted server-side: a query flag cannot reveal active exam answers/statistics, and mismatched attempt/quiz/user IDs are rejected. Legacy attempts without stored mode resume as exam mode rather than exposing answers; their saved answers remain intact. The new `d94a26b8f302` migration must run before deployment.
|
||||
|
||||
Combined verification after integrating login removal: **22 backend tests passed in the deployed image; 48 frontend tests and production build passed.** Actual PostgreSQL mode/lab migration verification and independent review are being completed separately.
|
||||
|
||||
Browser checks used a loopback-only fixture with synthetic accounts/questions and in-memory data, not production authentication. Desktop layout, provisional/confirmed answers, live fixture response counts, calculator arithmetic, honest empty lab references, and review confirmation were checked. At 390×844 the document width was exactly 390 and no question, option, image or toolbar overflowed; a taller narrow frame captures the full page. A browser-profile reset interrupted the check; re-login restored the saved attempt successfully.
|
||||
|
||||
Proof images: [desktop](quiz-revamp/desktop-question.png), [study feedback](quiz-revamp/study-feedback.png), [mobile width](quiz-revamp/mobile-question.png). These are synthetic previews, not screenshots of deployed clinical content.
|
||||
|
||||
## Next
|
||||
|
||||
Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment.
|
||||
|
|
|
|||
BIN
docs/quiz-revamp/desktop-question.png
Normal file
BIN
docs/quiz-revamp/desktop-question.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/quiz-revamp/mobile-question.png
Normal file
BIN
docs/quiz-revamp/mobile-question.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/quiz-revamp/study-feedback.png
Normal file
BIN
docs/quiz-revamp/study-feedback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
36
frontend/src/components/QuizTools.css
Normal file
36
frontend/src/components/QuizTools.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
.quiz-tool-dialog { margin: auto; width: min(760px, calc(100vw - 24px)); max-height: 88vh; padding: 0; border: 1px solid #d9dfe8; border-radius: 6px; background: #fff; color: #333; box-shadow: 0 12px 48px #18243640; }
|
||||
.quiz-tool-dialog::backdrop { background: #172b464d; }
|
||||
.quiz-tool-dialog:has(.quiz-calculator-keys) { width: min(520px, calc(100vw - 24px)); }
|
||||
.quiz-tool-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; background: #eff0f6; border-bottom: 1px solid #dfe3eb; }
|
||||
.quiz-tool-dialog h2 { margin: 0; font-size: 1.2rem; }
|
||||
.quiz-tool-dialog > header button { border: 0; background: none; font-size: 1.7rem; padding: 0 8px; cursor: pointer; }
|
||||
.quiz-tool-body { padding: 20px; overflow: auto; }
|
||||
.quiz-tool-body p { margin: 10px 0; }
|
||||
.quiz-tool-body button { padding: 8px 12px; border: 1px solid #d7dce6; border-radius: 4px; background: #f7f8fa; color: #303640; cursor: pointer; font: inherit; }
|
||||
.quiz-tool-body button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.quiz-tool-body button:focus-visible, .quiz-tool-body input:focus-visible { outline: 2px solid #426da7; outline-offset: 2px; }
|
||||
.quiz-tool-body input:not([type=checkbox]) { display: block; width: 100%; padding: 10px; border: 1px solid #cdd4df; border-radius: 4px; font: inherit; color: #333; background: white; }
|
||||
.quiz-tool-body table { width: 100%; border-collapse: collapse; text-align: left; font-size: .9rem; }
|
||||
.quiz-tool-body th, .quiz-tool-body td { padding: 12px; vertical-align: top; border-bottom: 1px solid #e2e8ef; }
|
||||
.quiz-tool-body thead { background: #edf5fb; }
|
||||
.quiz-tool-body tbody tr:nth-child(even) { background: #edf5fb; }
|
||||
.quiz-tool-body small { display: block; margin-top: 4px; font-size: .78rem; color: #5d6776; font-weight: normal; }
|
||||
.quiz-tool-body a { color: #38669d; }
|
||||
.quiz-tool-body [role=alert] { color: #9c2639; }
|
||||
.quiz-tool-body input.quiz-calculation { font-size: 1.8rem; text-align: right; margin-bottom: 12px; }
|
||||
.quiz-calculator-keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.quiz-calculator-keys button { min-height: 48px; font-size: 1.2rem; font-weight: 600; }
|
||||
.quiz-calculator-keys button:last-child { grid-column: 1 / -1; }
|
||||
.quiz-calculator-keys .quiz-equals, .quiz-tool-tabs button[aria-pressed=true] { background: #496fa5; color: white; }
|
||||
.quiz-tool-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0; }
|
||||
.quiz-reference-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; }
|
||||
.quiz-reference-controls label { flex: 1; min-width: 150px; }
|
||||
.quiz-reference-note { color: #5d6776; font-size: .83rem; }
|
||||
.quiz-reference-scroll { overflow-x: auto; max-height: 45vh; }
|
||||
.quiz-reference-scroll table { min-width: 450px; }
|
||||
.quiz-reference-form { margin-top: 20px; padding-top: 16px; border-top: 1px solid #d7dce6; display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.quiz-reference-form h3, .quiz-reference-form > div, .quiz-reference-form > .quiz-check { grid-column: 1 / -1; }
|
||||
.quiz-check { display: flex; align-items: baseline; gap: 8px; margin: 10px 0; }
|
||||
.quiz-check input { width: auto; }
|
||||
.quiz-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 520px) { .quiz-tool-body { padding: 14px; } .quiz-reference-form { grid-template-columns: minmax(0, 1fr); } }
|
||||
132
frontend/src/components/QuizTools.jsx
Normal file
132
frontend/src/components/QuizTools.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { calculate } from '../utils/calculator'
|
||||
import './QuizTools.css'
|
||||
|
||||
export function QuizDialog({ title, children, onClose }) {
|
||||
const ref = useRef(null)
|
||||
const titleId = useId()
|
||||
useEffect(() => {
|
||||
const dialog = ref.current
|
||||
dialog.showModal()
|
||||
return () => { if (dialog.open) dialog.close() }
|
||||
}, [])
|
||||
return <dialog ref={ref} className="quiz-tool-dialog" aria-labelledby={titleId} onCancel={onClose}>
|
||||
<header><h2 id={titleId}>{title}</h2><button type="button" onClick={onClose} aria-label={`Close ${title}`}>×</button></header>
|
||||
<div className="quiz-tool-body">{children}</div>
|
||||
</dialog>
|
||||
}
|
||||
|
||||
function Calculator() {
|
||||
const [expression, setExpression] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const evaluate = e => {
|
||||
e?.preventDefault()
|
||||
try {
|
||||
setExpression(calculate(expression).toLocaleString('en-US', { useGrouping: false, maximumSignificantDigits: 12 }))
|
||||
setError('')
|
||||
} catch (err) { setError(err.message) }
|
||||
}
|
||||
const press = key => {
|
||||
if (key === '=') return evaluate()
|
||||
setError('')
|
||||
setExpression(value => key === 'AC' ? '' : key === '⌫' ? value.slice(0, -1) : value + key)
|
||||
}
|
||||
return <form onSubmit={evaluate}>
|
||||
<label className="quiz-sr-only" htmlFor="quiz-calculation">Calculation</label>
|
||||
<input id="quiz-calculation" className="quiz-calculation" value={expression} maxLength={300} onChange={e => { setExpression(e.target.value); setError('') }} placeholder="0" autoComplete="off" autoFocus />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<div className="quiz-calculator-keys">{['(', ')', '^', 'AC', '7', '8', '9', '÷', '4', '5', '6', '×', '1', '2', '3', '-', '0', '.', '=', '+', '⌫'].map(key =>
|
||||
<button type="button" key={key} className={key === '=' ? 'quiz-equals' : ''} aria-label={key === '⌫' ? 'Backspace' : key === 'AC' ? 'Clear calculator' : key} onClick={() => press(key)}>{key}</button>,
|
||||
)}</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', is_published: false }
|
||||
const labFields = [
|
||||
['name', 'Test name', 120], ['group', 'Group', 60], ['reference_range', 'Reference range', 250],
|
||||
['units', 'Units', 80], ['age_group', 'Age / population', 120], ['specimen', 'Specimen', 120],
|
||||
['source', 'Source citation', 500], ['source_url', 'Source URL (optional)', 2000],
|
||||
]
|
||||
|
||||
function LabValues() {
|
||||
const { user } = useAuth()
|
||||
const isEducator = ['admin', 'moderator'].includes(user?.role)
|
||||
const [rows, setRows] = useState([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [group, setGroup] = useState('All')
|
||||
const [manage, setManage] = useState(false)
|
||||
const [form, setForm] = useState(null)
|
||||
const [removeId, setRemoveId] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
api.get('/study-tools/lab-values', { params: { include_drafts: manage } }).then(r => { if (active) setRows(r.data) })
|
||||
.catch(() => { if (active) setError('Could not load reference values. Try again.') })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [manage, refresh])
|
||||
const save = async event => {
|
||||
event.preventDefault()
|
||||
setSaving(true); setError('')
|
||||
const payload = Object.fromEntries(Object.keys(emptyReference).map(key => [key, form[key]]))
|
||||
payload.source_url ||= null
|
||||
try {
|
||||
if (form.id) await api.put(`/study-tools/lab-values/${form.id}`, payload)
|
||||
else await api.post('/study-tools/lab-values', payload)
|
||||
setForm(null); setRefresh(v => v + 1)
|
||||
} catch { setError('Could not save. Check all fields and the source URL.') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
const remove = async id => {
|
||||
setSaving(true); setError('')
|
||||
try { await api.delete(`/study-tools/lab-values/${id}`); setRemoveId(null); setRefresh(v => v + 1) }
|
||||
catch { setError('Could not delete this reference.') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
const filtered = rows.filter(row => (group === 'All' || row.group === group) &&
|
||||
[row.name, row.age_group, row.specimen, row.units].some(value => value.toLowerCase().includes(query.toLowerCase())))
|
||||
return <>
|
||||
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Use the source and population shown; follow local laboratory intervals.</p>
|
||||
{isEducator && <label className="quiz-check"><input type="checkbox" checked={manage} onChange={e => { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts</label>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<div className="quiz-reference-controls"><label>Search references<input value={query} onChange={e => setQuery(e.target.value)} /></label>
|
||||
<button type="button" onClick={() => setRefresh(v => v + 1)}>Refresh</button>
|
||||
{manage && <button type="button" onClick={() => setForm({ ...emptyReference })}>Add reference</button>}
|
||||
</div>
|
||||
<div className="quiz-tool-tabs" aria-label="Reference groups">{['All', ...new Set(rows.map(row => row.group))].map(name => <button type="button" key={name} aria-pressed={group === name} onClick={() => setGroup(name)}>{name}</button>)}</div>
|
||||
{loading ? <p role="status">Loading references…</p> : filtered.length === 0 ? <p>No published reference values match. Educators can add sourced, age-specific entries; no ranges are assumed.</p> :
|
||||
<div className="quiz-reference-scroll"><table><thead><tr><th>Test / specimen</th><th>Reference range</th><th>Population / source</th>{manage && <th>Actions</th>}</tr></thead><tbody>{filtered.map(row => <tr key={row.id}>
|
||||
<th scope="row">{row.name}<small>{row.specimen}{!row.is_published && ' · Draft'}</small></th>
|
||||
<td>{row.reference_range} <span>{row.units}</span></td>
|
||||
<td>{row.age_group}<small>{row.source_url ? <a href={row.source_url} target="_blank" rel="noopener noreferrer">{row.source}</a> : row.source}</small></td>
|
||||
{manage && <td><button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '' })}>Edit {row.name}</button>{removeId === row.id ? <>
|
||||
<button type="button" disabled={saving} onClick={() => remove(row.id)}>Confirm delete</button><button type="button" onClick={() => setRemoveId(null)}>Cancel</button>
|
||||
</> : <button type="button" onClick={() => setRemoveId(row.id)}>Delete {row.name}</button>}</td>}
|
||||
</tr>)}</tbody></table></div>}
|
||||
{form && manage && <form className="quiz-reference-form" onSubmit={save}>
|
||||
<h3>{form.id ? 'Edit reference' : 'New reference'}</h3>
|
||||
{labFields.map(([key, label, max]) => <label key={key}>{label}<input required={key !== 'source_url'} type={key === 'source_url' ? 'url' : 'text'} maxLength={max} value={form[key]} onChange={e => setForm(value => ({ ...value, [key]: e.target.value }))} /></label>)}
|
||||
<label className="quiz-check"><input type="checkbox" checked={form.is_published} onChange={e => setForm(value => ({ ...value, is_published: e.target.checked }))} /> I have verified this source and population; publish this reference</label>
|
||||
<div><button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save reference'}</button><button type="button" onClick={() => setForm(null)}>Cancel editing</button></div>
|
||||
</form>}
|
||||
</>
|
||||
}
|
||||
|
||||
const shortcuts = [['Choose answer', '1–9'], ['Submit study response', 'Enter'], ['Previous question', '←'], ['Next question', '→ or N'], ['Bookmark question', 'B'], ['Open question image', 'Space'], ['Close tool', 'Escape']]
|
||||
export default function QuizTools({ tool, onClose }) {
|
||||
const title = { calculator: 'Calculator', labs: 'Lab values', shortcuts: 'Keyboard shortcuts' }[tool]
|
||||
if (!title) return null
|
||||
return <QuizDialog title={title} onClose={onClose}>
|
||||
{tool === 'calculator' ? <Calculator /> : tool === 'labs' ? <LabValues /> : <>
|
||||
<p>Shortcuts pause while typing or using a dialog.</p>
|
||||
<table><thead><tr><th>Action</th><th>Key</th></tr></thead><tbody>{shortcuts.map(([action, key]) => <tr key={action}><td>{action}</td><td><kbd>{key}</kbd></td></tr>)}</tbody></table>
|
||||
</>}
|
||||
</QuizDialog>
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import './CustomQuizPage.css'
|
||||
|
|
@ -7,8 +7,9 @@ import './CustomQuizPage.css'
|
|||
export default function CustomQuizPage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [categories, setCategories] = useState([])
|
||||
const [categoryIds, setCategoryIds] = useState([])
|
||||
const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
|
||||
const [state, setState] = useState('all')
|
||||
const [shared, setShared] = useState(false)
|
||||
const [title, setTitle] = useState('My Custom Test')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import MyNote from '../components/MyNote'
|
||||
import QuizTools, { QuizDialog } from '../components/QuizTools'
|
||||
import './QuizPlayer.css'
|
||||
|
||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
|
||||
|
|
@ -280,7 +282,7 @@ function QuizCodeBadge({ code }) {
|
|||
}
|
||||
|
||||
function CourseQuizStart({ quiz, onStart }) {
|
||||
const mode = quiz.mode === 'timed' ? 'exam' : 'study'
|
||||
const mode = quiz.mode === 'timed' || quiz.allow_review === 0 ? 'exam' : 'study'
|
||||
const [error, setError] = useState('')
|
||||
const [starting, setStarting] = useState(false)
|
||||
const begin = async () => {
|
||||
|
|
@ -327,7 +329,11 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
const [startingMode, setStartingMode] = useState('')
|
||||
|
||||
const handleStart = async (mode) => {
|
||||
const timerMinutes = mode === 'exam' && customTimer ? parseInt(customTimer) : null
|
||||
if (mode === 'exam' && customTimer && (!Number.isInteger(Number(customTimer)) || Number(customTimer) < 1)) {
|
||||
setStartError('Enter a positive whole number of minutes, or leave the timer blank.')
|
||||
return
|
||||
}
|
||||
const timerMinutes = mode === 'exam' && customTimer ? Number(customTimer) : null
|
||||
setStartError('')
|
||||
setStartingMode(mode)
|
||||
try {
|
||||
|
|
@ -355,7 +361,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
|
||||
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
|
||||
].map(({ mode, icon, label, desc, color, bg }) => (
|
||||
<div key={mode} onClick={() => !startingMode && handleStart(mode)}
|
||||
<button type="button" key={mode} onClick={() => !startingMode && handleStart(mode)}
|
||||
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||||
|
|
@ -363,7 +369,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
|||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{startError && (
|
||||
|
|
@ -441,6 +447,12 @@ export default function QuizPage() {
|
|||
const [favorites, setFavorites] = useState([])
|
||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||
const [manualHighlights, setManualHighlights] = useState({})
|
||||
const [draftAnswer, setDraftAnswer] = useState('')
|
||||
const [tool, setTool] = useState(null)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [responseStats, setResponseStats] = useState(null)
|
||||
const [statsError, setStatsError] = useState('')
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const timerRef = useRef(null)
|
||||
const toastRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
|
@ -559,6 +571,7 @@ export default function QuizPage() {
|
|||
useEffect(() => {
|
||||
setActiveReadSegment(null)
|
||||
setTtsActive(false)
|
||||
setDraftAnswer('')
|
||||
savedHighlightSelectionRef.current = null
|
||||
clearTimeout(autoHighlightTimerRef.current)
|
||||
if (!readThrough) setActiveReadSegment(null)
|
||||
|
|
@ -575,33 +588,13 @@ export default function QuizPage() {
|
|||
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
|
||||
|
||||
const resumeQuiz = useCallback(async (saved, availableVoices = []) => {
|
||||
const savedMode = saved.mode || saved.quizMode
|
||||
const mode = savedMode === 'exam' ? 'exam' : 'study'
|
||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
const savedAnswers = saved.answers || {}
|
||||
|
||||
const aid = saved.attempt_id || saved.attemptId || ''
|
||||
// For feedback modes, load quiz with correct answers BEFORE showing.
|
||||
if (mode === 'study') {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load quiz:', err)
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// For exam mode, ensure quiz data is loaded
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}${aid ? `?attempt_id=${aid}` : ''}`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load quiz:', err)
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
const aid = saved.attempt_id || saved.attemptId
|
||||
if (!aid) return
|
||||
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}`)
|
||||
const mode = quizRes.data.attempt_mode === 'study' ? 'study' : 'exam'
|
||||
setQuiz(quizRes.data)
|
||||
|
||||
hasStarted.current = true
|
||||
setQuizMode(mode)
|
||||
|
|
@ -671,7 +664,8 @@ export default function QuizPage() {
|
|||
|
||||
try {
|
||||
// Start attempt first (may select random question subset)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}`)
|
||||
mode = attemptRes.data.mode || mode
|
||||
setAttemptId(attemptRes.data.id)
|
||||
const aid = attemptRes.data.id
|
||||
|
||||
|
|
@ -760,6 +754,25 @@ const timerStarted = timeLeft !== null
|
|||
}, [attemptId, quizMode, saveProgressNow])
|
||||
|
||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
const chooseAnswer = value => {
|
||||
if (!current || (isStudy && answers[current.id])) return
|
||||
if (isStudy) setDraftAnswer(value)
|
||||
else setAnswer(current.id, value)
|
||||
}
|
||||
const submitStudyResponse = () => {
|
||||
if (isStudy && current && !answers[current.id] && draftAnswer.trim()) setAnswer(current.id, draftAnswer)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setResponseStats(null); setStatsError('')
|
||||
if (isStudy && attemptId && current && answers[current.id]) {
|
||||
api.get(`/study-tools/attempts/${attemptId}/questions/${current.id}/responses`)
|
||||
.then(r => { if (active) setResponseStats(r.data) })
|
||||
.catch(() => { if (active) setStatsError('Response statistics are unavailable.') })
|
||||
}
|
||||
return () => { active = false }
|
||||
}, [isStudy, attemptId, current?.id, answers[current?.id]])
|
||||
|
||||
const clearCurrentHighlights = () => {
|
||||
if (!current || !manualHighlights[current.id]) return
|
||||
|
|
@ -803,9 +816,8 @@ const timerStarted = timeLeft !== null
|
|||
showToast('No answers selected — submitting with 0 answered.')
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
}
|
||||
clearInterval(timerRef.current)
|
||||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||||
setSubmitting(true)
|
||||
setSubmitError('')
|
||||
try {
|
||||
const submission = {
|
||||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||||
|
|
@ -813,16 +825,42 @@ const timerStarted = timeLeft !== null
|
|||
})),
|
||||
}
|
||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||
clearInterval(timerRef.current)
|
||||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||||
if (returnTo) {
|
||||
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
|
||||
} else {
|
||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||
}
|
||||
} catch (err) {
|
||||
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
|
||||
const detail = err.response?.data?.detail
|
||||
setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.')
|
||||
} finally { setSubmitting(false) }
|
||||
}, [attemptId, answers, submitting, navigate, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
if (!quizMode || !current) return
|
||||
const keydown = event => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.('input, textarea, select, [contenteditable="true"]') || document.querySelector('dialog[open]')) return
|
||||
if (expandedImagePath) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); setExpandedImagePath('') }
|
||||
return
|
||||
}
|
||||
if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return
|
||||
if (hasActiveTextSelection()) return
|
||||
if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1])
|
||||
else if (event.key === 'Enter' && isStudy) submitStudyResponse()
|
||||
else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1))
|
||||
else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1))
|
||||
else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id)
|
||||
else if (event.key === ' ' && current.image_path) { setImageZoom(1); setExpandedImagePath(current.image_path) }
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('keydown', keydown)
|
||||
return () => window.removeEventListener('keydown', keydown)
|
||||
}, [quizMode, current, currentIdx, answers, draftAnswer, favorites, expandedImagePath])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||
if (!quiz) return null
|
||||
|
||||
|
|
@ -862,8 +900,8 @@ const timerStarted = timeLeft !== null
|
|||
</button>
|
||||
|
||||
{isLast ? (
|
||||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review & Complete
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||
|
|
@ -911,8 +949,17 @@ const timerStarted = timeLeft !== null
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="quiz-bottom">
|
||||
<div className="quiz-bottom quiz-player">
|
||||
<MyNote variant="tab" />
|
||||
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
|
||||
{showReview && <QuizDialog title="Review & Complete" onClose={() => setShowReview(false)}>
|
||||
<p>{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.</p>
|
||||
<div className="quiz-review-grid">{questions.map((question, index) => <button type="button" key={question.id} onClick={() => { safeNavigate(index); setShowReview(false) }}>
|
||||
{index + 1} · {answers[question.id] ? 'Answered' : 'Unanswered'}{favorites.includes(question.id) ? ' · Bookmarked' : ''}
|
||||
</button>)}</div>
|
||||
<button type="button" className="quiz-complete-confirm" disabled={submitting} onClick={() => { setShowReview(false); handleSubmit(false) }}>Complete test</button>
|
||||
</QuizDialog>}
|
||||
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
|
||||
{/* In-app leave confirmation */}
|
||||
{leaveTarget && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
|
|
@ -1016,16 +1063,27 @@ const timerStarted = timeLeft !== null
|
|||
<div className="quiz-layout">
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{quizNavigation('top')}
|
||||
<div className="quiz-topbar">
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ▾</button>
|
||||
<div className="quiz-top-actions">
|
||||
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}>⌨ <span>Shortcuts</span></button>
|
||||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||||
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}>⚗ <span>Lab values</span></button>
|
||||
<button type="button" className="quiz-review-button" onClick={() => setShowReview(true)}>Review & Complete</button>
|
||||
<button type="button" aria-label="Previous question" disabled={currentIdx === 0} onClick={() => safeNavigate(currentIdx - 1)}>‹</button>
|
||||
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
{navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
|
||||
|
||||
{current && (
|
||||
<div className="question-card" style={{
|
||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 style={{ marginBottom: 0, flex: 1 }}>
|
||||
Q{currentIdx + 1}.{' '}
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{current.category_breadcrumbs?.length ? current.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && <span aria-hidden="true"> › </span>}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
<div className="quiz-stem" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 id="quiz-question-heading" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<ManualHighlightText
|
||||
text={questionStem(current)}
|
||||
textId={`${current.id}::question`}
|
||||
|
|
@ -1126,21 +1184,21 @@ const timerStarted = timeLeft !== null
|
|||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||
<div className="options" style={{ marginTop: 8 }}>
|
||||
{current.options.map((opt, i) => {
|
||||
const isSelected = answers[current.id] === opt
|
||||
const isSelected = (answers[current.id] || draftAnswer) === opt
|
||||
const hasAnswered = isStudy && !!answers[current.id]
|
||||
const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
|
||||
const showCorrect = hasAnswered && isCorrectOpt
|
||||
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
const letter = i + 1
|
||||
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
||||
? activeReadSegment.chunkIndex
|
||||
: null
|
||||
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||||
const optionFieldKey = `option-${i}`
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
|
||||
<button type="button" key={i} aria-pressed={isSelected} aria-disabled={hasAnswered}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && !hasActiveTextSelection() && chooseAnswer(opt)}
|
||||
style={{
|
||||
cursor: hasAnswered ? 'default' : 'pointer',
|
||||
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
|
||||
|
|
@ -1156,21 +1214,31 @@ const timerStarted = timeLeft !== null
|
|||
speechRange={optionSpeechRange}
|
||||
onRemoveHighlight={removeJoinedHighlight}
|
||||
/>
|
||||
{responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
|
||||
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>
|
||||
<span>{responseStats.options[i].percentage}%</span><span>{responseStats.options[i].count}/{responseStats.sample_size}</span>
|
||||
</span>}
|
||||
</span>
|
||||
{showCorrect && <span className="option-status option-status-correct">✓ Correct</span>}
|
||||
{showWrong && <span className="option-status option-status-wrong">✗ Wrong</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<input type="text" placeholder="Type your answer..."
|
||||
value={answers[current.id] || ''}
|
||||
onChange={e => setAnswer(current.id, e.target.value)}
|
||||
value={answers[current.id] || draftAnswer}
|
||||
readOnly={isStudy && Boolean(answers[current.id])}
|
||||
onChange={e => chooseAnswer(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && isStudy) { e.preventDefault(); submitStudyResponse() } }}
|
||||
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
)}
|
||||
{isStudy && !answers[current.id] && <button type="button" className="btn btn-primary quiz-submit-response" disabled={!draftAnswer.trim()} onClick={submitStudyResponse}>Submit response</button>}
|
||||
{isStudy && answers[current.id] && (
|
||||
<>
|
||||
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
|
||||
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}</p>}
|
||||
{statsError && <p className="quiz-stats-note">{statsError}</p>}
|
||||
{(current.explanation || current.explanation_image_path) && (
|
||||
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||||
{current.explanation && <><strong>Explanation:</strong> {current.explanation}</>}
|
||||
|
|
@ -1194,17 +1262,10 @@ const timerStarted = timeLeft !== null
|
|||
|
||||
{quizNavigation('bottom')}
|
||||
|
||||
{/* Mobile: collapsible number grid */}
|
||||
{navOpen && (
|
||||
<div className="quiz-nav-mobile-grid">
|
||||
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answeredCount > 0 && !isLast && (
|
||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
Submit now ({answeredCount}/{totalCount} answered)
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review ({answeredCount}/{totalCount} answered)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1225,8 +1286,8 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
{answeredCount > 0 && !isLast && (
|
||||
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
|
||||
onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review & Complete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
128
frontend/src/pages/QuizPage.test.jsx
Normal file
128
frontend/src/pages/QuizPage.test.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import QuizPage from './QuizPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
|
||||
vi.mock('../components/MyNote', () => ({ default: () => null }))
|
||||
vi.mock('../components/TeachChat', () => ({ default: () => <div>Study tutor</div> }))
|
||||
|
||||
const questions = [
|
||||
{ id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] },
|
||||
{ id: 2, question_text: 'Full second clinical question.', question_type: 'mcq', options: ['Third answer', 'Fourth answer'], category_breadcrumbs: [] },
|
||||
]
|
||||
let mode
|
||||
let showModal
|
||||
let close
|
||||
beforeAll(() => {
|
||||
showModal = HTMLDialogElement.prototype.showModal
|
||||
close = HTMLDialogElement.prototype.close
|
||||
HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', '') }
|
||||
HTMLDialogElement.prototype.close = function () { this.removeAttribute('open') }
|
||||
})
|
||||
afterAll(() => { HTMLDialogElement.prototype.showModal = showModal; HTMLDialogElement.prototype.close = close })
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
mode = 'exam'
|
||||
api.get.mockImplementation(url => {
|
||||
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
|
||||
id: 10, title: 'Personal test', mode: 'timed', questions_count: 2, user_id: 1, time_limit_minutes: null,
|
||||
attempt_mode: url.includes('attempt_id=') ? mode : null,
|
||||
questions: questions.map(q => url.includes('attempt_id=') && mode === 'study' ? { ...q, correct_answer: q.options[0], explanation: 'Full explanation, preserved without shortening.' } : q),
|
||||
} })
|
||||
if (url === '/attempts/progress') return Promise.resolve({ data: null })
|
||||
if (url.startsWith('/study-tools/attempts/')) return Promise.resolve({ data: { sample_size: 10, options: [{ count: 8, percentage: 80 }, { count: 2, percentage: 20 }], basis: 'Recorded answers.' } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
api.post.mockImplementation(url => {
|
||||
if (url.startsWith('/attempts/start')) { mode = url.includes('mode=study') ? 'study' : 'exam'; return Promise.resolve({ data: { id: 50, mode } }) }
|
||||
return Promise.resolve({ data: { id: 50, score: 1, total_questions: 2 } })
|
||||
})
|
||||
api.delete.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function mount() {
|
||||
render(<MemoryRouter initialEntries={['/quizzes/10']}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
|
||||
}
|
||||
async function begin(study = true) {
|
||||
mount()
|
||||
await userEvent.click(await screen.findByRole('button', { name: study ? /Study Mode/ : /Exam Mode/ }))
|
||||
await screen.findByText('Full first clinical question.')
|
||||
}
|
||||
|
||||
describe('quiz player', () => {
|
||||
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => {
|
||||
await begin()
|
||||
expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/quizzes/create?category=11')
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
|
||||
expect(await screen.findByText('8/10')).toBeInTheDocument()
|
||||
expect(screen.getByText('80%')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'b' })
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/favorites', { question_id: 1 }))
|
||||
})
|
||||
|
||||
it('offers a functional calculator without hijacking input shortcuts', async () => {
|
||||
await begin()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Calculator/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Calculator' })
|
||||
const input = within(dialog).getByLabelText('Calculation')
|
||||
await userEvent.type(input, '(2+3)*4')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: '=', exact: true }))
|
||||
expect(input).toHaveValue('20')
|
||||
expect(api.post.mock.calls.some(([url]) => url === '/favorites')).toBe(false)
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Close Calculator' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Submit response' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps exam answers hidden and reviews unanswered questions before completing', async () => {
|
||||
await begin(false)
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
|
||||
expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/attempts/'))).toBe(false)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
|
||||
const review = screen.getByRole('dialog', { name: 'Review & Complete' })
|
||||
expect(within(review).getByText(/1 of 2 questions answered/)).toBeInTheDocument()
|
||||
await userEvent.click(within(review).getByRole('button', { name: '2 · Unanswered' }))
|
||||
await screen.findByText('Full second clinical question.')
|
||||
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' }))
|
||||
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'First answer' }] })
|
||||
})
|
||||
|
||||
it('rejects an invalid timer before creating an attempt', async () => {
|
||||
mount()
|
||||
await screen.findByRole('button', { name: /Exam Mode/ })
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-1' } })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Exam Mode/ }))
|
||||
expect(await screen.findByText(/Enter a positive whole number of minutes/)).toBeInTheDocument()
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains answers and saved progress when submission fails', async () => {
|
||||
await begin(false)
|
||||
fireEvent.keyDown(window, { key: '2' })
|
||||
const originalPost = api.post.getMockImplementation()
|
||||
let failed = false
|
||||
api.post.mockImplementation((url, ...args) => {
|
||||
if (url === '/attempts/50/submit' && !failed) { failed = true; return Promise.reject({ response: { data: { detail: 'Try again safely' } } }) }
|
||||
return originalPost(url, ...args)
|
||||
})
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Try again safely')
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry submission' }))
|
||||
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenLastCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'Second answer' }] })
|
||||
})
|
||||
})
|
||||
73
frontend/src/pages/QuizPlayer.css
Normal file
73
frontend/src/pages/QuizPlayer.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
.container:has(> .quiz-player), .container:has(> .quiz-results) { max-width: 1500px; }
|
||||
.quiz-player, .quiz-results { --primary: #496fa5; --primary-hover: #365b8d; --border: #e8eaf1; --card-shadow: none; --card-radius: 0; --text: #333; --text-muted: #737982; --correct-fg: #327b64; --correct-bg: #e6edf8; --wrong-fg: #a13c51; background: #fff; padding: 24px 32px 40px; min-width: 0; }
|
||||
.quiz-player .quiz-header-card { padding: 0 0 18px; border: 0; border-bottom: 1px solid var(--border); box-shadow: none; }
|
||||
.quiz-player .quiz-header-title { font-size: .9rem; font-weight: 500; color: #737982; }
|
||||
.quiz-player .quiz-code-badge { font-size: .75rem; }
|
||||
.quiz-player .quiz-code-badge code { background: transparent; border: 0; }
|
||||
.quiz-player .quiz-layout { display: block; }
|
||||
.quiz-player .quiz-sidebar { display: none; }
|
||||
.quiz-player .quiz-nav-toggle { display: inline-flex; }
|
||||
.quiz-player .progress-bar { height: 3px; }
|
||||
.quiz-topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 10px 0 20px; margin-bottom: 0; border-bottom: 1px solid var(--border); }
|
||||
.quiz-topbar button { border: 1px solid transparent; padding: 10px 13px; background: #f8f9fb; color: #333; cursor: pointer; font: inherit; border-radius: 2px; }
|
||||
.quiz-topbar button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.quiz-topbar .quiz-question-select { padding: 0; background: transparent; text-align: left; white-space: nowrap; font-size: 1.15rem; color: #898d92; }
|
||||
.quiz-question-select small { display: block; font-size: .8rem; margin-bottom: 3px; color: #858b94; }
|
||||
.quiz-question-select strong { color: #30343a; font-weight: 650; }
|
||||
.quiz-top-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.quiz-top-actions button { font-size: .85rem; min-height: 42px; }
|
||||
.quiz-top-actions button[title] { background: transparent; }
|
||||
.quiz-top-actions .quiz-review-button { background: #496fa5; color: white; font-weight: 650; text-transform: uppercase; padding: 12px 18px; margin-left: 12px; }
|
||||
.quiz-player button:focus-visible, .quiz-player a:focus-visible, .quiz-results a:focus-visible { outline: 3px solid #779ad1; outline-offset: 3px; }
|
||||
.quiz-player .question-card { border: 0; padding: 0; border-radius: 0; box-shadow: none; margin-bottom: 22px; }
|
||||
.quiz-breadcrumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 78px; padding: 20px 0; font-size: .95rem; color: #737982; }
|
||||
.quiz-breadcrumbs a { color: #416fa7; text-decoration: none; }
|
||||
.quiz-breadcrumbs a:hover { text-decoration: underline; }
|
||||
.quiz-player .quiz-stem { padding: 12px 0 24px; }
|
||||
.quiz-player .question-card h3 { font-size: 1.08rem; line-height: 1.65; font-weight: 400; font-family: inherit; }
|
||||
.quiz-player .question-card .options { gap: 0; margin-top: 24px !important; padding: 18px 0 8px; border-top: 1px solid var(--border); }
|
||||
.quiz-player .question-card .option { padding: 15px 12px; border: 0; border-radius: 0; gap: 18px; font: inherit; font-size: 1rem; text-align: left; background: white; }
|
||||
.quiz-player .question-card .option:hover { background: #f4f6fb; }
|
||||
.quiz-player .question-card .option.selected { background: #e5ecf8; }
|
||||
.quiz-player .question-card .option.correct { color: #327b64; background: #eef7f3; }
|
||||
.quiz-player .question-card .option.correct.selected { background: #e5ecf8; }
|
||||
.quiz-player .question-card .option.incorrect { color: #a13c51; background: #fbecf0; }
|
||||
.quiz-player .option-letter { width: 35px; height: 35px; font-size: .86rem; border: 1px solid #cdd0d5; background: #fff; color: #757a82; font-weight: 600; }
|
||||
.quiz-player .option.selected .option-letter { border-color: #496fa5; background: #496fa5; color: white; }
|
||||
.quiz-player .option.correct:not(.selected) .option-letter { color: #327b64; border-color: #77af99; }
|
||||
.quiz-player .option.incorrect .option-letter { background: #a13c51; border-color: #a13c51; color: white; }
|
||||
.quiz-player .option-text { flex: 1; }
|
||||
.quiz-response-stat { display: flex; align-items: center; gap: 16px; color: #777d88; font-size: .78rem; font-weight: 400; margin-top: 6px; }
|
||||
.quiz-response-track { display: block; width: min(36vw, 340px); height: 8px; background: #edeef4; flex-shrink: 1; }
|
||||
.quiz-response-track > span { display: block; height: 100%; background: #444; }
|
||||
.option.correct .quiz-response-track > span { background: #71b298; }
|
||||
.quiz-review-tabs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-bottom: 1px solid var(--border); padding: 10px 0; margin-top: 18px; }
|
||||
.quiz-review-tabs > span:first-child { background: #333; color: white; padding: 11px 24px; font-size: .85rem; font-weight: 600; }
|
||||
.quiz-review-tabs .quiz-source-page { color: #6f7886; padding: 10px; font-size: .84rem; }
|
||||
.quiz-player .explanation { border: 0; border-radius: 0; background: transparent; padding: 16px 0; font-size: 1rem; line-height: 1.75; color: #383d43; }
|
||||
.quiz-player .quiz-stats-note { color: #7a818c; font-size: .74rem; margin: 10px 0; }
|
||||
.quiz-submit-response { margin: 16px 0; }
|
||||
.quiz-submit-error { padding: 14px; color: #962b3e; background: #fff0f2; margin-bottom: 14px; }
|
||||
.quiz-review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin: 20px 0; }
|
||||
.quiz-tool-body .quiz-complete-confirm { background: #496fa5; color: white; padding: 12px 20px; }
|
||||
.quiz-player .quiz-nav-mobile-grid { box-shadow: none; border-radius: 0; margin-bottom: 16px; }
|
||||
.quiz-player .manual-highlight-toolbar { position: static; width: auto; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
||||
.quiz-results .review-card { box-shadow: none; border: 0; border-bottom: 1px solid #e8eaf1; padding: 20px 0 32px; border-radius: 0; }
|
||||
.quiz-results .score-display { padding: 20px 10px; }
|
||||
.quiz-results .card { box-shadow: none; border-radius: 0; }
|
||||
@media (max-width: 800px) {
|
||||
.quiz-player, .quiz-results { padding: 16px; }
|
||||
.quiz-topbar { align-items: flex-start; gap: 10px; }
|
||||
.quiz-top-actions { gap: 5px; }
|
||||
.quiz-top-actions button { padding: 8px; min-height: 40px; }
|
||||
.quiz-top-actions button[title] span { display: none; }
|
||||
.quiz-top-actions button[aria-label="Next question"] { font-size: 0; }
|
||||
.quiz-top-actions button[aria-label="Next question"]::after { content: '›'; font-size: 1.25rem; }
|
||||
.quiz-top-actions .quiz-review-button { padding: 9px; margin-left: 0; font-size: .73rem; }
|
||||
.quiz-breadcrumbs { min-height: 60px; font-size: .86rem; }
|
||||
.quiz-player .question-card .option { gap: 10px; padding: 13px 8px; }
|
||||
.quiz-player .option-status { margin-left: 45px; }
|
||||
.quiz-response-stat { flex-wrap: wrap; gap: 8px; }
|
||||
.quiz-response-track { width: min(48vw, 240px); }
|
||||
}
|
||||
@media (max-width: 430px) { .quiz-topbar { flex-wrap: wrap; } .quiz-top-actions { justify-content: flex-start; width: 100%; } }
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import QuizTools from '../components/QuizTools'
|
||||
import './QuizPlayer.css'
|
||||
|
||||
export default function ResultsPage() {
|
||||
const { id } = useParams()
|
||||
|
|
@ -12,6 +14,9 @@ export default function ResultsPage() {
|
|||
const [loading, setLoading] = useState(!result)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [tool, setTool] = useState(null)
|
||||
const [responseStats, setResponseStats] = useState(null)
|
||||
|
||||
const isCourseQuiz = result?.course_id != null
|
||||
const reviewAllowed = result?.allow_review !== false
|
||||
|
|
@ -37,6 +42,15 @@ export default function ResultsPage() {
|
|||
}
|
||||
}, [id])
|
||||
|
||||
const reviewQuestion = result?.answers?.[reviewIndex]
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setResponseStats(null)
|
||||
if (reviewQuestion && result?.completed_at && reviewAllowed) api.get(`/study-tools/attempts/${result.id}/questions/${reviewQuestion.question_id}/responses`)
|
||||
.then(r => { if (active) setResponseStats(r.data) }).catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [result?.id, reviewQuestion?.question_id, reviewAllowed])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
|
||||
if (!result) return null
|
||||
|
||||
|
|
@ -46,7 +60,8 @@ export default function ResultsPage() {
|
|||
const total = result.total_questions
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto' }}>
|
||||
<div className="quiz-results">
|
||||
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
|
||||
{/* Score card */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="score-display">
|
||||
|
|
@ -131,12 +146,19 @@ export default function ResultsPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
||||
<div className="quiz-topbar">
|
||||
<label>Question Review<select aria-label="Review question" value={reviewIndex} onChange={e => setReviewIndex(Number(e.target.value))}>{result.answers.map((answer, index) => <option key={answer.question_id} value={index}>{index + 1} of {result.answers.length} · {answer.is_correct ? 'Correct' : answer.user_answer ? 'Incorrect' : 'Skipped'}</option>)}</select></label>
|
||||
<div className="quiz-top-actions"><button type="button" onClick={() => setTool('calculator')}>Calculator</button><button type="button" onClick={() => setTool('labs')}>Lab values</button>
|
||||
<button type="button" disabled={reviewIndex === 0} onClick={() => setReviewIndex(value => value - 1)}>‹ Previous</button><button type="button" disabled={reviewIndex === result.answers.length - 1} onClick={() => setReviewIndex(value => value + 1)}>Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.answers.map((ans, idx) => {
|
||||
{result.answers.slice(reviewIndex, reviewIndex + 1).map(ans => {
|
||||
const idx = reviewIndex
|
||||
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
||||
return (
|
||||
<div className={`review-card ${cardClass}`} key={idx}>
|
||||
<div className={`review-card ${cardClass}`} key={ans.question_id}>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' › '}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
{/* Question header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
|
@ -156,14 +178,15 @@ export default function ResultsPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Options with letter badges */}
|
||||
{ans.image_path && <img src={`/uploads/${ans.image_path}`} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
|
||||
{/* Numbered answers */}
|
||||
{ans.options && ans.options.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{ans.options.map((opt, i) => {
|
||||
const isCorrect = opt === ans.correct_answer
|
||||
const isUser = opt === ans.user_answer
|
||||
const isWrong = isUser && !isCorrect
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
const letter = i + 1
|
||||
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
|
||||
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
|
||||
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
|
||||
|
|
@ -176,7 +199,9 @@ export default function ResultsPage() {
|
|||
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
|
||||
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
|
||||
}}>{letter}</span>
|
||||
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
|
||||
<span style={{ flex: 1, fontSize: '1rem' }}>{opt}
|
||||
{responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat"><span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%`, background: isCorrect ? '#71b298' : '#444' }} /></span><span>{responseStats.options[i].percentage}%</span><span>{responseStats.options[i].count}/{responseStats.sample_size}</span></span>}
|
||||
</span>
|
||||
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Correct answer</span>}
|
||||
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Your answer</span>}
|
||||
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✗ Your answer</span>}
|
||||
|
|
@ -200,6 +225,8 @@ export default function ResultsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="quiz-review-tabs"><span>Preferred response</span>{ans.page_reference && <span className="quiz-source-page">Source page {ans.page_reference}</span>}</div>
|
||||
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}</p>}
|
||||
{/* Explanation */}
|
||||
{(ans.explanation || ans.explanation_image_path) && (
|
||||
<div className="explanation">
|
||||
|
|
|
|||
39
frontend/src/utils/calculator.js
Normal file
39
frontend/src/utils/calculator.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Arithmetic only: no eval, identifiers, calls or property access.
|
||||
export function calculate(expression) {
|
||||
const source = expression.replace(/\s/g, '').replaceAll('×', '*').replaceAll('÷', '/')
|
||||
if (!source || source.length > 300) throw new Error('Enter an arithmetic expression (up to 300 characters).')
|
||||
const tokens = source.match(/(?:\d+(?:\.\d*)?|\.\d+)|[()+\-*/^]/g) || []
|
||||
if (tokens.join('') !== source) throw new Error('Use numbers, parentheses and arithmetic operators only.')
|
||||
let index = 0
|
||||
const precedence = { '+': 1, '-': 1, '*': 2, '/': 2, '^': 4 }
|
||||
function parse(minimum = 0) {
|
||||
const token = tokens[index++]
|
||||
let value
|
||||
if (token === '+' || token === '-') value = (token === '-' ? -1 : 1) * parse(3)
|
||||
else if (token === '(') {
|
||||
value = parse()
|
||||
if (tokens[index++] !== ')') throw new Error('Check the parentheses.')
|
||||
} else {
|
||||
if (token === undefined || !/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(token)) throw new Error('Expected a number.')
|
||||
value = Number(token)
|
||||
}
|
||||
while (precedence[tokens[index]] >= minimum) {
|
||||
const operator = tokens[index++]
|
||||
const right = parse(precedence[operator] + (operator === '^' ? 0 : 1))
|
||||
if (operator === '+') value += right
|
||||
if (operator === '-') value -= right
|
||||
if (operator === '*') value *= right
|
||||
if (operator === '/') {
|
||||
if (right === 0) throw new Error('Cannot divide by zero.')
|
||||
value /= right
|
||||
}
|
||||
if (operator === '^') value **= right
|
||||
if (!Number.isFinite(value)) throw new Error('Result is outside the supported range.')
|
||||
}
|
||||
if (!Number.isFinite(value)) throw new Error('Result is outside the supported range.')
|
||||
return value
|
||||
}
|
||||
const result = parse()
|
||||
if (index !== tokens.length) throw new Error('Check the expression.')
|
||||
return Object.is(result, -0) ? 0 : result
|
||||
}
|
||||
14
frontend/src/utils/calculator.test.js
Normal file
14
frontend/src/utils/calculator.test.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { calculate } from './calculator'
|
||||
|
||||
describe('study calculator', () => {
|
||||
it.each([
|
||||
['1 + 2 * 3', 7], ['(1 + 2) * 3', 9], ['12 ÷ 4 × 2', 6],
|
||||
['2^3^2', 512], ['-2^2', -4], ['(-2)^2', 4], ['2^-2', 0.25],
|
||||
['.5 + 1.5', 2], ['1--2', 3], ['-(2+3)', -5], ['0/2', 0],
|
||||
])('evaluates %s', (expression, result) => expect(calculate(expression)).toBe(result))
|
||||
|
||||
it.each(['', '1/0', '(1+2', '1+2)', '2(3)', '1e3', '1..2', '1+', '2**3', '2^9999', 'Math.random()', 'globalThis', 'alert(1)', '1;2', '1'.repeat(301)])(
|
||||
'rejects invalid or unsafe input %s', expression => expect(() => calculate(expression)).toThrow(),
|
||||
)
|
||||
})
|
||||
Loading…
Reference in a new issue