fix: an exam that is still running is not marked
Opening the analysis of a live attempt graded it whatever the mode. In an exam that is a way to answer, look at whether it was right, and go back and change it — the exam defeated rather than analysed. It reports progress now: how many are answered, how long it is taking, and each row as answered or not. No score, no percentage, and the donut counts how far through it is instead of how much of it is right. Study mode still grades live, because study mode marks each answer as it is given; there is nothing here it has not already said. Recommendations are withheld too, which is stricter than AMBOSS — they show a dash for correct and then list the topics to go back to, which says which questions were wrong by another route. A recommendation is a verdict. The withholding stops the moment the exam is over, submitted or expired: settle_if_expired grades through the same function a manual submit does and sets completed_at, and everything opens from there. Tested on both sides, because this is an integrity rule and would come back quietly the next time the live-analysis path was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
28cef1e75f
commit
c353373231
5 changed files with 140 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<svg className="an-donut" viewBox="0 0 140 140" role="img"
|
||||
aria-label={`${correct} correct, ${incorrect} incorrect, ${skipped} unanswered`}>
|
||||
<svg className="an-donut" viewBox="0 0 140 140" role="img" aria-label={label}>
|
||||
{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. */}
|
||||
<text x="70" y="68" textAnchor="middle" className="an-donut-figure">
|
||||
{Math.round((correct / total) * 100)}%
|
||||
{graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`}
|
||||
</text>
|
||||
<text x="70" y="86" textAnchor="middle" className="an-donut-label">
|
||||
{graded ? 'correct' : 'answered'}
|
||||
</text>
|
||||
<text x="70" y="86" textAnchor="middle" className="an-donut-label">correct</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -124,8 +132,13 @@ export default function AnalysisSessionPage() {
|
|||
return <AnalysisShell><div className="an-empty">{error || 'Session not found.'}</div></AnalysisShell>
|
||||
}
|
||||
|
||||
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 && (
|
||||
<p className="an-incomplete" role="status">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{(data.not_started || data.answered < data.total) && (
|
||||
<p className="an-incomplete" role="status">
|
||||
{data.not_started
|
||||
|
|
@ -170,7 +189,7 @@ export default function AnalysisSessionPage() {
|
|||
|
||||
<div className="an-figures">
|
||||
{[
|
||||
['✓', `${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() {
|
|||
<section className="an-card an-result">
|
||||
<h2>{data.title}</h2>
|
||||
<div className="an-donut-wrap">
|
||||
<Donut correct={correct} incorrect={incorrect} skipped={skipped} />
|
||||
<Donut correct={correct} incorrect={answeredOnly ? 0 : incorrect}
|
||||
answered={answeredOnly} skipped={skipped} graded={graded} />
|
||||
<ul className="an-legend">
|
||||
<li><i className="is-right" />{correct} correct</li>
|
||||
<li><i className="is-wrong" />{incorrect} incorrect</li>
|
||||
{graded ? (
|
||||
<>
|
||||
<li><i className="is-right" />{correct} correct</li>
|
||||
<li><i className="is-wrong" />{incorrect} incorrect</li>
|
||||
</>
|
||||
) : (
|
||||
<li><i className="is-answered" />{data.answered} answered</li>
|
||||
)}
|
||||
<li><i className="is-none" />{skipped} unanswered</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(<MemoryRouter initialEntries={['/sessions/5']}><Routes>
|
||||
<Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} />
|
||||
</Routes></MemoryRouter>)
|
||||
|
||||
// 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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue