Repository navigation
fix(quiz): say when a scheduled quiz opens; spend hit codes as the browser does - #80
Conversation
…owser does A learner attempt recorded on Lighthouse on 2026-10-29 showed two gaps. - Scheduled open: before its open time a quiz's summary allows a start (canTakeQuiz and startQuiz true) but renders Start disabled and runs StartCountdown(<.NET ticks, UTC>) to enable it. attempt start gave the generic "cannot be started" refusal. It now reads the open time and refuses with "This quiz opens at HH:MM ZONE on D Month YYYY", plus "(in N seconds/minutes)" within the hour, counted on the server's clock from the summary's Date. With --json the error adds opens_at (UTC) and seconds_until_open. Nothing is sent, and the previous cursor is kept. - Hit codes: UI.Init spends one hit code as each page loads, so a window's first request ends in 2. The CLI's ended in 1, and its submit RPC reused the page's counter (3 after the finish save). Each page's FormProtection now starts there, and both ProcessQuizSubmission RPCs take their code from the attempt frame's own counter, as the browser sends them. .secrets.baseline: only the audited README entry's line number moved (365 -> 367) after the README sentence; no new findings.
|
ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing |
|
Droid finished @rabesss's task —— View job Validated the single candidate: the new summary serialization field has no production reader, while the CLI correctly serializes the scheduled-open error separately. This is a P2 unused-addition finding, not a functional failure. Validation used code and caller inspection; tests were not run. 1 inline comment posted |
| return {"course_id": self.course_id, "quiz_id": self.quiz_id, "can_start": self.can_start, | ||
| "can_continue": self.can_continue, "attempt_in_progress": self.attempt_in_progress, | ||
| "attempt_processing": self.attempt_processing} | ||
| "attempt_processing": self.attempt_processing, "opens_at": self.opens_at} |
There was a problem hiding this comment.
[P2] Drop the unused summary serialization field
Readers: rg -n '\.public_data\(|LearnerSummary|read_learner_summary' lighthouse_cli tests shows no production caller of LearnerSummary.public_data(); only tests consume its newly added opens_at key. The actual CLI error uses LearnerNotOpenError.public_data() through quiz_learner_commands.py::_execute. Drop this additional dictionary member and its test assertions; retain LearnerSummary.opens_at, which _start_fields needs.
[Medium impact] The changes affect learner and preview quiz-attempt workflows. The PR appears safe to merge, with a non-blocking date-boundary issue in the scheduled-open message. Findings
SummaryThe PR explains when a scheduled quiz opens and adjusts hit codes to match the browser.
Reviews (1) · Last reviewed commit: "fix(quiz): say when a scheduled quiz ope..." · Reviewed by Greptile |
| def __init__(self, opens_at: float, read_at: float | None) -> None: | ||
| self.opens_at = opens_at | ||
| self.seconds_until_open = None if read_at is None else max(0, math.ceil(opens_at - read_at)) | ||
| local = datetime.fromtimestamp(opens_at).astimezone() |
There was a problem hiding this comment.
Accepted open time cannot display
_opens_at accepts 9999-12-31T23:59:59Z, but LearnerNotOpenError cannot format it in a local timezone east of UTC. That conversion requires year 10000 and raises ValueError. The command then returns a generic error without opens_at or seconds_until_open.
Fall back to UTC when the local date is out of range, and add a boundary test.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — single-commit PR reviewed end-to-end against the head state, with the repo's full local quality gate run at 366de4c.
- Scheduled-open refusal: new
LearnerNotOpenError(PreviewRefusedError)inquiz_learner_transport.py, raised strictly pre-dispatch from_start_fieldswhen a start's button is disabled (active_buttonsalready excludes disabled ones) and the summary'sStartCountdown(<.NET ticks>)call yields an open time._opens_at()collapses agreeing calls, fails closed toPreviewPageErrorfor disagreeing or out-of-range times (2000-01-01..9999-12-31), and leavesopens_at=None— the generic refusal — for any other shape. Every earlier check keeps its place (processing, role, password, unavailable, continue-only), test-pinned. - Server-clock countdown: the summary keeps the response
Date(_read_atvia the existing case-insensitive_server_time); the message adds "(in N seconds/minutes)" within the hour, and--jsongainsopens_at(UTCZstring) andseconds_until_open(int|null) via_execute'spublic_data()merge — stdout stays pure JSON, diagnostics on stderr. - Hit-code parity:
FormProtection._counternow starts at 1, so each page-instance's first request ends in 2 (itsUI.Initspends code 1), and bothProcessQuizSubmissionRPCs (submit_learner,submit_preview) draw from a fresh per-attempt-frameFormProtection— the window the browser sends them from — instead of the page's counter. The shift is uniform across every consumer, including the scratch probe pass, which stays a separate instance. - Docs/baseline: one README sentence on the refusal,
docs/quiz-protocol.md"Scheduled open" and "Hit codes" bullets (cross-checked against the modules), and the.secrets.baselineREADME entry shift 365→367 (verified against the actual line — the auditedLIGHTHOUSE_PASSWORDexport). - Tests: refusal-order pins, countdown parse variants, timezone-robust message assertions, JSON error-shape pins, and hit-code last-digit pins across the learner, preview and protection suites.
Verification at 366de4c: ruff, mypy, lint-imports (2 contracts kept), deptry, xenon, scripts/check_secrets.py all pass; 2962 of 2966 tests pass, the 4 failures matching this runner's documented environmental signatures exactly (test_quiz_media.py chardet mojibake ×3, test_session_renewal.py::test_modules_import_without_fcntl) — all in files this PR does not touch.
glm-5.3 | 𝕏
…view Open times now end at 2999-12-31T23:59:59Z, not the last second of 9999: at 9999 the local-time message raised ValueError in every zone (found by GLM 5.3 and gpt-6.1-sol). The submit test parses its own page, since the shared one's hit codes are spent by tests that run before it in some orders (gpt-6.1-sol). A start into an attempt already in progress with a countdown on the page is still refused as unavailable, not as not open (GLM 5.3).
Nothing outside the tests reads LearnerSummary.public_data(); the open time reaches the user only through the not-open error (found by Droid).
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta since the prior pullfrog review at 366de4c (the commit addressing that round's three P2s), re-verified against the code and the touched test files.
- Capped accepted open times at the end of 2999 —
_LATEST_OPENis now32_503_679_999(2999-12-31 23:59:59 UTC), so thedatetime.fromtimestampon the refusal-message path stays representable in every local zone (UTC+14 at the cap reaches only 3000-01-01 13:59:59 local). The implausible-countdown rejection now tests the exact 3000-01-01 boundary, and a new zone-parametrized test parses the last accepted second in UTC, Kolkata, Kiritimati and Los Angeles, restoring the zone in afinally. - Pinned the resumed-attempt countdown case — the new refusal-order case (
IN_PROGRESS+NOT_OPEN) asserts that a resume held back by a countdown keeps the genericREFUSE_START_UNAVAILABLErather than the open-time message, matching theresume = summary.can_continuegate in_start_fields. - Made the submit hit-code pins order-independent —
test_submit_saves_confirms_and_verifies_the_receipt_and_list_rownow parses its own attempt page instead of the module-levelLAST, whoseFormProtectioncounter is spent by the earlier submit tests. It was the only order-sensitive hit-code assertion: the otherendswith("2")builds its own page, and the RPC pin draws from a fresh per-frame protection.
Verified at a7235d8: 345 tests in tests/test_quiz_learner.py and 192 across tests/test_quiz_learner_session.py, tests/test_quiz_preview_finish.py and tests/test_request_protection.py pass; ruff check is clean on the changed files; the boundary arithmetic (32_503_680_000 = 3000-01-01T00:00:00Z) confirmed directly.
glm-5.3 | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single new commit since the review at a7235d8 (the fourth follow-up from the review round).
- Kept the open time out of the summary's public data —
LearnerSummary.public_data()no longer includesopens_at, and the claim that nothing reads it holds: no production code calls the summary'spublic_data()(the learner--jsonsurface flows throughLearnerNotOpenError.public_data()for the refusal and the attempt page'spublic_data()for reads), so no user-visible output changes. Theopens_atattribute itself stays on the dataclass for the refusal path, and the three test assertions that pinned the dead field were removed with it.
Verified at ee7d5a1: all 345 tests in tests/test_quiz_learner.py pass and ruff check is clean on the changed files.
glm-5.3 | 𝕏

