feat: right after a tip is its own slice
Opening a tip before answering is a nudge. The answer that follows is still right — it is counted as right, and the percentage is not docked — but it is not the same as right, so it keeps its own arc on the donut and its own line in the legend: "3 correct after a tip". attempt_answers.used_hint records it. The player reports which questions had a tip opened before the answer went in; a tip read afterwards is revision and does not count, which is the difference two of the tests turn on. Both endings agree about it — an explicit submit carries the list, and an exam that runs out takes it from the saved progress, so a tab closing cannot launder a score. Found while wiring this: RichText declared its component overrides inline in the render, so every one was a fresh component type and React remounted the whole rendered tree on each render. An open tip closed itself every time the exam clock ticked. The map is memoised now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
f048b1f4b6
commit
789cd1cc81
16 changed files with 242 additions and 47 deletions
28
backend/alembic/versions/e1f2a3b4c5d6_used_hint.py
Normal file
28
backend/alembic/versions/e1f2a3b4c5d6_used_hint.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Record whether a tip was opened before the question was answered.
|
||||||
|
|
||||||
|
Getting an answer right after reading a nudge is not the same as getting it
|
||||||
|
right, and neither is it wrong. Kept as its own fact so the analysis can say
|
||||||
|
which it was rather than folding one into the other.
|
||||||
|
|
||||||
|
Rows written before tips existed take 0. That is not a guess: there was
|
||||||
|
nothing to open.
|
||||||
|
|
||||||
|
Revision ID: e1f2a3b4c5d6
|
||||||
|
Revises: d0e1f2a3b4c5
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "e1f2a3b4c5d6"
|
||||||
|
down_revision = "d0e1f2a3b4c5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("attempt_answers", sa.Column(
|
||||||
|
"used_hint", sa.Boolean(), nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("attempt_answers", "used_hint")
|
||||||
|
|
@ -36,6 +36,10 @@ class AttemptAnswer(Base):
|
||||||
# measured, which is not the same claim as zero.
|
# measured, which is not the same claim as zero.
|
||||||
seconds_spent = Column(Integer, nullable=True)
|
seconds_spent = Column(Integer, nullable=True)
|
||||||
is_correct = Column(Boolean, default=False)
|
is_correct = Column(Boolean, default=False)
|
||||||
|
# Whether a tip was opened on this question before it was answered. Rows
|
||||||
|
# written before tips existed carry 0, which is not a guess: there was
|
||||||
|
# nothing to open.
|
||||||
|
used_hint = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
attempt = relationship("QuizAttempt", back_populates="answers")
|
attempt = relationship("QuizAttempt", back_populates="answers")
|
||||||
question = relationship("Question")
|
question = relationship("Question")
|
||||||
|
|
|
||||||
|
|
@ -137,10 +137,12 @@ def submit_attempt(
|
||||||
[(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
|
[(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
|
||||||
score = sum(correct for _, _, correct in grades)
|
score = sum(correct for _, _, correct in grades)
|
||||||
timings = submission.timings or {}
|
timings = submission.timings or {}
|
||||||
|
hinted = set(submission.hints or [])
|
||||||
for question, user_answer, is_correct in grades:
|
for question, user_answer, is_correct in grades:
|
||||||
db.add(AttemptAnswer(attempt_id=attempt_id, question_id=question.id,
|
db.add(AttemptAnswer(attempt_id=attempt_id, question_id=question.id,
|
||||||
user_answer=user_answer, is_correct=is_correct,
|
user_answer=user_answer, is_correct=is_correct,
|
||||||
seconds_spent=timings.get(question.id)))
|
seconds_spent=timings.get(question.id),
|
||||||
|
used_hint=question.id in hinted))
|
||||||
attempt.total_questions = len(grades)
|
attempt.total_questions = len(grades)
|
||||||
|
|
||||||
# Review and grading use the same selected set, including skipped outcomes.
|
# Review and grading use the same selected set, including skipped outcomes.
|
||||||
|
|
@ -234,6 +236,7 @@ class ProgressSave(BaseModel):
|
||||||
quiz_id: int
|
quiz_id: int
|
||||||
attempt_id: int
|
attempt_id: int
|
||||||
answers: dict[int, str] # {question_id: answer}; reject malformed cached UI values
|
answers: dict[int, str] # {question_id: answer}; reject malformed cached UI values
|
||||||
|
hints: list[int] | None = None # questions where a tip was opened before answering
|
||||||
current_idx: int
|
current_idx: int
|
||||||
mode: str
|
mode: str
|
||||||
voice: str | None = None
|
voice: str | None = None
|
||||||
|
|
@ -278,6 +281,7 @@ def save_progress(
|
||||||
"quiz_id": data.quiz_id,
|
"quiz_id": data.quiz_id,
|
||||||
"attempt_id": data.attempt_id,
|
"attempt_id": data.attempt_id,
|
||||||
"answers": data.answers,
|
"answers": data.answers,
|
||||||
|
"hints": data.hints or [],
|
||||||
"current_idx": data.current_idx,
|
"current_idx": data.current_idx,
|
||||||
"mode": attempt.mode or "exam",
|
"mode": attempt.mode or "exam",
|
||||||
"voice": data.voice,
|
"voice": data.voice,
|
||||||
|
|
|
||||||
|
|
@ -291,18 +291,25 @@ def completion(
|
||||||
|
|
||||||
|
|
||||||
def _split(rows) -> dict:
|
def _split(rows) -> dict:
|
||||||
"""Correct / incorrect / unanswered, with the percentage out of answered."""
|
"""Correct / correct with a tip / incorrect / unanswered.
|
||||||
correct = sum(1 for row in rows if row.user_answer and row.is_correct)
|
|
||||||
|
A tip opened before answering is a nudge, not a mistake and not nothing.
|
||||||
|
It is counted as correct — because it was — and named separately, so a
|
||||||
|
learner can see how much of a score leaned on one.
|
||||||
|
"""
|
||||||
|
right = [row for row in rows if row.user_answer and row.is_correct]
|
||||||
|
hinted = sum(1 for row in right if getattr(row, "used_hint", False))
|
||||||
incorrect = sum(1 for row in rows if row.user_answer and not row.is_correct)
|
incorrect = sum(1 for row in rows if row.user_answer and not row.is_correct)
|
||||||
blank = sum(1 for row in rows if not row.user_answer)
|
blank = sum(1 for row in rows if not row.user_answer)
|
||||||
answered = correct + incorrect
|
answered = len(right) + incorrect
|
||||||
return {
|
return {
|
||||||
"correct": correct,
|
"correct": len(right) - hinted,
|
||||||
|
"correct_with_hints": hinted,
|
||||||
"incorrect": incorrect,
|
"incorrect": incorrect,
|
||||||
"unanswered": blank,
|
"unanswered": blank,
|
||||||
"answered": answered,
|
"answered": answered,
|
||||||
"total": len(rows),
|
"total": len(rows),
|
||||||
"percent_correct": round(100 * correct / answered, 1) if answered else None,
|
"percent_correct": round(100 * len(right) / answered, 1) if answered else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -324,6 +331,7 @@ def answer_split(
|
||||||
"""
|
"""
|
||||||
rows = db.query(
|
rows = db.query(
|
||||||
AttemptAnswer.question_id, AttemptAnswer.is_correct, AttemptAnswer.user_answer,
|
AttemptAnswer.question_id, AttemptAnswer.is_correct, AttemptAnswer.user_answer,
|
||||||
|
AttemptAnswer.used_hint,
|
||||||
QuizAttempt.id.label("attempt_id"), QuizAttempt.completed_at,
|
QuizAttempt.id.label("attempt_id"), QuizAttempt.completed_at,
|
||||||
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
||||||
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class AttemptSubmit(BaseModel):
|
||||||
# {question_id: seconds}. Absent for a client that does not measure, which is
|
# {question_id: seconds}. Absent for a client that does not measure, which is
|
||||||
# why the column is nullable rather than defaulted to zero.
|
# why the column is nullable rather than defaulted to zero.
|
||||||
timings: dict[int, int] | None = None
|
timings: dict[int, int] | None = None
|
||||||
|
# Question ids where the learner opened a tip before answering. Getting it
|
||||||
|
# right after a nudge is not the same as getting it right, and the two are
|
||||||
|
# worth telling apart without calling either of them wrong.
|
||||||
|
hints: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AnswerDetail(BaseModel):
|
class AnswerDetail(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,11 @@ def _submit(db: Session, redis_client, user_id: int, attempt: QuizAttempt, saved
|
||||||
answers = [(int(qid), answer) for qid, answer in (saved.get("answers") or {}).items()]
|
answers = [(int(qid), answer) for qid, answer in (saved.get("answers") or {}).items()]
|
||||||
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), answers,
|
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), answers,
|
||||||
attempt.selected_question_ids)
|
attempt.selected_question_ids)
|
||||||
|
hinted = {int(qid) for qid in (saved.get("hints") or [])}
|
||||||
for question, answer, correct in grades:
|
for question, answer, correct in grades:
|
||||||
db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question.id,
|
db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question.id,
|
||||||
user_answer=answer, is_correct=correct))
|
user_answer=answer, is_correct=correct,
|
||||||
|
used_hint=question.id in hinted))
|
||||||
attempt.score = sum(correct for _, _, correct in grades)
|
attempt.score = sum(correct for _, _, correct in grades)
|
||||||
attempt.total_questions = len(grades)
|
attempt.total_questions = len(grades)
|
||||||
attempt.completed_at = datetime.utcnow()
|
attempt.completed_at = datetime.utcnow()
|
||||||
|
|
|
||||||
|
|
@ -333,3 +333,43 @@ class LiveAnalysisTests(unittest.TestCase):
|
||||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").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()
|
body = self.client.get(f"/attempts/{aid}/analysis").json()
|
||||||
self.assertEqual((body["answered"], body["score"]), (0, 0))
|
self.assertEqual((body["answered"], body["score"]), (0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class HintRecordingTests(SessionLifecycleTests):
|
||||||
|
"""A tip opened before answering is remembered with the answer.
|
||||||
|
|
||||||
|
Both ways a session can end have to agree about it, or an exam that ran out
|
||||||
|
would quietly report a clean score the learner did not have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_submitted_answer_remembers_the_tip_that_was_opened(self):
|
||||||
|
quiz_id, aid = self.timed_quiz()
|
||||||
|
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||||
|
response = self.client.post(f"/attempts/{aid}/submit", json={
|
||||||
|
"answers": [{"question_id": 1, "user_answer": "yes"},
|
||||||
|
{"question_id": 2, "user_answer": "yes"}],
|
||||||
|
"hints": [1],
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
rows = {row.question_id: row for row in
|
||||||
|
self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid)}
|
||||||
|
self.assertTrue(rows[1].used_hint)
|
||||||
|
self.assertFalse(rows[2].used_hint)
|
||||||
|
|
||||||
|
def test_an_answer_with_no_tips_reported_records_none(self):
|
||||||
|
quiz_id, aid = self.timed_quiz()
|
||||||
|
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||||
|
self.client.post(f"/attempts/{aid}/submit", json={
|
||||||
|
"answers": [{"question_id": 1, "user_answer": "yes"}]})
|
||||||
|
rows = self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid).all()
|
||||||
|
self.assertTrue(rows)
|
||||||
|
self.assertFalse(any(row.used_hint for row in rows))
|
||||||
|
|
||||||
|
def test_an_exam_that_runs_out_carries_the_tips_from_its_saved_progress(self):
|
||||||
|
quiz_id, aid = self.timed_quiz()
|
||||||
|
self.save(self.bank.owner.id, aid, time_left=0, hints=[1])
|
||||||
|
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||||
|
self.client.get("/quizzes/sessions")
|
||||||
|
rows = {row.question_id: row for row in
|
||||||
|
self.db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == aid)}
|
||||||
|
self.assertTrue(rows[1].used_hint)
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ class CompletionTests(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.bank.tearDown()
|
self.bank.tearDown()
|
||||||
|
|
||||||
def sat(self, qid, correct, ago_days, seconds, answered=True, quiz_id=1):
|
def sat(self, qid, correct, ago_days, seconds, answered=True, quiz_id=1, hint=False):
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.models.attempt import AttemptAnswer, QuizAttempt
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
||||||
|
|
@ -301,7 +301,7 @@ class CompletionTests(unittest.TestCase):
|
||||||
# A question left blank is stored as an empty answer, not a missing
|
# A question left blank is stored as an empty answer, not a missing
|
||||||
# row — the same shape the player submits.
|
# row — the same shape the player submits.
|
||||||
user_answer=('yes' if correct else 'no') if answered else '',
|
user_answer=('yes' if correct else 'no') if answered else '',
|
||||||
seconds_spent=seconds))
|
seconds_spent=seconds, used_hint=hint))
|
||||||
self.bank.db.commit()
|
self.bank.db.commit()
|
||||||
|
|
||||||
def get(self, **params):
|
def get(self, **params):
|
||||||
|
|
@ -374,10 +374,10 @@ class AnswerSplitTests(CompletionTests):
|
||||||
self.assertEqual(data['unique_questions'], 1)
|
self.assertEqual(data['unique_questions'], 1)
|
||||||
# Half the work was wrong; what is known now is right.
|
# Half the work was wrong; what is known now is right.
|
||||||
self.assertEqual(data['all'], {
|
self.assertEqual(data['all'], {
|
||||||
'correct': 1, 'incorrect': 1, 'unanswered': 0,
|
'correct': 1, 'correct_with_hints': 0, 'incorrect': 1, 'unanswered': 0,
|
||||||
'answered': 2, 'total': 2, 'percent_correct': 50.0})
|
'answered': 2, 'total': 2, 'percent_correct': 50.0})
|
||||||
self.assertEqual(data['latest'], {
|
self.assertEqual(data['latest'], {
|
||||||
'correct': 1, 'incorrect': 0, 'unanswered': 0,
|
'correct': 1, 'correct_with_hints': 0, 'incorrect': 0, 'unanswered': 0,
|
||||||
'answered': 1, 'total': 1, 'percent_correct': 100.0})
|
'answered': 1, 'total': 1, 'percent_correct': 100.0})
|
||||||
|
|
||||||
def test_a_blank_is_its_own_slice_and_not_a_wrong_answer(self):
|
def test_a_blank_is_its_own_slice_and_not_a_wrong_answer(self):
|
||||||
|
|
@ -394,3 +394,13 @@ class AnswerSplitTests(CompletionTests):
|
||||||
self.assertEqual(data['attempts'], 0)
|
self.assertEqual(data['attempts'], 0)
|
||||||
self.assertEqual(data['unique_questions'], 0)
|
self.assertEqual(data['unique_questions'], 0)
|
||||||
self.assertIsNone(data['all']['percent_correct'])
|
self.assertIsNone(data['all']['percent_correct'])
|
||||||
|
|
||||||
|
def test_a_tip_opened_before_answering_is_right_but_named_apart(self):
|
||||||
|
self.sat(1, True, ago_days=1, seconds=30)
|
||||||
|
self.sat(2, True, ago_days=1, seconds=30, hint=True)
|
||||||
|
data = self.split()['all']
|
||||||
|
self.assertEqual(data['correct'], 1)
|
||||||
|
self.assertEqual(data['correct_with_hints'], 1)
|
||||||
|
# It was right, so it counts as right: the percentage is not docked.
|
||||||
|
self.assertEqual(data['percent_correct'], 100.0)
|
||||||
|
self.assertEqual(data['answered'], 2)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
.an-legend li { display: flex; align-items: center; gap: 8px; }
|
.an-legend li { display: flex; align-items: center; gap: 8px; }
|
||||||
.an-legend i { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
|
.an-legend i { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.an-legend .is-right { background: var(--correct-fg); }
|
.an-legend .is-right { background: var(--correct-fg); }
|
||||||
|
.an-legend .is-hinted { background: var(--correct-bd); }
|
||||||
.an-legend .is-wrong { background: var(--wrong-fg); }
|
.an-legend .is-wrong { background: var(--wrong-fg); }
|
||||||
.an-legend .is-answered { background: var(--primary); }
|
.an-legend .is-answered { background: var(--primary); }
|
||||||
.an-legend .is-none { background: var(--border); }
|
.an-legend .is-none { background: var(--border); }
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,27 @@ import './Donut.css'
|
||||||
* scales, and two drawings of it would eventually disagree.
|
* scales, and two drawings of it would eventually disagree.
|
||||||
*
|
*
|
||||||
* `answered` is for work that is not marked yet — an exam still running has no
|
* `answered` is for work that is not marked yet — an exam still running has no
|
||||||
* score, and a nought in the middle would read as one.
|
* score, and a nought in the middle would read as one. `hinted` is right after
|
||||||
|
* a tip was opened: it counts towards the figure in the middle, because it was
|
||||||
|
* right, and keeps its own arc so it can be seen.
|
||||||
*/
|
*/
|
||||||
export default function Donut({ correct, incorrect, answered = 0, skipped, graded = true }) {
|
export default function Donut({ correct, hinted = 0, incorrect, answered = 0, skipped, graded = true }) {
|
||||||
const total = correct + incorrect + answered + skipped
|
const total = correct + hinted + incorrect + answered + skipped
|
||||||
if (!total) return null
|
if (!total) return null
|
||||||
const circumference = 2 * Math.PI * 54
|
const circumference = 2 * Math.PI * 54
|
||||||
const slice = (n) => (n / total) * circumference
|
const slice = (n) => (n / total) * circumference
|
||||||
let offset = 0
|
let offset = 0
|
||||||
const arcs = [
|
const arcs = [
|
||||||
{ value: correct, colour: 'var(--correct-fg)' },
|
{ value: correct, colour: 'var(--correct-fg)' },
|
||||||
|
// Right after a tip: the same family as right, lighter, because it is the
|
||||||
|
// same answer arrived at with help.
|
||||||
|
{ value: hinted, colour: 'var(--correct-bd)' },
|
||||||
{ value: incorrect, colour: 'var(--wrong-fg)' },
|
{ value: incorrect, colour: 'var(--wrong-fg)' },
|
||||||
{ value: answered, colour: 'var(--primary)' },
|
{ value: answered, colour: 'var(--primary)' },
|
||||||
{ value: skipped, colour: 'var(--border)' },
|
{ value: skipped, colour: 'var(--border)' },
|
||||||
]
|
]
|
||||||
const label = graded
|
const label = graded
|
||||||
? `${correct} correct, ${incorrect} incorrect, ${skipped} unanswered`
|
? `${correct} correct, ${hinted} correct with a tip, ${incorrect} incorrect, ${skipped} unanswered`
|
||||||
: `${answered} answered, ${skipped} unanswered — not marked yet`
|
: `${answered} answered, ${skipped} unanswered — not marked yet`
|
||||||
return (
|
return (
|
||||||
<svg className="an-donut" viewBox="0 0 140 140" role="img" aria-label={label}>
|
<svg className="an-donut" viewBox="0 0 140 140" role="img" aria-label={label}>
|
||||||
|
|
@ -40,7 +45,7 @@ export default function Donut({ correct, incorrect, answered = 0, skipped, grade
|
||||||
{/* A percentage here would be a score, and an exam still running has
|
{/* A percentage here would be a score, and an exam still running has
|
||||||
none. It says how far through it is instead. */}
|
none. It says how far through it is instead. */}
|
||||||
<text x="70" y="68" textAnchor="middle" className="an-donut-figure">
|
<text x="70" y="68" textAnchor="middle" className="an-donut-figure">
|
||||||
{graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`}
|
{graded ? `${Math.round(((correct + hinted) / total) * 100)}%` : `${answered}/${total}`}
|
||||||
</text>
|
</text>
|
||||||
<text x="70" y="86" textAnchor="middle" className="an-donut-label">
|
<text x="70" y="86" textAnchor="middle" className="an-donut-label">
|
||||||
{graded ? 'correct' : 'answered'}
|
{graded ? 'correct' : 'answered'}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
|
@ -51,10 +52,36 @@ export default function RichText({
|
||||||
highlights = [],
|
highlights = [],
|
||||||
speechRange = null,
|
speechRange = null,
|
||||||
onRemoveHighlight = null,
|
onRemoveHighlight = null,
|
||||||
|
// Called the first time a reader opens a tip in this text. The player uses
|
||||||
|
// it to record that an answer was given after a nudge.
|
||||||
|
onTipOpen = null,
|
||||||
}) {
|
}) {
|
||||||
const rehypePlugins = [rehypeKatex]
|
const rehypePlugins = [rehypeKatex]
|
||||||
if (textId) rehypePlugins.unshift([rehypeHighlightOffsets, { textId, highlights, speechRange }])
|
if (textId) rehypePlugins.unshift([rehypeHighlightOffsets, { textId, highlights, speechRange }])
|
||||||
|
|
||||||
|
// Held across renders on purpose. A component type declared inline is a new
|
||||||
|
// type every render, and React unmounts and remounts everything it drew — so
|
||||||
|
// an open tip closed itself each time the exam clock ticked.
|
||||||
|
const components = useMemo(() => ({
|
||||||
|
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
||||||
|
// `{{phrase|tip}}` — a teaching point that opens where the phrase is.
|
||||||
|
span: ({ node, children, ...props }) => (
|
||||||
|
props.className === 'tip-term'
|
||||||
|
? <TipTerm tip={props['data-tip']} onOpen={onTipOpen}>{children}</TipTerm>
|
||||||
|
: <span {...props}>{children}</span>
|
||||||
|
),
|
||||||
|
a: ({ node, href, children, ...props }) => {
|
||||||
|
const target = linkArticles && internalArticle(href)
|
||||||
|
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
|
||||||
|
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||||
|
},
|
||||||
|
// A wide table is the reason this exists; it scrolls inside its own box
|
||||||
|
// rather than pushing the page sideways.
|
||||||
|
table: ({ node, ...props }) => (
|
||||||
|
<div className="rich-table-wrap"><table {...props} /></div>
|
||||||
|
),
|
||||||
|
}), [attemptId, linkArticles, onTipOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rich-text ${className}`.trim()}
|
<div className={`rich-text ${className}`.trim()}
|
||||||
onContextMenu={onRemoveHighlight ? (event) => {
|
onContextMenu={onRemoveHighlight ? (event) => {
|
||||||
|
|
@ -66,25 +93,7 @@ export default function RichText({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
|
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
components={{
|
components={components}>
|
||||||
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
|
||||||
// `{{phrase|tip}}` — a teaching point that opens where the phrase is.
|
|
||||||
span: ({ node, children, ...props }) => (
|
|
||||||
props.className === 'tip-term'
|
|
||||||
? <TipTerm tip={props['data-tip']}>{children}</TipTerm>
|
|
||||||
: <span {...props}>{children}</span>
|
|
||||||
),
|
|
||||||
a: ({ node, href, children, ...props }) => {
|
|
||||||
const target = linkArticles && internalArticle(href)
|
|
||||||
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
|
|
||||||
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
|
||||||
},
|
|
||||||
// A wide table is the reason this exists; it scrolls inside its own
|
|
||||||
// box rather than pushing the page sideways.
|
|
||||||
table: ({ node, ...props }) => (
|
|
||||||
<div className="rich-table-wrap"><table {...props} /></div>
|
|
||||||
),
|
|
||||||
}}>
|
|
||||||
{linkArticles ? expandWikiLinks(value) : (value || '')}
|
{linkArticles ? expandWikiLinks(value) : (value || '')}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import './TipTerm.css'
|
||||||
*
|
*
|
||||||
* Opening one closes the last, so a stem does not fill with panels.
|
* Opening one closes the last, so a stem does not fill with panels.
|
||||||
*/
|
*/
|
||||||
export default function TipTerm({ tip, children }) {
|
export default function TipTerm({ tip, children, onOpen }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [above, setAbove] = useState(false)
|
const [above, setAbove] = useState(false)
|
||||||
const anchor = useRef(null)
|
const anchor = useRef(null)
|
||||||
|
|
@ -29,11 +29,17 @@ export default function TipTerm({ tip, children }) {
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = (event) => {
|
||||||
|
// A tip can sit inside an answer option, which is itself clickable. Asking
|
||||||
|
// for the tip is not choosing that option.
|
||||||
|
event.stopPropagation()
|
||||||
// Flip the card above the phrase when there is no room beneath it.
|
// Flip the card above the phrase when there is no room beneath it.
|
||||||
const box = anchor.current?.getBoundingClientRect?.()
|
const box = anchor.current?.getBoundingClientRect?.()
|
||||||
if (box) setAbove(window.innerHeight - box.bottom < 160)
|
if (box) setAbove(window.innerHeight - box.bottom < 160)
|
||||||
setOpen(value => !value)
|
setOpen(value => {
|
||||||
|
if (!value) onOpen?.()
|
||||||
|
return !value
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,13 @@ function AnswerSplit() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="an-donut-wrap">
|
<div className="an-donut-wrap">
|
||||||
<Donut correct={split.correct} incorrect={split.incorrect}
|
<Donut correct={split.correct} hinted={split.correct_with_hints}
|
||||||
skipped={split.unanswered} />
|
incorrect={split.incorrect} skipped={split.unanswered} />
|
||||||
<ul className="an-legend">
|
<ul className="an-legend">
|
||||||
<li><i className="is-right" />{split.correct} correct</li>
|
<li><i className="is-right" />{split.correct} correct</li>
|
||||||
|
{split.correct_with_hints > 0 && (
|
||||||
|
<li><i className="is-hinted" />{split.correct_with_hints} correct after a tip</li>
|
||||||
|
)}
|
||||||
<li><i className="is-wrong" />{split.incorrect} incorrect</li>
|
<li><i className="is-wrong" />{split.incorrect} incorrect</li>
|
||||||
<li><i className="is-none" />{split.unanswered} unanswered</li>
|
<li><i className="is-none" />{split.unanswered} unanswered</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,8 @@ it('reports completion over a window, and changes the window', async () => {
|
||||||
|
|
||||||
it('counts the same answers two ways: all attempts and the latest one', async () => {
|
it('counts the same answers two ways: all attempts and the latest one', async () => {
|
||||||
const split = {
|
const split = {
|
||||||
all: { correct: 1, incorrect: 1, unanswered: 0, answered: 2, total: 2, percent_correct: 50.0 },
|
all: { correct: 1, correct_with_hints: 1, incorrect: 1, unanswered: 0, answered: 3, total: 3, percent_correct: 66.7 },
|
||||||
latest: { correct: 1, incorrect: 0, unanswered: 0, answered: 1, total: 1, percent_correct: 100.0 },
|
latest: { correct: 1, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 1, total: 1, percent_correct: 100.0 },
|
||||||
attempts: 2, unique_questions: 1,
|
attempts: 2, unique_questions: 1,
|
||||||
}
|
}
|
||||||
api.get.mockImplementation(url =>
|
api.get.mockImplementation(url =>
|
||||||
|
|
@ -126,6 +126,8 @@ it('counts the same answers two ways: all attempts and the latest one', async ()
|
||||||
|
|
||||||
const panel = (await screen.findByText('Analysis')).closest('.an-split-card')
|
const panel = (await screen.findByText('Analysis')).closest('.an-split-card')
|
||||||
expect(within(panel).getByText('1 correct')).toBeInTheDocument()
|
expect(within(panel).getByText('1 correct')).toBeInTheDocument()
|
||||||
|
// Right after a tip is still right, and still says so.
|
||||||
|
expect(within(panel).getByText('1 correct after a tip')).toBeInTheDocument()
|
||||||
expect(within(panel).getByText('1 incorrect')).toBeInTheDocument()
|
expect(within(panel).getByText('1 incorrect')).toBeInTheDocument()
|
||||||
expect(within(panel).getByText(/2 sessions, 1 unique question\./)).toBeInTheDocument()
|
expect(within(panel).getByText(/2 sessions, 1 unique question\./)).toBeInTheDocument()
|
||||||
|
|
||||||
|
|
@ -133,13 +135,15 @@ it('counts the same answers two ways: all attempts and the latest one', async ()
|
||||||
// what is known now.
|
// what is known now.
|
||||||
await userEvent.click(within(panel).getByRole('tab', { name: 'Latest attempt' }))
|
await userEvent.click(within(panel).getByRole('tab', { name: 'Latest attempt' }))
|
||||||
expect(within(panel).getByText('0 incorrect')).toBeInTheDocument()
|
expect(within(panel).getByText('0 incorrect')).toBeInTheDocument()
|
||||||
|
// Nothing to say when no tip was used, so the row is not there at all.
|
||||||
|
expect(within(panel).queryByText(/after a tip/)).not.toBeInTheDocument()
|
||||||
expect(within(panel).getByText(/most recent answer to each of 1 question/)).toBeInTheDocument()
|
expect(within(panel).getByText(/most recent answer to each of 1 question/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('says plainly when nothing has been sat', async () => {
|
it('says plainly when nothing has been sat', async () => {
|
||||||
const empty = {
|
const empty = {
|
||||||
all: { correct: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null },
|
all: { correct: 0, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null },
|
||||||
latest: { correct: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null },
|
latest: { correct: 0, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null },
|
||||||
attempts: 0, unique_questions: 0,
|
attempts: 0, unique_questions: 0,
|
||||||
}
|
}
|
||||||
api.get.mockImplementation(url =>
|
api.get.mockImplementation(url =>
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,10 @@ export default function QuizPage() {
|
||||||
// Seconds spent on each question, banked when you leave it. Without this the
|
// Seconds spent on each question, banked when you leave it. Without this the
|
||||||
// analysis can report a total but never a per-question time.
|
// analysis can report a total but never a per-question time.
|
||||||
const [questionTimes, setQuestionTimes] = useState({})
|
const [questionTimes, setQuestionTimes] = useState({})
|
||||||
|
// Questions where a tip was opened before the answer went in. Right after a
|
||||||
|
// nudge is still right — it is counted as correct — but it is not the same
|
||||||
|
// as right, and the analysis says which.
|
||||||
|
const [hints, setHints] = useState([])
|
||||||
const [favorites, setFavorites] = useState([])
|
const [favorites, setFavorites] = useState([])
|
||||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||||
const [manualHighlights, setManualHighlights] = useState({})
|
const [manualHighlights, setManualHighlights] = useState({})
|
||||||
|
|
@ -660,6 +664,9 @@ export default function QuizPage() {
|
||||||
hasStarted.current = true
|
hasStarted.current = true
|
||||||
setQuizMode(mode)
|
setQuizMode(mode)
|
||||||
setAnswers(savedAnswers)
|
setAnswers(savedAnswers)
|
||||||
|
// A tip read before the break was still read; closing the tab does not
|
||||||
|
// unsee it.
|
||||||
|
setHints((saved.hints || []).map(Number).filter(Number.isFinite))
|
||||||
setCurrentIdx(savedIdx)
|
setCurrentIdx(savedIdx)
|
||||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
setAttemptId(saved.attempt_id || saved.attemptId)
|
||||||
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
||||||
|
|
@ -858,6 +865,7 @@ const timerStarted = timeLeft !== null
|
||||||
quiz_id: parseInt(id),
|
quiz_id: parseInt(id),
|
||||||
attempt_id: attemptId,
|
attempt_id: attemptId,
|
||||||
answers,
|
answers,
|
||||||
|
hints,
|
||||||
current_idx: currentIdx,
|
current_idx: currentIdx,
|
||||||
mode: quizMode,
|
mode: quizMode,
|
||||||
voice: selectedVoice || null,
|
voice: selectedVoice || null,
|
||||||
|
|
@ -868,7 +876,7 @@ const timerStarted = timeLeft !== null
|
||||||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||||||
.then(() => setProgressError(''))
|
.then(() => setProgressError(''))
|
||||||
.catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
|
.catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
|
||||||
}, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
}, [id, answers, hints, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
||||||
|
|
||||||
// Save progress to Redis (survives logout/browser change)
|
// Save progress to Redis (survives logout/browser change)
|
||||||
const saveProgressRef = useRef(null)
|
const saveProgressRef = useRef(null)
|
||||||
|
|
@ -1047,6 +1055,14 @@ const timerStarted = timeLeft !== null
|
||||||
|
|
||||||
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
||||||
|
|
||||||
|
// Only before the answer is in: opening a tip while reading the explanation
|
||||||
|
// is revision, and docking it would punish looking things up afterwards.
|
||||||
|
const noteHint = useCallback(() => {
|
||||||
|
const qid = current?.id
|
||||||
|
if (!qid || answers[qid]) return
|
||||||
|
setHints(prev => (prev.includes(qid) ? prev : [...prev, qid]))
|
||||||
|
}, [current?.id, answers])
|
||||||
|
|
||||||
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
||||||
|
|
||||||
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
||||||
|
|
@ -1073,6 +1089,7 @@ const timerStarted = timeLeft !== null
|
||||||
...questionTimes,
|
...questionTimes,
|
||||||
...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}),
|
...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}),
|
||||||
},
|
},
|
||||||
|
hints,
|
||||||
}
|
}
|
||||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||||
clearInterval(timerRef.current)
|
clearInterval(timerRef.current)
|
||||||
|
|
@ -1093,7 +1110,7 @@ const timerStarted = timeLeft !== null
|
||||||
const detail = err.response?.data?.detail
|
const detail = err.response?.data?.detail
|
||||||
setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.')
|
setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.')
|
||||||
} finally { setSubmitting(false) }
|
} finally { setSubmitting(false) }
|
||||||
}, [attemptId, answers, submitting, navigate, showToast, returnTo])
|
}, [attemptId, answers, hints, submitting, navigate, showToast, returnTo])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leave.
|
* Leave.
|
||||||
|
|
@ -1115,6 +1132,7 @@ const timerStarted = timeLeft !== null
|
||||||
quiz_id: parseInt(id),
|
quiz_id: parseInt(id),
|
||||||
attempt_id: attemptId,
|
attempt_id: attemptId,
|
||||||
answers,
|
answers,
|
||||||
|
hints,
|
||||||
current_idx: currentIdx,
|
current_idx: currentIdx,
|
||||||
mode: quizMode,
|
mode: quizMode,
|
||||||
voice: selectedVoice || null,
|
voice: selectedVoice || null,
|
||||||
|
|
@ -1505,13 +1523,17 @@ const timerStarted = timeLeft !== null
|
||||||
highlights={highlightsFor('question')}
|
highlights={highlightsFor('question')}
|
||||||
speechRange={questionSpeechRange}
|
speechRange={questionSpeechRange}
|
||||||
onRemoveHighlight={removeJoinedHighlight}
|
onRemoveHighlight={removeJoinedHighlight}
|
||||||
|
onTipOpen={noteHint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
||||||
{current.attending_tip && (
|
{current.attending_tip && (
|
||||||
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
||||||
aria-pressed={panel === 'tip'}
|
aria-pressed={panel === 'tip'}
|
||||||
onClick={() => setPanel(p => (p === 'tip' ? null : 'tip'))}>
|
onClick={() => {
|
||||||
|
if (panel !== 'tip') noteHint()
|
||||||
|
setPanel(p => (p === 'tip' ? null : 'tip'))
|
||||||
|
}}>
|
||||||
⚕ <span>Attending tip</span>
|
⚕ <span>Attending tip</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1701,6 +1723,7 @@ const timerStarted = timeLeft !== null
|
||||||
highlights={highlightsFor(optionFieldKey)}
|
highlights={highlightsFor(optionFieldKey)}
|
||||||
speechRange={optionSpeechRange}
|
speechRange={optionSpeechRange}
|
||||||
onRemoveHighlight={removeJoinedHighlight}
|
onRemoveHighlight={removeJoinedHighlight}
|
||||||
|
onTipOpen={noteHint}
|
||||||
/>
|
/>
|
||||||
{showStats && responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
|
{showStats && responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
|
||||||
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>
|
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@ async function begin(study = true) {
|
||||||
await findStem('Full first clinical question.')
|
await findStem('Full first clinical question.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Starting an exam whose first stem carries a tip, so the stem lookup that
|
||||||
|
* `begin` does cannot be used. */
|
||||||
|
async function beginWithTip() {
|
||||||
|
quizModeVar = 'timed'
|
||||||
|
mount()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
|
||||||
|
await screen.findByRole('button', { name: 'stridor' })
|
||||||
|
}
|
||||||
|
|
||||||
describe('quiz player', () => {
|
describe('quiz player', () => {
|
||||||
it('reveals a rail excerpt only once the question has been reached', async () => {
|
it('reveals a rail excerpt only once the question has been reached', async () => {
|
||||||
await begin()
|
await begin()
|
||||||
|
|
@ -465,6 +474,41 @@ describe('quiz player', () => {
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('remembers a tip opened before answering, and submits it with the answer', async () => {
|
||||||
|
questions[0].question_text = 'A child has {{stridor|Inspiratory stridor is extrathoracic}} at rest.'
|
||||||
|
try {
|
||||||
|
await beginWithTip()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'stridor' }))
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent('extrathoracic')
|
||||||
|
fireEvent.keyDown(window, { key: '1' })
|
||||||
|
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', expect.objectContaining({
|
||||||
|
hints: [1],
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
questions[0].question_text = 'Full first clinical question.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a tip read after answering is revision, not a hint', async () => {
|
||||||
|
questions[0].question_text = 'A child has {{stridor|Inspiratory stridor is extrathoracic}} at rest.'
|
||||||
|
try {
|
||||||
|
await beginWithTip()
|
||||||
|
fireEvent.keyDown(window, { key: '1' })
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'stridor' }))
|
||||||
|
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', expect.objectContaining({
|
||||||
|
hints: [],
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
questions[0].question_text = 'Full first clinical question.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('starts timed quizzes in exam mode without a mode prompt', async () => {
|
it('starts timed quizzes in exam mode without a mode prompt', async () => {
|
||||||
quizModeVar = 'timed'
|
quizModeVar = 'timed'
|
||||||
await begin(false)
|
await begin(false)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue