Skip to content

fix(quiz): say when a scheduled quiz opens; spend hit codes as the browser does - #80

Merged
rabesss merged 3 commits into
mainfrom
fix/quiz-scheduled-open-hit-codes
Oct 30, 2026
Merged

rabesss merged 3 commits into
mainfrom
fix/quiz-scheduled-open-hit-codes

Conversation

@rabesss

@rabesss rabesss commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

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.

  • Scheduled quizzes. Before its open time, quiz attempt start gave the generic "cannot be started" refusal. It now says when the quiz opens.
  • Hit codes. Each browser window's first request ends its d2l_hitCode in 2. The CLI's ended in 1, and its submit RPC reused the page's counter. They now match the browser.

Changes

  • Scheduled open (quiz_learner_transport.py):
    • Before the open time, the summary allows a start (canTakeQuiz and startQuiz true) but renders Start disabled. It runs StartCountdown(<.NET ticks, UTC>) on its own line to enable the button later.
    • LearnerSummary.opens_at comes 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 it None, which falls back to the generic refusal.
    • The summary keeps the server time from its response's 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."
    • Every earlier check keeps its place and message (processing, role, password, unavailable, continue-only).
    • --json adds opens_at (UTC, Z) and seconds_until_open (int or null) to the error.
    • No request is sent, and the previous cursor is kept.
  • Hit codes:
    • UI.Init spends one code as each page loads, so FormProtection's counter now starts at 1. Each instance is one page's.
    • Both ProcessQuizSubmission RPCs (learner and preview) take their code from a fresh counter for the attempt frame, the window the browser sends them from.
  • Docs. README gets one sentence on the refusal. docs/quiz-protocol.md gets "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

  • Full gate. ruff check, mypy, lint-imports, deptry, xenon, the secret check and pytest all pass (2966 tests). Every changed line in lighthouse_cli/ is covered. The new tests pass in the UTC, Asia/Kolkata and America/Los_Angeles time zones.
  • Offline replay against the recording (local only, nothing sent):
    • The recorded pre-open summary gives opens_at 12:30:00Z. With its Date (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 is seconds_until_open: 10.
    • All 11 save and finish forms still match the browser field for field.
    • The last digits of their hit codes now match the browser's exactly (23456789012).
    • The submit RPC body still matches.
  • Not run live. Nothing was written to Lighthouse.

Review

One round at 366de4c:

  • gpt-6.1-sol — xhigh: SHIP WITH FOLLOW-UPS
  • GLM 5.3 — max: SHIP WITH FOLLOW-UPS
  • CodeRabbit: no findings
  • Hosted Droid and Greptile: one P2 each. Greptile's was the open-time display, also raised by Sol and GLM.

No P0 or P1 findings. The P2s are fixed in a7235d8 and ee7d5a1:

  • The latest accepted open time could not be shown in local time. Open times now end at 2999-12-31.
  • The submit test's hit-code check depended on test order.
  • No test covered a resumed attempt that has a countdown.
  • The summary's public data gained an opens_at that nothing reads. Dropped.

…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-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@factory-droid

factory-droid Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@greptile-apps

greptile-apps Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[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

  1. P2 Accepted open time cannot display ▶

Summary

The PR explains when a scheduled quiz opens and adjusts hit codes to match the browser.

  • Scheduled quiz starts tell learners when the quiz opens.
  • Quiz requests use the hit-code sequence for their sending window.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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) in quiz_learner_transport.py, raised strictly pre-dispatch from _start_fields when a start's button is disabled (active_buttons already excludes disabled ones) and the summary's StartCountdown(<.NET ticks>) call yields an open time. _opens_at() collapses agreeing calls, fails closed to PreviewPageError for disagreeing or out-of-range times (2000-01-01..9999-12-31), and leaves opens_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_at via the existing case-insensitive _server_time); the message adds "(in N seconds/minutes)" within the hour, and --json gains opens_at (UTC Z string) and seconds_until_open (int|null) via _execute's public_data() merge — stdout stays pure JSON, diagnostics on stderr.
  • Hit-code parity: FormProtection._counter now starts at 1, so each page-instance's first request ends in 2 (its UI.Init spends code 1), and both ProcessQuizSubmission RPCs (submit_learner, submit_preview) draw from a fresh per-attempt-frame FormProtection — 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.baseline README entry shift 365→367 (verified against the actual line — the audited LIGHTHOUSE_PASSWORD export).
  • 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.

Pullfrog  | View workflow run | Using 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).
@factory-droid

factory-droid Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


The candidate list is empty, so there are no new comments to approve or reject. The full PR diff and existing comments were read; tests were not run during this validation pass.

0 inline comments posted

Nothing outside the tests reads LearnerSummary.public_data(); the open
time reaches the user only through the not-open error (found by Droid).
@factory-droid

factory-droid Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


The candidate list is empty, so there are no comments to approve or reject. The full PR diff and existing comments were read; tests were not run during this validation pass.

0 inline comments posted

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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_OPEN is now 32_503_679_999 (2999-12-31 23:59:59 UTC), so the datetime.fromtimestamp on 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 a finally.
  • 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 generic REFUSE_START_UNAVAILABLE rather than the open-time message, matching the resume = summary.can_continue gate in _start_fields.
  • Made the submit hit-code pins order-independent — test_submit_saves_confirms_and_verifies_the_receipt_and_list_row now parses its own attempt page instead of the module-level LAST, whose FormProtection counter is spent by the earlier submit tests. It was the only order-sensitive hit-code assertion: the other endswith("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.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 includes opens_at, and the claim that nothing reads it holds: no production code calls the summary's public_data() (the learner --json surface flows through LearnerNotOpenError.public_data() for the refusal and the attempt page's public_data() for reads), so no user-visible output changes. The opens_at attribute 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.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@rabesss
rabesss merged commit e655691 into main Oct 30, 2026
8 checks passed
@rabesss
rabesss deleted the fix/quiz-scheduled-open-hit-codes branch October 30, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant