diff --git a/backend/alembic/versions/e1f2a3b4c5d6_used_hint.py b/backend/alembic/versions/e1f2a3b4c5d6_used_hint.py new file mode 100644 index 0000000..a449bbb --- /dev/null +++ b/backend/alembic/versions/e1f2a3b4c5d6_used_hint.py @@ -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") diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 324749f..f0cbb4d 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -36,6 +36,10 @@ class AttemptAnswer(Base): # measured, which is not the same claim as zero. seconds_spent = Column(Integer, nullable=True) 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") question = relationship("Question") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 95c2adc..39c63f4 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -137,10 +137,12 @@ def submit_attempt( [(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids) score = sum(correct for _, _, correct in grades) timings = submission.timings or {} + hinted = set(submission.hints or []) for question, user_answer, is_correct in grades: db.add(AttemptAnswer(attempt_id=attempt_id, question_id=question.id, 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) # Review and grading use the same selected set, including skipped outcomes. @@ -234,6 +236,7 @@ class ProgressSave(BaseModel): quiz_id: int attempt_id: int 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 mode: str voice: str | None = None @@ -278,6 +281,7 @@ def save_progress( "quiz_id": data.quiz_id, "attempt_id": data.attempt_id, "answers": data.answers, + "hints": data.hints or [], "current_idx": data.current_idx, "mode": attempt.mode or "exam", "voice": data.voice, diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 33a757f..2b3b916 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -291,18 +291,25 @@ def completion( def _split(rows) -> dict: - """Correct / incorrect / unanswered, with the percentage out of answered.""" - correct = sum(1 for row in rows if row.user_answer and row.is_correct) + """Correct / correct with a tip / incorrect / unanswered. + + 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) blank = sum(1 for row in rows if not row.user_answer) - answered = correct + incorrect + answered = len(right) + incorrect return { - "correct": correct, + "correct": len(right) - hinted, + "correct_with_hints": hinted, "incorrect": incorrect, "unanswered": blank, "answered": answered, "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( AttemptAnswer.question_id, AttemptAnswer.is_correct, AttemptAnswer.user_answer, + AttemptAnswer.used_hint, QuizAttempt.id.label("attempt_id"), QuizAttempt.completed_at, ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id ).join(Quiz, Quiz.id == QuizAttempt.quiz_id diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index b5da978..e42708b 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -13,6 +13,10 @@ class AttemptSubmit(BaseModel): # {question_id: seconds}. Absent for a client that does not measure, which is # why the column is nullable rather than defaulted to zero. 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): diff --git a/backend/app/services/attempt_expiry.py b/backend/app/services/attempt_expiry.py index 29854b4..f1def40 100644 --- a/backend/app/services/attempt_expiry.py +++ b/backend/app/services/attempt_expiry.py @@ -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()] grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), answers, attempt.selected_question_ids) + hinted = {int(qid) for qid in (saved.get("hints") or [])} for question, answer, correct in grades: 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.total_questions = len(grades) attempt.completed_at = datetime.utcnow() diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index a2123a1..5074835 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -333,3 +333,43 @@ class LiveAnalysisTests(unittest.TestCase): 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)) + + +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) diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index d0d4b57..7af7078 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -288,7 +288,7 @@ class CompletionTests(unittest.TestCase): def tearDown(self): 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 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 # row — the same shape the player submits. user_answer=('yes' if correct else 'no') if answered else '', - seconds_spent=seconds)) + seconds_spent=seconds, used_hint=hint)) self.bank.db.commit() def get(self, **params): @@ -374,10 +374,10 @@ class AnswerSplitTests(CompletionTests): self.assertEqual(data['unique_questions'], 1) # Half the work was wrong; what is known now is right. 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}) 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}) 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['unique_questions'], 0) 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) diff --git a/frontend/src/components/Donut.css b/frontend/src/components/Donut.css index 124577d..85a3998 100644 --- a/frontend/src/components/Donut.css +++ b/frontend/src/components/Donut.css @@ -8,6 +8,7 @@ .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 .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-answered { background: var(--primary); } .an-legend .is-none { background: var(--border); } diff --git a/frontend/src/components/Donut.jsx b/frontend/src/components/Donut.jsx index baf92f2..c8391a8 100644 --- a/frontend/src/components/Donut.jsx +++ b/frontend/src/components/Donut.jsx @@ -7,22 +7,27 @@ import './Donut.css' * scales, and two drawings of it would eventually disagree. * * `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 }) { - const total = correct + incorrect + answered + skipped +export default function Donut({ correct, hinted = 0, incorrect, answered = 0, skipped, graded = true }) { + const total = correct + hinted + incorrect + answered + skipped if (!total) return null const circumference = 2 * Math.PI * 54 const slice = (n) => (n / total) * circumference let offset = 0 const arcs = [ { 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: answered, colour: 'var(--primary)' }, { value: skipped, colour: 'var(--border)' }, ] 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` return ( @@ -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 none. It says how far through it is instead. */} - {graded ? `${Math.round((correct / total) * 100)}%` : `${answered}/${total}`} + {graded ? `${Math.round(((correct + hinted) / total) * 100)}%` : `${answered}/${total}`} {graded ? 'correct' : 'answered'} diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx index 1ba9203..fed3715 100644 --- a/frontend/src/components/RichText.jsx +++ b/frontend/src/components/RichText.jsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' @@ -51,10 +52,36 @@ export default function RichText({ highlights = [], speechRange = 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] 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 }) => , + // `{{phrase|tip}}` — a teaching point that opens where the phrase is. + span: ({ node, children, ...props }) => ( + props.className === 'tip-term' + ? {children} + : {children} + ), + a: ({ node, href, children, ...props }) => { + const target = linkArticles && internalArticle(href) + if (target) return {children} + return {children} + }, + // A wide table is the reason this exists; it scrolls inside its own box + // rather than pushing the page sideways. + table: ({ node, ...props }) => ( +
+ ), + }), [attemptId, linkArticles, onTipOpen]) + return (
{ @@ -66,25 +93,7 @@ export default function RichText({ , - // `{{phrase|tip}}` — a teaching point that opens where the phrase is. - span: ({ node, children, ...props }) => ( - props.className === 'tip-term' - ? {children} - : {children} - ), - a: ({ node, href, children, ...props }) => { - const target = linkArticles && internalArticle(href) - if (target) return {children} - return {children} - }, - // A wide table is the reason this exists; it scrolls inside its own - // box rather than pushing the page sideways. - table: ({ node, ...props }) => ( -
- ), - }}> + components={components}> {linkArticles ? expandWikiLinks(value) : (value || '')} diff --git a/frontend/src/components/TipTerm.jsx b/frontend/src/components/TipTerm.jsx index 3856187..4013f78 100644 --- a/frontend/src/components/TipTerm.jsx +++ b/frontend/src/components/TipTerm.jsx @@ -10,7 +10,7 @@ import './TipTerm.css' * * 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 [above, setAbove] = useState(false) const anchor = useRef(null) @@ -29,11 +29,17 @@ export default function TipTerm({ tip, children }) { } }, [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. const box = anchor.current?.getBoundingClientRect?.() if (box) setAbove(window.innerHeight - box.bottom < 160) - setOpen(value => !value) + setOpen(value => { + if (!value) onOpen?.() + return !value + }) } return ( diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index de164b2..86c146d 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -144,10 +144,13 @@ function AnswerSplit() { ) : ( <>
- +
  • {split.correct} correct
  • + {split.correct_with_hints > 0 && ( +
  • {split.correct_with_hints} correct after a tip
  • + )}
  • {split.incorrect} incorrect
  • {split.unanswered} unanswered
diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx index e6d24ba..a2159f8 100644 --- a/frontend/src/pages/AnalysisPage.test.jsx +++ b/frontend/src/pages/AnalysisPage.test.jsx @@ -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 () => { const split = { - all: { correct: 1, incorrect: 1, unanswered: 0, answered: 2, total: 2, percent_correct: 50.0 }, - latest: { correct: 1, incorrect: 0, unanswered: 0, answered: 1, total: 1, percent_correct: 100.0 }, + all: { correct: 1, correct_with_hints: 1, incorrect: 1, unanswered: 0, answered: 3, total: 3, percent_correct: 66.7 }, + latest: { correct: 1, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 1, total: 1, percent_correct: 100.0 }, attempts: 2, unique_questions: 1, } 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') 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(/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. await userEvent.click(within(panel).getByRole('tab', { name: 'Latest attempt' })) 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() }) it('says plainly when nothing has been sat', async () => { const empty = { - all: { correct: 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 }, + all: { correct: 0, correct_with_hints: 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, } api.get.mockImplementation(url => diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 27b0144..3b7ffc9 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -446,6 +446,10 @@ export default function QuizPage() { // Seconds spent on each question, banked when you leave it. Without this the // analysis can report a total but never a per-question time. 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 [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) @@ -660,6 +664,9 @@ export default function QuizPage() { hasStarted.current = true setQuizMode(mode) 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) setAttemptId(saved.attempt_id || saved.attemptId) 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), attempt_id: attemptId, answers, + hints, current_idx: currentIdx, mode: quizMode, voice: selectedVoice || null, @@ -868,7 +876,7 @@ const timerStarted = timeLeft !== null }, { headers: { 'x-quiz-session': SESSION_ID } }) .then(() => setProgressError('')) .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) const saveProgressRef = useRef(null) @@ -1047,6 +1055,14 @@ const timerStarted = timeLeft !== null 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 safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => { @@ -1073,6 +1089,7 @@ const timerStarted = timeLeft !== null ...questionTimes, ...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}), }, + hints, } const res = await api.post(`/attempts/${attemptId}/submit`, submission) clearInterval(timerRef.current) @@ -1093,7 +1110,7 @@ const timerStarted = timeLeft !== null 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, returnTo]) + }, [attemptId, answers, hints, submitting, navigate, showToast, returnTo]) /** * Leave. @@ -1115,6 +1132,7 @@ const timerStarted = timeLeft !== null quiz_id: parseInt(id), attempt_id: attemptId, answers, + hints, current_idx: currentIdx, mode: quizMode, voice: selectedVoice || null, @@ -1505,13 +1523,17 @@ const timerStarted = timeLeft !== null highlights={highlightsFor('question')} speechRange={questionSpeechRange} onRemoveHighlight={removeJoinedHighlight} + onTipOpen={noteHint} />
{current.attending_tip && ( )} @@ -1701,6 +1723,7 @@ const timerStarted = timeLeft !== null highlights={highlightsFor(optionFieldKey)} speechRange={optionSpeechRange} onRemoveHighlight={removeJoinedHighlight} + onTipOpen={noteHint} /> {showStats && responseStats?.sample_size > 0 && responseStats.options?.[i] && diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 3f98e49..4a8c71e 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -73,6 +73,15 @@ async function begin(study = true) { 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', () => { it('reveals a rail excerpt only once the question has been reached', async () => { 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 () => { quizModeVar = 'timed' await begin(false)