diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 9f94333..51d980e 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -838,6 +838,17 @@ def attempt_analysis( # answered. The two are now looking at the same thing. if not rows and attempt.completed_at is None: rows = _rows_from_progress(db, current_user.id, attempt) + + # An exam that is still running is not marked. Grading it here would let a + # learner answer, open this page to see whether it was right, and go back + # and change it — which is the exam defeated, not analysed. Progress is + # still shown: how many are answered, and how long it is taking. + # + # Study mode is graded live, because study mode marks each answer as it is + # given; there is nothing here it has not already said. + # Only an exam withholds, and only while it is running. Anything else — + # study mode, or a mode that was never recorded — is graded as before. + graded = attempt.completed_at is not None or attempt.mode != "exam" question_ids = [row.question_id for row in rows] questions = {q.id: q for q in db.query(Question).filter(Question.id.in_(question_ids)).all()} \ if question_ids else {} @@ -868,7 +879,8 @@ def attempt_analysis( "position": index, "question_id": row.question_id, "excerpt": (getattr(question, "question_text", "") or "")[:120], - "status": "correct" if row.is_correct else "skipped" if not row.user_answer else "incorrect", + "status": ("correct" if row.is_correct else "skipped" if not row.user_answer else "incorrect") + if graded else ("answered" if row.user_answer else "skipped"), "difficulty": getattr(question, "difficulty", None), "category": categories.get(getattr(question, "question_category_id", None)), "seconds_spent": row.seconds_spent, @@ -879,7 +891,7 @@ def attempt_analysis( }) answered = sum(1 for row in rows if row.user_answer) - score = sum(1 for row in rows if row.is_correct) + score = sum(1 for row in rows if row.is_correct) if graded else None elapsed = None if attempt.completed_at and attempt.started_at: elapsed = int((attempt.completed_at - attempt.started_at).total_seconds()) @@ -889,7 +901,7 @@ def attempt_analysis( for row in rows: question = questions.get(row.question_id) name = categories.get(getattr(question, "question_category_id", None)) - if name and row.user_answer: + if name and row.user_answer and graded: by_category.setdefault(name, []).append(bool(row.is_correct)) recommendations = sorted( ({"name": name, "correct": sum(marks), "total": len(marks), @@ -906,7 +918,10 @@ def attempt_analysis( "total": len(rows), "answered": answered, "score": score, - "percent": round(100 * score / len(rows)) if rows else 0, + "percent": (round(100 * score / len(rows)) if rows else 0) if graded else None, + # False while an exam is still running: the interface shows progress + # and says why there is no score yet, rather than showing a nought. + "graded": graded, "seconds_total": elapsed, "seconds_per_question": round(sum(timed) / len(timed)) if timed else None, "questions": detail, diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index 461fdb7..dce8cd0 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -254,7 +254,7 @@ class LiveAnalysisTests(unittest.TestCase): def test_answers_given_but_not_submitted_are_analysed(self): quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] with patch.dict(sys.modules, {"redis": self.redis}): - aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"] + aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"] # One answered, saved to progress; nothing submitted. self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}}) @@ -272,9 +272,49 @@ class LiveAnalysisTests(unittest.TestCase): # Nothing was written: submitting is what records answers. self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 0) + def test_an_exam_still_running_is_not_marked(self): + """The integrity rule: a running exam reports progress, never a score. + + Grading it live would let a learner answer, open the analysis to see + whether it was right, and go back and change it — the exam defeated + rather than analysed. + """ + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] + with patch.dict(sys.modules, {"redis": self.redis}): + aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"] + self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}}) + + with patch.dict(sys.modules, {"redis": self.redis}): + body = self.client.get(f"/attempts/{aid}/analysis").json() + + self.assertFalse(body["graded"]) + self.assertIsNone(body["score"]) + self.assertIsNone(body["percent"]) + # How far through, which is not a leak, and nothing about rightness. + self.assertEqual(body["answered"], 1) + statuses = {q["status"] for q in body["questions"]} + self.assertEqual(statuses, {"answered", "skipped"}) + self.assertNotIn("correct", statuses) + self.assertNotIn("incorrect", statuses) + # And nothing to go back to yet: a recommendation is a verdict. + self.assertEqual(body["recommendations"], []) + + def test_a_finished_exam_is_marked(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] + with patch.dict(sys.modules, {"redis": self.redis}): + aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"] + self.client.post(f"/attempts/{aid}/submit", + json={"answers": [{"question_id": 1, "user_answer": "yes"}]}) + body = self.client.get(f"/attempts/{aid}/analysis").json() + # Once it is over the withholding stops: this is the "converts to + # study" moment, and it is what the clock running out does too. + self.assertTrue(body["graded"]) + self.assertIsNotNone(body["percent"]) + self.assertIn("correct", {q["status"] for q in body["questions"]}) + def test_a_live_attempt_with_nothing_answered_reads_as_nothing_answered(self): quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] with patch.dict(sys.modules, {"redis": self.redis}): - aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"] + aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"] body = self.client.get(f"/attempts/{aid}/analysis").json() self.assertEqual((body["answered"], body["score"]), (0, 0)) diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css index d983a5b..ffb6a31 100644 --- a/frontend/src/pages/AnalysisSessionPage.css +++ b/frontend/src/pages/AnalysisSessionPage.css @@ -157,3 +157,7 @@ td.an-col-q { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } .an-actions .btn { flex: 1 1 auto; text-align: center; } .an-remove { margin-left: 0; width: 100%; text-align: center; } } + +/* Answered, but not yet marked — an exam that is still running. */ +.an-legend .is-answered { background: var(--primary); } +.an-status.is-answered { background: var(--option-sel-bg); color: var(--primary); } diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index f73a2ed..7de87f7 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -20,8 +20,8 @@ const hours = (seconds) => { } /** Correct / incorrect / unanswered as one ring. */ -function Donut({ correct, incorrect, skipped }) { - const total = correct + incorrect + skipped +function Donut({ correct, incorrect, answered = 0, skipped, graded = true }) { + const total = correct + incorrect + answered + skipped if (!total) return null const circumference = 2 * Math.PI * 54 const slice = (n) => (n / total) * circumference @@ -29,11 +29,15 @@ function Donut({ correct, incorrect, skipped }) { const arcs = [ { value: correct, colour: 'var(--correct-fg)' }, { value: incorrect, colour: 'var(--wrong-fg)' }, + // Neither right nor wrong yet: an exam that is still running is not marked. + { value: answered, colour: 'var(--primary)' }, { value: skipped, colour: 'var(--border)' }, ] + const label = graded + ? `${correct} correct, ${incorrect} incorrect, ${skipped} unanswered` + : `${answered} answered, ${skipped} unanswered — not marked yet` return ( - + {arcs.map((arc, i) => { const length = slice(arc.value) const dash = `${length} ${circumference - length}` @@ -45,10 +49,14 @@ function Donut({ correct, incorrect, skipped }) { offset += length return node })} + {/* A percentage here would be a score, and an exam still running has + none. It says how far through it is instead. */} - {Math.round((correct / total) * 100)}% + {graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`} + + + {graded ? 'correct' : 'answered'} - correct ) } @@ -124,8 +132,13 @@ export default function AnalysisSessionPage() { return
{error || 'Session not found.'}
} - const correct = data.score - const incorrect = data.answered - data.score + // An exam still running is not marked, so there is nothing to split the + // donut by except answered and not. Showing a nought here would read as a + // score, and a score before the exam is over is the exam defeated. + const graded = data.graded !== false + const correct = graded ? data.score : 0 + const incorrect = graded ? data.answered - data.score : 0 + const answeredOnly = graded ? 0 : data.answered const skipped = data.total - data.answered return ( @@ -158,6 +171,12 @@ export default function AnalysisSessionPage() { {/* The figures below are all zero and every row reads "skipped", which is the true picture. Saying why keeps that from looking like a score of nought. */} + {!graded && !data.not_started && ( +

