diff --git a/backend/alembic/versions/d94a26b8f302_study_tools.py b/backend/alembic/versions/d94a26b8f302_study_tools.py new file mode 100644 index 0000000..ac4d510 --- /dev/null +++ b/backend/alembic/versions/d94a26b8f302_study_tools.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 179a790..994e964 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote +from app.routers import study_tools from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -633,6 +634,7 @@ app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcard app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"]) app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) +app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) @app.get("/api/health") diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 3798ac4..802de0d 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -12,6 +12,7 @@ class QuizAttempt(Base): id = Column(Integer, primary_key=True, index=True) quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + mode = Column(String(10), nullable=True) # legacy NULL resumes without exposing answers score = Column(Integer, default=0) total_questions = Column(Integer, default=0) started_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/lab_reference.py b/backend/app/models/lab_reference.py new file mode 100644 index 0000000..11cbd30 --- /dev/null +++ b/backend/app/models/lab_reference.py @@ -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) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 141c92e..ac5e363 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request @@ -11,6 +12,8 @@ from sqlalchemy import func from app.database import get_db from app.models.quiz import Quiz from app.models.question import Question +from app.models.question_category import QuestionCategory +from app.services.quiz_builder import category_breadcrumbs from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.pdf_document import PDFDocument from app.models.user import User @@ -33,6 +36,7 @@ router = APIRouter() def start_attempt( quiz_id: int, fresh: bool = False, + mode: Literal["study", "exam"] | None = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -42,6 +46,10 @@ def start_attempt( if not can_access_quiz(db, quiz, current_user): raise HTTPException(status_code=403, detail="This quiz is private") + chosen_mode = mode or ("study" if quiz.mode == "learning" else "exam") + if quiz.course_id is not None: + chosen_mode = "study" if quiz.mode == "learning" and quiz.allow_review == 1 else "exam" + # Enforce max_attempts if quiz.max_attempts: completed_count = db.query(QuizAttempt).filter( @@ -68,6 +76,7 @@ def start_attempt( percentage=0.0, started_at=existing.started_at, completed_at=None, + mode=existing.mode or "exam", ) # Question pool: randomly select N questions if questions_per_attempt is set @@ -84,6 +93,7 @@ def start_attempt( user_id=current_user.id, total_questions=total_q, selected_question_ids=selected_ids, + mode=chosen_mode, ) db.add(attempt) db.commit() @@ -96,6 +106,7 @@ def start_attempt( percentage=0.0, started_at=attempt.started_at, completed_at=None, + mode=attempt.mode, ) @@ -118,6 +129,7 @@ def submit_attempt( quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() require_quiz_access(db, quiz, current_user) + categories = db.query(QuestionCategory).all() grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), [(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids) score = sum(correct for _, _, correct in grades) @@ -142,6 +154,9 @@ def submit_attempt( is_correct=is_correct, explanation=q.explanation, explanation_image_path=q.explanation_image_path, + image_path=q.image_path, + page_reference=q.page_reference, + category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id), )) attempt.score = score @@ -258,7 +273,7 @@ def save_progress( "attempt_id": data.attempt_id, "answers": data.answers, "current_idx": data.current_idx, - "mode": data.mode, + "mode": attempt.mode or "exam", "voice": data.voice, "time_left": data.time_left, "started_at": data.started_at, @@ -311,6 +326,7 @@ def get_progress( return None saved = _json.loads(data) + saved["mode"] = attempt.mode or "exam" # If the quiz was suspended, timer is paused — re-anchor on resume so # the held time_left becomes the new total_time starting now. @@ -588,6 +604,7 @@ def get_attempt( is_course_quiz = quiz and quiz.course_id is not None review_allowed = not is_course_quiz or (quiz.allow_review == 1) + categories = db.query(QuestionCategory).all() # Never reveal review content for an unfinished attempt or an unselected pool question. review_allowed = review_allowed and attempt.completed_at is not None submitted_map = {ans.question_id: ans for ans in attempt.answers} @@ -607,6 +624,9 @@ def get_attempt( is_correct=ans.is_correct if ans else False, explanation=q.explanation, explanation_image_path=q.explanation_image_path, + image_path=q.image_path, + page_reference=q.page_reference, + category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id), )) percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index fbc4934..f5f9f92 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -10,6 +10,8 @@ from app.models.question import Question as QuestionModel from app.models.section import Section from app.models.attempt import QuizAttempt from app.models.user import User +from app.models.question_category import QuestionCategory +from app.services.quiz_builder import category_breadcrumbs from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.services import quiz_service from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared @@ -286,33 +288,32 @@ def get_quiz( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Get quiz for taking. study=true forces answers/explanations to be included. - attempt_id filters to the question subset selected for that attempt (question pool).""" + """Return selected questions; only an authorized study/completed attempt can reveal answers.""" quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") if not can_access_quiz(db, quiz, current_user): raise HTTPException(status_code=403, detail="This quiz is private") - if study or quiz.mode == "learning": - if quiz.mode != "learning": - require_quiz_access(db, quiz, current_user, review=True) - result = QuizLearningDetail.model_validate(quiz) - else: - result = QuizDetail.model_validate(quiz) - - # Filter questions if attempt has a question pool selection - if attempt_id: - from app.models.attempt import QuizAttempt - attempt = db.query(QuizAttempt).filter( - QuizAttempt.id == attempt_id, - QuizAttempt.user_id == current_user.id, - ).first() - if attempt and attempt.selected_question_ids: - sel = set(attempt.selected_question_ids) - result.questions = [q for q in result.questions if q.id in sel] - result.questions_count = len(result.questions) - + attempt = None + if attempt_id is not None: + attempt = db.query(QuizAttempt).filter_by(id=attempt_id, quiz_id=quiz.id, user_id=current_user.id).first() + if not attempt: + raise HTTPException(404, "Attempt not found for this quiz") + reveal = attempt is not None and (attempt.mode == "study" or attempt.completed_at is not None) + if study and not reveal: + raise HTTPException(403, "Answers are hidden until the exam is submitted") + if reveal: + require_quiz_access(db, quiz, current_user, review=True) + result = (QuizLearningDetail if reveal else QuizDetail).model_validate(quiz) + result.attempt_mode = (attempt.mode or "exam") if attempt else None + if attempt and attempt.selected_question_ids is not None: + selected = set(attempt.selected_question_ids) + result.questions = [q for q in result.questions if q.id in selected] + result.questions_count = len(result.questions) + categories = db.query(QuestionCategory).all() + for question in result.questions: + question.category_breadcrumbs = category_breadcrumbs(categories, question.question_category_id) return result diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py new file mode 100644 index 0000000..3a576d2 --- /dev/null +++ b/backend/app/routers/study_tools.py @@ -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."} diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index d55f777..d996b89 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class AnswerSubmission(BaseModel): @@ -22,6 +22,9 @@ class AnswerDetail(BaseModel): is_correct: bool explanation: str | None explanation_image_path: str | None = None + image_path: str | None = None + page_reference: int | None = None + category_breadcrumbs: list[dict] = Field(default_factory=list) class Config: from_attributes = True @@ -35,6 +38,7 @@ class AttemptResponse(BaseModel): percentage: float started_at: datetime completed_at: datetime | None + mode: str | None = None course_id: int | None = None allow_review: bool = True diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 5ba607f..19f2263 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class QuizCreate(BaseModel): @@ -30,6 +30,8 @@ class QuestionResponse(BaseModel): question_type: str options: list[str] | None image_path: str | None = None + question_category_id: int | None = None + category_breadcrumbs: list[dict] = Field(default_factory=list) class Config: from_attributes = True @@ -57,6 +59,10 @@ class QuizResponse(BaseModel): is_published: int = 1 is_shared: int = 0 questions_per_attempt: int | None = None + course_id: int | None = None + allow_review: int = 1 + max_attempts: int | None = None + attempt_mode: str | None = None class Config: from_attributes = True diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py new file mode 100644 index 0000000..a141457 --- /dev/null +++ b/backend/tests/test_study_tools.py @@ -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() diff --git a/backend/tests/test_study_tools_migration.py b/backend/tests/test_study_tools_migration.py new file mode 100644 index 0000000..277c4dc --- /dev/null +++ b/backend/tests/test_study_tools_migration.py @@ -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() diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md index 1862eff..5586c4b 100644 --- a/docs/quiz-revamp-progress.md +++ b/docs/quiz-revamp-progress.md @@ -21,7 +21,7 @@ Initial implementation received two independent read-only reviews (access/correc - PostgreSQL 16: actual migration upgrade, FK enforcement, existing-row/question-assignment preservation, repeated upgrade, downgrade and re-upgrade passed in a disposable network-isolated container. Container/data cleaned up automatically. - Alembic offline SQL: explicit `5f8c1c2a9d40:c82d19e4a601` upgrade and reverse downgrade exercised without mocking inspection. - Changed Python syntax and `git diff --check` passed. -- Regression negative-control run against the unfixed `affd717` is tracked separately; do not infer its result from the passing fixed-code suite. +- The same regression suite fails against frozen `affd717` as expected: unauthorized revocation/deletion, premature review answers, invalid mobile selections, duplicate scoring (200%) and offline SQL inspection are reproduced. The legacy-Hide test also catches its missing sharing-state response. All tests pass on the fixed code. The first Docker validation attempt failed before tests because a read-only mountpoint was absent; setup was corrected. One frontend regression initially used an ambiguous Create Quiz selector; the modal was labeled and the test scoped to it, then all tests passed. @@ -39,6 +39,20 @@ Verification: 18 backend tests passed in the exact deployed image, including log This source change is committed/pushed with the feature work; it has not been deployed to production. +## Milestone 2 — runner and study tools (under verification) + +Implemented a wide white/blue question layout with category breadcrumbs, numbered answers, provisional study selections, explicit response confirmation, real response-distribution bars, question navigation and Review & Complete. The results screen reviews one question at a time. Full question/explanation text, images, notes, highlighting and tutor access are retained. + +Added a safe arithmetic calculator (no eval), keyboard shortcuts that do not interfere with text entry/dialogs, native focus-trapping tool dialogs, and educator-managed lab references with required population/specimen/units/source and explicit publication. No clinical reference ranges are fabricated or seeded without verification. + +Attempt mode is persisted server-side: a query flag cannot reveal active exam answers/statistics, and mismatched attempt/quiz/user IDs are rejected. Legacy attempts without stored mode resume as exam mode rather than exposing answers; their saved answers remain intact. The new `d94a26b8f302` migration must run before deployment. + +Combined verification after integrating login removal: **22 backend tests passed in the deployed image; 48 frontend tests and production build passed.** Actual PostgreSQL mode/lab migration verification and independent review are being completed separately. + +Browser checks used a loopback-only fixture with synthetic accounts/questions and in-memory data, not production authentication. Desktop layout, provisional/confirmed answers, live fixture response counts, calculator arithmetic, honest empty lab references, and review confirmation were checked. At 390×844 the document width was exactly 390 and no question, option, image or toolbar overflowed; a taller narrow frame captures the full page. A browser-profile reset interrupted the check; re-login restored the saved attempt successfully. + +Proof images: [desktop](quiz-revamp/desktop-question.png), [study feedback](quiz-revamp/study-feedback.png), [mobile width](quiz-revamp/mobile-question.png). These are synthetic previews, not screenshots of deployed clinical content. + ## Next Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment. diff --git a/docs/quiz-revamp/desktop-question.png b/docs/quiz-revamp/desktop-question.png new file mode 100644 index 0000000..7030246 Binary files /dev/null and b/docs/quiz-revamp/desktop-question.png differ diff --git a/docs/quiz-revamp/mobile-question.png b/docs/quiz-revamp/mobile-question.png new file mode 100644 index 0000000..f9414f6 Binary files /dev/null and b/docs/quiz-revamp/mobile-question.png differ diff --git a/docs/quiz-revamp/study-feedback.png b/docs/quiz-revamp/study-feedback.png new file mode 100644 index 0000000..8acbfa0 Binary files /dev/null and b/docs/quiz-revamp/study-feedback.png differ diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css new file mode 100644 index 0000000..abba534 --- /dev/null +++ b/frontend/src/components/QuizTools.css @@ -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); } } diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx new file mode 100644 index 0000000..830bfd0 --- /dev/null +++ b/frontend/src/components/QuizTools.jsx @@ -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 +

{title}

+
{children}
+
+} + +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
+ + { setExpression(e.target.value); setError('') }} placeholder="0" autoComplete="off" autoFocus /> + {error &&

{error}

} +
{['(', ')', '^', 'AC', '7', '8', '9', '÷', '4', '5', '6', '×', '1', '2', '3', '-', '0', '.', '=', '+', '⌫'].map(key => + , + )}
+
+} + +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 <> +

