feat: the exam player as the reference has it

The incomplete-block warning is the one from the screenshot: a red
heading that says the block is incomplete, the count of unanswered items,
the sentence about resuming not matching exam day, and End Block against
Remain in Block. My version asked the question in my own words and led
with the wrong button.

Pausing says "Exam Paused" and offers Return to exam. Nothing else — the
warning about real exams is somebody else's disclaimer, not ours.

Exit session asks "Are you sure you want to end this session?" before it
goes, rather than going.

Time's Up says what it is and the button says Close, which is the only
thing left to do: it is already handed in and marked, and Close lands on
the session's analysis.

One name for one action: the bottom button read Skip on an unanswered
question and Next on an answered one, while the arrow an inch above it
said Next for both.

And the rail shows stems again once the block is handed in. Numbers while
it is being sat — reading ahead is not something the exam being rehearsed
allows — but there is nothing left to protect afterwards, so the review
reads like study mode.

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 05:43:25 +02:00
parent 1959534754
commit 7ba37561e4
9 changed files with 194 additions and 129 deletions

View file

@ -53,7 +53,13 @@ class Settings(BaseSettings):
S3_REGION: str = "us-east-1" S3_REGION: str = "us-east-1"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB MAX_UPLOAD_SIZE: int = 524288000 # 500MB
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha # hCaptcha. Leave the secret blank to disable the challenge entirely.
HCAPTCHA_SECRET_KEY: str = ""
# The browser gets its own copy of the site key from the frontend
# container, which is a separate image with a separate .env. This one is
# here so an operator can keep both halves of the pair together and see
# at a glance which widget the secret belongs to.
HCAPTCHA_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email

View file