+ This exam is still running, so it has not been marked. How you did + is shown once you finish it or the clock runs out. +

+ )} {(data.not_started || data.answered < data.total) && (

{data.not_started @@ -170,7 +189,7 @@ export default function AnalysisSessionPage() {

{[ - ['✓', `${data.percent}%`, 'correct'], + ['✓', graded ? `${data.percent}%` : '—', 'correct'], ['◷', `${data.answered}/${data.total}`, 'completed'], ['⏱', clock(data.seconds_per_question), 'time per question'], ['⏲', hours(data.seconds_total), 'total time spent'], @@ -186,10 +205,17 @@ export default function AnalysisSessionPage() {

{data.title}

- +
    -
  • {correct} correct
  • -
  • {incorrect} incorrect
  • + {graded ? ( + <> +
  • {correct} correct
  • +
  • {incorrect} incorrect
  • + + ) : ( +
  • {data.answered} answered
  • + )}
  • {skipped} unanswered
diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index c6a4698..754e207 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -134,6 +134,43 @@ describe('a session whose only attempt is still in progress', () => { }) }) +describe('an exam that is still running', () => { + it('shows progress but no score, and says why', async () => { + api.get.mockImplementation(url => { + if (url === '/attempts/5/analysis') return Promise.resolve({ data: { + attempt_id: 5, quiz_id: 3, title: 'Block 1', mode: 'timed', + completed_at: null, graded: false, + total: 5, answered: 1, score: null, percent: null, + seconds_total: null, seconds_per_question: 4, + questions: [ + { position: 1, question_id: 11, excerpt: 'A 4-month-old…', status: 'answered', + difficulty: null, category: 'Allergy', seconds_spent: 4, peer_percent: null, peer_sample: 0 }, + { position: 2, question_id: 12, excerpt: 'A 4-year-old…', status: 'skipped', + difficulty: null, category: null, seconds_spent: null, peer_percent: null, peer_sample: 0 }, + ], + recommendations: [], plan: null, not_started: false, + } }) + if (url === '/attempts/sessions') return Promise.resolve({ data: [] }) + return Promise.resolve({ data: [] }) + }) + render( + } /> + ) + + // Grading it here would let a learner answer, look, and go back and change + // it — the exam defeated rather than analysed. + expect(await screen.findByText(/still running, so it has not been marked/)).toBeInTheDocument() + // No percentage anywhere: the correct figure is a dash and the donut + // counts how far through it is instead of how much of it is right. + expect(screen.getAllByText('—').length).toBeGreaterThan(0) + expect(screen.queryByText(/^\d+%$/)).toBeNull() + expect(screen.getByText(/1 answered/)).toBeInTheDocument() + // The row says it was answered, not whether it was right. ("correct" still + // appears as the label of the dashed figure, which is the point of it.) + expect(screen.queryByText('incorrect')).toBeNull() + }) +}) + describe('a session left part way', () => { beforeEach(() => { vi.clearAllMocks()