Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
44c7185
docs(quiz): the frontend redesign contract (#537)
AndresL230 Aug 22, 2026
7a5331b
test(graph): pin the tree's paint with a golden snapshot before the n…
AndresL230 Aug 22, 2026
4cd07d4
refactor(graph): extract the pure node-style layer into lib/graph/nod…
AndresL230 Aug 22, 2026
fc7ffbc
feat(quiz): the coded error envelope and the quiz client (#537 A2)
AndresL230 Aug 22, 2026
cfb03da
feat(graph): siblingsFor — the deterministic neighbourhood pick (#537)
AndresL230 Aug 22, 2026
9c5a2a9
feat(graph): ConceptNode + ConceptNeighbourhood, the quiz's still tre…
AndresL230 Aug 22, 2026
b3f7656
feat(quiz): the session machine, source tracking and persistence (#53…
AndresL230 Aug 22, 2026
bf657a2
feat(quiz): mirror get_recommendations for the quiz-home proposals (#…
AndresL230 Aug 22, 2026
cd5be43
feat(ui): six shared primitives + Button's link variant for the quiz …
AndresL230 Aug 22, 2026
fd19d2d
feat(quiz): the four hooks that own the quiz's effects (#537 A2)
AndresL230 Aug 22, 2026
1feefec
test(ui): a gallery of every primitive in every state (#537)
AndresL230 Aug 22, 2026
100a3c8
docs(quiz): contract amendments from A1 (home scale 2, primitive prop…
AndresL230 Aug 22, 2026
e372073
feat(quiz): QuizScreen, the screen-props seam and the new testids (#5…
AndresL230 Aug 22, 2026
ee9cc13
fix(ui): fade a disabled primary button instead of leaving it indisti…
AndresL230 Aug 22, 2026
37e2bd5
feat(quiz): resolve ?concept= / ?topic= deep links against the scoped…
AndresL230 Aug 22, 2026
b029238
fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_…
AndresL230 Aug 22, 2026
c2a083b
docs(quiz): contract amendments from A2 (events, hook return, entrySe…
AndresL230 Aug 22, 2026
b5e153b
feat(quiz): announce a mastery move on the submit path (#537 A2, fix …
AndresL230 Aug 22, 2026
f64e816
fix(ui): the promoted EmptyState renders Gradebook unchanged, and ans…
AndresL230 Aug 22, 2026
8bcb03d
refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, re…
AndresL230 Aug 22, 2026
33ad2ff
fix(graph): the results neighbourhood is drawn where the design draws…
AndresL230 Aug 22, 2026
48d8577
fix(quiz): guard a double-pressed Submit, and stop hiding two files f…
AndresL230 Aug 22, 2026
f4fa561
docs(quiz): contract — Wave 3 seam (AskPanel props, testids) (#537)
AndresL230 Aug 22, 2026
4c9fa61
feat(notetaker): wire Generate quiz through buildQuizHref + ?note= re…
AndresL230 Aug 22, 2026
2ea3b35
fix(quiz): wire the dashboard's quiz entry points into the #537 URL API
AndresL230 Aug 22, 2026
1db2783
feat(tree): quiz entry by node id, ?node= focus, and recent-quiz hist…
AndresL230 Aug 22, 2026
761a7d2
chore(lint): prune the Dashboard suppression freed by the quiz rewiri…
AndresL230 Aug 22, 2026
cf51bb3
feat(quiz): the results screen — growth node, missed list, three exit…
AndresL230 Aug 22, 2026
76ad9aa
fix(quiz): reach the due-review CTA at mobile width in the sidenav la…
AndresL230 Aug 22, 2026
66b19c2
feat(graph): let a neighbourhood drop its centre caption (#537)
AndresL230 Aug 22, 2026
1c2c6f5
feat(quiz): name the next concept in a queued session (#537 A2, fix r…
AndresL230 Aug 22, 2026
9ae207a
feat(quiz): the question screen — Ask about this opens over the attem…
AndresL230 Aug 22, 2026
4cb1c8e
feat(quiz): quiz home — the proposal, the alternatives, the pick list…
AndresL230 Aug 22, 2026
973a81e
feat(ui): Button forwards its ref (#537)
AndresL230 Aug 22, 2026
55684cd
docs(quiz): contract — testids added during Waves 3–4 (#537)
AndresL230 Aug 22, 2026
ed1bca6
feat(quiz): pass `pending` to the question screen, and register every…
AndresL230 Aug 22, 2026
045b2cd
fix(quiz): results — prototype plural, the two new seam props, a11y r…
AndresL230 Aug 22, 2026
03afed6
feat(quiz): full-bleed home, and describe the concept the card actual…
AndresL230 Aug 22, 2026
b26ce09
fix(quiz): keep the half-written tutor reply, and consume the landed …
AndresL230 Aug 22, 2026
bec932e
fix(quiz): quiz home — review round 1 (#537 B1)
AndresL230 Aug 22, 2026
3b81d61
chore(quiz): remove the old QuizPanel implementation (#537 D1)
AndresL230 Aug 22, 2026
db6d3aa
docs(quiz): the testid registry's `quiz-adjust` row is a toggle, not …
AndresL230 Aug 22, 2026
197f655
fix(quiz): document the nested home classes, and skip the description…
AndresL230 Aug 22, 2026
28ea50f
docs(quiz): contract — drop the never-built quiz-results-exits testid…
AndresL230 Aug 22, 2026
d7d1045
docs(ui): AnswerOption describes its own contract, not the deleted Qu…
AndresL230 Aug 22, 2026
c68b4c5
docs(quiz): retire the comments that still describe deleted code (#53…
AndresL230 Aug 22, 2026
8bbf1d1
test(e2e): quiz integration + error-state journeys (#537 D3)
AndresL230 Aug 22, 2026
66166c9
docs(e2e): name the quiz short-delivery toast as a function-mode seam…
AndresL230 Aug 22, 2026
4b88759
test(e2e): quiz journeys for the redesigned quiz (#537 D2)
AndresL230 Aug 22, 2026
c3580e9
fix(quiz): [A2-e2e-fix] a resumed quiz keeps its verdicts, and Done d…
AndresL230 Aug 22, 2026
cab4ccf
chore(e2e): make the quiz generate limiter env-overridable and raise …
AndresL230 Aug 22, 2026
835ffc0
docs(quiz): contract §3 reconciled with the shipped primitives (size …
AndresL230 Aug 22, 2026
1783a12
test(e2e): un-fixme the resume journey after the A2 fix; Done stays r…
AndresL230 Aug 22, 2026
301270a
test(e2e): retire the generation-budget wording the raised limiter ma…
AndresL230 Aug 22, 2026
099874f
fix(quiz): final review wave — persist guard, AskPanel teardown, time…
AndresL230 Aug 22, 2026
4a80da2
fix(quiz): [A2-done-fix] Done edits the URL instead of asking for a n…
AndresL230 Aug 22, 2026
a2f5a16
fix(quiz): final review wave — unmount guards in the session hook (#537)
AndresL230 Aug 22, 2026
3028cec
docs(testids): quiz-results-mastery is the delta line, not a score (#…
AndresL230 Aug 22, 2026
4048a79
fix(quiz): [A2-done-fix-2] pass a null history state so Next actually…
AndresL230 Aug 22, 2026
d966da6
test(e2e): un-fixme the Done journey after the A2 root-cause fix (#537)
AndresL230 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,12 @@ jobs:
# Dummy on purpose: the below-seam RAG embed path (#439) must not
# be able to bill; its failures are swallowed by design.
GEMINI_API_KEY: e2e-dummy-key-no-billing
# #537: the quiz-generation guard keeps its sliding window in a
# process-local dict, so ONE Playwright run shares a single
# 8-per-300s budget across every spec and the quiz specs that run
# last 429. e2e-up.sh sets the same value; kept here so what the
# lane actually runs under is visible beside the rest of the seam.
QUIZ_GENERATE_RATE_LIMIT: "1000"
run: make e2e-up

- name: Run Playwright suite
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false
GEMINI_VISION_OCR_MAX_PAGES=10
# The transcription model is the ADR-0008 per-task slot, not a knob of its own:
# SAPLING_MODEL_OCR_VISION=gemini-2.5-flash

# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window
# lives in a process-local dict, so ONE Playwright run spends a single budget
# across every spec — the E2E lanes raise this; production keeps the default.
# Unparseable or non-positive values fall back to the default.
# QUIZ_GENERATE_RATE_LIMIT=8
# Sliding-window length for that limit, in seconds. E2E lanes raise the limit
# rather than shrink the window; production keeps the default.
# QUIZ_GENERATE_RATE_WINDOW_SEC=300
49 changes: 47 additions & 2 deletions backend/services/quiz_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,41 @@
Standalone constants module: no imports from models/routes/services so
it can be imported from anywhere without cycles.
"""
import logging
import os

logger = logging.getLogger(__name__)


def _positive_int_env(name: str, default: int) -> int:
"""A deployment override for one guard constant, read at import.

Deliberately fail-SAFE rather than fail-fast: an unparseable or
non-positive value falls back to the shipped default (the conservative
one) with a warning, so a stray env var cannot boot the app with a
disabled guard — or refuse to boot at all. Read once at import time,
which is also when `routes/quiz.py` binds the value, so a mid-process
`os.environ` change has no effect.
"""
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"quiz_config: %s=%r is not an integer — using the default %d",
name, raw, default,
)
return default
if value <= 0:
logger.warning(
"quiz_config: %s=%d is not positive — using the default %d",
name, value, default,
)
return default
return value


QUIZ_MIN_QUESTIONS = 1

Expand DownExpand Up@@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float:
# The rate limit is sized for a human: a student comparing difficulties or
# retaking a concept might legitimately generate a handful of quizzes in a
# few minutes; nobody legitimately generates 10 in one.
QUIZ_GENERATE_RATE_LIMIT = 8
QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes
#
# Both are env-overridable (#537) for one reason: `request_limits._rate_state`
# is a module-level dict, so a whole Playwright run — every spec, every
# worker-shared window — spends ONE 8-per-300s budget that only a backend
# restart clears. The redesigned quiz lane needs ~20 real generations, and a
# function-mode generation costs nothing, so the E2E stacks raise the limit
# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and
# keeps the defaults below.
QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8)
QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env(
"QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes
)

# Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature
# (llm_usage records every agent call, not just quiz ones), but the ceiling
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/test_quiz_cost_observability_f.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,87 @@ def test_limit_is_per_user(self):
assert other.status_code == 200


class TestGenerateRateLimitIsEnvOverridable:
"""#537: the two guard constants read the environment; defaults intact.

`request_limits._rate_state` is module-level, so a whole Playwright run
shares ONE window that only a backend restart clears — and the redesigned
quiz lane needs more generations than any human would. The E2E stacks
raise the limit through the environment; production sets nothing and must
keep the shipped defaults.
"""

@staticmethod
def _reloaded():
"""Re-import services.quiz_config against the current environment.

`importlib.reload` mutates the module in place, so every caller must
restore it (see `_restore`) or later tests inherit the override.
"""
import importlib

from services import quiz_config

return importlib.reload(quiz_config)

@classmethod
def _restore(cls):
cls._reloaded()

def test_defaults_hold_when_unset(self, monkeypatch):
monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False)
monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300
finally:
monkeypatch.undo()
self._restore()

def test_an_override_is_honoured(self, monkeypatch):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000")
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60")
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60
finally:
monkeypatch.undo()
self._restore()

def test_a_junk_override_falls_back_to_the_default(self, monkeypatch):
"""Fail-safe, not fail-fast: a stray value must neither disable the
guard (limit=0 would 429 everyone, a negative window never expires)
nor stop the app from booting."""
for bad in ("", " ", "eight", "0", "-5", "3.5"):
monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad)
monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad)
try:
cfg = self._reloaded()
assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad
assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad
finally:
monkeypatch.undo()
self._restore()

def test_the_route_still_enforces_whatever_was_resolved(self):
"""The env seam moves the NUMBER, never the behaviour: the route binds
the value at its own import and still 429s one call past it."""
from routes import quiz as quiz_route
from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT

assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT
run = AsyncMock(return_value=SimpleNamespace(output=_quiz()))
with (
patch("routes.quiz.table", side_effect=_factory()),
patch("routes.quiz.quiz_agent.run", new=run),
):
for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT):
assert _generate().status_code == 200
assert _generate().status_code == 429


class TestDailySpendGuard:
def test_over_budget_user_is_refused_before_the_model_runs(self):
from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD
Expand Down
5 changes: 4 additions & 1 deletion docs/e2e-exploration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or
`E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s
"gradient descent... loss surface... learning rate" wording and the fixed
`lecture_notes` category. If a finding's "wrong" content matches one of
those constants verbatim, it's the seam, not the app — drop it, and if it
those constants verbatim, it's the seam, not the app (likewise the quiz's
"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always
returns exactly 3 questions, so any requested length above 3 is reported as a short delivery —
the honesty check working, not a generation bug) — drop it, and if it
recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name
the pattern explicitly so a future explorer recognizes it before writing the
stub, rather than filing an issue against the harness's own known-fixed
Expand Down
107 changes: 92 additions & 15 deletions docs/frontend-testids.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ renders the element.
| Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) |
| Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` |
| Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) |
| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) |
| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) |
| Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) |
| App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) |
| Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) |
Expand All@@ -95,8 +95,16 @@ route:
and the send `<button>` render in `ChatPanel.tsx`. `ChatPanel` has exactly one
consumer (Learn), so tagging it there is unambiguous. The notetaker's chat is
a separate `AIChatPanel` and is out of scope.
- **Quiz.** `screens/Quiz.tsx` only fetches concepts and mounts `QuizPanel`;
every answer/submit control renders in `QuizPanel.tsx`.
- **Quiz.** The route mounts one component, `components/quiz/QuizScreen.tsx`,
which switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`; each of the three
owns its own ids and its own dialogs. Three shared `ui/` primitives
(`AnswerOption`, `SegmentedControl`, `Sheet`) are in the eslint file list too:
the controls render inside them, so a testid-less `<button>` there would
un-anchor the surface from outside the screen files. Those primitives — plus
`ui/EmptyState`, `ui/InlineBanner`, `ui/ProgressDots` and
`graph/ConceptNeighbourhood` — take the id as a `testid` prop, so the value is
always written at the quiz call site, never inside the primitive.

## Current inventory

Expand DownExpand Up@@ -174,22 +182,87 @@ route:

### `quiz`

The quiz is one surface: `components/quiz/QuizScreen.tsx` reads the entry off
the URL and switches on phase between `home/QuizHome.tsx`,
`question/QuizQuestion.tsx` and `results/QuizResults.tsx`. The table is grouped
in that order; the screen-level ids (the error card) come last, followed by the
entry points the quiz wave added inside other surfaces' files.

Three ids are **renamed** from the pre-#537 quiz — same role, clearer name:
`quiz-exit` → `quiz-leave`, `quiz-retake` → `quiz-again`,
`quiz-explain-concept` → `quiz-ask`. Eleven are **kept** verbatim so existing
journeys keep anchoring: `quiz-panel`, `quiz-start`, `quiz-cancel`,
`quiz-answer-options`, `quiz-answer-option-{label}`, `quiz-submit-answer`,
`quiz-next`, `quiz-review-verdict`, `quiz-results-score`,
`quiz-results-mastery`, `quiz-done`.

| testid | element |
| --- | --- |
| `quiz-panel` | panel root (all phases) |
| `quiz-cancel` | "Cancel" (select phase) |
| `quiz-start` | "Start quiz" |
| `quiz-home` | quiz-home root (`phase: home \| configuring`) |
| `quiz-resume-strip` | the "you left a quiz on X" banner (`ui/InlineBanner`) |
| `quiz-resume` | "Resume" on that banner |
| `quiz-resume-discard` | "Discard" — hides the attempt client-side; there is no abandon endpoint |
| `quiz-proposal` | the "Ready for you" primary proposal card |
| `quiz-start` | "Start" on that card |
| `quiz-cancel` | "Cancel" — leaves for the entry source, or `/dashboard` when there is none |
| `quiz-adjust` | "adjust" on the card — toggles the adjust dialog (`aria-pressed`) |
| `quiz-alternative-{nodeId}` | one "Also worth a look" row |
| `quiz-review-due` | "Review everything due" row |
| `quiz-pick-open` | "Pick something specific →" |
| `quiz-pick-list` | the grouped concept list it reveals |
| `quiz-pick-{nodeId}` | one concept row in that list |
| `quiz-pick-back` | "← Back" out of that list |
| `quiz-empty-state` | the home's empty states (`ui/EmptyState`) — no courses, empty tree, or nothing left to propose; they are alternatives, so only one ever mounts |
| `quiz-home-error` | the home's load-failure card |
| `quiz-home-retry` | "Retry" inside it |
| `quiz-concept-dialog` | the concept dialog opened from an alternative or a pick row |
| `quiz-concept-start` | "Start · {count} {difficulty}" inside it |
| `quiz-concept-cancel` | its "Cancel" |
| `quiz-adjust-dialog` | "Adjust this quiz" dialog |
| `quiz-adjust-done` | its "Done" — keeps the settings without starting |
| `quiz-adjust-start` | its "Start · {count} {difficulty}" — saves and starts in one |
| `quiz-seg-count` / `quiz-seg-difficulty` / `quiz-seg-feedback` | the three `ui/SegmentedControl`s both dialogs share; each option is `${testid}-${value}` |
| `quiz-generating` | the skeleton question screen while a quiz is being written |
| `quiz-panel` | question root (`phase: active \| answered`) |
| `quiz-progress` | the question rail's progress dots (`ui/ProgressDots`) |
| `quiz-answer-options` | answer radiogroup |
| `quiz-answer-option-{label}` | one answer choice, suffixed with its label (`A`…) |
| `quiz-submit-answer` | "Submit answer" |
| `quiz-exit` | "Exit" (active phase) |
| `quiz-answer-option-{label}` | one answer choice (`ui/AnswerOption`), suffixed with its label (`A`…) |
| `quiz-submit-answer` | the footer's primary button while the answer is unrevealed |
| `quiz-next` | the same footer button once the verdict is showing — "Next question" / "See results" |
| `quiz-review-verdict` | "Correct." / "Not quite." banner |
| `quiz-explain-concept` | "Explain this" |
| `quiz-next` | "Next question" / "See results" |
| `quiz-results-score` | the score percentage |
| `quiz-results-mastery` | the "X / Y correct · mastery B% → A%" line |
| `quiz-retake` | "Retake" |
| `quiz-done` | "Done" |
| `quiz-flag` | "This question is confusing" (toggles, `aria-pressed`; persists nothing) |
| `quiz-ask` | "Ask about this" — opens the tutor sheet |
| `quiz-leave` | "Leave" |
| `quiz-leave-dialog` / `quiz-leave-cancel` / `quiz-leave-confirm` | the leave confirmation |
| `quiz-ask-panel` | the tutor sheet (`ui/Sheet`) |
| `quiz-ask-panel-close` | its × — the `Sheet` derives it as `${testid}-close` |
| `quiz-ask-seed` | the composed first message shown at the top of the sheet |
| `quiz-ask-retry` | "Try again" when the tutor stream failed |
| `quiz-ask-input` / `quiz-ask-send` | the sheet's composer |
| `quiz-results` | results root |
| `quiz-results-graph` | the concept neighbourhood above the score (`graph/ConceptNeighbourhood`) |
| `quiz-results-score` | the "{n} of {total} correct" line — a count, not a percentage |
| `quiz-results-mastery` | the mastery delta line, "B% → A% · tier-before → tier-after" |
| `quiz-results-xp` | "+{Δ} XP · {streak}-day streak" — absent when either gamification read failed |
| `quiz-results-perfect` | the line shown instead of the missed list on a clean sweep |
| `quiz-missed-list` | the "to look at" section |
| `quiz-missed-{questionId}` | one missed item |
| `quiz-missed-explain-{questionId}` | its "Show explanation" disclosure |
| `quiz-missed-ask-{questionId}` | its "Ask about this" |
| `quiz-practise-missed` | "Practise the one(s) you missed" |
| `quiz-next-concept` | "Next: {concept} →" while the scope queue has more |
| `quiz-again` | "Keep going — quiz again" (mounts in `quiz-practise-missed`'s place on a clean sweep) |
| `quiz-back-to-source` | the secondary exit, labelled by where the quiz was entered from |
| `quiz-done` | "Done" — back to `/quiz` |
| `quiz-error` | the route-level error card (mapped copy + retry/back), rendered by `QuizScreen` in place of the phase |
| `quiz-error-retry` / `quiz-error-back` | its "Try again" and "Back" |
| `tree-node-recent-quizzes` | the tree node panel's "Recent quizzes" block (`screens/Tree.tsx`) |
| `tree-node-recent-quiz-{attemptId}` | one row in it, suffixed with the attempt's `quiz_id` per the stable-domain-id rule |
| `dashboard-review-due` | the "Review what's due" quiz entry point (`screens/Dashboard.tsx`) — three call sites, but exactly one mounts for a given layout/viewport |

The last three carry another surface's prefix because they render in that
surface's file. They are listed here because they are quiz entry points, and a
quiz journey is what drives them.

### `graph`

Expand DownExpand Up@@ -279,6 +352,9 @@ never collide in the DOM.
| `dashboard-courses-manage` | the cog inside the expanded key that opens the Courses & Semesters hub (the hub's own semester tabs are plain text buttons — journeys select them by role/name, e.g. "All semesters" / "Fall 2025") |
| `dashboard-resume-{sessionId}` | a "Where you left off" card — deep-links to `/learn?resume={sessionId}` (#164), suffixed with the session's own id per the stable-domain-id rule |

`dashboard-review-due` also lives in `screens/Dashboard.tsx`; it is documented
with the `quiz` surface it links into.

### `calendar`

Added with the #185 load-failure fix (a failed initial fetch must be
Expand All@@ -300,6 +376,7 @@ inside the `role="grid"` "Courses" grid.
| testid | element |
| --- | --- |
| `gradebook-term-gpa` | landing: the selected term's credit-weighted GPA next to the chips (absent while the term has no graded work) |
| `gradebook-upload-syllabus` | landing: the blank-semester empty state's "Upload syllabus" CTA (`ui/EmptyState` action slot) |
| `gradebook-transcript-open` | landing: "Transcript" button opening the transcript modal |
| `gradebook-transcript-gpa` | transcript modal: the cumulative GPA value |
| `gradebook-transcript-retry` | transcript modal: inline "Try again" after a failed load (#463 catch+toast pattern) |
Expand Down
Loading
Loading