@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services import invites, site_settings from app.services import captcha, invites, site_settings
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
@ -61,25 +61,6 @@ def _check_reset_rate_limit(db: Session, email: str):
) )
async def _verify_turnstile(token: str) -> bool:
"""Verify Cloudflare Turnstile token. Returns True if valid or if not configured."""
from app.config import settings as cfg
secret = cfg.TURNSTILE_SECRET_KEY
if not secret:
return True
try:
import httpx
resp = httpx.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
return resp.json().get("success", False)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Turnstile verification failed (failing open): {e}")
return True
@router.get("/signup-policy") @router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)): def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody. """What a would-be member needs, before they are anybody.
@ -98,13 +79,9 @@ def signup_policy(db: Session = Depends(get_db)):
@router.post("/register") @router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Verify Turnstile if configured # Failing open: an hCaptcha outage should cost the site a little spam, not
from app.config import settings as cfg # every account that would have been created while it lasted.
if cfg.TURNSTILE_SECRET_KEY: await captcha.require_human(user_data.captcha_token, fail_open=True)
if not user_data.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(user_data.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
# Check if registration is enabled (unless this is the first user - always allow admin creation) # Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0 is_first_user = db.query(User).count() == 0

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.config import settings from app.config import settings
from app.services import captcha
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -20,25 +21,7 @@ class ContactRequest(BaseModel):
email: EmailStr email: EmailStr
type: str # "question" | "moderator" type: str # "question" | "moderator"
message: str message: str
turnstile_token: str | None = None captcha_token: str | None = None
async def _verify_turnstile(token: str) -> bool:
"""Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured."""
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
if not secret:
return True # Not configured — allow in dev/no-captcha mode
try:
import httpx
resp = httpx.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
return resp.json().get("success", False)
except Exception as e:
log.warning(f"Turnstile verification failed: {e}")
return False
@router.post("") @router.post("")
@ -52,13 +35,10 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
if not name or not message: if not name or not message:
raise HTTPException(status_code=400, detail="Name and message are required") raise HTTPException(status_code=400, detail="Name and message are required")
# Verify Turnstile if configured # Failing shut, unlike registration: if hCaptcha cannot be reached, the
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None) # cost of turning this form away is one retry, and the inbox behind it has
if secret: # no other defence.
if not req.turnstile_token: await captcha.require_human(req.captcha_token, fail_open=False)
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(req.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
db.execute(text( db.execute(text(
"INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)" "INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"

View file

@ -138,7 +138,7 @@ def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
@router.post("/auth/login") @router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)): def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
"""Password login for native apps. Uses rate limiting instead of browser Turnstile.""" """Password login for native apps. Uses rate limiting instead of a browser captcha."""
client_ip = request.client.host if request and request.client else "unknown" client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip) _mobile_login_rate_limit(client_ip)

View file

@ -7,7 +7,8 @@ class UserCreate(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
name: str name: str
turnstile_token: str | None = None #: Solved hCaptcha challenge. Absent when the site has no secret configured.
captcha_token: str | None = None
#: Required only while the site is invite-only. #: Required only while the site is invite-only.
invite_code: str | None = None invite_code: str | None = None

View file

@ -0,0 +1,66 @@
"""hCaptcha checks on the two forms a stranger can reach.
Both the sign-up and the contact form used to carry their own copy of this,
which is how they drifted apart. It lives here once so that the answer to
"what happens when the challenge is missing, wrong, or unanswerable" is given
in a single place.
A blank secret means no captcha at all. That is the deliberate default: a
deployment that has never signed up for hCaptcha must still be able to
register its first administrator, so an unset key reads as "there is no
challenge here", never as "reject everybody".
"""
import logging
from fastapi import HTTPException
from app.config import settings
logger = logging.getLogger(__name__)
VERIFY_URL = "https://api.hcaptcha.com/siteverify"
def configured() -> bool:
return bool(settings.HCAPTCHA_SECRET_KEY)
async def verify(token: str, *, fail_open: bool) -> bool:
"""Ask hCaptcha whether this token is a real, unspent solve.
`fail_open` decides what an unreachable hCaptcha means, and the two callers
genuinely want different answers: an outage that stops people creating
accounts costs the site its users, while an outage that bounces a contact
message costs the sender one retry.
"""
if not configured():
return True
try:
import httpx
# Awaited rather than blocking: uvicorn runs four workers, and a
# ten-second stall on a third party would otherwise hold up every
# other request that worker is serving.
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
VERIFY_URL,
data={"secret": settings.HCAPTCHA_SECRET_KEY, "response": token},
)
return resp.json().get("success", False)
except Exception:
logger.warning("hCaptcha verification unavailable; %s",
"allowing the request" if fail_open else "rejecting the request",
exc_info=True)
return fail_open
async def require_human(token: str | None, *, fail_open: bool) -> None:
"""Reject the submission unless it carries a solved challenge.
Silent when no secret is set, so an unconfigured site keeps working.
"""
if not configured():
return
if not token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await verify(token, fail_open=fail_open):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")

View file

@ -433,6 +433,10 @@ export default function QuizPage() {
const [sessionSeconds, setSessionSeconds] = useState(0) const [sessionSeconds, setSessionSeconds] = useState(0)
const [questionSeconds, setQuestionSeconds] = useState(0) const [questionSeconds, setQuestionSeconds] = useState(0)
const [clockPaused, setClockPaused] = useState(false) const [clockPaused, setClockPaused] = useState(false)
// Asked before the session closes. Leaving is not destructive the answers
// are saved and it can be resumed but it is not what a mis-aimed thumb
// should do either.
const [leaving, setLeaving] = useState(false)
// The clock ran out: the answers are in, and the analysis waits behind an // The clock ran out: the answers are in, and the analysis waits behind an
// acknowledgement rather than replacing the exam without a word. // acknowledgement rather than replacing the exam without a word.
const [timeUp, setTimeUp] = useState(false) const [timeUp, setTimeUp] = useState(false)
@ -1250,12 +1254,12 @@ const timerStarted = timeLeft !== null
{isStudy ? 'Finish session' : 'End block'} {isStudy ? 'Finish session' : 'End block'}
</button> </button>
) : ( ) : (
/* Skip, when nothing has been chosen. Moving on from a question you /* One name. It used to read Skip on an unanswered question and Next on
have not answered is a decision, and the button should say which an answered one, while the arrow at the top of the screen said Next
decision it is rather than calling both of them Next. */ for both two words for one action, an inch apart. */
<button className="btn btn-primary" <button className="btn btn-primary"
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}> onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
{answers[current?.id] ? 'Next →' : 'Skip →'} Next
</button> </button>
)} )}
</div> </div>
@ -1286,6 +1290,9 @@ const timerStarted = timeLeft !== null
} }
} }
// Handed in: the block is over, so the rail may say what each question was.
const reviewing = answeredCount >= totalCount && totalCount > 0
const QuestionRailItem = ({ q, i }) => { const QuestionRailItem = ({ q, i }) => {
const isActive = i === currentIdx const isActive = i === currentIdx
const isDone = !!answers[q.id] const isDone = !!answers[q.id]
@ -1293,10 +1300,11 @@ const timerStarted = timeLeft !== null
// Only questions the learner has reached show their text. Previewing one // Only questions the learner has reached show their text. Previewing one
// they have not opened would give away the case before they read it. // they have not opened would give away the case before they read it.
// //
// In exam mode none of them do. A real paper's status rail is a column of // While an exam is being sat, none of them do: a real paper's status rail
// numbers, and reading the stems of the questions still to come is not // is a column of numbers, and reading the stems still to come is not
// something the exam being rehearsed would allow. // something the exam being rehearsed would allow. Once it is handed in
const seen = !isStudy ? false : seenIndexes.has(i) // there is nothing left to protect, so the review reads like study mode.
const seen = (isStudy || reviewing) ? seenIndexes.has(i) : false
const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : '' const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : ''
return ( return (
<button type="button" <button type="button"
@ -1328,63 +1336,85 @@ const timerStarted = timeLeft !== null
.map((question, index) => ({ question, index })) .map((question, index) => ({ question, index }))
.filter(({ question }) => !answers[question.id]) .filter(({ question }) => !answers[question.id])
return ( return (
<QuizDialog title="End block?" onClose={() => setShowReview(false)}> <div className="quiz-away" role="dialog" aria-modal="true"
{/* A confirmation with one button is not a confirmation. The way aria-labelledby="endblock-heading">
back into the block is the first thing offered, and the count <div className={`quiz-away-card quiz-endblock${missing.length ? ' is-warning' : ''}`}>
is a sentence rather than a grid to be counted by eye. */} <h2 id="endblock-heading">
<p className="quiz-end-lead"> {missing.length
{missing.length === 0 ? 'Warning - This block is incomplete!'
? `All ${totalCount} questions are answered.` : 'End Block'}
: `${missing.length} of ${totalCount} question${totalCount === 1 ? '' : 's'} ` </h2>
+ `${missing.length === 1 ? 'is' : 'are'} unanswered.`} {missing.length ? (
</p>
{missing.length > 0 && (
<> <>
<p className="quiz-end-note"> <p>Number of unanswered items in this block: {missing.length}</p>
They are not marked wrong the score is out of what you {/* Said because it is true, not to talk anybody out of it:
answered but they are not marked at all. the block can be resumed, and a real paper cannot. */}
<p>
You will be able to resume, however we do not recommend this
as this deviates from your exam day experience.
</p> </p>
<div className="quiz-end-missing">
{missing.map(({ question, index }) => (
<button type="button" key={question.id}
onClick={() => { safeNavigate(index); setShowReview(false) }}>
{index + 1}
</button>
))}
</div>
</> </>
) : (
<p>All {totalCount} questions are answered.</p>
)} )}
<div className="quiz-end-actions"> <div className="quiz-away-actions">
<button type="button" className="btn btn-secondary" <button type="button" className="btn quiz-endblock-go" disabled={submitting}
onClick={() => setShowReview(false)}>
{missing.length > 0 ? 'Go back and answer them' : 'Keep working'}
</button>
<button type="button" className="btn btn-primary" disabled={submitting}
onClick={() => { setShowReview(false); handleSubmit(false) }}> onClick={() => { setShowReview(false); handleSubmit(false) }}>
{submitting ? 'Ending…' : 'End block'} {submitting ? 'Ending…' : 'End Block'}
</button> </button>
<button type="button" className="btn btn-primary"
onClick={() => setShowReview(false)}>Remain in Block</button>
</div>
</div>
</div> </div>
</QuizDialog>
) )
})()} })()}
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>} {submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>} {progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
{timeUp && ( {timeUp && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading"> <div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading">
<div className="quiz-away-card"> <div className="quiz-away-card">
<h2 id="timeup-heading">Time's up</h2> <h2 id="timeup-heading">Time&apos;s Up</h2>
<p> <p>You have run out of time to complete this question block.</p>
You have run out of time for this block. What you answered has {/* Closing is the only thing left to do: it is already handed in
been handed in and marked. and marked, and this lands on the session's analysis. */}
</p>
<button type="button" className="btn btn-primary" disabled={submitting} <button type="button" className="btn btn-primary" disabled={submitting}
onClick={() => { setTimeUp(false); afterTimeUp?.() }}> onClick={() => { setTimeUp(false); afterTimeUp?.() }}>
{submitting ? 'Marking…' : 'See how you did'} {submitting ? 'Marking…' : 'Close'}
</button> </button>
</div> </div>
</div> </div>
)} )}
{clockPaused && !isStudy && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="paused-heading">
<div className="quiz-away-card">
<h2 id="paused-heading">Exam Paused</h2>
{/* Nothing else. The clock is stopped and the questions are
covered; a paragraph about how real exams work is somebody
else's disclaimer. */}
<button type="button" className="btn btn-primary"
onClick={() => setClockPaused(false)}>Return to exam</button>
</div>
</div>
)}
{leaving && (
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="leaving-heading">
<div className="quiz-away-card">
<h2 id="leaving-heading">End Session</h2>
<p>Are you sure you want to end this session?</p>
<div className="quiz-away-actions">
<button type="button" className="btn btn-primary"
onClick={() => { setLeaving(false); leaveNow() }}>End Session</button>
<button type="button" className="btn btn-secondary"
onClick={() => setLeaving(false)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Still there? The clock is already stopped by the time this shows {/* Still there? The clock is already stopped by the time this shows
it is not a threat, it is how the time stays honest. */} it is not a threat, it is how the time stays honest. */}
{(askingStillHere || away) && ( {(askingStillHere || away) && (
@ -1906,7 +1936,7 @@ const timerStarted = timeLeft !== null
scrolls inside it, rather than the whole page scrolling. */} scrolls inside it, rather than the whole page scrolling. */}
<div className="quiz-footbar"> <div className="quiz-footbar">
<button type="button" className="btn btn-secondary btn-sm quiz-exit" <button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => leaveNow()}>Exit session</button> onClick={() => setLeaving(true)}>Exit session</button>
{quizNavigation('bottom')} {quizNavigation('bottom')}
{/* Reviewing a block before handing it in is an exam idea. A study {/* Reviewing a block before handing it in is an exam idea. A study
session has nothing to hand in: it keeps going until every session has nothing to hand in: it keeps going until every

View file

@ -241,6 +241,7 @@ describe('quiz player', () => {
// 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: 'Exit session' })) await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
await userEvent.click(screen.getByRole('button', { name: 'End 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
@ -255,6 +256,7 @@ describe('quiz player', () => {
// 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: 'Exit session' })) await userEvent.click(screen.getByRole('button', { name: 'Exit session' }))
await userEvent.click(screen.getByRole('button', { name: 'End 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()
@ -350,7 +352,7 @@ describe('quiz player', () => {
expect(bar.closest('.quiz-layout')).toBeNull() expect(bar.closest('.quiz-layout')).toBeNull()
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument() expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
// Nothing answered yet, so moving on is a skip and the button says so. // Nothing answered yet, so moving on is a skip and the button says so.
expect(within(bar).getByRole('button', { name: /Skip/ })).toBeInTheDocument() expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
}) })
it('keeps notes with the question, not in a second notepad floating over it', async () => { it('keeps notes with the question, not in a second notepad floating over it', async () => {
@ -461,17 +463,18 @@ describe('quiz player', () => {
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/attempts/'))).toBe(false) expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/attempts/'))).toBe(false)
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
const review = screen.getByRole('dialog', { name: 'End block?' }) const review = screen.getByRole('dialog', { name: /This block is incomplete/ })
// The sentence names what is missing, not what is done that is the // The sentence names what is missing, not what is done that is the
// decision being confirmed. // decision being confirmed.
expect(within(review).getByText(/1 of 2 questions is unanswered/)).toBeInTheDocument() expect(within(review).getByText(/Number of unanswered items in this block: 1/)).toBeInTheDocument()
// And there is a way back into the block, not only a way out of it. // And there is a way back into the block, not only a way out of it.
expect(within(review).getByRole('button', { name: 'Go back and answer them' })).toBeInTheDocument() expect(within(review).getByRole('button', { name: 'Remain in Block' })).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: '2' })) await userEvent.click(within(review).getByRole('button', { name: 'Remain in Block' }))
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
await findStem('Full second clinical question.') await findStem('Full second clinical question.')
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' })) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
answers: [{ question_id: 1, user_answer: 'First answer' }], answers: [{ question_id: 1, user_answer: 'First answer' }],
@ -486,7 +489,7 @@ describe('quiz player', () => {
expect(screen.getByRole('note')).toHaveTextContent('extrathoracic') expect(screen.getByRole('note')).toHaveTextContent('extrathoracic')
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' })) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
hints: [1], hints: [1],
@ -503,7 +506,7 @@ describe('quiz player', () => {
fireEvent.keyDown(window, { key: '1' }) fireEvent.keyDown(window, { key: '1' })
await userEvent.click(await screen.findByRole('button', { name: 'stridor' })) await userEvent.click(await screen.findByRole('button', { name: 'stridor' }))
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' })) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
hints: [], hints: [],
@ -580,7 +583,7 @@ describe('quiz player', () => {
return originalPost(url, ...args) return originalPost(url, ...args)
}) })
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End block' })) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Try again safely') expect(await screen.findByRole('alert')).toHaveTextContent('Try again safely')
expect(api.delete).not.toHaveBeenCalled() expect(api.delete).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry submission' })) await userEvent.click(screen.getByRole('button', { name: 'Retry submission' }))

View file

@ -442,17 +442,19 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
.quiz-drawer-menu a:hover { background: var(--bg); } .quiz-drawer-menu a:hover { background: var(--bg); }
/* Ending a block /* Ending a block
A confirmation with one button is not a confirmation. The way back into Leaving questions behind is the thing worth saying out loud, so an
the block is offered first, and the unanswered are numbers you can press incomplete block wears the warning in its heading and the button that
rather than a list to be read. */ ends it is the one that looks like a consequence. */
.quiz-end-lead { margin: 0 0 8px; font-size: 1rem; font-weight: 600; } .quiz-endblock p { margin: 0 0 10px; font-size: 0.92rem; line-height: 1.6; }
.quiz-end-note { margin: 0 0 14px; font-size: 0.86rem; line-height: 1.6; color: var(--text-muted); } .quiz-endblock.is-warning h2 {
.quiz-end-missing { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } margin: -20px -20px 16px; padding: 12px 16px;
.quiz-end-missing button { background: var(--wrong-fg); color: #fff;
min-width: 42px; min-height: 42px; padding: 0 12px; border-radius: 12px 12px 0 0; font-size: 1rem;
font: inherit; font-weight: 700; cursor: pointer;
background: var(--wrong-bg); color: var(--wrong-fg);
border: 1px solid var(--wrong-bd); border-radius: 9px;
} }
.quiz-end-missing button:hover { border-color: var(--wrong-fg); } .quiz-endblock-go {
.quiz-end-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } background: var(--wrong-fg); color: #fff; border: 1px solid var(--wrong-fg);
}
.quiz-endblock-go:hover:not(:disabled) { filter: brightness(1.1); }
/* Two buttons in a dialog sit side by side, the one that acts first. */
.quiz-away-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }