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:
Daniel 2026-09-12 01:51:03 +02:00
parent f048b1f4b6
commit 789cd1cc81
16 changed files with 242 additions and 47 deletions

View 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")

View file

@ -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")

View file

@ -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,

View file

@ -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

View file

@ -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):

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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); }

View file

@ -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 (
<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
none. It says how far through it is instead. */}
<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 x="70" y="86" textAnchor="middle" className="an-donut-label">
{graded ? 'correct' : 'answered'}

View file

@ -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 }) => <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 (
<div className={`rich-text ${className}`.trim()}
onContextMenu={onRemoveHighlight ? (event) => {
@ -66,25 +93,7 @@ export default function RichText({
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
rehypePlugins={rehypePlugins}
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>
),
}}>
components={components}>
{linkArticles ? expandWikiLinks(value) : (value || '')}
</ReactMarkdown>
</div>

View file

@ -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 (

View file

@ -144,10 +144,13 @@ function AnswerSplit() {
) : (
<>
<div className="an-donut-wrap">
<Donut correct={split.correct} incorrect={split.incorrect}
skipped={split.unanswered} />
<Donut correct={split.correct} hinted={split.correct_with_hints}
incorrect={split.incorrect} skipped={split.unanswered} />
<ul className="an-legend">
<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-none" />{split.unanswered} unanswered</li>
</ul>

View file

@ -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 =>

View file

@ -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}
/>
</div>
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
{current.attending_tip && (
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
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>
</button>
)}
@ -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] && <span className="quiz-response-stat">
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>

View file

@ -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)