diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index dce8cd0..e8fa36c 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -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) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index a4bdda6..ed77a11 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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. ) : ( @@ -1276,6 +1292,22 @@ const timerStarted = timeLeft !== null } {submitError &&
+ You have run out of time for this block. What you answered has + been handed in and marked. +
+ +