fix: choosing is answering; figures say only what an educator wrote

Study mode held a choice as a draft and waited for "Submit response" — a
second press to confirm something already decided, on every question.
Clicking an option marks it now, green or red, with the explanation.
Free text is the exception and keeps Enter, because typing is not
choosing.

Figures carried a generated caption: "Figure from question #3360 (from
images/doc_23/page_704_img_0.jpeg)". That describes the database, not
the picture, and showed a learner an internal file path. 341 of them are
cleared, the indexer no longer writes them, and an unlabelled figure now
says nothing rather than "Figure 1". A screen reader still gets the
label and caption when there are any, and the position when there are
not.

Suspend, Restart and Edit are gone from above the question. Three
buttons over a question nobody was looking away from to press them; Exit
is in the bar at the bottom with the session's own controls, and
restarting and editing belong to the session list and the editor.

And iOS Safari's zoom-on-focus is fixed once rather than per field.
Safari zooms the whole page in when a control smaller than 16px takes
focus and never zooms back out, leaving the layout scaled and broken. It
was being remembered at each individual field, which meant it was
forgotten at most of them — a dozen were still under 16px. One rule for
every control on a coarse pointer.

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 00:19:35 +02:00
parent 202979f7c0
commit e311a6b5ad
6 changed files with 79 additions and 55 deletions

View file

@ -24,7 +24,10 @@ def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict:
"role": link.role, "role": link.role,
# The label the prose refers to. Falls back to a number so a figure is # The label the prose refers to. Falls back to a number so a figure is
# never nameless, which is what makes "see the figure" ambiguous. # never nameless, which is what makes "see the figure" ambiguous.
"label": link.label or f"Figure {link.position + 1}", # An educator's label, or nothing. "Figure 1" and "Figure from question
# #3360" told a learner only that an image was an image, and the second
# one told them the internal path it came from as well.
"label": link.label or None,
"caption": link.caption or getattr(asset, "caption", None), "caption": link.caption or getattr(asset, "caption", None),
"title": getattr(asset, "title", None), "title": getattr(asset, "title", None),
"path": getattr(asset, "path", None), "path": getattr(asset, "path", None),

View file

@ -112,7 +112,11 @@ def main():
was = historical.get(key) was = historical.get(key)
source = question or was source = question or was
if question: if question:
caption = f"Figure from question #{question[0]}" # No caption unless an educator writes one. A generated one
# ("Figure from question #3360") describes the database, not
# the picture, and was shown to learners as though it were a
# caption — file path and all.
caption = None
state = "in use" state = "in use"
elif was: elif was:
caption = (f"Detached from question #{was[0]} during the stem/answer review — " caption = (f"Detached from question #{was[0]} during the stem/answer review — "

View file

@ -34,12 +34,18 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label }
{figures.map((figure, index) => ( {figures.map((figure, index) => (
<li key={figure.id}> <li key={figure.id}>
<button type="button" className="fs-item" <button type="button" className="fs-item"
aria-label={`Open ${figure.label}${figure.caption ? `: ${figure.caption}` : ''}`} // Whatever an educator wrote, both parts if both exist a
// screen reader gets the caption too, not only the label. With
// neither, the position is the only honest name there is.
aria-label={`Open ${[figure.label, figure.caption].filter(Boolean).join(': ')
|| `figure ${index + 1}`}`}
onClick={() => setOpen(index)}> onClick={() => setOpen(index)}>
<img src={uploadUrl(figure.path, attemptId)} alt={figure.caption || figure.label} <img src={uploadUrl(figure.path, attemptId)} alt={figure.caption || figure.label || ''}
loading="lazy" onError={e => { e.currentTarget.style.visibility = 'hidden' }} /> loading="lazy" onError={e => { e.currentTarget.style.visibility = 'hidden' }} />
<span className="fs-cap"> <span className="fs-cap">
<strong>{figure.label}</strong> {/* Only what somebody wrote. An unlabelled figure says
nothing rather than "Figure 1". */}
{figure.label && <strong>{figure.label}</strong>}
{figure.caption && <span>{figure.caption}</span>} {figure.caption && <span>{figure.caption}</span>}
</span> </span>
</button> </button>

View file

@ -127,6 +127,26 @@ html, body { overflow-x: hidden; max-width: 100%; }
that pins itself below the header measures from here rather than guessing. */ that pins itself below the header measures from here rather than guessing. */
--app-header: 98px; --app-header: 98px;
} }
/* iOS Safari's zoom-on-focus
Safari on iOS zooms the whole page in when a form control whose font is
smaller than 16px takes focus, and it never zooms back out. The page is
left scaled, the layout looks broken, and the only way back is a manual
pinch. It is not a bug we can catch it is the platform's behaviour, and
the only lever is the font size.
Set once, for every control, on touch pointers. It was being remembered at
each individual field, which meant it was being forgotten at most of them
`!important` because those per-field rules are class-scoped and would
otherwise win. Above 16px nothing here applies, so a deliberately larger
field keeps its size. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea {
font-size: max(16px, 1rem) !important;
}
}
.app-shell { display: flex; flex-direction: column; min-height: 100dvh; } .app-shell { display: flex; flex-direction: column; min-height: 100dvh; }
.app-main { flex: 1 0 auto; width: 100%; } .app-main { flex: 1 0 auto; width: 100%; }
.app-shell > .site-footer { flex: none; } .app-shell > .site-footer { flex: none; }

View file

@ -448,7 +448,6 @@ export default function QuizPage() {
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({})
const [draftAnswer, setDraftAnswer] = useState('')
const [tool, setTool] = useState(null) const [tool, setTool] = useState(null)
// Which of the per-question panels is open. One at a time: they sit in the // Which of the per-question panels is open. One at a time: they sit in the
// same place under the stem, and two at once would push the options off screen. // same place under the stem, and two at once would push the options off screen.
@ -478,7 +477,6 @@ export default function QuizPage() {
const [resumeError, setResumeError] = useState('') const [resumeError, setResumeError] = useState('')
const [resumeRetry, setResumeRetry] = useState(0) const [resumeRetry, setResumeRetry] = useState(0)
const [progressError, setProgressError] = useState('') const [progressError, setProgressError] = useState('')
const [restartConfirm, setRestartConfirm] = useState(false)
const timerRef = useRef(null) const timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
@ -632,7 +630,7 @@ export default function QuizPage() {
useEffect(() => { useEffect(() => {
setActiveReadSegment(null) setActiveReadSegment(null)
setTtsActive(false) setTtsActive(false)
setDraftAnswer('') setTyped('')
setOpenExplanations(new Set()) setOpenExplanations(new Set())
savedHighlightSelectionRef.current = null savedHighlightSelectionRef.current = null
clearTimeout(autoHighlightTimerRef.current) clearTimeout(autoHighlightTimerRef.current)
@ -950,7 +948,7 @@ const timerStarted = timeLeft !== null
delete next[questionId] delete next[questionId]
return next return next
}) })
setDraftAnswer('') setTyped('')
} }
const saveNote = async (questionId, content) => { const saveNote = async (questionId, content) => {
@ -968,13 +966,27 @@ const timerStarted = timeLeft !== null
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f))) ? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
} catch { /* the row stays unticked, which is the honest signal */ } } catch { /* the row stays unticked, which is the honest signal */ }
} }
/**
* Choosing is answering.
*
* Study mode used to hold the choice as a draft and wait for "Submit
* response" a second press to confirm something you had already decided,
* on every question. Clicking an option marks it: green if it was right, red
* if it was not, with the explanation. Exam mode records it and moves on
* when you do.
*/
const chooseAnswer = value => { const chooseAnswer = value => {
if (!current || (isStudy && answers[current.id])) return if (!current || (isStudy && answers[current.id])) return
if (isStudy) setDraftAnswer(value) setAnswer(current.id, value)
else setAnswer(current.id, value)
} }
const submitStudyResponse = () => {
if (isStudy && current && !answers[current.id] && draftAnswer.trim()) setAnswer(current.id, draftAnswer) // Free text is the exception: clicking an option is a decision, typing is
// not, so a typed answer is held until Enter or leaving the field.
const [typed, setTyped] = useState('')
const commitTyped = () => {
if (!current || !typed.trim()) return
if (isStudy && answers[current.id]) return
setAnswer(current.id, typed.trim())
} }
useEffect(() => { useEffect(() => {
@ -1114,7 +1126,6 @@ const timerStarted = timeLeft !== null
if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return
if (hasActiveTextSelection()) return if (hasActiveTextSelection()) return
if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1]) if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1])
else if (event.key === 'Enter' && isStudy) submitStudyResponse()
else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1)) else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1))
else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1)) else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1))
else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id) else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id)
@ -1124,7 +1135,7 @@ const timerStarted = timeLeft !== null
} }
window.addEventListener('keydown', keydown) window.addEventListener('keydown', keydown)
return () => window.removeEventListener('keydown', keydown) return () => window.removeEventListener('keydown', keydown)
}, [quizMode, current, currentIdx, answers, draftAnswer, favorites, expandedImagePath]) }, [quizMode, current, currentIdx, answers, favorites, expandedImagePath])
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null if (!quiz) return null
@ -1362,25 +1373,10 @@ const timerStarted = timeLeft !== null
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />} {timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => leaveNow()} {/* Suspend, Restart and Edit were three buttons above a question
title="Save your answers and pick this up later — the clock stops too"> nobody was looking away from to press them. Exit is in the bar
Suspend at the bottom, where the session's own controls are; restarting
</button> and editing belong to the session list and the editor. */}
{restartConfirm ? (
<span className="quiz-restart-confirm" role="alert">Restart from the beginning?
<button className="btn btn-primary btn-sm" onClick={async () => {
setRestartConfirm(false)
setAnswers({}); setCurrentIdx(0); setDraftAnswer(''); setStartedAt(null)
setTimeLeft(null); setTotalTime(null); setResponseStats(null); setStatsError('')
hasStarted.current = false
await startAttempt(quizMode || (quiz?.mode === 'timed' ? 'exam' : 'study'), selectedVoice || null, quiz?.time_limit_minutes || null, true)
}}>Yes, restart</button>
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(false)}>Cancel</button>
</span>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning"> Restart</button>
)}
{isModerator && <Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div> </div>
</div> </div>
{voices.length > 1 && ( {voices.length > 1 && (
@ -1605,7 +1601,7 @@ const timerStarted = timeLeft !== null
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? ( {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 8 }}> <div className="options" style={{ marginTop: 8 }}>
{current.options.map((opt, i) => { {current.options.map((opt, i) => {
const isSelected = (answers[current.id] || draftAnswer) === opt const isSelected = answers[current.id] === opt
const hasAnswered = isStudy && !!answers[current.id] const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase() const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
const showCorrect = hasAnswered && isCorrectOpt const showCorrect = hasAnswered && isCorrectOpt
@ -1660,14 +1656,14 @@ const timerStarted = timeLeft !== null
})} })}
</div> </div>
) : ( ) : (
<input type="text" placeholder="Type your answer..." <input type="text" placeholder="Type your answer, then press Enter"
value={answers[current.id] || draftAnswer} value={answers[current.id] ?? typed}
readOnly={isStudy && Boolean(answers[current.id])} readOnly={isStudy && Boolean(answers[current.id])}
onChange={e => chooseAnswer(e.target.value)} onChange={e => setTyped(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && isStudy) { e.preventDefault(); submitStudyResponse() } }} onBlur={commitTyped}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitTyped() } }}
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} /> style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
)} )}
{isStudy && !answers[current.id] && <button type="button" className="btn btn-primary quiz-submit-response" disabled={!draftAnswer.trim()} onClick={submitStudyResponse}>Submit response</button>}
{isStudy && answers[current.id] && ( {isStudy && answers[current.id] && (
<> <>
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div> <div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>

View file

@ -125,9 +125,8 @@ describe('quiz player', () => {
await begin() await begin()
const before = document.querySelectorAll('.quiz-rail-item') const before = document.querySelectorAll('.quiz-rail-item')
expect(before[0].className).not.toMatch(/is-done/) expect(before[0].className).not.toMatch(/is-done/)
// Choosing is answering: the rail marks it on the click.
await userEvent.click(inCard().getByText('First answer').closest('.option')) await userEvent.click(inCard().getByText('First answer').closest('.option'))
// Study mode holds a draft until it is submitted, so the rail marks it then.
await userEvent.click(screen.getByRole('button', { name: 'Submit response' }))
await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/)) await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/))
}) })
@ -143,7 +142,6 @@ describe('quiz player', () => {
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument() expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await within(meta).findByText('hard')).toBeInTheDocument() expect(await within(meta).findByText('hard')).toBeInTheDocument()
// The category trail is gone: it named the answer's own topic, and led out // The category trail is gone: it named the answer's own topic, and led out
// of a session you are part-way through. // of a session you are part-way through.
@ -233,7 +231,7 @@ describe('quiz player', () => {
// Study mode holds a selection until it is confirmed, so confirm it the // Study mode holds a selection until it is confirmed, so confirm it the
// point of this test is what is saved, not what is provisionally picked. // point of this test is what is saved, not what is provisionally picked.
fireEvent.keyDown(window, { key: 'Enter' }) fireEvent.keyDown(window, { key: 'Enter' })
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open') expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
failSaving = false failSaving = false
@ -247,17 +245,18 @@ describe('quiz player', () => {
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
// One press. Nothing is lost by leaving the answers are saved and the // One press. Nothing is lost by leaving the answers are saved and the
// clock pauses so there is nothing to confirm. // clock pauses so there is nothing to confirm.
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
// Leaving a session part-way through should land where what you answered // Leaving a session part-way through should land where what you answered
// is scored and Resume sits not on a list of every session you own. // is scored and Resume sits not on a list of every session you own.
expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(await screen.findByText('Submitted results')).toBeInTheDocument()
}) })
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => { it('marks a study answer the moment it is chosen, and shows genuine response data', async () => {
await begin() await begin()
// Choosing is answering. It used to hold the choice and wait for "Submit
// response" a second press to confirm something already decided, on
// every question.
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
expect(await screen.findByText('8/10')).toBeInTheDocument() expect(await screen.findByText('8/10')).toBeInTheDocument()
expect(screen.getByText('80%')).toBeInTheDocument() expect(screen.getByText('80%')).toBeInTheDocument()
@ -296,7 +295,6 @@ describe('quiz player', () => {
await begin() await begin()
await findStem('Full first clinical question.') await findStem('Full first clinical question.')
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
const option = await waitFor(() => inCard().getByText('First answer').closest('button')) const option = await waitFor(() => inCard().getByText('First answer').closest('button'))
expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument() expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument()
@ -350,7 +348,6 @@ describe('quiz player', () => {
}) })
await begin() await begin()
await userEvent.click(screen.getByRole('button', { name: /1\s*First answer/ })) await userEvent.click(screen.getByRole('button', { name: /1\s*First answer/ }))
await userEvent.click(screen.getByRole('button', { name: 'Submit response' }))
expect(screen.queryByText('Preferred because of this')).not.toBeInTheDocument() expect(screen.queryByText('Preferred because of this')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Show all explanations' })) await userEvent.click(screen.getByRole('button', { name: 'Show all explanations' }))
expect(await screen.findByText('Preferred because of this')).toBeInTheDocument() expect(await screen.findByText('Preferred because of this')).toBeInTheDocument()
@ -364,7 +361,6 @@ describe('quiz player', () => {
it('lets the learner hide response statistics', async () => { it('lets the learner hide response statistics', async () => {
await begin() await begin()
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText('8/10')).toBeInTheDocument() expect(await screen.findByText('8/10')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Hide stats' })) await userEvent.click(screen.getByRole('button', { name: 'Hide stats' }))
expect(screen.queryByText('8/10')).not.toBeInTheDocument() expect(screen.queryByText('8/10')).not.toBeInTheDocument()
@ -377,7 +373,6 @@ describe('quiz player', () => {
mode = 'exam' mode = 'exam'
await begin(false) await begin(false)
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(screen.queryByText(/recorded answers/)).not.toBeInTheDocument() expect(screen.queryByText(/recorded answers/)).not.toBeInTheDocument()
expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/'))).toBe(false) expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/'))).toBe(false)
}) })
@ -390,7 +385,6 @@ describe('quiz player', () => {
}) })
await begin() await begin()
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText('No response statistics available yet')).toBeInTheDocument() expect(await screen.findByText('No response statistics available yet')).toBeInTheDocument()
expect(screen.queryByText('0%')).not.toBeInTheDocument() expect(screen.queryByText('0%')).not.toBeInTheDocument()
}) })
@ -409,7 +403,6 @@ describe('quiz player', () => {
expect(screen.getByAltText('Expanded question illustration')).toHaveAttribute('src', '/uploads/questions/stem.png?attempt_id=50') expect(screen.getByAltText('Expanded question illustration')).toHaveAttribute('src', '/uploads/questions/stem.png?attempt_id=50')
await userEvent.click(screen.getByRole('button', { name: 'Close expanded image' })) await userEvent.click(screen.getByRole('button', { name: 'Close expanded image' }))
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByAltText('Explanation illustration')).toHaveAttribute('src', '/uploads/questions/answer.png?attempt_id=50') expect(await screen.findByAltText('Explanation illustration')).toHaveAttribute('src', '/uploads/questions/answer.png?attempt_id=50')
}) })
@ -424,7 +417,9 @@ describe('quiz player', () => {
expect(api.post.mock.calls.some(([url]) => url === '/favorites')).toBe(false) expect(api.post.mock.calls.some(([url]) => url === '/favorites')).toBe(false)
await userEvent.click(within(dialog).getByRole('button', { name: 'Close Calculator' })) await userEvent.click(within(dialog).getByRole('button', { name: 'Close Calculator' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Submit response' })).toBeDisabled() // The keystrokes typed into the calculator did not answer the question
// behind it which is the point of the test.
expect(document.querySelector('.option.correct')).toBeNull()
}) })
it('keeps exam answers hidden and reviews unanswered questions before completing', async () => { it('keeps exam answers hidden and reviews unanswered questions before completing', async () => {