feat: time's up is said, not just done; and it is acknowledged before the screen changes
The clock reaching zero submitted the attempt and replaced the exam with an analysis, with no word about why. "It submitted itself" is the one thing a learner must not have to infer. The answers go in immediately — that part must not wait for anybody — and the screen holds on "Time's up" until it is acknowledged, then goes. The last question's control says "End block" in exam mode. It opens the same dialog it always did, which names how many are still unanswered and that they count as incorrect, so the warning arrives before anything is handed in rather than after. Four tests for the time accounting the previous commit changed, covering what it is actually for: an exam closed with eight minutes left still has eight minutes however long ago it was started; opening and closing spends only the time it is open for and does eventually reach zero; an exam closed at zero is settled on the next look, for when the tab goes before the submit lands; and one with time on it is left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
6860750770
commit
cc1c981b9e
2 changed files with 83 additions and 9 deletions
|
|
@ -77,6 +77,48 @@ class SessionLifecycleTests(unittest.TestCase):
|
|||
self.assertIsNone(seconds_remaining({"answers": {}}))
|
||||
self.assertLess(seconds_remaining({"total_time": 1, "started_at": LONG_AGO}), 0)
|
||||
|
||||
def test_a_closed_exam_spends_no_time_while_it_is_closed(self):
|
||||
"""The crux: what is left is what the player last saved.
|
||||
|
||||
It used to be computed from `started_at` and `total_time`, so an
|
||||
afternoon with the tab closed spent an afternoon of the exam on
|
||||
questions nobody was shown. An exam not on screen is not being sat.
|
||||
"""
|
||||
# Started long ago by the wall clock, but with eight minutes still on
|
||||
# it — because the clock only ran while it was open.
|
||||
self.assertEqual(
|
||||
seconds_remaining({"total_time": 600, "started_at": LONG_AGO, "time_left": 480}),
|
||||
480.0)
|
||||
|
||||
def test_opening_and_closing_still_spends_the_time_it_is_open_for(self):
|
||||
"""And eventually runs out, which is the other half of the rule."""
|
||||
left = 600.0
|
||||
for spent in (200, 200, 200):
|
||||
# Each sitting saves what is left when it ends.
|
||||
left = max(0.0, left - spent)
|
||||
self.assertEqual(
|
||||
seconds_remaining({"total_time": 600, "started_at": LONG_AGO, "time_left": left}),
|
||||
left)
|
||||
self.assertEqual(left, 0.0)
|
||||
|
||||
def test_an_exam_closed_at_zero_is_submitted_on_the_next_look(self):
|
||||
"""If the tab goes before the submit lands, the next page settles it."""
|
||||
quiz_id, aid = self.timed_quiz()
|
||||
self.save(self.bank.owner.id, aid, time_left=0)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
|
||||
self.assertEqual(rows[quiz_id]["state"], "completed")
|
||||
self.assertIsNotNone(self.db.get(QuizAttempt, aid).completed_at)
|
||||
|
||||
def test_an_exam_with_time_on_it_is_left_alone(self):
|
||||
quiz_id, aid = self.timed_quiz()
|
||||
self.save(self.bank.owner.id, aid, time_left=90)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
|
||||
# Still in progress, however long ago it was started.
|
||||
self.assertNotEqual(rows[quiz_id]["state"], "completed")
|
||||
self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at)
|
||||
|
||||
def test_an_unsuspended_exam_that_ran_out_is_a_finished_exam_in_the_session_list(self):
|
||||
quiz_id, aid = self.timed_quiz()
|
||||
self.save(self.bank.owner.id, aid)
|
||||
|
|
|
|||
|
|
@ -437,6 +437,11 @@ export default function QuizPage() {
|
|||
const [sessionSeconds, setSessionSeconds] = useState(0)
|
||||
const [questionSeconds, setQuestionSeconds] = useState(0)
|
||||
const [clockPaused, setClockPaused] = useState(false)
|
||||
// The clock ran out: the answers are in, and the analysis waits behind an
|
||||
// acknowledgement rather than replacing the exam without a word.
|
||||
const [timeUp, setTimeUp] = useState(false)
|
||||
const [afterTimeUp, setAfterTimeUp] = useState(null)
|
||||
const timeUpRef = useRef(false)
|
||||
// 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({})
|
||||
|
|
@ -838,9 +843,14 @@ const timerStarted = timeLeft !== null
|
|||
showToast('Five minutes left in this block.')
|
||||
}, [timeLeft])
|
||||
|
||||
// Auto-submit when the timer expires during an active session — never right after resume.
|
||||
// Auto-submit when the timer expires during an active session — never right
|
||||
// after resume. "It submitted itself" is the one thing a learner must not
|
||||
// have to infer, so the answers go in immediately and the screen waits.
|
||||
useEffect(() => {
|
||||
if (timeLeft === 0) handleSubmit(true)
|
||||
if (timeLeft !== 0) return
|
||||
timeUpRef.current = true
|
||||
setTimeUp(true)
|
||||
handleSubmit(true)
|
||||
}, [timeLeft])
|
||||
|
||||
const saveProgressNow = useCallback((overrides = {}) => {
|
||||
|
|
@ -1037,20 +1047,23 @@ const timerStarted = timeLeft !== null
|
|||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||
clearInterval(timerRef.current)
|
||||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||||
if (returnTo) {
|
||||
const target = returnTo
|
||||
// A course quiz reports back to its course, and has no analysis of its
|
||||
// own — the answer review is the whole of its result.
|
||||
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
|
||||
} else {
|
||||
? `/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`
|
||||
// Everywhere else the session ends on its analysis: score, timing and
|
||||
// what to do next. The answer-by-answer review is one link from there.
|
||||
navigate(`/sessions/${attemptId}`, { state: { result: res.data } })
|
||||
}
|
||||
: `/sessions/${attemptId}`
|
||||
const go = () => navigate(target, { state: { result: res.data } })
|
||||
// When the clock ended it rather than the learner, the screen must not
|
||||
// simply change underneath them: say so, and go when they acknowledge.
|
||||
if (timeUpRef.current) setAfterTimeUp(() => go)
|
||||
else go()
|
||||
} catch (err) {
|
||||
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])
|
||||
}, [attemptId, answers, submitting, navigate, showToast, returnTo])
|
||||
|
||||
/**
|
||||
* Leave.
|
||||
|
|
@ -1183,8 +1196,11 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
|
||||
{isLast ? (
|
||||
// The end of the block. The dialog it opens names how many are still
|
||||
// unanswered and that they count against you, which is the warning
|
||||
// worth giving before anything is handed in.
|
||||
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review & Complete
|
||||
{isStudy ? 'Review & Complete' : 'End block'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||
|
|
@ -1276,6 +1292,22 @@ const timerStarted = timeLeft !== null
|
|||
</QuizDialog>}
|
||||
{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>}
|
||||
{timeUp && (
|
||||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading">
|
||||
<div className="quiz-away-card">
|
||||
<h2 id="timeup-heading">Time's up</h2>
|
||||
<p>
|
||||
You have run out of time for this block. What you answered has
|
||||
been handed in and marked.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" disabled={submitting}
|
||||
onClick={() => { setTimeUp(false); afterTimeUp?.() }}>
|
||||
{submitting ? 'Marking…' : 'See how you did'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Still there? The clock is already stopped by the time this shows —
|
||||
it is not a threat, it is how the time stays honest. */}
|
||||
{(askingStillHere || away) && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue