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:
Daniel 2026-09-07 03:10:23 +02:00
parent 38f3fb8250
commit a3a6ef7995
24 changed files with 977 additions and 93 deletions

View 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")

View file

@ -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")

View file

@ -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)

View 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)

View file

@ -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

View file

@ -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

View 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."}

View file

@ -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

View file

@ -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

View 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()

View 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()

View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View 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); } }

View 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', '19'], ['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>
}

View file

@ -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')

View file

@ -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>

View 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' }] })
})
})

View 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%; } }

View file

@ -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">

View 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
}

View 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(),
)
})