Ranges vary with age, laboratory and method. Use the source and population shown; follow local laboratory intervals.

+ {isEducator && } + {error &&

{error}

} +
+ + {manage && } +
+
{['All', ...new Set(rows.map(row => row.group))].map(name => )}
+ {loading ?

Loading references…

: filtered.length === 0 ?

No published reference values match. Educators can add sourced, age-specific entries; no ranges are assumed.

: +
{manage && }{filtered.map(row => + + + + {manage && } + )}
Test / specimenReference rangePopulation / sourceActions
{row.name}{row.specimen}{!row.is_published && ' · Draft'}{row.reference_range} {row.units}{row.age_group}{row.source_url ? {row.source} : row.source}{removeId === row.id ? <> + + : }
} + {form && manage &&
+

{form.id ? 'Edit reference' : 'New reference'}

+ {labFields.map(([key, label, max]) => )} + +
+
} + +} + +const shortcuts = [['Choose answer', '1–9'], ['Submit study response', 'Enter'], ['Previous question', '←'], ['Next question', '→ or N'], ['Bookmark question', 'B'], ['Open question image', 'Space'], ['Close tool', 'Escape']] +export default function QuizTools({ tool, onClose }) { + const title = { calculator: 'Calculator', labs: 'Lab values', shortcuts: 'Keyboard shortcuts' }[tool] + if (!title) return null + return + {tool === 'calculator' ? : tool === 'labs' ? : <> +

Shortcuts pause while typing or using a dialog.

+ {shortcuts.map(([action, key]) => )}
ActionKey
{action}{key}
+ } +
+} diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 414c52f..a4d4ec8 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' import './CustomQuizPage.css' @@ -7,8 +7,9 @@ import './CustomQuizPage.css' export default function CustomQuizPage() { const { user } = useAuth() const navigate = useNavigate() + const [searchParams] = useSearchParams() const [categories, setCategories] = useState([]) - const [categoryIds, setCategoryIds] = useState([]) + const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))]) const [state, setState] = useState('all') const [shared, setShared] = useState(false) const [title, setTitle] = useState('My Custom Test') diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 8bf54d8..a7216d5 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -3,6 +3,8 @@ import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import MyNote from '../components/MyNote' +import QuizTools, { QuizDialog } from '../components/QuizTools' +import './QuizPlayer.css' const TeachChat = lazy(() => import('../components/TeachChat')) @@ -280,7 +282,7 @@ function QuizCodeBadge({ code }) { } function CourseQuizStart({ quiz, onStart }) { - const mode = quiz.mode === 'timed' ? 'exam' : 'study' + const mode = quiz.mode === 'timed' || quiz.allow_review === 0 ? 'exam' : 'study' const [error, setError] = useState('') const [starting, setStarting] = useState(false) const begin = async () => { @@ -327,7 +329,11 @@ function ModeSelectScreen({ quiz, voices, onStart }) { const [startingMode, setStartingMode] = useState('') const handleStart = async (mode) => { - const timerMinutes = mode === 'exam' && customTimer ? parseInt(customTimer) : null + if (mode === 'exam' && customTimer && (!Number.isInteger(Number(customTimer)) || Number(customTimer) < 1)) { + setStartError('Enter a positive whole number of minutes, or leave the timer blank.') + return + } + const timerMinutes = mode === 'exam' && customTimer ? Number(customTimer) : null setStartError('') setStartingMode(mode) try { @@ -355,7 +361,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) { { mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' }, { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' }, ].map(({ mode, icon, label, desc, color, bg }) => ( -
!startingMode && handleStart(mode)} +
+ ))} {startError && ( @@ -441,6 +447,12 @@ export default function QuizPage() { const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) + const [draftAnswer, setDraftAnswer] = useState('') + const [tool, setTool] = useState(null) + const [showReview, setShowReview] = useState(false) + const [responseStats, setResponseStats] = useState(null) + const [statsError, setStatsError] = useState('') + const [submitError, setSubmitError] = useState('') const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) @@ -559,6 +571,7 @@ export default function QuizPage() { useEffect(() => { setActiveReadSegment(null) setTtsActive(false) + setDraftAnswer('') savedHighlightSelectionRef.current = null clearTimeout(autoHighlightTimerRef.current) if (!readThrough) setActiveReadSegment(null) @@ -575,33 +588,13 @@ export default function QuizPage() { }, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio]) const resumeQuiz = useCallback(async (saved, availableVoices = []) => { - const savedMode = saved.mode || saved.quizMode - const mode = savedMode === 'exam' ? 'exam' : 'study' const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0 const savedAnswers = saved.answers || {} - - const aid = saved.attempt_id || saved.attemptId || '' - // For feedback modes, load quiz with correct answers BEFORE showing. - if (mode === 'study') { - try { - const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`) - setQuiz(quizRes.data) - } catch (err) { - console.error('Failed to load quiz:', err) - navigate('/') - return - } - } else { - // For exam mode, ensure quiz data is loaded - try { - const quizRes = await api.get(`/quizzes/${id}${aid ? `?attempt_id=${aid}` : ''}`) - setQuiz(quizRes.data) - } catch (err) { - console.error('Failed to load quiz:', err) - navigate('/') - return - } - } + const aid = saved.attempt_id || saved.attemptId + if (!aid) return + const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}`) + const mode = quizRes.data.attempt_mode === 'study' ? 'study' : 'exam' + setQuiz(quizRes.data) hasStarted.current = true setQuizMode(mode) @@ -671,7 +664,8 @@ export default function QuizPage() { try { // Start attempt first (may select random question subset) - const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`) + const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}`) + mode = attemptRes.data.mode || mode setAttemptId(attemptRes.data.id) const aid = attemptRes.data.id @@ -760,6 +754,25 @@ const timerStarted = timeLeft !== null }, [attemptId, quizMode, saveProgressNow]) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) + const chooseAnswer = value => { + if (!current || (isStudy && answers[current.id])) return + if (isStudy) setDraftAnswer(value) + else setAnswer(current.id, value) + } + const submitStudyResponse = () => { + if (isStudy && current && !answers[current.id] && draftAnswer.trim()) setAnswer(current.id, draftAnswer) + } + + useEffect(() => { + let active = true + setResponseStats(null); setStatsError('') + if (isStudy && attemptId && current && answers[current.id]) { + api.get(`/study-tools/attempts/${attemptId}/questions/${current.id}/responses`) + .then(r => { if (active) setResponseStats(r.data) }) + .catch(() => { if (active) setStatsError('Response statistics are unavailable.') }) + } + return () => { active = false } + }, [isStudy, attemptId, current?.id, answers[current?.id]]) const clearCurrentHighlights = () => { if (!current || !manualHighlights[current.id]) return @@ -803,9 +816,8 @@ const timerStarted = timeLeft !== null showToast('No answers selected — submitting with 0 answered.') await new Promise(r => setTimeout(r, 1200)) } - clearInterval(timerRef.current) - api.delete(`/attempts/progress/${attemptId}`).catch(() => {}) setSubmitting(true) + setSubmitError('') try { const submission = { answers: Object.entries(answers).map(([qid, answer]) => ({ @@ -813,16 +825,42 @@ const timerStarted = timeLeft !== null })), } const res = await api.post(`/attempts/${attemptId}/submit`, submission) + clearInterval(timerRef.current) + api.delete(`/attempts/progress/${attemptId}`).catch(() => {}) if (returnTo) { navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } }) } else { navigate(`/results/${attemptId}`, { state: { result: res.data } }) } } catch (err) { - if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed') + const detail = err.response?.data?.detail + setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.') } finally { setSubmitting(false) } }, [attemptId, answers, submitting, navigate, showToast]) + useEffect(() => { + if (!quizMode || !current) return + const keydown = event => { + if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.('input, textarea, select, [contenteditable="true"]') || document.querySelector('dialog[open]')) return + if (expandedImagePath) { + if (event.key === 'Escape') { event.preventDefault(); setExpandedImagePath('') } + return + } + if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return + if (hasActiveTextSelection()) return + if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1]) + else if (event.key === 'Enter' && isStudy) submitStudyResponse() + else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1)) + else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1)) + else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id) + else if (event.key === ' ' && current.image_path) { setImageZoom(1); setExpandedImagePath(current.image_path) } + else return + event.preventDefault() + } + window.addEventListener('keydown', keydown) + return () => window.removeEventListener('keydown', keydown) + }, [quizMode, current, currentIdx, answers, draftAnswer, favorites, expandedImagePath]) + if (loading) return
Loading quiz...
if (!quiz) return null @@ -862,8 +900,8 @@ const timerStarted = timeLeft !== null {isLast ? ( - ) : ( @@ -911,8 +949,17 @@ const timerStarted = timeLeft !== null } return ( -
+
+ {tool && setTool(null)} />} + {showReview && setShowReview(false)}> +

{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.

+
{questions.map((question, index) => )}
+ +
} + {submitError &&
{submitError}
} {/* In-app leave confirmation */} {leaveTarget && (
@@ -1016,16 +1063,27 @@ const timerStarted = timeLeft !== null
{/* Main content */}
- {quizNavigation('top')} +
+ +
+ + + + + + +
+
+ {navOpen &&
{questions.map((q, i) => )}
} {current && (
-
-

- Q{currentIdx + 1}.{' '} + +
+

{current.options.map((opt, i) => { - const isSelected = answers[current.id] === opt + const isSelected = (answers[current.id] || draftAnswer) === opt const hasAnswered = isStudy && !!answers[current.id] const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase() const showCorrect = hasAnswered && isCorrectOpt const showWrong = hasAnswered && isSelected && !isCorrectOpt - const letter = String.fromCharCode(65 + i) + const letter = i + 1 const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i ? activeReadSegment.chunkIndex : null const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk) const optionFieldKey = `option-${i}` return ( -
!hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)} +
+ ) })}

) : ( setAnswer(current.id, e.target.value)} + value={answers[current.id] || draftAnswer} + readOnly={isStudy && Boolean(answers[current.id])} + onChange={e => chooseAnswer(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && isStudy) { e.preventDefault(); submitStudyResponse() } }} style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} /> )} + {isStudy && !answers[current.id] && } {isStudy && answers[current.id] && ( <> +
Preferred response{current.page_reference && Source page {current.page_reference}}
+ {responseStats &&

{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}

} + {statsError &&

{statsError}

} {(current.explanation || current.explanation_image_path) && (
{current.explanation && <>Explanation: {current.explanation}} @@ -1194,17 +1262,10 @@ const timerStarted = timeLeft !== null {quizNavigation('bottom')} - {/* Mobile: collapsible number grid */} - {navOpen && ( -
- {questions.map((q, i) => )} -
- )} - {answeredCount > 0 && !isLast && (
-
)} @@ -1225,8 +1286,8 @@ const timerStarted = timeLeft !== null
{answeredCount > 0 && !isLast && ( )}

diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx new file mode 100644 index 0000000..6d06a0c --- /dev/null +++ b/frontend/src/pages/QuizPage.test.jsx @@ -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: () =>
Study tutor
})) + +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(} />Submitted results
} />) +} +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' }] }) + }) +}) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css new file mode 100644 index 0000000..688f73c --- /dev/null +++ b/frontend/src/pages/QuizPlayer.css @@ -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%; } } diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx index 0dcdad7..9d918ec 100644 --- a/frontend/src/pages/ResultsPage.jsx +++ b/frontend/src/pages/ResultsPage.jsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom' import api from '../api/client' +import QuizTools from '../components/QuizTools' +import './QuizPlayer.css' export default function ResultsPage() { const { id } = useParams() @@ -12,6 +14,9 @@ export default function ResultsPage() { const [loading, setLoading] = useState(!result) const [deleting, setDeleting] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const [reviewIndex, setReviewIndex] = useState(0) + const [tool, setTool] = useState(null) + const [responseStats, setResponseStats] = useState(null) const isCourseQuiz = result?.course_id != null const reviewAllowed = result?.allow_review !== false @@ -37,6 +42,15 @@ export default function ResultsPage() { } }, [id]) + const reviewQuestion = result?.answers?.[reviewIndex] + useEffect(() => { + let active = true + setResponseStats(null) + if (reviewQuestion && result?.completed_at && reviewAllowed) api.get(`/study-tools/attempts/${result.id}/questions/${reviewQuestion.question_id}/responses`) + .then(r => { if (active) setResponseStats(r.data) }).catch(() => {}) + return () => { active = false } + }, [result?.id, reviewQuestion?.question_id, reviewAllowed]) + if (loading) return
Loading results...
if (!result) return null @@ -46,7 +60,8 @@ export default function ResultsPage() { const total = result.total_questions return ( -
+
+ {tool && setTool(null)} />} {/* Score card */}
@@ -131,12 +146,19 @@ export default function ResultsPage() { ))}
-

