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) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal 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 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.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler 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(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"]) app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) 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") @app.get("/api/health")

View file

@ -12,6 +12,7 @@ class QuizAttempt(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.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) score = Column(Integer, default=0)
total_questions = Column(Integer, default=0) total_questions = Column(Integer, default=0)
started_at = Column(DateTime, default=datetime.utcnow) 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 import logging
from datetime import datetime from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@ -11,6 +12,8 @@ from sqlalchemy import func
from app.database import get_db from app.database import get_db
from app.models.quiz import Quiz from app.models.quiz import Quiz
from app.models.question import Question 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.attempt import QuizAttempt, AttemptAnswer
from app.models.pdf_document import PDFDocument from app.models.pdf_document import PDFDocument
from app.models.user import User from app.models.user import User
@ -33,6 +36,7 @@ router = APIRouter()
def start_attempt( def start_attempt(
quiz_id: int, quiz_id: int,
fresh: bool = False, fresh: bool = False,
mode: Literal["study", "exam"] | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -42,6 +46,10 @@ def start_attempt(
if not can_access_quiz(db, quiz, current_user): if not can_access_quiz(db, quiz, current_user):
raise HTTPException(status_code=403, detail="This quiz is private") 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 # Enforce max_attempts
if quiz.max_attempts: if quiz.max_attempts:
completed_count = db.query(QuizAttempt).filter( completed_count = db.query(QuizAttempt).filter(
@ -68,6 +76,7 @@ def start_attempt(
percentage=0.0, percentage=0.0,
started_at=existing.started_at, started_at=existing.started_at,
completed_at=None, completed_at=None,
mode=existing.mode or "exam",
) )
# Question pool: randomly select N questions if questions_per_attempt is set # Question pool: randomly select N questions if questions_per_attempt is set
@ -84,6 +93,7 @@ def start_attempt(
user_id=current_user.id, user_id=current_user.id,
total_questions=total_q, total_questions=total_q,
selected_question_ids=selected_ids, selected_question_ids=selected_ids,
mode=chosen_mode,
) )
db.add(attempt) db.add(attempt)
db.commit() db.commit()
@ -96,6 +106,7 @@ def start_attempt(
percentage=0.0, percentage=0.0,
started_at=attempt.started_at, started_at=attempt.started_at,
completed_at=None, completed_at=None,
mode=attempt.mode,
) )
@ -118,6 +129,7 @@ def submit_attempt(
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
require_quiz_access(db, quiz, current_user) require_quiz_access(db, quiz, current_user)
categories = db.query(QuestionCategory).all()
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), 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) [(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
score = sum(correct for _, _, correct in grades) score = sum(correct for _, _, correct in grades)
@ -142,6 +154,9 @@ def submit_attempt(
is_correct=is_correct, is_correct=is_correct,
explanation=q.explanation, explanation=q.explanation,
explanation_image_path=q.explanation_image_path, 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 attempt.score = score
@ -258,7 +273,7 @@ def save_progress(
"attempt_id": data.attempt_id, "attempt_id": data.attempt_id,
"answers": data.answers, "answers": data.answers,
"current_idx": data.current_idx, "current_idx": data.current_idx,
"mode": data.mode, "mode": attempt.mode or "exam",
"voice": data.voice, "voice": data.voice,
"time_left": data.time_left, "time_left": data.time_left,
"started_at": data.started_at, "started_at": data.started_at,
@ -311,6 +326,7 @@ def get_progress(
return None return None
saved = _json.loads(data) saved = _json.loads(data)
saved["mode"] = attempt.mode or "exam"
# If the quiz was suspended, timer is paused — re-anchor on resume so # If the quiz was suspended, timer is paused — re-anchor on resume so
# the held time_left becomes the new total_time starting now. # 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 is_course_quiz = quiz and quiz.course_id is not None
review_allowed = not is_course_quiz or (quiz.allow_review == 1) 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. # Never reveal review content for an unfinished attempt or an unselected pool question.
review_allowed = review_allowed and attempt.completed_at is not None review_allowed = review_allowed and attempt.completed_at is not None
submitted_map = {ans.question_id: ans for ans in attempt.answers} 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, is_correct=ans.is_correct if ans else False,
explanation=q.explanation, explanation=q.explanation,
explanation_image_path=q.explanation_image_path, 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 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.section import Section
from app.models.attempt import QuizAttempt from app.models.attempt import QuizAttempt
from app.models.user import User 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.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service from app.services import quiz_service
from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared 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), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Get quiz for taking. study=true forces answers/explanations to be included. """Return selected questions; only an authorized study/completed attempt can reveal answers."""
attempt_id filters to the question subset selected for that attempt (question pool)."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz: if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found") raise HTTPException(status_code=404, detail="Quiz not found")
if not can_access_quiz(db, quiz, current_user): if not can_access_quiz(db, quiz, current_user):
raise HTTPException(status_code=403, detail="This quiz is private") raise HTTPException(status_code=403, detail="This quiz is private")
if study or quiz.mode == "learning": attempt = None
if quiz.mode != "learning": if attempt_id is not None:
require_quiz_access(db, quiz, current_user, review=True) attempt = db.query(QuizAttempt).filter_by(id=attempt_id, quiz_id=quiz.id, user_id=current_user.id).first()
result = QuizLearningDetail.model_validate(quiz) if not attempt:
else: raise HTTPException(404, "Attempt not found for this quiz")
result = QuizDetail.model_validate(quiz) reveal = attempt is not None and (attempt.mode == "study" or attempt.completed_at is not None)
if study and not reveal:
# Filter questions if attempt has a question pool selection raise HTTPException(403, "Answers are hidden until the exam is submitted")
if attempt_id: if reveal:
from app.models.attempt import QuizAttempt require_quiz_access(db, quiz, current_user, review=True)
attempt = db.query(QuizAttempt).filter( result = (QuizLearningDetail if reveal else QuizDetail).model_validate(quiz)
QuizAttempt.id == attempt_id, result.attempt_mode = (attempt.mode or "exam") if attempt else None
QuizAttempt.user_id == current_user.id, if attempt and attempt.selected_question_ids is not None:
).first() selected = set(attempt.selected_question_ids)
if attempt and attempt.selected_question_ids: result.questions = [q for q in result.questions if q.id in selected]
sel = set(attempt.selected_question_ids) result.questions_count = len(result.questions)
result.questions = [q for q in result.questions if q.id in sel] categories = db.query(QuestionCategory).all()
result.questions_count = len(result.questions) for question in result.questions:
question.category_breadcrumbs = category_breadcrumbs(categories, question.question_category_id)
return result 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 datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, Field
class AnswerSubmission(BaseModel): class AnswerSubmission(BaseModel):
@ -22,6 +22,9 @@ class AnswerDetail(BaseModel):
is_correct: bool is_correct: bool
explanation: str | None explanation: str | None
explanation_image_path: str | None = 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: class Config:
from_attributes = True from_attributes = True
@ -35,6 +38,7 @@ class AttemptResponse(BaseModel):
percentage: float percentage: float
started_at: datetime started_at: datetime
completed_at: datetime | None completed_at: datetime | None
mode: str | None = None
course_id: int | None = None course_id: int | None = None
allow_review: bool = True allow_review: bool = True

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, Field
class QuizCreate(BaseModel): class QuizCreate(BaseModel):
@ -30,6 +30,8 @@ class QuestionResponse(BaseModel):
question_type: str question_type: str
options: list[str] | None options: list[str] | None
image_path: str | None = None image_path: str | None = None
question_category_id: int | None = None
category_breadcrumbs: list[dict] = Field(default_factory=list)
class Config: class Config:
from_attributes = True from_attributes = True
@ -57,6 +59,10 @@ class QuizResponse(BaseModel):
is_published: int = 1 is_published: int = 1
is_shared: int = 0 is_shared: int = 0
questions_per_attempt: int | None = None 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: class Config:
from_attributes = True 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. - 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. - Alembic offline SQL: explicit `5f8c1c2a9d40:c82d19e4a601` upgrade and reverse downgrade exercised without mocking inspection.
- Changed Python syntax and `git diff --check` passed. - 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. 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. 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 ## 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. 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 { 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 api from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import './CustomQuizPage.css' import './CustomQuizPage.css'
@ -7,8 +7,9 @@ import './CustomQuizPage.css'
export default function CustomQuizPage() { export default function CustomQuizPage() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [categories, setCategories] = useState([]) 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 [state, setState] = useState('all')
const [shared, setShared] = useState(false) const [shared, setShared] = useState(false)
const [title, setTitle] = useState('My Custom Test') 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 { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import MyNote from '../components/MyNote' import MyNote from '../components/MyNote'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
const TeachChat = lazy(() => import('../components/TeachChat')) const TeachChat = lazy(() => import('../components/TeachChat'))
@ -280,7 +282,7 @@ function QuizCodeBadge({ code }) {
} }
function CourseQuizStart({ quiz, onStart }) { 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 [error, setError] = useState('')
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const begin = async () => { const begin = async () => {
@ -327,7 +329,11 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
const [startingMode, setStartingMode] = useState('') const [startingMode, setStartingMode] = useState('')
const handleStart = async (mode) => { 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('') setStartError('')
setStartingMode(mode) setStartingMode(mode)
try { 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: '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' }, { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => ( ].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 }} 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)'} onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'} 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={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div> <div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div> <div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
</div> </button>
))} ))}
</div> </div>
{startError && ( {startError && (
@ -441,6 +447,12 @@ export default function QuizPage() {
const [favorites, setFavorites] = useState([]) const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null) const [activeReadSegment, setActiveReadSegment] = useState(null)
const [manualHighlights, setManualHighlights] = useState({}) 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 timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
@ -559,6 +571,7 @@ export default function QuizPage() {
useEffect(() => { useEffect(() => {
setActiveReadSegment(null) setActiveReadSegment(null)
setTtsActive(false) setTtsActive(false)
setDraftAnswer('')
savedHighlightSelectionRef.current = null savedHighlightSelectionRef.current = null
clearTimeout(autoHighlightTimerRef.current) clearTimeout(autoHighlightTimerRef.current)
if (!readThrough) setActiveReadSegment(null) if (!readThrough) setActiveReadSegment(null)
@ -575,33 +588,13 @@ export default function QuizPage() {
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio]) }, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
const resumeQuiz = useCallback(async (saved, availableVoices = []) => { 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 savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {} const savedAnswers = saved.answers || {}
const aid = saved.attempt_id || saved.attemptId
const aid = saved.attempt_id || saved.attemptId || '' if (!aid) return
// For feedback modes, load quiz with correct answers BEFORE showing. const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}`)
if (mode === 'study') { const mode = quizRes.data.attempt_mode === 'study' ? 'study' : 'exam'
try { setQuiz(quizRes.data)
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
}
}
hasStarted.current = true hasStarted.current = true
setQuizMode(mode) setQuizMode(mode)
@ -671,7 +664,8 @@ export default function QuizPage() {
try { try {
// Start attempt first (may select random question subset) // 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) setAttemptId(attemptRes.data.id)
const aid = attemptRes.data.id const aid = attemptRes.data.id
@ -760,6 +754,25 @@ const timerStarted = timeLeft !== null
}, [attemptId, quizMode, saveProgressNow]) }, [attemptId, quizMode, saveProgressNow])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) 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 = () => { const clearCurrentHighlights = () => {
if (!current || !manualHighlights[current.id]) return if (!current || !manualHighlights[current.id]) return
@ -803,9 +816,8 @@ const timerStarted = timeLeft !== null
showToast('No answers selected — submitting with 0 answered.') showToast('No answers selected — submitting with 0 answered.')
await new Promise(r => setTimeout(r, 1200)) await new Promise(r => setTimeout(r, 1200))
} }
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
setSubmitting(true) setSubmitting(true)
setSubmitError('')
try { try {
const submission = { const submission = {
answers: Object.entries(answers).map(([qid, answer]) => ({ answers: Object.entries(answers).map(([qid, answer]) => ({
@ -813,16 +825,42 @@ const timerStarted = timeLeft !== null
})), })),
} }
const res = await api.post(`/attempts/${attemptId}/submit`, submission) const res = await api.post(`/attempts/${attemptId}/submit`, submission)
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
if (returnTo) { if (returnTo) {
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } }) navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
} else { } else {
navigate(`/results/${attemptId}`, { state: { result: res.data } }) navigate(`/results/${attemptId}`, { state: { result: res.data } })
} }
} catch (err) { } 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) } } finally { setSubmitting(false) }
}, [attemptId, answers, submitting, navigate, showToast]) }, [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 (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null if (!quiz) return null
@ -862,8 +900,8 @@ const timerStarted = timeLeft !== null
</button> </button>
{isLast ? ( {isLast ? (
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}> <button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'} Review & Complete
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </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 ( return (
<div className="quiz-bottom"> <div className="quiz-bottom quiz-player">
<MyNote variant="tab" /> <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 */} {/* In-app leave confirmation */}
{leaveTarget && ( {leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}> <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"> <div className="quiz-layout">
{/* Main content */} {/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}> <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 && ( {current && (
<div className="question-card" style={{ <div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined, boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
borderColor: activeReadForCurrent ? '#60a5fa' : undefined, borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}> <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>
<h3 style={{ marginBottom: 0, flex: 1 }}> <div className="quiz-stem" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
Q{currentIdx + 1}.{' '} <h3 id="quiz-question-heading" style={{ marginBottom: 0, flex: 1 }}>
<ManualHighlightText <ManualHighlightText
text={questionStem(current)} text={questionStem(current)}
textId={`${current.id}::question`} textId={`${current.id}::question`}
@ -1126,21 +1184,21 @@ const timerStarted = timeLeft !== null
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? ( {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 8 }}> <div className="options" style={{ marginTop: 8 }}>
{current.options.map((opt, i) => { {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 hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase() const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
const showCorrect = hasAnswered && isCorrectOpt const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !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 const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
? activeReadSegment.chunkIndex ? activeReadSegment.chunkIndex
: null : null
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk) const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
const optionFieldKey = `option-${i}` const optionFieldKey = `option-${i}`
return ( return (
<div key={i} <button type="button" key={i} aria-pressed={isSelected} aria-disabled={hasAnswered}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`} className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)} onClick={() => !hasAnswered && !hasActiveTextSelection() && chooseAnswer(opt)}
style={{ style={{
cursor: hasAnswered ? 'default' : 'pointer', cursor: hasAnswered ? 'default' : 'pointer',
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined, borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
@ -1156,21 +1214,31 @@ const timerStarted = timeLeft !== null
speechRange={optionSpeechRange} speechRange={optionSpeechRange}
onRemoveHighlight={removeJoinedHighlight} 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> </span>
{showCorrect && <span className="option-status option-status-correct"> Correct</span>} {showCorrect && <span className="option-status option-status-correct"> Correct</span>}
{showWrong && <span className="option-status option-status-wrong"> Wrong</span>} {showWrong && <span className="option-status option-status-wrong"> Wrong</span>}
</div> </button>
) )
})} })}
</div> </div>
) : ( ) : (
<input type="text" placeholder="Type your answer..." <input type="text" placeholder="Type your answer..."
value={answers[current.id] || ''} value={answers[current.id] || draftAnswer}
onChange={e => setAnswer(current.id, e.target.value)} 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)' }} /> 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] && ( {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) && ( {(current.explanation || current.explanation_image_path) && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}> <div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
{current.explanation && <><strong>Explanation:</strong> {current.explanation}</>} {current.explanation && <><strong>Explanation:</strong> {current.explanation}</>}
@ -1194,17 +1262,10 @@ const timerStarted = timeLeft !== null
{quizNavigation('bottom')} {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 && ( {answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 14 }}> <div style={{ textAlign: 'center', marginTop: 14 }}>
<button className="btn btn-secondary btn-sm" onClick={() => handleSubmit(false)} disabled={submitting}> <button className="btn btn-secondary btn-sm" onClick={() => setShowReview(true)} disabled={submitting}>
Submit now ({answeredCount}/{totalCount} answered) Review ({answeredCount}/{totalCount} answered)
</button> </button>
</div> </div>
)} )}
@ -1225,8 +1286,8 @@ const timerStarted = timeLeft !== null
</div> </div>
{answeredCount > 0 && !isLast && ( {answeredCount > 0 && !isLast && (
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }} <button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
onClick={() => handleSubmit(false)} disabled={submitting}> onClick={() => setShowReview(true)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'} Review & Complete
</button> </button>
)} )}
</div> </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 { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom' import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import QuizTools from '../components/QuizTools'
import './QuizPlayer.css'
export default function ResultsPage() { export default function ResultsPage() {
const { id } = useParams() const { id } = useParams()
@ -12,6 +14,9 @@ export default function ResultsPage() {
const [loading, setLoading] = useState(!result) const [loading, setLoading] = useState(!result)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = 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 isCourseQuiz = result?.course_id != null
const reviewAllowed = result?.allow_review !== false const reviewAllowed = result?.allow_review !== false
@ -37,6 +42,15 @@ export default function ResultsPage() {
} }
}, [id]) }, [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 (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
if (!result) return null if (!result) return null
@ -46,7 +60,8 @@ export default function ResultsPage() {
const total = result.total_questions const total = result.total_questions
return ( return (
<div style={{ maxWidth: 780, margin: '0 auto' }}> <div className="quiz-results">
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
{/* Score card */} {/* Score card */}
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
<div className="score-display"> <div className="score-display">
@ -131,12 +146,19 @@ export default function ResultsPage() {
))} ))}
</div> </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' const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return ( 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 */} {/* Question header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@ -156,14 +178,15 @@ export default function ResultsPage() {
</span> </span>
</div> </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 && ( {ans.options && ans.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{ans.options.map((opt, i) => { {ans.options.map((opt, i) => {
const isCorrect = opt === ans.correct_answer const isCorrect = opt === ans.correct_answer
const isUser = opt === ans.user_answer const isUser = opt === ans.user_answer
const isWrong = isUser && !isCorrect 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)' 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 (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)' } 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)', background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)', color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
}}>{letter}</span> }}>{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 }}> Correct answer</span>}
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your 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>} {isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
@ -200,6 +225,8 @@ export default function ResultsPage() {
</div> </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 */} {/* Explanation */}
{(ans.explanation || ans.explanation_image_path) && ( {(ans.explanation || ans.explanation_image_path) && (
<div className="explanation"> <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(),
)
})