test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393) - #437

Merged
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery
Jul 28, 2026
Merged

test(e2e): journey — quiz answer → mastery update (UI + DB) (#393)#437
AndresL230 merged 3 commits into
mainfrom
test/393-journey-quiz-mastery

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the quiz → mastery path (#393): sign in as the seeded rich-user-active (storageState), deep-link to /quiz?concept=rich-node-cs-recursion, answer a deterministic three-question quiz through the real UI, and assert the mastery change in the UI and in the database:

  • graph_nodes.mastery_score moves 0.25 → 0.34 (3 correct × +0.03), times_studied bumps 0 → 1, last_studied_at set;
  • exactly one new row appends to node_mastery_events (the 0023 table — never a mastery_events column) with delta ≈ +0.09 and reason Quiz: 3/3 correct;
  • the journey's quiz_attempts row completes (score=3, total=3, completed_at set; scoped id NOT LIKE 'rich-%' because the seed carries a completed baseline attempt on the same node);
  • the percentages the student saw (quiz-results-mastery) equal the DB state read back over the raw-SQL seam — writes go through the app, assertions never ride the layer that wrote.

Monotonicity — the property no mocked test can falsify — is pinned three ways: a fully correct submission must never lower the rendered score (uiAfter >= uiBefore), the persisted score (masteryAfter >= masteryBefore), or the event delta sign (delta >= 0). If the scoring math in routes/quiz.py ever regresses to subtract on correct answers, this journey fails.

Product bugs found (2, both fixed here)

  1. Every UI quiz submission has 422'd since the shell revamp.QuizPanel submits {question_id, selected}; backend models.AnswerItem requires selected_label. Introduced by 399eae3 ("frontend: ship revamp shell, screens, and API client"), which dropped the old selected_label payload; verified live on origin/main. No mocked test on either side could catch it — backend tests construct AnswerItem directly, frontend tests mock fetch. The journey caught it on its first live run (POST /api/quiz/submit -> 422, .e2e/backend.log). Fixed by renaming the request field to the backend contract (the response items keep their separate selected key). Distinct from the known [P2] Quiz scoring & idempotency: double-submit double-counts, free point on malformed item #129 scoring bug, which this journey does not exercise (single submit per answer, well-formed items).
  2. The quiz half of the test(e2e): journey — tutor conversation persists to messages #392-discovered seam bypass.routes/quiz.py::_resolve_model_pref built a live GoogleModel for a fast/smart pref regardless of SAPLING_MODEL_MODE — same bug the test(e2e): journey — tutor conversation persists to messages #392 sibling fixed in routes/learn.py, deliberately left to this PR. The quiz UI sends no pref today (generateQuiz omits model_pref, which is why this journey's runs stayed deterministic regardless), but any client that did would silently dial Gemini in function mode. Fixed with the same pattern + the same test shape as learn's.

Seam findings (#391 / ADR 0019 / #392 convergence)

  • Quiz generation IS covered by the FunctionModel seam — it does not route through gemini_service.call_gemini_json; /api/quiz/generate runs agents/quiz.py::quiz_agent built via model_for("quiz"). Verified with evidence, not assumption: the spec's mode guard asserts the scripted fixture's question text before answering (a real-Gemini stack fails loudly there), generate round-trips in ~530 ms (.e2e/backend.log), and the only Gemini egress in a full run is the one below-seam embedding call (next bullet).
  • Env propagation confirmed:SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up reaches uvicorn by plain env inheritance; load_dotenv (main.py, config.py) never overrides exported vars. No script changes needed.
  • Handler registration converged on test(e2e): journey — tutor conversation persists to messages #392's mechanism — now upstream. After test(e2e): journey — tutor conversation persists to messages (#392) #434 merged (including the review-found latch fix in _load_env_handlers_module and its dispatch-twice regression test), this branch was rebased onto it: the shared seam files (_providers.py, learn.py, db.ts, global-setup.ts) collapsed into main entirely, and the PR diff is down to the quiz-specific surface — the quiz handler appended to agents/function_handlers_e2e.py (per that module's own instruction) and the quiz contract tests appended to its test file. The scripted quiz contract (labels B, C, A = E2E_QUIZ_CORRECT_LABELS) is pinned hermetically through the real agent + real wire mapping.
  • Below-the-seam egress (follow-up candidate):/api/quiz/generate's best-effort RAG grounding (_course_material_blockretrieve_chunks_embed_query) sits below the model seam: with a real key in backend/.env each generate makes one live gemini-embedding-001:batchEmbedContents call even in function mode (observed in .e2e/backend.log). It cannot affect the journey's outcome (the scripted handler ignores prompt content; course_chunks is empty post-reset; failures are swallowed by design) but it is incidental egress and a bounded (60 s) latency source the spec budgets for. Worth a follow-up to short-circuit grounding in non-real modes.
  • quiz_context deliberately unregistered:submit_quiz's background context update runs in try/except pass; without a handler it fails fast with no post-response DB write racing the next test's truncate + re-seed.

10-run tally

10/10 consecutive local runs green in one lock-held cycle (fresh SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up boot, make e2e-down after; cycle exit rc=0):

RUN 1/10 1 passed (7.9s) RUN 6/10 1 passed (6.6s)
RUN 2/10 1 passed (6.2s) RUN 7/10 1 passed (6.2s)
RUN 3/10 1 passed (6.4s) RUN 8/10 1 passed (5.2s)
RUN 4/10 1 passed (6.7s) RUN 9/10 1 passed (6.7s)
RUN 5/10 1 passed (6.6s) RUN 10/10 1 passed (5.4s)

The consecutive greens double as isolation proof: every run asserts the seeded 0.25 baseline before playing, which would fail if the prior run's 0.34 write leaked past the per-test truncate + re-seed.

Post-rebase confirmation (after re-syncing onto main's merged #434 seam, incl. the latch fix): fresh lock-held cycle, 3/3 green — 8.4 s / 6.9 s / 6.8 s.

Harness changes (additive)

Backend hermetic suite: 1080 passed, 23 skipped on the rebased branch. Frontend: tsc clean, eslint 0 errors, vitest 204 passed.

Part of #402, closes#393

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds lazy function-handler module loading, deterministic E2E tutor and quiz handlers, non-real-mode model override bypasses, updated quiz answer payloads, a mastery test ID, and browser/database assertions for all-correct quiz mastery updates.

Changes

Deterministic quiz E2E flow

Layer / File(s)Summary
Function handler autoloading
backend/agents/_providers.py
Function-mode dispatch lazily imports SAPLING_FUNCTION_HANDLERS modules and provides expanded missing-handler errors.
Deterministic backend handlers and model gating
backend/agents/function_handlers_e2e.py, backend/routes/learn.py, backend/routes/quiz.py, backend/tests/test_e2e_function_handlers.py
Fixed tutor and quiz handlers are registered for E2E use, model overrides are limited to real mode, and handler and route contracts are tested.
Quiz answer and results contracts
frontend/src/components/QuizPanel.tsx, docs/frontend-testids.md
Quiz submissions use selected_label, and the mastery results line receives the quiz-results-mastery test ID.
Browser journey and database verification
frontend/e2e/quiz.spec.ts, frontend/e2e/support/db.ts
The Playwright journey submits three deterministic answers and verifies UI mastery, graph-node updates, mastery events, and completed quiz attempts through raw SQL readback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • #393 — Adds the deterministic Playwright quiz journey and UI/database mastery monotonicity assertions described by the issue.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 66.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title is concise and accurately summarizes the main E2E quiz mastery update and UI/DB verification.
Description check✅ PassedThe description is detailed and covers the main changes, related issues, and testing, though it doesn't follow the exact template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/393-journey-quiz-mastery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment threadbackend/tests/test_e2e_function_handlers.py Fixed
Comment threadbackend/agents/_providers.py Fixed
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging2b0a1c5Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:35 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
backend/routes/learn.py (1)

90-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated model-mode gate in learn.py and quiz.py. Both _resolve_model_pref implementations add the identical _model_mode() != "real" early-return block to fix the same bypass; one root cause, two copies to keep in sync.

  • backend/routes/learn.py#L90-L98: extract the _model_mode() != "real" check (and its rationale comment) into a shared helper in backend/agents/_providers.py (e.g. def block_pref_override_outside_real_mode() -> bool), and call it here.
  • backend/routes/quiz.py#L141-L150: call the same shared helper instead of re-implementing the check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/learn.py` around lines 90 - 98, Extract the duplicated
non-real model-mode guard and its rationale into a shared helper in
backend/agents/_providers.py, such as block_pref_override_outside_real_mode(),
returning whether the preference override must be blocked. In
backend/routes/learn.py lines 90-98 and backend/routes/quiz.py lines 141-150,
replace each local _model_mode() != "real" check and comment with a call to that
helper while preserving the existing early-return behavior.
frontend/e2e/support/db.ts (1)

147-160: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider enforcing the "SELECT-only" contract at runtime.

The docstring states queryRaw is for parameterized SELECTs only, but nothing stops a future caller from passing a mutating statement through this now-exported helper — bypassing the app-write/DB-read separation the rest of the file is deliberately built around (see the local-host guard in requireLocalDbUrl).

🛡️ Proposed guard
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
+ if (!/^\s*select\b/i.test(sql)) {+ throw new Error(+ `queryRaw is read-only: refusing non-SELECT statement: ${sql}`,+ );+ }
return withDb(async (client) => (await client.query(sql, params)).rows);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 147 - 160, Enforce the documented
SELECT-only contract in the exported queryRaw helper before calling
client.query: validate that the supplied SQL is a single parameterized SELECT
statement, reject mutations and other statement types, and preserve the existing
withDb local-host guard and readback behavior for valid queries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 90-98: Extract the duplicated non-real model-mode guard and its
rationale into a shared helper in backend/agents/_providers.py, such as
block_pref_override_outside_real_mode(), returning whether the preference
override must be blocked. In backend/routes/learn.py lines 90-98 and
backend/routes/quiz.py lines 141-150, replace each local _model_mode() != "real"
check and comment with a call to that helper while preserving the existing
early-return behavior.
In `@frontend/e2e/support/db.ts`:
- Around line 147-160: Enforce the documented SELECT-only contract in the
exported queryRaw helper before calling client.query: validate that the supplied
SQL is a single parameterized SELECT statement, reject mutations and other
statement types, and preserve the existing withDb local-host guard and readback
behavior for valid queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3d5839b-966d-4a82-a89e-6751a46ffce9

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and aebfbb1.

📒 Files selected for processing (9)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/db.ts
  • frontend/src/components/QuizPanel.tsx

AndresL230and others added 3 commits July 27, 2026 20:28
Browser journey: answer a scripted three-question quiz through the real
/quiz UI and assert the mastery change in the UI AND in the database —
graph_nodes.mastery_score plus exactly one append-only node_mastery_events
row (0023), read back over the raw-SQL queryRaw seam so the assertion
never rides the layer that wrote. Monotonicity pinned three ways: a fully
correct submission never lowers the rendered score, the persisted score,
or the event delta sign.
The spec pre-acks the AI disclaimer via addInitScript (the modal
intercepts every click for a browser that never acked — found on the
first live run) and guards loudly that the stack is in function mode
before answering. New quiz-results-mastery testid on the existing quiz
surface (inventory row appended; QuizPanel.tsx is already in the eslint
files array).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…del_pref bypass (#393)
Append the quiz handler to agents/function_handlers_e2e.py per that
module's own instruction (fixed three-question quiz; correct wire labels
B, C, A exported as E2E_QUIZ_CORRECT_LABELS) and pin the contract in
tests/test_e2e_function_handlers.py through the real quiz_agent and the
real routes/quiz.py wire mapping — a drift fails hermetic CI instead of
mid-browser-run.
Also fixes the quiz half of the seam bypass #392's review found in
learn.py: routes/quiz.py::_resolve_model_pref built a live GoogleModel
for a fast/smart pref regardless of SAPLING_MODEL_MODE. The quiz UI
sends no pref today (which is why the journey stayed deterministic
regardless), but any client that did would silently dial Gemini in
function mode — non-real modes now fall through to the agent's
mode-built default, same pattern and tests as learn.py's fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce the shell revamp
Found by the #393 journey (first live run): POST /api/quiz/submit
returned 422 because QuizPanel sends {question_id, selected} while
backend models.AnswerItem requires selected_label — a wire-format break
introduced by the frontend shell revamp (399eae3 removed the old
selected_label payload). Every quiz submitted through the UI has failed
since; no mocked test on either side could catch it (backend tests
build AnswerItem directly, frontend tests mock fetch). Rename the
request field to the backend contract — the RESPONSE items keep their
`selected` key, which is a different shape and untouched. Tracked as
issue #438.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/393-journey-quiz-mastery branch from aebfbb1 to 2b0a1c5CompareJuly 28, 2026 03:33
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Panel verified the #438 fix restores the exact pre-revamp submit contract, the quiz seam handler validates against the real output schema, and the mastery math matches the seeded baseline.)

🤖 Generated with Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — quiz answer → mastery update (UI + DB)

1 participant

@AndresL230