What
Two gaps found by recording a learner attempt in a browser on Lighthouse (2026-10-29). The recording stays local; every value in the code, tests and docs is synthetic.
quiz attempt startgave the generic "cannot be started" refusal. It now says when the quiz opens.d2l_hitCodein 2. The CLI's ended in 1, and its submit RPC reused the page's counter. They now match the browser.Changes
quiz_learner_transport.py):canTakeQuizandstartQuiztrue) but renders Start disabled. It runsStartCountdown(<.NET ticks, UTC>)on its own line to enable the button later.LearnerSummary.opens_atcomes from that call: one call or calls that agree. Calls that disagree, or a time outside 2000-01-01 to 9999-12-31, are refused as an unsupported page. Any other form leaves itNone, which falls back to the generic refusal.Date.LearnerNotOpenError(PreviewRefusedError)replaces the button check's generic refusal. It applies only to a start, with the button disabled and an open time known. It reads "This quiz opens at HH:MM ZONE on D Month YYYY", plus "(in N seconds/minutes)" within the hour, then "Nothing was started."--jsonaddsopens_at(UTC,Z) andseconds_until_open(int or null) to the error.UI.Initspends one code as each page loads, soFormProtection's counter now starts at 1. Each instance is one page's.ProcessQuizSubmissionRPCs (learner and preview) take their code from a fresh counter for the attempt frame, the window the browser sends them from.docs/quiz-protocol.mdgets "Scheduled open" and "Hit codes" bullets..secrets.baseline. Only the audited README entry's line number moved (365 to 367), after the README sentence. No new findings.Verification
ruff check,mypy,lint-imports,deptry,xenon, the secret check andpytestall pass (2966 tests). Every changed line inlighthouse_cli/is covered. The new tests pass in the UTC, Asia/Kolkata and America/Los_Angeles time zones.opens_at12:30:00Z. With itsDate(10 s before the open), the start is refused as "opens at 18:00 IST on 29 October 2026 (in 10 seconds)". The JSON error isseconds_until_open: 10.23456789012).Review
One round at 366de4c:
No P0 or P1 findings. The P2s are fixed in a7235d8 and ee7d5a1:
opens_atthat nothing reads. Dropped.