Question Review

+
+ +
+ +
+
- {result.answers.map((ans, idx) => { + {result.answers.slice(reviewIndex, reviewIndex + 1).map(ans => { + const idx = reviewIndex const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card' return ( -
+
+ {/* Question header */}
@@ -156,14 +178,15 @@ export default function ResultsPage() {
- {/* Options with letter badges */} + {ans.image_path && Question illustration} + {/* Numbered answers */} {ans.options && ans.options.length > 0 && (
{ans.options.map((opt, i) => { const isCorrect = opt === ans.correct_answer const isUser = opt === ans.user_answer const isWrong = isUser && !isCorrect - const letter = String.fromCharCode(65 + i) + const letter = i + 1 let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)' if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' } if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' } @@ -176,7 +199,9 @@ export default function ResultsPage() { background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)', color: isCorrect || isWrong ? 'white' : 'var(--text-muted)', }}>{letter} - {opt} + {opt} + {responseStats?.sample_size > 0 && responseStats.options?.[i] && {responseStats.options[i].percentage}%{responseStats.options[i].count}/{responseStats.sample_size}} + {isCorrect && !isUser && ✓ Correct answer} {isCorrect && isUser && ✓ Your answer} {isWrong && ✗ Your answer} @@ -200,6 +225,8 @@ export default function ResultsPage() {
)} +
Preferred response{ans.page_reference && Source page {ans.page_reference}}
+ {responseStats &&

{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}

} {/* Explanation */} {(ans.explanation || ans.explanation_image_path) && (
diff --git a/frontend/src/utils/calculator.js b/frontend/src/utils/calculator.js new file mode 100644 index 0000000..ee482b2 --- /dev/null +++ b/frontend/src/utils/calculator.js @@ -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 +} diff --git a/frontend/src/utils/calculator.test.js b/frontend/src/utils/calculator.test.js new file mode 100644 index 0000000..3327797 --- /dev/null +++ b/frontend/src/utils/calculator.test.js @@ -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(), + ) +})