Quiz System¶
Overview¶
Quizzes are interactive assessments authored in edX CMS as LMS entries with _type: 'quiz'. They render inside the standard entry route (/learn/course/\[slug\]/entry/\[entrySlug\]) using a client QuizPlayer component, accept learner answers in a single submission, are graded server-side, and emit a completion signal consumed by the pagination/gating layer described in entry-pagination.md.
This document is the binding spec for Phase 8 of implementation.md and refines its rules where noted (see Divergences). Where this document and implementation.md disagree, this document wins.
Authoring via the structured-quiz widget¶
Graded quizzes are authored with the structured-quiz CMS widget (one per page). The wire shape is defined once in @open-learning-hub/widget-wire-schemas (structuredQuizContentSchema) and the LMS maps it into the server-only Quiz domain object via src/lib/cms/quiz-adapter.ts (adaptQuizFromPage). A page is classified as a quiz entry only when it carries a structured-quiz widget — QUIZ_WIDGET_TYPES in src/lib/course/entry-classifier.ts is {"structured-quiz"}. Legacy assessment widgets (form, multiple-choice, etc.) are now formative and classify their pages as lessons; they are not graded. There is no data migration — existing quiz_attempts / entry_progress rows remain valid.
Quiz Entry Shape (CMS)¶
Quiz entries are fetched from edX CMS through src/lib/cms/client.ts, adapted by src/lib/cms/quiz-adapter.ts, and validated with the Zod schemas in src/schemas/cms/quiz.ts. The CMS-authored shape is:
type Quiz = {
_type: "quiz";
_order: number;
slug: string;
title: string;
instructions?: PortableText;
passThreshold: number; // 0..1, fraction of points required to pass
maxAttempts: number; // ≥1; 0 or null is rejected at validation
displayMode?: "full_page" | "one_at_a_time"; // default "full_page"
shuffleQuestions?: boolean; // default false
showPerQuestionFeedback?: boolean; // default true
questions: QuizQuestion[];
};
type QuizQuestion =
MultipleChoiceQuestion | TrueFalseQuestion | ShortAnswerQuestion;
Question and option text is CMS-sourced and rendered as-is — i18n is the CMS's responsibility (see Internationalisation). The Zod schemas are the single source of truth for the wire shape and feed the OpenAPI registry per .cursor/rules/050 - APIs.mdc.
Question Types¶
| Type | Authoring fields | Learner UI | Grading |
|---|---|---|---|
multiple_choice |
prompt, options[] (each {id, text, correct: boolean}), multiSelect: boolean |
Radio or checkbox set | Exact-set match against correct === true. |
true_false |
prompt, correct: boolean |
Two radio options | Boolean equality. |
short_answer |
prompt, acceptedAnswers[] (strings), caseSensitive?: boolean, trim?: boolean |
Single-line input | Normalised string membership. |
Multiple choice¶
multiSelect: false(default) renders radios; the learner picks exactly one option. Submission is invalid if zero options are selected.multiSelect: truerenders checkboxes; the answer is the set of selected option ids and is correct only when it equals the set of options wherecorrect === true. Partial credit is not awarded.
True/false¶
A degenerate two-option multiple choice. Modelled separately so authors can express boolean prompts without ceremony and so the UI can render a tighter affordance.
Short answer¶
trimdefaults totrue;caseSensitivedefaults tofalse.- The learner's answer is matched against
acceptedAnswers[]after applying the same normalisation. No fuzzy match, no regex, no Levenshtein — authors must enumerate accepted variants. This is intentional: learner-trust requires the grading rule be inspectable from the CMS payload alone. - Empty answers are graded as wrong, not skipped.
Future question types (essay, file upload, drag-and-drop) are out of scope and are not aliased onto these types.
Scoring Logic¶
Scoring is per-question, equal weight, all-or-nothing.
- Each question is worth one point. Total points =
questions.length. - A question is correct iff its grading rule above returns true.
scoreiscorrectCount / totalCount, in[0, 1].- The attempt is
passediffscore >= quiz.passThreshold.
perQuestionFeedback is computed when quiz.showPerQuestionFeedback === true (default) and is an array of { questionId, correct: boolean }. The expected/correct answer is not echoed back — this prevents trivial answer-mining across attempts. Authors who want to reveal the right answer post-quiz must do so through CMS-authored explanation copy rendered only in review mode.
Weighted scoring, partial credit, and negative marking are explicit non-goals; revisit only when a real curriculum need surfaces.
Retry & Attempt-Limit Policy¶
quiz.maxAttemptsis authoritative, fetched fresh from edX on every submission (the client value is never trusted — see Server Actions).- The number of attempts a learner has used is
count(*) FROM quiz_attempts WHERE user_id = ? AND tenant_id = ? AND course_slug = ? AND entry_slug = ? AND submitted_at IS NOT NULL. - Attempts in progress (
submitted_at IS NULL) do not count toward the limit; abandoned attempts are not preserved. - A new attempt is allowed iff
attemptsUsed < maxAttemptsand the learner has not already passed. After passing, the Retry affordance is hidden — passing is terminal for the entry's gating signal even if attempts remain. - When
attemptsUsed === maxAttemptsand the learner has not passed, the entry enters review-only mode (see Review Mode).
Progress & Gating Integration¶
Quizzes write two rows on submission:
- A row in
quiz_attemptsrecording the full attempt (always, on every submission). - An upsert into
entry_progresssettingcompleted_at = NOW()iff either the attempt passed or this submission exhaustsmaxAttempts. This matches the rule defined in entry-pagination.md § Quizzes.
The entry_progress upsert reuses the helper that backs markEntryComplete so persistence and revalidation behaviour is identical to lessons. quiz_attempts.passed is preserved separately so analytics surfaces (admin dashboards, learner score history) can distinguish "completed via pass" from "completed via attempts exhausted".
Gating only consumes entry_progress.completed_at; it never reads quiz_attempts.
Data Model¶
CREATE TABLE quiz_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
course_slug TEXT NOT NULL,
entry_slug TEXT NOT NULL,
attempt_number INT NOT NULL, -- 1-based, sequential per (user, tenant, course, entry)
score NUMERIC(5,4) NOT NULL, -- [0.0000 .. 1.0000]
passed BOOLEAN NOT NULL,
answers_json JSONB NOT NULL, -- learner-submitted answers as graded
feedback_json JSONB NOT NULL, -- per-question correct/incorrect
started_at TIMESTAMPTZ NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX quiz_attempts_lookup_idx
ON quiz_attempts (user_id, tenant_id, course_slug, entry_slug, attempt_number DESC);
attempt_numberis computed server-side at submission ascoalesce(max(attempt_number), 0) + 1for the same(user_id, tenant_id, course_slug, entry_slug)tuple, inside the same transaction that inserts the row, so two concurrent submissions are still consistent.answers_jsonstores the submitted answers in the same shape the client posted, post-normalisation.feedback_jsonstores the per-question correctness vector. Storing both makes review-mode rendering a pure read.- Kysely query helpers live under
src/db/queries/quiz-attempts/(insert,latest,stats) and every query is scoped by bothuser_idandtenant_id, per.cursor/rules/080 - Data & Auth Integration.mdc. Server-side grading lives insrc/lib/quiz/grade.ts.
The migration follows the numbered tsx convention in src/db/migrations/ and is run by scripts/db-migrate.ts.
Server Actions¶
All quiz mutations live in src/app/learn/course/\[slug\]/entry/\[entrySlug\]/actions.ts (alongside markEntryComplete), are marked 'use server', and validate every input with Zod.
submitQuizAttempt({ courseSlug, entrySlug, answers })¶
- Resolve session via
await auth(). Reject if unauthenticated. - Enforce course access via
requireCourseAccess(courseSlug). - Enforce sequential gating server-side: load the course hierarchy and learner progress map, and reject when the target entry is still locked.
- Re-fetch the quiz from edX CMS. Never grade against the client-supplied question shape. This is the single defence against tampered prompts/options/correct-flags.
- Validate
answersagainst the re-fetched quiz: every question id must be present, every payload must match its question type's schema. - Re-check attempt-limit: refuse submission with an
attemptsExhaustederror if the learner has already usedmaxAttemptsor has already passed. - Grade per Scoring Logic.
- In a single transaction:
- Insert
quiz_attemptsrow (computingattempt_numberas described in Data Model). - If
passed === trueor the newattempt_number === maxAttempts, upsertentry_progresswithcompleted_at = NOW()via the shared helper.
- Insert
revalidateTag('course:' + courseSlug)andrevalidateTag('progress:' + userId)so the sidebar, badges, and dashboard refresh.- Return
{ score, passed, correctCount, totalCount, attemptNumber, attemptsRemaining, perQuestionFeedback? }.perQuestionFeedbackis omitted whenquiz.showPerQuestionFeedback === false.
The action is idempotent against double-clicks via a client-side submitting flag; it is not idempotent at the database level (each call inserts a new attempt), and must never be retried automatically on network error — the client surfaces a retry affordance the learner activates explicitly.
startQuizAttempt(...) — explicitly out of scope¶
There is no "start" server action. quiz_attempts rows are written only on submission. started_at is captured by the client at mount and posted with the submission payload. Rationale: a separate start endpoint adds a write per quiz view with no user-visible benefit; we accept that started_at is client-clock and treat it as advisory.
Client Architecture¶
QuizPlayer and QuizResults are client components ('use client') at src/components/lms/quiz-player.tsx and src/components/lms/quiz-results.tsx. They are rendered by the entry page (src/app/\[locale\]/learn/course/\[slug\]/entry/\[entrySlug\]/page.tsx), which passes only toQuizPublic(quiz) plus the learner's prior attempt.
- Form state is held in local component state (
useState) keyed by question id; submission runs insideuseTransitionso the Submit button reflects pending state. - One submission per render — the Submit button is disabled while the submit transition is pending.
displayModecontrols learner flow:full_pagerenders all questions in one form (existing behavior).one_at_a_timerenders one question per step with Back/Next navigation and a final review step before submit.
- When
quiz.shuffleQuestionsistrue,QuizPlayerdeterministically shuffles question order and multiple-choice option order (seeded byquiz.slug+ currentattemptCount) so SSR and client hydration stay in sync. - Client-side validation enforces "every question answered" before allowing submit; server still re-validates.
- On a successful response the player swaps into
QuizResults(see Results UI). - The player never reads
correctflags from the CMS payload — those fields are stripped from the typed CMS response on the public path; only the serversubmitQuizAttemptaction sees the un-stripped CMS document. Achieved by exposing two Zod schemas:Quiz(server-only, includescorrect) andQuizPublic(client/public, omits it).QuizPlayerdoes not implement timers or autosave.
Results UI¶
After a successful submission the QuizResults view renders inside the same entry page:
- Score line:
correctCount / totalCountand the percentage. Translation-keyed. - Pass/fail banner: colour, icon, and copy keyed on
passed. WCAG AA contrast in both themes. - Per-question feedback list (if enabled): one row per question echoing the learner's answer and a correct/incorrect indicator. The correct answer is not shown (see Scoring Logic).
- Attempt history: when prior attempts exist,
QuizResultsrenders a per-attempt history list (attempt number, score percent, pass/fail, submission timestamp) for the current learner and entry. - Affordances:
- Retry — visible iff
passed === falseandattemptsRemaining > 0. Clicking remountsQuizPlayerwith a cleared form. - Review — visible iff the entry is in review-only mode. Renders the latest attempt as a read-only
QuizResults. - Next entry — visible iff
entry_progress.completed_at IS NOT NULLfor this entry (i.e., passed or attempts exhausted). Routes to the next entry via the same prev/next helper used elsewhere.
- Retry — visible iff
Focus management: on submission, focus moves to the pass/fail banner so screen-reader users hear the outcome immediately. On retry, focus moves to the first question's first input.
Review Mode¶
When attemptsUsed === maxAttempts and the learner has not passed, the entry is in review-only mode:
QuizPlayeris not rendered. Instead,QuizResultsis shown for the most recent attempt (highestattempt_number).- The CMS-authored
explanationfield on each question (if present) is rendered alongside the learner's answer. This is the only context where explanation copy is ever shown. - The Submit and Retry affordances are absent. The Next-entry affordance is present (the entry is complete via attempts-exhausted).
Review mode is also the surface a learner returns to after passing — same view, but with the highest-scoring attempt as the source row.
Components & Test IDs¶
Per the AGENTS.md data-testid convention, every custom component and interactive sub-element exposes a stable selector for Playwright. Primitives from @open-learning-hub/ui are exempt.
| Component | data-testid |
Notes |
|---|---|---|
QuizPlayer |
quiz-player |
Wraps the form. |
QuizQuestion (MC) |
quiz-question-mc-<questionId> |
Radios or checkboxes per multiSelect. |
QuizQuestion (TF) |
quiz-question-tf-<questionId> |
Two radios. |
QuizQuestion (SA) |
quiz-question-sa-<questionId> |
Single text input. |
QuizSubmitButton |
quiz-submit-button |
Disabled while isSubmitting or invalid. |
QuizResults |
quiz-results |
Replaces QuizPlayer after submission. |
QuizPassFailBanner |
quiz-passfail-banner |
Receives focus on submission. |
QuizScoreLine |
quiz-score-line |
<correct>/<total> and percentage. |
QuizFeedbackRow |
quiz-feedback-<questionId> |
Per-question correct/incorrect indicator. |
QuizRetryButton |
quiz-retry-button |
Hidden after pass; hidden when attempts exhausted. |
QuizReviewBanner |
quiz-review-banner |
Visible only in review-only mode. |
Each component above ships with a co-located *.test.tsx, per the 100% file-coverage rule in implementation.md Phase 11.
Implementation note. The shipped data-testids use a flatter quiz-* scheme: quiz-player-<entrySlug>, quiz-question-<questionId>, quiz-option-<questionId>-<optionId>, quiz-input-<questionId>, quiz-submit, quiz-results-<slug>, quiz-result-banner, quiz-score-line, quiz-review-<questionId>, quiz-review-status-<questionId>, quiz-retry, and quiz-exhausted. Treat this list as authoritative for Playwright/RTL selectors.
Internationalisation¶
UI chrome is translation-keyed; CMS-authored question/option/explanation copy is rendered as-is. Required keys (in all six locale files — en/es/fr/de/pt/zh.json — per AGENTS.md):
quiz.submit,quiz.retry,quiz.review,quiz.nextquiz.score.line(with{correct}/{total}/{percent}placeholders)quiz.banner.passed,quiz.banner.failed,quiz.banner.exhaustedquiz.feedback.correct,quiz.feedback.incorrectquiz.attempts.remaining(with{count}placeholder),quiz.attempts.exhaustedquiz.error.unanswered,quiz.error.previewReadOnly,quiz.error.networkquiz.review.heading,quiz.review.explanationLabel
Accessibility¶
- Radio groups and checkbox groups use
<fieldset>+<legend>for the question prompt. - Question prompts are rendered as
h3inside anh2quiz title so the heading order is flat and predictable. - The pass/fail banner sets
role="status"and receives focus on submission; it also announces via a polite live region for SR users who navigate away from focus. - Form errors (e.g. unanswered question on submit) are associated with their inputs via
aria-describedbyand surfaced in a singlerole="alert"summary above the Submit button. - Submit and Retry buttons disable via
aria-disabled, not the HTMLdisabledattribute, when the disabled reason is policy (attempts exhausted) — so SR users can still discover them. They use the HTMLdisabledattribute when the reason is transient (isSubmitting). - All colour pairings in the pass/fail banner and feedback rows pass WCAG AA contrast in both light and dark themes (verified by the
@axe-core/playwrightsweep — seeimplementation.mdPhase 11).
Caching & Revalidation¶
- Quiz CMS payloads are fetched through
src/lib/cms/client.tswithrevalidate = 60and taggedcourse:<slug>andentry:<courseSlug>:<entrySlug>so admin-driven invalidations scope cleanly. - Per-learner state — attempt count, latest attempt for review, completion badge — is read from Kysely on every request and is never cached.
submitQuizAttemptcallsrevalidateTag('course:' + courseSlug)andrevalidateTag('progress:' + userId)so sidebar lock states, completion badges, and the dashboard refresh.
Testing Requirements¶
Unit (Vitest, happy-dom)¶
- Scoring: all-correct, all-wrong, mixed; per-question-type grading rules; multi-select set equality (correct subset, superset, missing-one); short-answer normalisation (trim, case).
- Retry-policy resolution: zero-used / mid-used / fully-used; "passed already" hides retry; pass on the final attempt does not double-write
entry_progress. - Quiz Zod schemas: reject
maxAttempts < 1, rejectpassThresholdoutside[0,1], reject MC questions with nocorrect: trueoption, reject SA with emptyacceptedAnswers.
Component (Vitest)¶
Every component listed in Components & Test IDs has a co-located .test.tsx covering rendered states (idle, submitting, passed, failed, review-only) and interaction handlers.
E2E (Playwright)¶
- Pass on first attempt: submit → pass banner → next entry unlocked →
quiz_attemptsrow written →entry_progress.completed_atset. - Fail-then-pass: fail attempt 1, retry, pass attempt 2; both attempts in
quiz_attempts;attempt_numberis monotonically increasing; entry unlocks on attempt 2. - Attempts exhausted without passing: entry unlocks anyway;
QuizPlayeris replaced by review-onlyQuizResults; Retry button is absent. - Tampered submission: a request mutating an option's
correctflag client-side is graded against the server-fetched quiz and produces the correct score. - Preview mode: a
course_adminfor the course can render the quiz under?preview=1butsubmitQuizAttemptreturnspreviewReadOnlyand writes nothing. - Unanswered submit: clicking Submit with one question blank surfaces an inline error and does not call the server.
- Theme + locale sweep: pass/fail banner and feedback row render in light and dark, and at least one scenario renders in
esto prove translation keys are wired.
Use DATA_DIR=./data/test and unique resource names per test (per AGENTS.md test convention).
Open Questions / Deferred¶
- Per-question time limits / overall quiz timer: out of scope.
- Partial credit / weighted scoring: out of scope; revisit with curriculum-team requirements.
- Essay / file-upload questions: out of scope; require a separate grading workflow.
- "Mark incomplete" undo for quizzes: not supported. A passed quiz cannot be retaken, by design.
- Cross-attempt analytics surfaces (item-difficulty, distractor analysis): dashboard-side concern, tracked under Phase 9.
Divergences from implementation.md¶
These requirements refine implementation.md and should be propagated when that file is next updated (Phase 12):
- Preview-mode short-circuit.
submitQuizAttemptis read-only whengetEntryAccess(...)returns{ mode: 'preview' }. Noquiz_attemptsrow is written. Not mentioned inimplementation.mdPhase 8. - Two-schema split (
QuizvsQuizPublic). Thecorrectflags on options and theacceptedAnswersarray on short-answer questions are stripped before the CMS payload reaches the client bundle.implementation.mddoes not specify the split. - Always-on submit gating. Quiz submissions enforce prerequisite completion server-side even when the
entries.sequentialGatingfeature flag is disabled for sidebar navigation. This is an intentional hardening choice and is now documented here.
Related Documents¶
- implementation.md — Phase 2 (data model), Phase 4 (Zod schemas), Phase 8 (binding spec).
- analytics.md — learner and admin reporting built on
quiz_attempts(pass rates, per-question averages, attempts-to-pass). - entry-pagination.md — completion handoff, gating, preview mode.
- hierarchy-structure.md — Course / Module / Entry shape.
- user-management.md — admin roles consumed by preview-mode short-circuit.
.cursor/rules/030 - Coding Best Practices.mdc,.cursor/rules/080 - Data & Auth Integration.mdc— server-only enforcement and multi-tenancy scoping..cursor/rules/050 - APIs.mdc— Zod-to-OpenAPI registry for the public quiz schema..cursor/rules/060 - WCAG.mdc— accessibility baseline referenced from this document.