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.
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue