diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ec216673..475c6100 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -83,6 +83,12 @@ jobs: # Dummy on purpose: the below-seam RAG embed path (#439) must not # be able to bill; its failures are swallowed by design. GEMINI_API_KEY: e2e-dummy-key-no-billing + # #537: the quiz-generation guard keeps its sliding window in a + # process-local dict, so ONE Playwright run shares a single + # 8-per-300s budget across every spec and the quiz specs that run + # last 429. e2e-up.sh sets the same value; kept here so what the + # lane actually runs under is visible beside the rest of the seam. + QUIZ_GENERATE_RATE_LIMIT: "1000" run: make e2e-up - name: Run Playwright suite diff --git a/backend/.env.example b/backend/.env.example index 09c284e0..a8ffc20c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -69,3 +69,12 @@ GEMINI_VISION_OCR_ENABLED=false GEMINI_VISION_OCR_MAX_PAGES=10 # The transcription model is the ADR-0008 per-task slot, not a knob of its own: # SAPLING_MODEL_OCR_VISION=gemini-2.5-flash + +# Quiz generation abuse guard (#544 F1; env-overridable since #537). The window +# lives in a process-local dict, so ONE Playwright run spends a single budget +# across every spec — the E2E lanes raise this; production keeps the default. +# Unparseable or non-positive values fall back to the default. +# QUIZ_GENERATE_RATE_LIMIT=8 +# Sliding-window length for that limit, in seconds. E2E lanes raise the limit +# rather than shrink the window; production keeps the default. +# QUIZ_GENERATE_RATE_WINDOW_SEC=300 diff --git a/backend/services/quiz_config.py b/backend/services/quiz_config.py index fa9983a3..66db26ef 100644 --- a/backend/services/quiz_config.py +++ b/backend/services/quiz_config.py @@ -9,6 +9,41 @@ Standalone constants module: no imports from models/routes/services so it can be imported from anywhere without cycles. """ +import logging +import os + +logger = logging.getLogger(__name__) + + +def _positive_int_env(name: str, default: int) -> int: + """A deployment override for one guard constant, read at import. + + Deliberately fail-SAFE rather than fail-fast: an unparseable or + non-positive value falls back to the shipped default (the conservative + one) with a warning, so a stray env var cannot boot the app with a + disabled guard — or refuse to boot at all. Read once at import time, + which is also when `routes/quiz.py` binds the value, so a mid-process + `os.environ` change has no effect. + """ + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + try: + value = int(raw.strip()) + except ValueError: + logger.warning( + "quiz_config: %s=%r is not an integer — using the default %d", + name, raw, default, + ) + return default + if value <= 0: + logger.warning( + "quiz_config: %s=%d is not positive — using the default %d", + name, value, default, + ) + return default + return value + QUIZ_MIN_QUESTIONS = 1 @@ -87,8 +122,18 @@ def mastery_after(before: float, *, score: int, total: int) -> float: # The rate limit is sized for a human: a student comparing difficulties or # retaking a concept might legitimately generate a handful of quizzes in a # few minutes; nobody legitimately generates 10 in one. -QUIZ_GENERATE_RATE_LIMIT = 8 -QUIZ_GENERATE_RATE_WINDOW_SEC = 300 # 5 minutes +# +# Both are env-overridable (#537) for one reason: `request_limits._rate_state` +# is a module-level dict, so a whole Playwright run — every spec, every +# worker-shared window — spends ONE 8-per-300s budget that only a backend +# restart clears. The redesigned quiz lane needs ~20 real generations, and a +# function-mode generation costs nothing, so the E2E stacks raise the limit +# (scripts/e2e-up.sh, .github/workflows/e2e.yml). Production sets neither and +# keeps the defaults below. +QUIZ_GENERATE_RATE_LIMIT = _positive_int_env("QUIZ_GENERATE_RATE_LIMIT", 8) +QUIZ_GENERATE_RATE_WINDOW_SEC = _positive_int_env( + "QUIZ_GENERATE_RATE_WINDOW_SEC", 300, # 5 minutes +) # Daily per-user LLM spend ceiling. The SPEND it measures is cross-feature # (llm_usage records every agent call, not just quiz ones), but the ceiling diff --git a/backend/tests/test_quiz_cost_observability_f.py b/backend/tests/test_quiz_cost_observability_f.py index 8f508c26..c4a1b1c2 100644 --- a/backend/tests/test_quiz_cost_observability_f.py +++ b/backend/tests/test_quiz_cost_observability_f.py @@ -132,6 +132,87 @@ def test_limit_is_per_user(self): assert other.status_code == 200 +class TestGenerateRateLimitIsEnvOverridable: + """#537: the two guard constants read the environment; defaults intact. + + `request_limits._rate_state` is module-level, so a whole Playwright run + shares ONE window that only a backend restart clears — and the redesigned + quiz lane needs more generations than any human would. The E2E stacks + raise the limit through the environment; production sets nothing and must + keep the shipped defaults. + """ + + @staticmethod + def _reloaded(): + """Re-import services.quiz_config against the current environment. + + `importlib.reload` mutates the module in place, so every caller must + restore it (see `_restore`) or later tests inherit the override. + """ + import importlib + + from services import quiz_config + + return importlib.reload(quiz_config) + + @classmethod + def _restore(cls): + cls._reloaded() + + def test_defaults_hold_when_unset(self, monkeypatch): + monkeypatch.delenv("QUIZ_GENERATE_RATE_LIMIT", raising=False) + monkeypatch.delenv("QUIZ_GENERATE_RATE_WINDOW_SEC", raising=False) + try: + cfg = self._reloaded() + assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8 + assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300 + finally: + monkeypatch.undo() + self._restore() + + def test_an_override_is_honoured(self, monkeypatch): + monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", "1000") + monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", "60") + try: + cfg = self._reloaded() + assert cfg.QUIZ_GENERATE_RATE_LIMIT == 1000 + assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 60 + finally: + monkeypatch.undo() + self._restore() + + def test_a_junk_override_falls_back_to_the_default(self, monkeypatch): + """Fail-safe, not fail-fast: a stray value must neither disable the + guard (limit=0 would 429 everyone, a negative window never expires) + nor stop the app from booting.""" + for bad in ("", " ", "eight", "0", "-5", "3.5"): + monkeypatch.setenv("QUIZ_GENERATE_RATE_LIMIT", bad) + monkeypatch.setenv("QUIZ_GENERATE_RATE_WINDOW_SEC", bad) + try: + cfg = self._reloaded() + assert cfg.QUIZ_GENERATE_RATE_LIMIT == 8, bad + assert cfg.QUIZ_GENERATE_RATE_WINDOW_SEC == 300, bad + finally: + monkeypatch.undo() + self._restore() + + def test_the_route_still_enforces_whatever_was_resolved(self): + """The env seam moves the NUMBER, never the behaviour: the route binds + the value at its own import and still 429s one call past it.""" + from routes import quiz as quiz_route + from services.quiz_config import QUIZ_GENERATE_RATE_LIMIT + + assert quiz_route.QUIZ_GENERATE_RATE_LIMIT == QUIZ_GENERATE_RATE_LIMIT + run = AsyncMock(return_value=SimpleNamespace(output=_quiz())) + with ( + patch("routes.quiz.table", side_effect=_factory()), + patch("routes.quiz.quiz_agent.run", new=run), + ): + for _ in range(quiz_route.QUIZ_GENERATE_RATE_LIMIT): + assert _generate().status_code == 200 + assert _generate().status_code == 429 + + class TestDailySpendGuard: def test_over_budget_user_is_refused_before_the_model_runs(self): from services.quiz_config import QUIZ_DAILY_SPEND_CAP_USD diff --git a/docs/e2e-exploration.md b/docs/e2e-exploration.md index 1284cc71..f1206f36 100644 --- a/docs/e2e-exploration.md +++ b/docs/e2e-exploration.md @@ -226,7 +226,10 @@ byte-for-byte match to one of `function_handlers_e2e.py`'s `E2E_DOC_*` (or `E2E_TUTOR_REPLY`, `E2E_QUIZ_*`) constants — here, `E2E_DOC_ABSTRACT`'s "gradient descent... loss surface... learning rate" wording and the fixed `lecture_notes` category. If a finding's "wrong" content matches one of -those constants verbatim, it's the seam, not the app — drop it, and if it +those constants verbatim, it's the seam, not the app (likewise the quiz's +"Only 3 questions were ready for this concept" toast: the function-mode quiz handler always +returns exactly 3 questions, so any requested length above 3 is reported as a short delivery — +the honesty check working, not a generation bug) — drop it, and if it recurs, improve `scripts/explore/explorer-prompt.md`'s ground rules to name the pattern explicitly so a future explorer recognizes it before writing the stub, rather than filing an issue against the harness's own known-fixed diff --git a/docs/frontend-testids.md b/docs/frontend-testids.md index c657941b..87cb27d4 100644 --- a/docs/frontend-testids.md +++ b/docs/frontend-testids.md @@ -71,7 +71,7 @@ renders the element. | Onboarding | `onboarding` | `frontend/src/components/screens/Onboarding.tsx` (the first-run funnel, rendered bare outside `(shell)`) | | Upload modal | `upload-modal` | `frontend/src/components/DocumentUploadModal.tsx` | | Tutor | `tutor` | `frontend/src/components/chat/ChatPanel.tsx` (rendered by `screens/Learn.tsx`; + the session-resume rows in `src/components/screens/Learn.tsx` itself) | -| Quiz | `quiz` | `frontend/src/components/QuizPanel.tsx` (rendered by `screens/Quiz.tsx`) | +| Quiz | `quiz` | `frontend/src/components/quiz/**` — `QuizScreen.tsx` (route root + error card) switching on phase between `home/QuizHome.tsx` (+ `ConceptDialog`/`AdjustDialog`/`PickList`/`QuizSettings`), `question/QuizQuestion.tsx` (+ `AskPanel`/`LeaveDialog`) and `results/QuizResults.tsx` (+ `MissedList`); the ids on the shared `ui/` primitives and `graph/ConceptNeighbourhood.tsx` are passed in from those files as a `testid` prop (#537) | | Knowledge graph | `graph` | `frontend/src/components/graph/KnowledgeGraph.tsx` (wrapper: container root + mode toggle) plus `KnowledgeGraph2D.tsx`/`KnowledgeGraph3D.tsx` (the render/data-layer seam — hidden a11y node list, SVG node/edge marks, zoom controls — added with the #395 graph-integrity journey) | | App shell | `app` | `frontend/src/components/ShellFrame.tsx` (the authed layout frame every `(shell)` route renders inside) | | Study rooms | `social` | `frontend/src/components/screens/Social.tsx` (rooms sidebar, chat, overview, study match, directory — added with the #394 two-context journey) | @@ -95,8 +95,16 @@ route: and the send ` - - - - )} - - {phase === "active" && currentQuestion && ( - <> -
-
Question {qIndex + 1} of {questions.length}
-
- {difficulty === "adaptive" && resolvedDifficulty && ( -
- Adaptive · {resolvedDifficulty} -
- )} -
{currentQuestion.difficulty}
-
-
-
{currentQuestion.question}
-
- {currentQuestion.options.map(o => { - const selected = currentSelection === o.label; - return ( - - ); - })} -
-
- - -
- - )} - - {phase === "review" && currentQuestion && ( - <> -
Review
-
- {lastCorrect ? "Correct." : "Not quite."} -
-
{currentQuestion.question}
-
- {currentQuestion.options.map(o => { - const picked = currentSelection === o.label; - const right = o.correct; - const bg = right ? "var(--accent-soft)" : picked ? "var(--err-soft)" : "var(--bg-subtle)"; - const color = right ? "var(--accent)" : picked ? "var(--err)" : "var(--text-dim)"; - return ( -
- {o.label}. - {o.text} - {right && } - {!right && picked && } -
- ); - })} -
-
- {currentQuestion.explanation} -
-
- - -
- - )} - - {phase === "results" && results && ( - <> -
Results
-
- {Math.round((results.score / Math.max(1, results.total)) * 100)}% -
-
- {results.score} / {results.total} correct · mastery{" "} - = results.mastery_before ? "var(--accent)" : "var(--err)", fontWeight: 600 }}> - {Math.round(results.mastery_before * 100)}% → {Math.round(results.mastery_after * 100)}% - -
-
- - -
- - )} - - ); -} diff --git a/frontend/src/components/graph/ConceptNeighbourhood.test.tsx b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx new file mode 100644 index 00000000..7c6c9098 --- /dev/null +++ b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx @@ -0,0 +1,242 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import React from "react"; +import { __resetReducedMotionStoreForTests } from "@/lib/usePrefersReducedMotion"; +import { edgeWidthFor, shadeFor } from "@/lib/graph/nodeStyle"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { ConceptNeighbourhood } from "./ConceptNeighbourhood"; + +afterEach(() => { + cleanup(); + __resetReducedMotionStoreForTests(); +}); + +const CENTRE = { id: "recursion", name: "Recursion", mastery: 0.29, tier: "struggling" }; +const SIBLINGS: NeighbourNode[] = [ + { id: "base-cases", name: "Base cases", mastery: 0.52, tier: "learning", strength: 0.9 }, + { id: "stack-frames", name: "Stack frames", mastery: 0.3, tier: "struggling", strength: 0.7 }, + { id: "tail-recursion", name: "Tail recursion", mastery: 0.12, tier: "struggling", strength: 0.4 }, +]; + +const HOME = { width: 320, height: 204, scale: 2 } as const; + +function renderHome(over: Partial> = {}) { + return render( + , + ); +} + +const num = (el: Element, attr: string) => Number(el.getAttribute(attr)); + +describe("ConceptNeighbourhood", () => { + it("is one image to a screen reader, sized to the preset", () => { + const { container } = renderHome({ testid: "quiz-neighbourhood" }); + const svg = container.querySelector("svg")!; + expect(svg).toHaveAttribute("role", "img"); + expect(svg).toHaveAttribute( + "aria-label", + "Recursion and its neighbours on your knowledge tree", + ); + expect(svg).toHaveAttribute("width", "320"); + expect(svg).toHaveAttribute("height", "204"); + expect(svg).toHaveAttribute("data-testid", "quiz-neighbourhood"); + }); + + it("draws one edge per sibling, from the centre, at the tree's width and opacity", () => { + const { container } = renderHome(); + const edges = Array.from(container.querySelectorAll(".concept-neighbourhood__edge")); + expect(edges).toHaveLength(3); + for (const edge of edges) { + expect(num(edge, "x1")).toBe(320 / 2 + 8); + expect(num(edge, "y1")).toBe(204 / 2); + expect(num(edge, "stroke-opacity")).toBe(0.2); + } + expect(num(edges[0], "stroke-width")).toBeCloseTo(edgeWidthFor(0.9), 6); + expect(num(edges[2], "stroke-width")).toBeCloseTo(edgeWidthFor(0.4), 6); + }); + + it("scales every mark by the preset's scale and shades each by its own id", () => { + const { container } = renderHome(); + const bodies = Array.from(container.querySelectorAll(".concept-node__body")); + // three siblings then the centre + expect(bodies).toHaveLength(4); + // radiusFor(0.52) * 2 + expect(num(bodies[0], "r")).toBeCloseTo((8 + 0.52 * 12) * 2, 6); + expect(bodies[0]).toHaveAttribute("fill", shadeFor("#7b4b99", "base-cases")); + // the centre, radiusFor(0.29) * 2 + expect(num(bodies[3], "r")).toBeCloseTo((8 + 0.29 * 12) * 2, 6); + expect(bodies[3]).toHaveAttribute("fill", shadeFor("#7b4b99", "recursion")); + }); + + it("glows only under the centre — the siblings stay flat", () => { + const { container } = renderHome(); + expect(container.querySelectorAll(".concept-node__glow")).toHaveLength(1); + }); + + it("places the three siblings in the design's slots and the centre right of true centre", () => { + const { container } = renderHome(); + const bodies = Array.from(container.querySelectorAll(".concept-node__body")); + const at = (el: Element) => [num(el, "cx"), num(el, "cy")]; + expect(at(bodies[0])[0]).toBeLessThan(160); // top-left + expect(at(bodies[0])[1]).toBeLessThan(102); + expect(at(bodies[1])[0]).toBeGreaterThan(160); // top-right + expect(at(bodies[1])[1]).toBeLessThan(102); + expect(at(bodies[2])[0]).toBeLessThan(160); // bottom-left + expect(at(bodies[2])[1]).toBeGreaterThan(102); + expect(at(bodies[3])).toEqual([168, 102]); + }); + + it("captions the centre and the two left-hand siblings, never the one on the edge", () => { + const { container } = renderHome(); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toEqual(["Base cases", "Tail recursion", "Recursion"]); + expect(labels).not.toContain("Stack frames"); + }); + + it("truncates a caption at 18 characters", () => { + const { container } = renderHome({ + centre: { ...CENTRE, name: "Fundamental theorem of calculus" }, + }); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toContain("Fundamental theor…"); + }); + + it("flips a caption above the mark when below would fall off the canvas", () => { + const { container } = renderHome(); + const bodies = Array.from(container.querySelectorAll(".concept-node__body")); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")); + // slot 2 (bottom-left) sits at 0.96 * height, so its caption goes above. + const bottomLeft = labels.find((l) => l.textContent === "Tail recursion")!; + expect(num(bottomLeft, "y")).toBeLessThan(num(bodies[2], "cy")); + // slot 0 (top-left) has room below. + const topLeft = labels.find((l) => l.textContent === "Base cases")!; + expect(num(topLeft, "y")).toBeGreaterThan(num(bodies[0], "cy")); + }); + + it("drops only the centre caption under showCentreLabel={false}", () => { + // Every screen prints the concept's name in HTML below the canvas, so the + // SVG copy would say it twice — the siblings still need theirs. + const { container } = renderHome({ showCentreLabel: false }); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toEqual(["Base cases", "Tail recursion"]); + expect(labels).not.toContain("Recursion"); + // …and nothing else moves: the marks and edges are untouched. + expect(container.querySelectorAll(".concept-node__body")).toHaveLength(4); + expect(container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(3); + }); + + it("captions the centre by default, so no existing caller changes", () => { + const { container } = renderHome(); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toContain("Recursion"); + }); + + it("still drops the centre caption when showLabels is false, whatever showCentreLabel says", () => { + const { container } = renderHome({ showLabels: false, showCentreLabel: true }); + expect(container.querySelectorAll(".concept-neighbourhood__label")).toHaveLength(0); + }); + + it("drops every caption when showLabels is false", () => { + const { container } = renderHome({ showLabels: false }); + expect(container.querySelectorAll(".concept-neighbourhood__label")).toHaveLength(0); + }); + + it("renders with fewer than three siblings, and with none at all", () => { + const one = renderHome({ siblings: SIBLINGS.slice(0, 1) }); + expect(one.container.querySelectorAll(".concept-node__body")).toHaveLength(2); + expect(one.container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(1); + cleanup(); + const none = renderHome({ siblings: [] }); + expect(none.container.querySelectorAll(".concept-node__body")).toHaveLength(1); + expect(none.container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(0); + }); + + it("ignores a fourth sibling rather than stacking it on a used slot", () => { + const { container } = renderHome({ + siblings: [ + ...SIBLINGS, + { id: "closures", name: "Closures", mastery: 0.7, tier: "learning", strength: 0.3 }, + ], + }); + expect(container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(3); + }); + + it("draws the wide results canvas exactly where the design draws it", () => { + const { container } = render( + , + ); + const bodies = Array.from(container.querySelectorAll(".concept-node__body")); + const at = (el: Element) => [num(el, "cx"), num(el, "cy")]; + // The design's own numbers: centre (320,106) with NO rightward nudge, and + // siblings at (96,34) / (628,48) / (86,208). Within a pixel on each. + expect(at(bodies[3])).toEqual([320, 106]); + const expected = [ + [96, 34], + [628, 48], + [86, 208], + ]; + expected.forEach(([x, y], i) => { + expect(at(bodies[i])[0]).toBeCloseTo(x, 0); + expect(at(bodies[i])[1]).toBeCloseTo(y, 0); + }); + }); + + it("picks the composition from the width, and lets a caller override it", () => { + // 640 wide → wide → centred. + const wide = renderHome({ width: 640, height: 212, scale: 2.5 }); + expect(num(wide.container.querySelectorAll(".concept-node__body")[3], "cx")).toBe(320); + cleanup(); + // The same canvas, forced compact → the 8px nudge comes back. + const forced = renderHome({ + width: 640, + height: 212, + scale: 2.5, + composition: "compact", + }); + expect(num(forced.container.querySelectorAll(".concept-node__body")[3], "cx")).toBe(328); + }); + + it("renders the results preset's growth centre at the after-radius with reduced motion", () => { + const { container } = render( + , + ); + const centre = container.querySelector(".concept-node__body--growth")!; + expect(num(centre, "r")).toBeCloseTo((8 + 0.46 * 12) * 2.5, 6); + expect(centre.style.getPropertyValue("--concept-grow")).toBe("1.0000"); + const before = container.querySelector(".concept-node__before")!; + expect(num(before, "r")).toBeCloseTo((8 + 0.29 * 12) * 2.5, 6); + }); +}); diff --git a/frontend/src/components/graph/ConceptNeighbourhood.tsx b/frontend/src/components/graph/ConceptNeighbourhood.tsx new file mode 100644 index 00000000..84e647e9 --- /dev/null +++ b/frontend/src/components/graph/ConceptNeighbourhood.tsx @@ -0,0 +1,236 @@ +"use client"; + +/** + * ConceptNeighbourhood — a concept and up to three of its neighbours (#537). + * + * A still fragment of the tree, not a second renderer: no simulation, no + * interaction, fixed positions. `siblingsFor` (lib/graph/neighbourhood) picks + * and orders the neighbours; this component only lays them out, and every + * radius/colour/opacity comes from the same `ConceptMark` the tree's + * arithmetic feeds. + * + * LAYOUT. The three sibling slots are the design's own — top-left, top-right, + * bottom-left — expressed as fractions of the canvas. + * + * There are TWO compositions, not one, because the design has two. The small + * canvases (home 320×204, concept dialog 300×200) nudge the centre 8px right + * of true centre, balancing the two left-hand slots against the single right + * one. The wide results canvas (640×212) has room to spare and is drawn + * centred, with its siblings pushed outward; forcing the small canvases' + * fractions onto it left the top-left slot 19px adrift. `compact` and `wide` + * reproduce the design's own three canvases to the pixel, and the default is + * picked from `width`, so a caller passing the documented presets gets the + * right one without knowing this exists. + * + * The top-right slot sits on the canvas edge, so it never gets a caption — it + * would clip. That matches the design, which drops that label in every preset. + * + * The CENTRE's caption is optional for a different reason: every screen the + * design draws already sets the concept's name in HTML below the canvas, so + * repeating it inside the SVG says it twice. `showCentreLabel={false}` drops + * the SVG copy and leaves the siblings captioned. + */ + +import React from "react"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { GLOW, edgeWidthFor, truncateLabel } from "@/lib/graph/nodeStyle"; +import { + ConceptMark, + LABEL_GAP, + markRadius, + useGrowth, + type ConceptNodeVariant, +} from "./ConceptNode"; + +export type NeighbourhoodComposition = "compact" | "wide"; + +/** + * The two arrangements, each as (x, y) fractions of the canvas plus the + * centre's px nudge. Slot order is `siblingsFor`'s: strongest neighbour first. + * + * `compact` is the average of the design's two small canvases (it lands within + * ~6px on both); `wide` is the results canvas read off directly — centre + * (320, 106) and slots (96, 34) / (628, 48) / (86, 208) at 640×212, which + * these fractions reproduce to under a pixel. + */ +const COMPOSITIONS: Record< + NeighbourhoodComposition, + { centreOffsetX: number; slots: readonly (readonly [number, number])[] } +> = { + compact: { + centreOffsetX: 8, + slots: [ + [0.12, 0.14], // top-left + [0.975, 0.18], // top-right — on the edge, so never captioned + [0.15, 0.96], // bottom-left + ], + }, + wide: { + centreOffsetX: 0, + slots: [ + [0.15, 0.16], + [0.98125, 0.22642], + [0.134375, 0.98113], + ], + }, +}; + +/** At or above this width the canvas is drawn as the results set piece. */ +const WIDE_CANVAS_MIN_WIDTH = 480; + +/** Edge opacity — the tree's resting value for the `organism` variant. */ +const EDGE_OPACITY = 0.2; + +export interface ConceptNeighbourhoodProps { + centre: { id: string; name: string; mastery: number; tier: string }; + /** Up to 3, already picked and ordered by `siblingsFor`. */ + siblings: NeighbourNode[]; + /** Course base colour; shading happens per node inside the mark. */ + courseColor: string; + width: number; + height: number; + /** Multiplier on the reference radii. The presets use 2 (dialog/home) or 2.5 (results). */ + scale: number; + centreVariant?: ConceptNodeVariant; + showLabels?: boolean; + /** + * Caption the centre mark. Default `true`. Screens that already print the + * concept's name below the canvas pass `false` — the siblings stay labelled. + */ + showCentreLabel?: boolean; + /** Required: the whole fragment is one image to a screen reader. */ + ariaLabel: string; + /** Growth only. `prefers-reduced-motion` overrides a `true` here. */ + animate?: boolean; + /** + * Which of the design's two arrangements to draw. Defaults from `width` + * (>= 480 → `wide`), so the three documented presets need never pass it. + */ + composition?: NeighbourhoodComposition; + testid?: string; +} + +export function ConceptNeighbourhood({ + centre, + siblings, + courseColor, + width, + height, + scale, + centreVariant = { kind: "node" }, + showLabels = true, + showCentreLabel = true, + ariaLabel, + animate = true, + composition = width >= WIDE_CANVAS_MIN_WIDTH ? "wide" : "compact", + testid, +}: ConceptNeighbourhoodProps) { + const filterId = `concept-neighbourhood-glow-${React.useId()}`; + const [grown] = useGrowth(centreVariant, animate); + + const { centreOffsetX, slots } = COMPOSITIONS[composition]; + const cx = width / 2 + centreOffsetX; + const cy = height / 2; + + const placed = siblings.slice(0, slots.length).map((sibling, i) => ({ + sibling, + x: slots[i][0] * width, + y: slots[i][1] * height, + // Only the two left-hand slots are captioned (see the header note). + captioned: showLabels && i !== 1, + })); + + /** Caption baseline: under the mark, flipped above it when that would fall + * off the canvas — which is exactly the bottom-left slot's situation. */ + const captionY = (y: number, r: number) => { + const below = y + r + LABEL_GAP; + return below <= height - 2 ? below : y - r - LABEL_GAP / 2; + }; + + return ( + + + + + + + + {placed.map(({ sibling, x, y }) => ( + + ))} + + {placed.map(({ sibling, x, y, captioned }) => ( + + + {captioned && ( + + {truncateLabel(sibling.name)} + + )} + + ))} + + + {showLabels && showCentreLabel && ( + + {truncateLabel(centre.name)} + + )} + + ); +} diff --git a/frontend/src/components/graph/ConceptNode.test.tsx b/frontend/src/components/graph/ConceptNode.test.tsx new file mode 100644 index 00000000..33c70cdb --- /dev/null +++ b/frontend/src/components/graph/ConceptNode.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import React from "react"; +import { __resetReducedMotionStoreForTests } from "@/lib/usePrefersReducedMotion"; +import { opacityFor, shadeFor } from "@/lib/graph/nodeStyle"; +import { ConceptNode, NODE_REF_BOX } from "./ConceptNode"; + +/** + * The shared setup stubs `matchMedia` to REDUCED MOTION = true (see + * vitest.setup.ts), which is the right default here: it is the static frame, + * and it is the case the design contract cares about most. + */ +afterEach(() => { + cleanup(); + __resetReducedMotionStoreForTests(); +}); + +const BASE = { + courseColor: "#7b4b99", + nodeId: "recursion", + mastery: 0.29, + tier: "struggling", +}; + +const body = (c: HTMLElement) => c.querySelector(".concept-node__body")!; +const num = (el: Element, attr: string) => Number(el.getAttribute(attr)); + +describe("ConceptNode", () => { + it("draws the mark in reference units so size only scales it", () => { + const { container } = render(); + const svg = container.querySelector("svg")!; + expect(svg).toHaveAttribute("width", "26"); + expect(svg).toHaveAttribute("viewBox", `0 0 ${NODE_REF_BOX} ${NODE_REF_BOX}`); + // radiusFor(0.29) = 8 + 0.29*12 + expect(num(body(container), "r")).toBeCloseTo(11.48, 6); + }); + + it("is the same mark at 15px and 26px — only the CSS width differs", () => { + const small = render(); + const rSmall = num(body(small.container), "r"); + const viewSmall = small.container.querySelector("svg")!.getAttribute("viewBox"); + cleanup(); + const big = render(); + expect(num(body(big.container), "r")).toBe(rSmall); + expect(big.container.querySelector("svg")!.getAttribute("viewBox")).toBe(viewSmall); + }); + + it("takes its colour from shadeFor and its opacity from the tier", () => { + const { container } = render(); + const circle = body(container); + const expected = shadeFor("#7b4b99", "recursion"); + expect(circle).toHaveAttribute("fill", expected); + expect(circle).toHaveAttribute("stroke", expected); + expect(num(circle, "opacity")).toBe(opacityFor("struggling")); + expect(num(circle, "stroke-opacity")).toBe(0.4); + }); + + it("clamps a fully-mastered concept so it can't overflow the reference box", () => { + const { container } = render( + , + ); + expect(num(body(container), "r")).toBeLessThanOrEqual(NODE_REF_BOX / 2); + }); + + it("leaves a subject root unshaded, fully opaque, and at the flat root radius", () => { + const { container } = render( + , + ); + const circle = body(container); + expect(circle).toHaveAttribute("fill", "#7b4b99"); + expect(num(circle, "opacity")).toBe(1); + // radiusFor(_, true) is 22, clamped into the reference box. + expect(num(circle, "r")).toBe(NODE_REF_BOX / 2 - 0.75); + }); + + it("adds the glow to `node` and `growth` but not to `dot`", () => { + const dot = render(); + expect(dot.container.querySelector(".concept-node__glow")).toBeNull(); + cleanup(); + const node = render(); + expect(node.container.querySelector(".concept-node__glow")).not.toBeNull(); + expect(node.container.querySelector("filter")).not.toBeNull(); + }); + + it("truncates the caption at 18 characters, like the tree", () => { + const { container } = render( + , + ); + expect(container.querySelector(".concept-node__label")!.textContent).toBe( + "Fundamental theor…", + ); + // The caption needs vertical room, so the box grows below the mark only. + const svg = container.querySelector("svg")!; + expect(svg.getAttribute("viewBox")).toBe(`0 0 ${NODE_REF_BOX} ${NODE_REF_BOX + 18}`); + expect(Number(svg.getAttribute("height"))).toBeGreaterThan(26); + }); + + it("leaves a caption of 18 characters or fewer alone", () => { + const { container } = render(); + expect(container.querySelector(".concept-node__label")!.textContent).toBe("Recursion"); + }); + + it("is decorative without a title and an image with one", () => { + const bare = render(); + expect(bare.container.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + cleanup(); + const named = render( + , + ); + const svg = named.container.querySelector("svg")!; + expect(svg).toHaveAttribute("role", "img"); + expect(svg).toHaveAttribute("aria-label", "Recursion, 29% mastery"); + expect(svg).not.toHaveAttribute("aria-hidden"); + }); + + it("passes a testid through", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-testid="quiz-proposal-node"]')).not.toBeNull(); + }); +}); + +describe("ConceptNode — the growth variant", () => { + const GROWTH = { kind: "growth", before: 0.29, after: 0.46 } as const; + + it("renders the after-radius immediately under prefers-reduced-motion", () => { + const { container } = render(); + const circle = body(container); + // radiusFor(0.46) = 8 + 0.46*12 — the END state, on the very first paint. + expect(num(circle, "r")).toBeCloseTo(13.52, 6); + // …and no pre-grow scale for a transition to run from. + expect(circle.style.getPropertyValue("--concept-grow")).toBe("1.0000"); + }); + + it("renders the after-radius immediately with animate={false}", () => { + const { container } = render( + , + ); + const circle = body(container); + expect(num(circle, "r")).toBeCloseTo(13.52, 6); + expect(circle.style.getPropertyValue("--concept-grow")).toBe("1.0000"); + }); + + it("draws the dashed before-ring at the before-radius in every case", () => { + const { container } = render(); + const ring = container.querySelector(".concept-node__before")!; + expect(num(ring, "r")).toBeCloseTo(11.48, 6); + expect(ring).toHaveAttribute("stroke-dasharray", "4 4"); + expect(ring).toHaveAttribute("fill", "none"); + expect(num(ring, "opacity")).toBe(0.5); + }); + + it("takes its opacity from tierFor(after), the one place a tier is derived (R-12)", () => { + const { container } = render( + // after 0.8 → mastered, though the passed-in tier says struggling. + , + ); + expect(num(body(container), "opacity")).toBe(opacityFor("mastered")); + }); + + it("starts scaled down when motion is allowed, so the transition has somewhere to grow from", () => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + __resetReducedMotionStoreForTests(); + + const { container } = render(); + const circle = body(container); + // The drawn circle is always the END radius; only the scale animates. + expect(num(circle, "r")).toBeCloseTo(13.52, 6); + expect(Number(circle.style.getPropertyValue("--concept-grow"))).toBeCloseTo(11.48 / 13.52, 3); + }); +}); diff --git a/frontend/src/components/graph/ConceptNode.tsx b/frontend/src/components/graph/ConceptNode.tsx new file mode 100644 index 00000000..c446171e --- /dev/null +++ b/frontend/src/components/graph/ConceptNode.tsx @@ -0,0 +1,285 @@ +"use client"; + +/** + * ConceptNode — one concept, drawn the way the tree draws it (#537). + * + * A NEW component, not a refactor of `KnowledgeGraph2D`'s ``: that mark is + * welded to the d3 tick fast-path, five Playwright testids and two overlay + * rings the quiz doesn't want. What the two share is the arithmetic — + * everything numeric here comes from `lib/graph/nodeStyle`, so a retune of the + * tree moves this mark too. + * + * GEOMETRY. The mark is authored once in *reference units* — a 30×30 box whose + * half-width is `NODE_REF_RADIUS`, the same scale `radiusFor()` returns — and + * the `size` prop only sets the SVG's CSS width. That is what makes a 15px dot + * and a 26px node "the same mark at two sizes": the mastery radius is scaled + * into `size` rather than recomputed per call site. A fully-mastered concept + * (r 20) would overflow the reference box, so the body radius is clamped; + * the glow is allowed to spill (the class sets `overflow: visible`), because a + * blurred halo clipped to a square edge reads as a box, not a glow. + */ + +import React from "react"; +import { usePrefersReducedMotion } from "@/lib/usePrefersReducedMotion"; +import { + GLOW, + NODE_STROKE_OPACITY, + opacityFor, + radiusFor, + shadeFor, + tierFor, + truncateLabel, +} from "@/lib/graph/nodeStyle"; + +export type ConceptNodeVariant = + | { kind: "dot" } + | { kind: "node" } + | { kind: "growth"; before: number; after: number }; + +/** Half the reference box. `radiusFor()`'s outputs are in these units. + * Module-local: consumers want `NODE_REF_BOX` or `markRadius()`. */ +const NODE_REF_RADIUS = 15; +/** The reference viewBox is square at twice the reference radius. */ +export const NODE_REF_BOX = NODE_REF_RADIUS * 2; +/** Reference-unit gap between the mark and its caption — the tree's `r + 13`. */ +export const LABEL_GAP = 13; +/** Reference-unit band added below the box when a caption is drawn. */ +const LABEL_BAND = 18; +/** Stroke weights, in reference units. The tree does not scale these. */ +const STROKE_WIDTH = 1.5; +const ROOT_STROKE_WIDTH = 2.5; +/** The grown mark carries a slightly heavier ring — it's the point of the screen. */ +const GROWTH_STROKE_WIDTH = 2; +/** The "before" ring on a growth mark. */ +const BEFORE_RING = { dash: "4 4", opacity: 0.5 } as const; + +/** + * The drawn radius of a mark: `radiusFor()` in reference units, clamped so a + * fully-mastered concept (r 20) can't overflow the reference box, then scaled. + * Callers that need to place a caption relative to the mark use this too, so + * the two can't disagree. + */ +export function markRadius(mastery: number, isRoot = false, scale = 1): number { + return Math.min(radiusFor(mastery, isRoot), NODE_REF_RADIUS - STROKE_WIDTH / 2) * scale; +} + +/** + * True while the growth mark should be animating. Returns `[grown, animating]`: + * `grown` is the render-time state (false only for the first frame of a real + * animation), `animating` says whether a transition is expected at all. + * + * With `prefers-reduced-motion` or `animate={false}` this starts — and stays — + * grown, so the very first paint is the identical end state and no transition + * ever runs. The growth path uses `requestAnimationFrame` rather than a bare + * effect because the browser needs one paint at the start value for the + * transition to have anything to interpolate from. + */ +export function useGrowth(variant: ConceptNodeVariant, animate: boolean): [boolean, boolean] { + const prefersReducedMotion = usePrefersReducedMotion(); + const animating = variant.kind === "growth" && animate && !prefersReducedMotion; + const [grown, setGrown] = React.useState(!animating); + + React.useEffect(() => { + if (!animating) { + setGrown(true); + return; + } + setGrown(false); + const raf = requestAnimationFrame(() => setGrown(true)); + return () => cancelAnimationFrame(raf); + }, [animating, variant.kind]); + + return [grown, animating]; +} + +export interface ConceptMarkProps { + /** Centre, in the host SVG's own units. */ + cx: number; + cy: number; + /** Multiplier applied to every reference radius. 1 inside ``. */ + scale: number; + mastery: number; + tier: string; + /** The course base colour — shading is applied here, not by the caller. */ + courseColor: string; + nodeId: string; + isRoot?: boolean; + variant: ConceptNodeVariant; + /** `url(#…)` target for the blur filter. Omit for a flat mark. */ + glowFilterId?: string; + /** Growth only: false renders the "before" size so a transition has somewhere to start. */ + grown?: boolean; +} + +/** + * The mark itself, as bare SVG children so both `` (its own + * ``) and `` (one shared canvas) draw the identical + * shape. Callers own the `` that `glowFilterId` points at. + */ +export function ConceptMark({ + cx, + cy, + scale, + mastery, + tier, + courseColor, + nodeId, + isRoot = false, + variant, + glowFilterId, + grown = true, +}: ConceptMarkProps) { + // Subject roots are never shaded — the family reads as one colour (the tree + // does the same at KnowledgeGraph2D's `courseColor`). + const color = isRoot ? courseColor : shadeFor(courseColor, nodeId); + + const growth = variant.kind === "growth" ? variant : null; + const effectiveMastery = growth ? growth.after : mastery; + // R-12: the submit response carries `mastery_after` but no tier, so this is + // the one place the quiz derives a tier from a score. + const effectiveTier = growth ? tierFor(growth.after) : tier; + + const rBody = markRadius(effectiveMastery, isRoot, scale); + const rBefore = growth ? markRadius(growth.before, false, scale) : 0; + const opacity = isRoot ? 1 : opacityFor(effectiveTier); + const strokeWidth = growth ? GROWTH_STROKE_WIDTH : isRoot ? ROOT_STROKE_WIDTH : STROKE_WIDTH; + + // Pre-animation, the body sits at the "before" size. Scaling the drawn + // circle (rather than swapping `r`) keeps the transition on a property every + // browser composites, and the ratio rides a custom property so the rule + // itself stays in globals.css. + const growFrom = growth && !grown && rBody > 0 ? rBefore / rBody : 1; + + return ( + <> + {glowFilterId && ( + + )} + {growth && ( + + )} + + + ); +} + +export interface ConceptNodeProps { + /** Rendered diameter in CSS px. The mastery radius is scaled into it. */ + size: number; + /** 0..1. Ignored by the `growth` variant in favour of before/after. */ + mastery: number; + /** The server's `mastery_tier` string — never recompute it from a score (R-12). */ + tier: string; + /** Course base colour. A `var(--…)` value passes through `shadeFor` unshaded. */ + courseColor: string; + /** The shade seed. MUST be the graph node id, not the concept name. */ + nodeId: string; + /** + * Optional caption under the mark, truncated like the tree's. The box grows + * to fit it vertically, but the text can be wider than the mark and spills + * sideways (the SVG is `overflow: visible` for the glow) — exactly as the + * tree's own labels do inside the graph canvas. Marks laid out in a tight + * row should label themselves in HTML instead. + */ + label?: string; + variant?: ConceptNodeVariant; + isRoot?: boolean; + /** Growth only. `prefers-reduced-motion` overrides a `true` here. */ + animate?: boolean; + /** Accessible name. Without one the mark is decorative and hidden. */ + title?: string; + testid?: string; +} + +export function ConceptNode({ + size, + mastery, + tier, + courseColor, + nodeId, + label, + variant = { kind: "node" }, + isRoot = false, + animate = true, + title, + testid, +}: ConceptNodeProps) { + const filterId = `concept-node-glow-${React.useId()}`; + const [grown] = useGrowth(variant, animate); + + const hasGlow = variant.kind !== "dot"; + const truncated = label ? truncateLabel(label) : null; + const boxHeight = truncated ? NODE_REF_BOX + LABEL_BAND : NODE_REF_BOX; + const centre = NODE_REF_RADIUS; + const rBody = markRadius(variant.kind === "growth" ? variant.after : mastery, isRoot); + + return ( + + {hasGlow && ( + + + + + + )} + + {truncated && ( + + {truncated} + + )} + + ); +} diff --git a/frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx b/frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx index 5c47c7fb..470ed066 100644 --- a/frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx +++ b/frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx @@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup } from "@testing-library/react"; import React from "react"; import type { GraphEdge, GraphNode } from "@/lib/data"; +import GOLDEN from "./__fixtures__/knowledgeGraph2D.golden.json"; vi.stubEnv("NEXT_PUBLIC_TEST_MODE", "1"); @@ -52,24 +53,55 @@ const EDGES: GraphEdge[] = [ ]; /** - * Every rendered node position + edge coordinate, as attribute strings. - * Node positions live on the group's `transform` (#111 moved the per-tick - * writes off React: children sit at relative cx/cy 0 and the group carries - * the translate); circle radii are kept so size regressions still surface. + * The golden set (#537): the determinism cases above only need the node/edge + * subset, but the golden fixture pins the *paint* too, so it carries one more + * node — a >18-char name, which is where the label truncation shows up. + */ +const GOLDEN_NODES: GraphNode[] = [ + ...NODES, + { id: "e", name: "Fundamental theorem of calculus", subject: "Math", color: "#7a874f", mastery_tier: "learning", mastery_score: 0.55, course_id: "c1" }, +]; +const GOLDEN_EDGES: GraphEdge[] = [...EDGES, { source: "d", target: "e", strength: 0.3 }]; + +const at = (el: Element, name: string) => el.getAttribute(name) ?? ""; + +/** + * Every rendered node position + edge coordinate + the paint attributes, as + * attribute strings. Node positions live on the group's `transform` (#111 + * moved the per-tick writes off React: children sit at relative cx/cy 0 and + * the group carries the translate); circle radii are kept so size regressions + * still surface. + * + * `fill` / `opacity` / `stroke-opacity` (and the label font/fill/truncation) + * were added for #537: the node-style layer moved out to `lib/graph/nodeStyle` + * and this snapshot is the proof that the extraction changed nothing the tree + * paints. The committed fixture was captured from the pre-extraction renderer. + * Missing attributes read as "" rather than "null" so the NaN/null/undefined + * guard below stays meaningful. */ function snapshot(container: HTMLElement): string[] { const svg = container.querySelector("svg"); expect(svg).not.toBeNull(); const groups = Array.from(svg!.querySelectorAll('[data-testid="graph-node"]')).map( - (g) => `n:${g.getAttribute("transform")}`, + (g) => `n:${at(g, "transform")}`, ); const circles = Array.from(svg!.querySelectorAll("circle")).map( - (c) => `c:${c.getAttribute("cx")},${c.getAttribute("cy")},${c.getAttribute("r")}`, + (c) => + `c:${at(c, "cx")},${at(c, "cy")},${at(c, "r")}` + + `|fill=${at(c, "fill")}|op=${at(c, "opacity")}` + + `|stroke=${at(c, "stroke")}|sw=${at(c, "stroke-width")}|sop=${at(c, "stroke-opacity")}`, ); const lines = Array.from(svg!.querySelectorAll("line")).map( - (l) => `l:${l.getAttribute("x1")},${l.getAttribute("y1")},${l.getAttribute("x2")},${l.getAttribute("y2")}`, + (l) => + `l:${at(l, "x1")},${at(l, "y1")},${at(l, "x2")},${at(l, "y2")}` + + `|stroke=${at(l, "stroke")}|sop=${at(l, "stroke-opacity")}|sw=${at(l, "stroke-width")}`, ); - return [...groups, ...circles, ...lines]; + const texts = Array.from(svg!.querySelectorAll("text")).map( + (t) => + `t:${t.textContent}|${at(t, "x")},${at(t, "y")}` + + `|font=${at(t, "font-family")}|fs=${at(t, "font-size")}|fill=${at(t, "fill")}|op=${at(t, "opacity")}`, + ); + return [...groups, ...circles, ...lines, ...texts]; } describe("KnowledgeGraph2D — test-mode determinism", () => { @@ -108,4 +140,14 @@ describe("KnowledgeGraph2D — test-mode determinism", () => { // 5 nodes must occupy at least 5 distinct positions once settled. expect(centers.size).toBeGreaterThanOrEqual(NODES.length); }); + + it("paints byte-identically to the golden captured before the nodeStyle extraction (#537)", () => { + resetTestRng(); + const { container } = render( + , + ); + // Equality on the whole array, not a subset: a changed shade, a moved + // opacity ramp, a retuned radius or a lost label truncation all land here. + expect(snapshot(container)).toEqual(GOLDEN); + }); }); diff --git a/frontend/src/components/graph/KnowledgeGraph2D.tsx b/frontend/src/components/graph/KnowledgeGraph2D.tsx index d330508c..86da419e 100644 --- a/frontend/src/components/graph/KnowledgeGraph2D.tsx +++ b/frontend/src/components/graph/KnowledgeGraph2D.tsx @@ -12,7 +12,16 @@ import { type SimulationNodeDatum, type SimulationLinkDatum, } from "d3-force"; -import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; +import { type GraphEdge, type GraphNode } from "@/lib/data"; +import { + GLOW, + NODE_STROKE_OPACITY, + edgeWidthFor, + opacityFor, + radiusFor, + shadeFor, + truncateLabel, +} from "@/lib/graph/nodeStyle"; import { IS_TEST_MODE, random } from "@/lib/testMode"; export type GraphVariant = "orb" | "constellation" | "organism"; @@ -51,43 +60,10 @@ type Props = { comparisonLabel?: string; }; -// Deterministic per-node shade derived from the course color + node id. -// Keeps each course visually unified while giving every node its own tone, -// and produces identical output across pages because it depends only on the -// stable inputs (no per-screen overrides). Hashing is delegated to the -// shared, overflow-safe `hashSeed` in lib/data (the old local copy used -// `Math.abs(h)`, which stays negative for INT_MIN). - -function hexToHsl(hex: string): { h: number; s: number; l: number } | null { - const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); - if (!m) return null; - const r = parseInt(m[1].slice(0, 2), 16) / 255; - const g = parseInt(m[1].slice(2, 4), 16) / 255; - const b = parseInt(m[1].slice(4, 6), 16) / 255; - const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2; - let h = 0, s = 0; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60; - else if (max === g) h = ((b - r) / d + 2) * 60; - else h = ((r - g) / d + 4) * 60; - } - return { h, s: s * 100, l: l * 100 }; -} - -function shadeFor(baseHex: string, nodeId: string): string { - const hsl = hexToHsl(baseHex); - if (!hsl) return baseHex; - const seed = hashSeed(nodeId); - const dh = (seed % 51) - 25; - const ds = ((seed >> 5) % 17) - 8; - const dl = ((seed >> 10) % 25) - 12; - const h = (hsl.h + dh + 360) % 360; - const s = Math.max(20, Math.min(85, hsl.s + ds)); - const l = Math.max(28, Math.min(62, hsl.l + dl)); - return `hsl(${h.toFixed(0)} ${s.toFixed(0)}% ${l.toFixed(0)}%)`; -} +// The pure node-style layer (shade, radius, tier opacity, edge width, label +// truncation) lives in `lib/graph/nodeStyle` (#537). It was byte-duplicated +// here and in KnowledgeGraph3D; the quiz surfaces consume the same module, so +// a retune moves the tree and the quiz together. type DragState = | { kind: "node"; nodeId: string; pointerId: number } @@ -275,9 +251,7 @@ function KnowledgeGraph2DImpl({ }, []); // ── Helpers ────────────────────────────────────────────────────────────── - const masteryOpacity = (tier: GraphNode["mastery_tier"]) => - ({ mastered: 1, learning: 0.78, struggling: 0.55, unexplored: 0.28 })[tier] || 0.6; - const nodeRadius = (n: GraphNode) => (n.is_subject_root ? 22 : 8 + (n.mastery_score || 0) * 12); + const nodeRadius = (n: GraphNode) => radiusFor(n.mastery_score, n.is_subject_root); const courseColor = (n?: GraphNode) => { if (!n) return "var(--c-sage)"; const base = n.color || "var(--c-sage)"; @@ -495,7 +469,7 @@ function KnowledgeGraph2DImpl({ > - + @@ -521,7 +495,7 @@ function KnowledgeGraph2DImpl({ y2={t.y} stroke="var(--text-muted)" strokeOpacity={op} - strokeWidth={0.5 + (l.strength || 0.5) * 1.2} + strokeWidth={edgeWidthFor(l.strength)} strokeLinecap="round" /> ); @@ -534,7 +508,7 @@ function KnowledgeGraph2DImpl({ if (n.x == null || n.y == null) return null; const r = nodeRadius(n); const color = fillFor(n); - const op = n.is_subject_root ? 1 : masteryOpacity(n.mastery_tier); + const op = n.is_subject_root ? 1 : opacityFor(n.mastery_tier); const isHl = highlightId === n.id; const isHovered = hovered?.id === n.id; const isPinned = n.fx != null && n.fy != null; @@ -564,7 +538,7 @@ function KnowledgeGraph2DImpl({ }} > {variant === "organism" && ( - + )} {isHl && ( @@ -613,7 +587,7 @@ function KnowledgeGraph2DImpl({ opacity={op} stroke={color} strokeWidth={n.is_subject_root ? 2.5 : 1.5} - strokeOpacity={isPinned ? 1 : isHovered ? 0.9 : 0.4} + strokeOpacity={isPinned ? 1 : isHovered ? 0.9 : NODE_STROKE_OPACITY} /> )} {n.is_subject_root && ( @@ -641,7 +615,7 @@ function KnowledgeGraph2DImpl({ opacity={0.85} pointerEvents="none" > - {n.name.length > 18 ? n.name.slice(0, 17) + "…" : n.name} + {truncateLabel(n.name)} )} diff --git a/frontend/src/components/graph/KnowledgeGraph3D.tsx b/frontend/src/components/graph/KnowledgeGraph3D.tsx index 868e9fe2..6968e7b5 100644 --- a/frontend/src/components/graph/KnowledgeGraph3D.tsx +++ b/frontend/src/components/graph/KnowledgeGraph3D.tsx @@ -20,7 +20,8 @@ import React from "react"; import dynamic from "next/dynamic"; -import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; +import { type GraphEdge, type GraphNode } from "@/lib/data"; +import { radiusFor, shadeFor } from "@/lib/graph/nodeStyle"; import { IS_TEST_MODE } from "@/lib/testMode"; // `react-force-graph-3d`'s default export touches `document` at @@ -41,65 +42,12 @@ type Props = { onNodeClick?: (n: GraphNode) => void; }; -function hexToHsl(hex: string): { h: number; s: number; l: number } | null { - const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); - if (!m) return null; - const r = parseInt(m[1].slice(0, 2), 16) / 255; - const g = parseInt(m[1].slice(2, 4), 16) / 255; - const b = parseInt(m[1].slice(4, 6), 16) / 255; - const max = Math.max(r, g, b), - min = Math.min(r, g, b), - l = (max + min) / 2; - let h = 0, - s = 0; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60; - else if (max === g) h = ((b - r) / d + 2) * 60; - else h = ((r - g) / d + 4) * 60; - } - return { h, s: s * 100, l: l * 100 }; -} - -function hslToHex(h: number, s: number, l: number): string { - const sN = s / 100; - const lN = l / 100; - const c = (1 - Math.abs(2 * lN - 1)) * sN; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = lN - c / 2; - let r = 0, - g = 0, - b = 0; - if (h < 60) [r, g, b] = [c, x, 0]; - else if (h < 120) [r, g, b] = [x, c, 0]; - else if (h < 180) [r, g, b] = [0, c, x]; - else if (h < 240) [r, g, b] = [0, x, c]; - else if (h < 300) [r, g, b] = [x, 0, c]; - else [r, g, b] = [c, 0, x]; - const to = (v: number) => - Math.round((v + m) * 255) - .toString(16) - .padStart(2, "0"); - return `#${to(r)}${to(g)}${to(b)}`; -} - -function shadeFor(baseHex: string, nodeId: string): string { - const hsl = hexToHsl(baseHex); - if (!hsl) return baseHex; - const seed = hashSeed(nodeId); - const dh = (seed % 51) - 25; - const ds = ((seed >> 5) % 17) - 8; - const dl = ((seed >> 10) % 25) - 12; - const h = (hsl.h + dh + 360) % 360; - const s = Math.max(20, Math.min(85, hsl.s + ds)); - const l = Math.max(28, Math.min(62, hsl.l + dl)); - // Return hex (#RRGGBB), not `hsl(...)`. Three.js's Color.setStyle only - // accepts comma-separated `hsl(h, s%, l%)`, not the modern - // space-separated form; the space-separated string silently renders - // BLACK. Hex is unambiguous across consumers. - return hslToHex(h, s, l); -} +// The pure node-style layer (`hexToHsl` / `hslToHex` / `shadeFor`) lives in +// `lib/graph/nodeStyle` (#537) — it was byte-duplicated here and in +// KnowledgeGraph2D. This renderer takes the "hex" form: Three.js's +// `Color.setStyle` only accepts the comma-separated `hsl(h, s%, l%)` syntax, +// and the modern space-separated string the 2D path uses silently renders +// BLACK. type FG3DNode = GraphNode & { x?: number; @@ -171,7 +119,7 @@ export function KnowledgeGraph3D({ (raw: object) => { const n = raw as FG3DNode; if (n.id === highlightId) return "#8a9a5b"; - return shadeFor(n.color || "#8a9a5b", n.id); + return shadeFor(n.color || "#8a9a5b", n.id, "hex"); }, [highlightId], ); @@ -186,7 +134,10 @@ export function KnowledgeGraph3D({ // Course (root) nodes anchor each family — render them noticeably // larger than concept nodes so the eye lands on the family center // first. Concept nodes scale 4..10 with mastery_score. - if (n.is_subject_root) return 22; + // NOT `radiusFor`: react-force-graph reads this as a sphere VOLUME, not a + // radius, and it has always used its own 4..10 ramp. Only the root's flat + // 22 is shared with the 2D mark. + if (n.is_subject_root) return radiusFor(0, true); return 4 + (typeof n.mastery_score === "number" ? n.mastery_score : 0) * 6; }, []); diff --git a/frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json b/frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json new file mode 100644 index 00000000..5a936cc0 --- /dev/null +++ b/frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json @@ -0,0 +1,31 @@ +[ + "n:translate(381.3134363205003, 257.7849014368901)", + "n:translate(363.41908763393405, 187.5010235885237)", + "n:translate(285.6798582585873, 238.75076578253507)", + "n:translate(262.1454202800259, 338.0647352515794)", + "n:translate(294.167223400619, 160.80125995584248)", + "n:translate(210.99941155253603, 252.2584521551733)", + "c:0,0,30|fill=#7a874f|op=0.15|stroke=|sw=|sop=", + "c:0,0,22|fill=#7a874f|op=1|stroke=#7a874f|sw=2.5|sop=0.4", + "c:0,0,20.8|fill=hsl(95 21% 30%)|op=0.15|stroke=|sw=|sop=", + "c:0,0,12.8|fill=hsl(95 21% 30%)|op=0.78|stroke=hsl(95 21% 30%)|sw=1.5|sop=0.4", + "c:0,0,18.4|fill=hsl(96 21% 30%)|op=0.15|stroke=|sw=|sop=", + "c:0,0,10.4|fill=hsl(96 21% 30%)|op=0.55|stroke=hsl(96 21% 30%)|sw=1.5|sop=0.4", + "c:0,0,16|fill=hsl(97 21% 30%)|op=0.15|stroke=|sw=|sop=", + "c:0,0,8|fill=hsl(97 21% 30%)|op=0.28|stroke=hsl(97 21% 30%)|sw=1.5|sop=0.4", + "c:0,0,26.8|fill=hsl(98 21% 30%)|op=0.15|stroke=|sw=|sop=", + "c:0,0,18.8|fill=hsl(98 21% 30%)|op=1|stroke=hsl(98 21% 30%)|sw=1.5|sop=0.4", + "c:0,0,22.6|fill=hsl(99 21% 30%)|op=0.15|stroke=|sw=|sop=", + "c:0,0,14.600000000000001|fill=hsl(99 21% 30%)|op=0.78|stroke=hsl(99 21% 30%)|sw=1.5|sop=0.4", + "l:381.3134363205003,257.7849014368901,363.41908763393405,187.5010235885237|stroke=var(--text-muted)|sop=0.2|sw=1.46", + "l:363.41908763393405,187.5010235885237,285.6798582585873,238.75076578253507|stroke=var(--text-muted)|sop=0.2|sw=1.22", + "l:285.6798582585873,238.75076578253507,262.1454202800259,338.0647352515794|stroke=var(--text-muted)|sop=0.2|sw=1.1", + "l:363.41908763393405,187.5010235885237,294.167223400619,160.80125995584248|stroke=var(--text-muted)|sop=0.2|sw=1.3399999999999999", + "l:294.167223400619,160.80125995584248,210.99941155253603,252.2584521551733|stroke=var(--text-muted)|sop=0.2|sw=0.86", + "t:Math|0,38|font=var(--font-display)|fs=13|fill=#7a874f|op=", + "t:Limits|0,25.8|font=var(--font-sans)|fs=10.5|fill=var(--text-dim)|op=0.85", + "t:Derivatives|0,23.4|font=var(--font-sans)|fs=10.5|fill=var(--text-dim)|op=0.85", + "t:Integrals|0,21|font=var(--font-sans)|fs=10.5|fill=var(--text-dim)|op=0.85", + "t:Series|0,31.8|font=var(--font-sans)|fs=10.5|fill=var(--text-dim)|op=0.85", + "t:Fundamental theor…|0,27.6|font=var(--font-sans)|fs=10.5|fill=var(--text-dim)|op=0.85" +] diff --git a/frontend/src/components/quiz/QuizScreen.test.tsx b/frontend/src/components/quiz/QuizScreen.test.tsx new file mode 100644 index 00000000..522f6bea --- /dev/null +++ b/frontend/src/components/quiz/QuizScreen.test.tsx @@ -0,0 +1,314 @@ +// @vitest-environment jsdom +/** + * `QuizScreen` end to end against mocked clients, for the one question a hook + * test cannot answer: what the WHOLE screen does to the URL when the student + * presses Done. + * + * The browser lane found Done leaving `?concept=…` in the address bar through + * two fixes (`router.push`, then `router.replace`), so the useful assertion is + * not "we called the router" — it is "exactly one URL change happened, it was to + * a clean `/quiz`, and nothing afterwards put the deep link back". + */ + +import React from "react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AnswerResult, GenerateResult, SubmitResult } from "@/lib/quiz/types"; + +// ── next/navigation ──────────────────────────────────────────────────────── +let searchParams = new URLSearchParams(); +let pathname = "/quiz"; +const push = vi.fn(); +const replace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParams, + usePathname: () => pathname, + useRouter: () => ({ push, replace, back: vi.fn(), prefetch: vi.fn() }), +})); + +vi.mock("@/context/UserContext", () => ({ + useUser: () => ({ userId: "u1", userReady: true }), +})); + +const quizApi = vi.hoisted(() => ({ + fetchQuizConfig: vi.fn(), + generateQuiz: vi.fn(), + answerQuestion: vi.fn(), + submitQuiz: vi.fn(), + getAttempt: vi.fn(), + listAttempts: vi.fn(), + describeConcept: vi.fn(), +})); +vi.mock("@/lib/quiz/api", () => quizApi); + +const coreApi = vi.hoisted(() => ({ + getCourses: vi.fn(), + getGraph: vi.fn(), + fetchGamificationMe: vi.fn(), +})); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, ...coreApi }; +}); + +import { QuizScreen } from "./QuizScreen"; +import { ToastProvider } from "@/components/ToastProvider"; + +const CONFIG = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +const NODES = [ + { + id: "c1", concept_name: "Recursion", mastery_score: 0.25, mastery_tier: "struggling", + times_studied: 2, last_studied_at: "2026-08-18T00:00:00Z", subject: "CS", + course_id: "course-a", + }, + { + id: "c2", concept_name: "Big-O", mastery_score: 0.44, mastery_tier: "learning", + times_studied: 1, last_studied_at: "2026-08-19T00:00:00Z", subject: "CS", + course_id: "course-a", + }, +]; + +const COURSES = [{ + enrollment_id: "e1", course_id: "course-a", course_code: "CS 330", + course_name: "Algorithms", school: "BU", department: "CS", color: "#123456", + nickname: null, node_count: 2, enrolled_at: "2026-01-01T00:00:00Z", + term: "Fall 2026", terms: ["Fall 2026"], +}]; + +const GENERATED: GenerateResult = { + quiz_id: "attempt-1", + questions: [{ + id: 1, + question: "What is recursion?", + options: [ + { label: "A", text: "a" }, { label: "B", text: "b" }, + { label: "C", text: "c" }, { label: "D", text: "d" }, + ], + difficulty: "medium", + }], + requested_difficulty: "medium", + resolved_difficulty: "medium", + requested_count: 1, + delivered_count: 1, +}; + +const ANSWER: AnswerResult = { + question_index: 0, question_id: 1, is_correct: true, correct_index: 1, + explanation: "because", next_question: null, recorded: true, +}; + +const SUBMITTED: SubmitResult = { + score: 1, total: 1, mastery_before: 0.25, mastery_after: 0.34, results: [], +}; + +/** + * Every way the app can change the address bar, in the order it happened. + * `router.push`/`replace` are spies; the History API is patched so a direct + * `replaceState` is caught too — the point of the test is that we do not care + * WHICH mechanism moved the URL, only that it moved once and landed clean. + */ +let navigations: { via: string; url: string; state?: unknown; stateBefore?: unknown }[] = []; +const realReplaceState = window.history.replaceState.bind(window.history); +const realPushState = window.history.pushState.bind(window.history); + +beforeEach(() => { + // Put jsdom where the student actually is. `exit` reads + // `window.location.pathname` to tell "same route" from "real navigation", so a + // test left on jsdom's default "/" would exercise the wrong branch. Done + // before the spies go on, so this setup is not counted as a navigation. + // + // The state object carries `__NA`, as a real App Router entry does. That is + // load-bearing, not decoration: Next's patched `replaceState` early-returns on + // a state carrying `__NA`, so without it here `window.history.state` and + // `null` would be indistinguishable and the null-state assertion below would + // pass whatever the code did. + window.history.replaceState( + { __NA: true, __PRIVATE_NEXTJS_INTERNALS_TREE: ["quiz"] }, + "", + "/quiz?concept=c1&from=link", + ); + + navigations = []; + searchParams = new URLSearchParams("concept=c1&from=link"); + pathname = "/quiz"; + window.localStorage.clear(); + window.localStorage.setItem("sapling_disclaimer_ack", "true"); + + push.mockReset().mockImplementation((url: string) => navigations.push({ via: "push", url })); + replace.mockReset().mockImplementation((url: string) => navigations.push({ via: "replace", url })); + vi.spyOn(window.history, "replaceState").mockImplementation((s, t, url) => { + if (typeof url === "string") { + // `stateBefore` is the entry being replaced — recorded here because the + // call itself overwrites it. + navigations.push({ + via: "history.replaceState", url, state: s, stateBefore: window.history.state, + }); + } + return realReplaceState(s, t, url as string); + }); + vi.spyOn(window.history, "pushState").mockImplementation((s, t, url) => { + if (typeof url === "string") navigations.push({ via: "history.pushState", url }); + return realPushState(s, t, url as string); + }); + + // jsdom has no matchMedia; `usePrefersReducedMotion` reads it during render. + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: false, media: query, onchange: null, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), + }), + }); + + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + quizApi.generateQuiz.mockResolvedValue(GENERATED); + quizApi.answerQuestion.mockResolvedValue(ANSWER); + quizApi.submitQuiz.mockResolvedValue(SUBMITTED); + quizApi.listAttempts.mockResolvedValue({ total: 0, limit: 20, offset: 0, attempts: [] }); + quizApi.getAttempt.mockResolvedValue({ resumable: false }); + quizApi.describeConcept.mockResolvedValue("Recursion is a function calling itself."); + coreApi.getCourses.mockResolvedValue({ courses: COURSES }); + coreApi.getGraph.mockResolvedValue({ nodes: NODES, edges: [], stats: {} }); + coreApi.fetchGamificationMe.mockRejectedValue(new Error("not under test")); +}); + +afterEach(() => { + // vitest runs with globals:false, so testing-library auto-cleanup never hooks + // in — without this every render stacks and getByTestId finds duplicates. + cleanup(); + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +/** Home → start → answer the single question → results. */ +async function playToResults(): Promise { + render( + + + , + ); + + const start = await screen.findByTestId("quiz-start", undefined, { timeout: 3000 }); + await act(async () => { + start.click(); + }); + + const optionA = await screen.findByTestId("quiz-answer-option-A", undefined, { timeout: 3000 }); + await act(async () => { + optionA.click(); + }); + await act(async () => { + screen.getByTestId("quiz-submit-answer").click(); + }); + + await waitFor(() => expect(screen.getByTestId("quiz-results")).toBeTruthy(), { timeout: 3000 }); +} + +describe("QuizScreen — Done drops the deep link (#537)", () => { + it("changes the URL exactly once, to a clean /quiz", async () => { + await playToResults(); + expect(navigations).toEqual([]); + expect(location.pathname + location.search).toBe("/quiz?concept=c1&from=link"); + + await act(async () => { + screen.getByTestId("quiz-done").click(); + }); + + expect(navigations).toHaveLength(1); + expect(navigations[0].url).toBe("/quiz"); + // The assertion the browser lane makes, and the one that was failing: the + // address bar itself, not the call we hoped would move it. + expect(location.pathname + location.search).toBe("/quiz"); + }); + + it("never re-issues the deep link afterwards", async () => { + await playToResults(); + await act(async () => { + screen.getByTestId("quiz-done").click(); + }); + + // Let every effect that could re-sync the URL from the entry or the session + // settle — the lane's symptom was the query coming BACK, not never leaving. + await act(async () => { + await new Promise(r => setTimeout(r, 50)); + }); + + for (const nav of navigations) { + expect(nav.url, `${nav.via} re-issued the deep link`).not.toContain("concept="); + } + expect(navigations).toHaveLength(1); + }); + + it("does not ask the router to navigate to the route it is already on", async () => { + await playToResults(); + await act(async () => { + screen.getByTestId("quiz-done").click(); + }); + + // A route-tree-identical navigation is not a navigation; asking the router + // for one is what silently did nothing twice (push, then replace). The URL + // edit goes through the History API, which is what Next supports for a + // search-param change in place. + expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + expect(navigations.map(n => n.via)).toEqual(["history.replaceState"]); + }); + + it("passes a NULL history state, or Next's patch skips its own URL sync", async () => { + // Next patches `history.replaceState` and early-returns when the state + // object carries its own `__NA` marker (app-router.js, 16.2.9) — the guard + // against internal navigations looping. Handing it `window.history.state` + // therefore moved the address bar while leaving `useSearchParams()` on the + // old query, which is why the card stayed pinned after the URL was already + // clean. `null` is what lets Next run `applyUrlFromHistoryPushReplace`, and + // it loses nothing: `copyNextJsInternalHistoryState` copies `__NA` and the + // private tree off the current entry itself. + // + // The sync itself cannot be observed here — jsdom has no Next router — so + // this pins the one input that decides whether it happens. The e2e Done + // journey covers the outcome. + await playToResults(); + await act(async () => { + screen.getByTestId("quiz-done").click(); + }); + + expect(navigations).toHaveLength(1); + expect(navigations[0].via).toBe("history.replaceState"); + expect(navigations[0].state).toBeNull(); + // …and the entry it replaced really was a Next one, so `null` and + // `window.history.state` were distinguishable at the call site. Without + // this, both spellings record `null` and the assertion above is vacuous. + expect(navigations[0].stateBefore).toMatchObject({ __NA: true }); + }); + + it("lands back on quiz home with no results on screen", async () => { + await playToResults(); + await act(async () => { + screen.getByTestId("quiz-done").click(); + }); + + await waitFor(() => expect(screen.queryByTestId("quiz-results")).toBeNull()); + expect(screen.getByTestId("quiz-home")).toBeTruthy(); + }); + + it("still uses the router for an exit that really changes route", async () => { + await playToResults(); + await act(async () => { + screen.getByTestId("quiz-back-to-source").click(); + }); + + // `from=link` with no `return`, so R-10 falls back to the tree focused on + // the concept — a real route change, and the router's job. + expect(navigations).toEqual([{ via: "push", url: "/tree?node=c1" }]); + // …and the History API is NOT used to fake a cross-route move. + expect(location.pathname).toBe("/quiz"); + }); +}); diff --git a/frontend/src/components/quiz/QuizScreen.tsx b/frontend/src/components/quiz/QuizScreen.tsx new file mode 100644 index 00000000..563785e8 --- /dev/null +++ b/frontend/src/components/quiz/QuizScreen.tsx @@ -0,0 +1,213 @@ +"use client"; + +/** + * The quiz route's one component: reads the entry off the URL, owns the session, + * and switches the three screens on `phase`. + * + * It replaces the `screens/Quiz.tsx` → `QuizPanel.tsx` chain, whose whole + * concept-picking job now lives in `QuizHome`. The AI-disclaimer gate is carried + * over unchanged (it self-gates on `localStorage["sapling_disclaimer_ack"]`, and + * the chip in the TopBar reopens it on demand). + * + * `--quiz-accent` is bound here from the active concept's course colour. That + * inline custom property is the ONE inline style anywhere under + * `components/quiz/**` (R-1); everything else is a class over tokens. + */ + +import React, { useMemo } from "react"; +import type { CSSProperties } from "react"; +import { useSearchParams } from "next/navigation"; +import { TopBar } from "../TopBar"; +import { FullHeightScreen } from "../FullHeightScreen"; +import { AIDisclaimerChip } from "../chat/AIDisclaimerChip"; +import { DisclaimerModal } from "../DisclaimerModal"; +import { useUser } from "@/context/UserContext"; +import { useActiveSemester } from "@/lib/useActiveSemester"; +import { usePrefersReducedMotion } from "@/lib/usePrefersReducedMotion"; +import { siblingsFor } from "@/lib/graph/neighbourhood"; +import { apiToGraphNode } from "@/lib/data"; +import { parseEntry } from "@/lib/quiz/source"; +import { colorFor, entrySelection, nextConceptInQueue } from "@/lib/quiz/proposals"; +import { useQuizHome } from "@/lib/quiz/useQuizHome"; +import { useQuizSession } from "@/lib/quiz/useQuizSession"; +import { QuizHome } from "./home/QuizHome"; +import { QuizQuestion, type QuizConceptSummary } from "./question/QuizQuestion"; +import { QuizResults } from "./results/QuizResults"; +import "./quiz.css"; + +/** Phases the question screen owns — every state between "we asked for a quiz" + * and "we have a score". */ +const QUESTION_PHASES = new Set(["generating", "active", "answered", "confirm-leave", "submitting"]); + +export function QuizScreen() { + const searchParams = useSearchParams(); + const { userId, userReady } = useUser(); + const [activeSemester, , semesterHydrated] = useActiveSemester(); + + // `searchParams` is a fresh object every render; the entry only changes when + // the query string does. + const query = searchParams.toString(); + const entry = useMemo(() => parseEntry(new URLSearchParams(query)), [query]); + + // `entry` rides along so the home hook can describe the concept the CARD will + // show, which a deep link overrides (§5 B1.2). + const home = useQuizHome(userId ?? "", semesterHydrated ? activeSemester : null, entry); + const { session, pending, config, actions } = useQuizSession(userId ?? "", entry); + + // `?concept=` / `?topic=` resolved against the SCOPED graph, so a + // link into a term the student isn't looking at reads as unresolved rather + // than quietly quizzing something off-screen (§6). + const selection = useMemo( + () => entrySelection(entry, home.nodes, home.courses), + [entry, home.nodes, home.courses], + ); + + // The concept the screens are about: whatever the session is running, else the + // deep link, else the proposal on offer. + const activeNode = useMemo(() => { + const wanted = session.conceptId || selection.conceptId; + const byId = wanted ? home.nodes.find(n => n.id === wanted) : undefined; + return byId ?? home.primary?.node ?? null; + }, [home.nodes, home.primary, session.conceptId, selection.conceptId]); + + const activeCourse = useMemo( + () => home.courses.find(c => c.course_id === activeNode?.course_id) ?? null, + [home.courses, activeNode], + ); + + const accent = activeNode ? colorFor(activeNode, activeCourse) : null; + + const concept: QuizConceptSummary = useMemo( + () => ({ + id: activeNode?.id ?? session.conceptId, + name: activeNode?.concept_name ?? "This concept", + courseCode: activeCourse?.course_code ?? "", + color: accent ?? "", + tier: activeNode?.mastery_tier ?? "unexplored", + mastery: activeNode?.mastery_score ?? 0, + }), + [activeNode, activeCourse, accent, session.conceptId], + ); + + // `siblingsFor` works on the adapted `lib/data` node shape (colour resolved, + // `name` rather than `concept_name`) — the same one Tree/Learn/Dashboard feed + // the graph. `apiToGraphNode` is the one sanctioned adapter, so it is used + // here rather than re-deriving the join. + const siblings = useMemo(() => { + if (!activeNode) return []; + const adapted = home.nodes.map(n => apiToGraphNode(n, home.courses)); + return siblingsFor(activeNode.id, adapted, home.edges); + }, [activeNode, home.nodes, home.courses, home.edges]); + + // The queue holds ids; only the loaded graph can turn the next one into a name + // for the results screen's "Next: {concept}" exit. + const nextConcept = useMemo( + () => nextConceptInQueue(session, home.nodes), + [session, home.nodes], + ); + + const prefersReducedMotion = usePrefersReducedMotion(); + + // The accent is the one runtime-bound value; an unset one falls through to + // `var(--accent)` wherever it is read. + const rootStyle = accent ? ({ "--quiz-accent": accent } as CSSProperties) : undefined; + + const body = () => { + if (!userReady) return null; + if (!userId) return

Sign in to take a quiz.

; + + if (session.phase === "error" && session.error) { + return ( +
+

That didn't work

+

{session.error.message}

+
+ {session.error.retryable && ( + + )} + +
+ {session.error.requestId && ( + + Reference {session.error.requestId} + + )} +
+ ); + } + + if (QUESTION_PHASES.has(session.phase)) { + return ( + + ); + } + + if (session.phase === "results") { + return ( + + ); + } + + return ( + + ); + }; + + const layout = session.phase === "results" + ? "results" + : QUESTION_PHASES.has(session.phase) || session.phase === "error" + ? "question" + : "home"; + + return ( + + + } /> +
+ {/* Home is full-bleed: its resume strip has to reach both edges under + the TopBar, so the screen wraps its own content (`.quiz-col--home` + + `.quiz-inset--home`, see quiz.css). Question and results keep the + centred column here. */} + {layout === "home" + ? body() + :
{body()}
} +
+
+ ); +} diff --git a/frontend/src/components/quiz/home/AdjustDialog.tsx b/frontend/src/components/quiz/home/AdjustDialog.tsx new file mode 100644 index 00000000..ab097669 --- /dev/null +++ b/frontend/src/components/quiz/home/AdjustDialog.tsx @@ -0,0 +1,106 @@ +"use client"; + +/** + * The Adjust dialog (§5 B1.6) — "adjust" on the proposal card. + * + * Same three settings rows as the Concept dialog, but about the quiz already + * on offer rather than a different concept: no neighbourhood, no definition, + * and two ways out. `Done` keeps the choices and closes (`SET_CONFIG`, which + * also persists them to prefs); `Start` runs the quiz with them. + * + * The note under the rows says what the Answers choice actually changes, since + * "as you go / at the end" is otherwise a setting whose effect you only + * discover mid-quiz. + */ + +import React from "react"; +import Dialog from "@/components/Dialog"; +import { Button } from "@/components/ui"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import type { QuizConfig } from "@/lib/quiz/types"; +import { QuizSettings } from "./QuizSettings"; +import { accentStyle } from "./accent"; + +const NOTES = { + "as-you-go": + "After each answer you'll see whether it was right and which answer was correct, before moving on.", + "at-end": "Answers stay hidden while you work — you'll review everything on the results screen.", +} as const; + +export interface AdjustDialogProps { + open: boolean; + /** "{concept} · {CODE}" — the quiz these settings apply to. */ + subtitle: string; + accent: string | null; + config: QuizConfig | null; + initialConfig: SessionConfig; + /** Keep the choices, don't start. */ + onDone: (config: SessionConfig) => void; + /** Close without keeping anything (Escape, backdrop, the × ). */ + onClose: () => void; + onStart: (config: SessionConfig) => void; +} + +export function AdjustDialog({ + open, + subtitle, + accent, + config, + initialConfig, + onDone, + onClose, + onStart, +}: AdjustDialogProps) { + const titleId = React.useId(); + const [draft, setDraft] = React.useState(initialConfig); + + // Re-seed the draft when the dialog is (re)opened, and when the config it was + // opened against changes underneath it. The initial state alone is a + // one-shot: `GET /api/quiz/config` can resolve after this mounted, and the + // `SET_CONFIG` that lands then moves `session.config` while the draft keeps + // the pre-config scalar — Start would run settings the card behind the dialog + // stopped showing. + // + // Keyed on the three VALUES rather than on `initialConfig`: a queued card + // rebuilds that object on every render, so an identity-keyed effect would + // re-seed constantly and wipe the choice being made. Nothing else moves + // `session.config` while this is open (the machine refuses `SET_CONFIG` + // mid-quiz, and Done closes the dialog), so a value change here really is + // "the defaults finally arrived". + const { count, difficulty, feedback } = initialConfig; + React.useEffect(() => { + if (open) setDraft({ count, difficulty, feedback }); + }, [open, count, difficulty, feedback]); + + return ( + +
+

+ Adjust this quiz +

+

{subtitle}

+ + + +

{NOTES[draft.feedback]}

+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/home/ConceptDialog.tsx b/frontend/src/components/quiz/home/ConceptDialog.tsx new file mode 100644 index 00000000..06cf8715 --- /dev/null +++ b/frontend/src/components/quiz/home/ConceptDialog.tsx @@ -0,0 +1,181 @@ +"use client"; + +/** + * The Concept dialog (§5 B1.5) — what opens when you pick a concept off "Also + * worth a look" or out of the pick list. + * + * It is the proposal card again at dialog scale: the same name / meta / + * rationale / definition / neighbourhood, plus the three settings rows and a + * Start that carries the choices made in it. Nothing is committed until Start: + * the config lives in local state seeded from the session, so Cancel really + * does discard. + * + * The definition is R-8 for THIS concept: `concept-description` is fetched on + * open (the hook `useQuizHome` describes only the concept the card shows), with + * a built sentence showing while it is in flight and after a failure. The + * dialog never blocks on it. + */ + +import React from "react"; +import Dialog from "@/components/Dialog"; +import { Button } from "@/components/ui"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { describeConcept } from "@/lib/quiz/api"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import { metaLine, type Candidate } from "@/lib/quiz/proposals"; +import type { QuizConfig } from "@/lib/quiz/types"; +import { QuizSettings } from "./QuizSettings"; +import { accentStyle } from "./accent"; + +/** The dialog's canvas, per §3. */ +const CANVAS = { width: 300, height: 200, scale: 2 } as const; + +/** + * The stand-in definition, minus everything the meta line directly above it + * already says. + * + * `useQuizHome.fallbackDefinition` is "{CODE} · {tier} · {n} connected + * concepts", which on the card sits under a meta line that carries neither the + * code nor (visibly) the tier. In the dialog the meta IS "{CODE} · {pct}% · + * {tier} · {when}", so the built sentence opened the panel by repeating its own + * first two thirds. Only the connection count is new, so only the connection + * count is said. + */ +function connectionsLine(connected: number): string { + if (connected === 0) return "Not yet connected to anything else on your tree."; + return `${connected} connected ${connected === 1 ? "concept" : "concepts"} on your tree.`; +} + +export interface ConceptDialogProps { + open: boolean; + userId: string; + candidate: Candidate; + siblings: NeighbourNode[]; + /** Edges touching this concept — the `n` in the fallback definition. */ + connected: number; + config: QuizConfig | null; + /** The settings the dialog opens on. */ + initialConfig: SessionConfig; + onCancel: () => void; + onStart: (config: SessionConfig) => void; +} + +/** + * The AI one-liner for one concept (R-8). Returns `null` while in flight or + * after a failure — the caller shows the built sentence instead. + */ +function useConceptDescription( + userId: string, + open: boolean, + conceptName: string, + courseLabel?: string, +): string | null { + const [text, setText] = React.useState(null); + + React.useEffect(() => { + if (!open || !userId || !conceptName) return; + let cancelled = false; + describeConcept(userId, conceptName, courseLabel).then( + description => { + const trimmed = description.trim(); + if (!cancelled && trimmed) setText(trimmed); + }, + () => { + // The built sentence stands. A missing definition must never hold up + // the Start button. + }, + ); + return () => { + cancelled = true; + }; + }, [userId, open, conceptName, courseLabel]); + + return text; +} + +export function ConceptDialog({ + open, + userId, + candidate, + siblings, + connected, + config, + initialConfig, + onCancel, + onStart, +}: ConceptDialogProps) { + const titleId = React.useId(); + const [draft, setDraft] = React.useState(initialConfig); + + const { node, course, color } = candidate; + const description = useConceptDescription(userId, open, node.concept_name, course?.course_code); + const definition = description ?? connectionsLine(connected); + + // The design prefixes the concept's meta with its course code; `metaLine` + // itself is unchanged. + const meta = course ? `${course.course_code} · ${metaLine(node)}` : metaLine(node); + + // For a concept that has never been opened, `rationaleFor` degenerates to + // "{pct}% · not studied yet" — which is the tail of the meta line directly + // above it. Every other rationale says something the meta doesn't. + const neverStudied = !node.times_studied && !node.last_studied_at; + + return ( + +
+
+
+

+ {node.concept_name} +

+

{meta}

+ {candidate.rationale && !neverStudied && ( +

{candidate.rationale}

+ )} +

{definition}

+
+
+ +
+ + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/home/PickList.tsx b/frontend/src/components/quiz/home/PickList.tsx new file mode 100644 index 00000000..b03ea4fb --- /dev/null +++ b/frontend/src/components/quiz/home/PickList.tsx @@ -0,0 +1,94 @@ +"use client"; + +/** + * "Pick something specific" (§5 B1.4) — every concept on the tree, grouped + * under its course. + * + * A browse surface, not a ranking: `groupByCourse` sorts courses by code and + * concepts by name, so a known name is where you'd look for it. Each row is a + * real ` +
Pick something specific
+

What would you like to be tested on?

+ + {groups.map(({ course, nodes }) => { + const color = colorFor(nodes[0], course); + return ( +
+
+ {/* `dot`, not the default `node`: the design's course mark is a + flat circle, and the glow the node variant adds reads as a + halo the drawing doesn't have. */} + + + {course.course_code} · {course.course_name} + +
+ + {nodes.map(node => ( + + ))} +
+ ); + })} + + ); +} diff --git a/frontend/src/components/quiz/home/QuizHome.test.tsx b/frontend/src/components/quiz/home/QuizHome.test.tsx new file mode 100644 index 00000000..a146cde2 --- /dev/null +++ b/frontend/src/components/quiz/home/QuizHome.test.tsx @@ -0,0 +1,748 @@ +// @vitest-environment jsdom +/** + * Quiz home (§5 B1) — the behaviour the screen is specified by. + * + * The fixtures build the `useQuizHome` shape with the REAL `lib/quiz/proposals` + * functions rather than hand-written candidate lists: the ranking, the due set + * and the grouping are the hook's own, so a test that hand-rolled them would + * pass while the screen showed something else. + * + * What is pinned here: + * - the resume strip, its Resume, and its client-side Discard (R-3) + * - Start's request shape and the config it carries + * - the due row's queue cap and per-attempt count (R-4) + * - the concept dialog opening off an alternative, and starting with ITS config + * - Adjust's Done writing settings back without starting + * - `?concept=` overriding the ranked proposal, and an unresolved one toasting once + * - both empty states, and that neither is a dead end + * - that every option list comes off `/config` — the counts and difficulties + * below are deliberately NOT the server's real ones + */ + +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import type { EnrolledCourse } from "@/lib/api"; +import type { GraphEdge, GraphNode } from "@/lib/types"; +import { + alternativesOf, + dueSet, + groupByCourse, + primaryOf, + rankCandidates, +} from "@/lib/quiz/proposals"; +import { DISMISSED_KEY, PREFS_KEY, QUEUE_COUNT } from "@/lib/quiz/session"; +import type { EntryRequest } from "@/lib/quiz/source"; +import type { QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { + AttemptDetail, + AttemptSummary, + QuizConfig, + QuizSession, + WireQuestion, +} from "@/lib/quiz/types"; +import { QuizHome } from "./QuizHome"; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +const toast = vi.hoisted(() => ({ + show: vi.fn(), + dismiss: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +})); + +vi.mock("@/components/ToastProvider", () => ({ useToast: () => toast })); + +// R-8's per-concept sentence. Resolved empty so the dialog renders its built +// fallback deterministically, with no floating network promise. +vi.mock("@/lib/quiz/api", async importOriginal => ({ + ...(await importOriginal()), + describeConcept: vi.fn().mockResolvedValue(""), +})); + +// ── Fixtures ───────────────────────────────────────────────────────────── + +/** Two courses; the prototype's own pair. */ +const COURSES: EnrolledCourse[] = [ + course("c-cs", "CS101", "Intro to Computer Science", "#7b4b99"), + course("c-math", "MATH210", "Linear Algebra", "#3e6f8a"), +]; + +function course(id: string, code: string, name: string, color: string): EnrolledCourse { + return { + enrollment_id: `e-${id}`, + course_id: id, + course_code: code, + course_name: name, + school: "Test University", + department: "TEST", + color, + nickname: null, + node_count: 4, + enrolled_at: "2026-01-01T00:00:00Z", + term: "Spring 2026", + }; +} + +function node( + id: string, + name: string, + courseId: string, + mastery: number, + tier: GraphNode["mastery_tier"], + timesStudied: number, +): GraphNode { + return { + id, + concept_name: name, + mastery_score: mastery, + mastery_tier: tier, + times_studied: timesStudied, + last_studied_at: timesStudied > 0 ? "2026-08-18T00:00:00Z" : null, + subject: courseId === "c-cs" ? "Intro to Computer Science" : "Linear Algebra", + course_id: courseId, + is_subject_root: false, + }; +} + +/** Seven due concepts across two courses — enough to prove the queue cap. */ +const NODES: GraphNode[] = [ + node("recursion", "Recursion", "c-cs", 0.29, "struggling", 3), + node("base-cases", "Base cases", "c-cs", 0.52, "learning", 1), + node("stack-frames", "Stack frames", "c-cs", 0.3, "struggling", 0), + node("tail-recursion", "Tail recursion", "c-cs", 0.12, "struggling", 0), + node("eigenvalues", "Eigenvalues", "c-math", 0.31, "struggling", 1), + node("matrices", "Matrices", "c-math", 0.44, "struggling", 2), + node("determinants", "Determinants", "c-math", 0.05, "unexplored", 0), +]; + +const EDGES: GraphEdge[] = [ + { id: "e1", source: "recursion", target: "base-cases", strength: 0.9 }, + { id: "e2", source: "recursion", target: "stack-frames", strength: 0.7 }, + { id: "e3", source: "recursion", target: "tail-recursion", strength: 0.4 }, + { id: "e4", source: "eigenvalues", target: "matrices", strength: 0.8 }, +]; + +/** + * Deliberately NOT the server's real lists: if any option row is hardcoded + * rather than read off `/config`, "2 questions" and "gentle" disappear and a + * 3/5/10 or "medium" shows up instead. + */ +const CONFIG: QuizConfig = { + num_questions: { min: 2, max: 4, options: [2, 4] }, + difficulties: ["gentle", "fierce"], + question_types: ["multiple_choice"], +}; + +const SESSION_CONFIG = { count: 2, difficulty: "gentle", feedback: "at-end" as const }; + +function session(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: "" }, + source: { kind: "nav" }, + config: SESSION_CONFIG, + conceptId: "", + courseId: null, + attemptId: null, + items: [], + cursor: 0, + queueIndex: 0, + phase: "home", + error: null, + result: null, + xp: null, + deliveredShort: false, + ...over, + }; +} + +function entry(over: Partial = {}): EntryRequest { + return { source: { kind: "nav" }, ...over }; +} + +function question(id: number): WireQuestion { + return { + id, + question: `Question ${id}`, + options: [{ label: "A", text: "one" }], + difficulty: "gentle", + }; +} + +function attemptDetail(over: Partial = {}): AttemptDetail { + return { + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "gentle", + concept_node_id: "recursion", + questions: [question(1), question(2), question(3), question(4), question(5)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T00:00:00Z" }, + { question_index: 1, selected_index: 0, is_correct: false, answered_at: "2026-08-22T00:01:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T00:00:00Z", + ...over, + }; +} + +function buildHome(over: Partial = {}): QuizHomeData { + const nodes = over.nodes ?? NODES; + const courses = over.courses ?? COURSES; + const attempts: AttemptSummary[] = over.attempts ?? []; + const candidates = rankCandidates(nodes, courses, attempts); + const primary = primaryOf(candidates); + return { + status: "ready", + error: null, + nodes, + edges: over.edges ?? EDGES, + courses, + attempts, + candidates, + primary, + alternatives: alternativesOf(candidates, primary), + due: dueSet(nodes), + byCourse: groupByCourse(nodes, courses), + resumable: null, + // A2 fix round 5: the hook now describes the concept the CARD shows, which a + // deep link overrides. `primaryDescription` is a deprecated alias. + cardConceptId: primary?.node.id ?? null, + cardDescription: null, + primaryDescription: null, + refresh: vi.fn(), + ...over, + }; +} + +function buildActions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function mount( + over: { + home?: Partial; + entry?: EntryRequest; + config?: QuizConfig | null; + /** The live session. Cancel reads `session.source`, NOT the entry's. */ + session?: QuizSession; + } = {}, +) { + const home = buildHome(over.home); + const actions = buildActions(); + const view = render( + , + ); + return { home, actions, view }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("QuizHome — the proposal", () => { + it("offers the ranked primary and starts it with the session config", () => { + const { actions } = mount(); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Recursion"); + // The config line is read, not written: [2, 4] / gentle came off `/config`. + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("2 questions, gentle"); + + fireEvent.click(screen.getByTestId("quiz-start")); + + expect(actions.start).toHaveBeenCalledWith( + { + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + conceptId: "recursion", + courseId: "c-cs", + }, + SESSION_CONFIG, + ); + }); + + it("captions a deep-linked card with the sentence fetched for THAT concept", () => { + // The hook describes the concept the CARD shows, deep link included (A2 fix + // round 5) — so the paragraph belongs to Matrices, not to the ranked primary. + mount({ + home: { cardConceptId: "matrices", cardDescription: "A rectangular array of numbers." }, + entry: entry({ concept: "matrices", source: { kind: "tree" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Matrices"); + expect(proposal).toHaveTextContent("A rectangular array of numbers."); + }); + + it("falls back to the built sentence while the description is in flight", () => { + mount({ home: { cardDescription: null } }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent( + "CS101 · struggling · 3 connected concepts", + ); + }); + + it("cancels back to the session's source", () => { + // `cancelTarget` reads `session.source.returnTo`. A session that carries one + // must land there — not on the fallback the no-source case uses. + const { actions } = mount({ + session: session({ source: { kind: "tree", returnTo: "/tree?node=recursion" } }), + }); + fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/tree?node=recursion"); + }); + + it("cancels to the dashboard when the session has no source", () => { + // The fixture session is `{kind:"nav"}` with no `returnTo` (§5 B1.8). + const { actions } = mount(); + fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/dashboard"); + }); + + it("keeps Cancel reachable when there is nothing to propose", () => { + // The three no-card states are still a way back to wherever you came from. + const { actions } = mount({ + home: { nodes: [], edges: [] }, + session: session({ source: { kind: "tree", returnTo: "/tree?node=recursion" } }), + }); + + expect(screen.getByTestId("quiz-empty-state")).toHaveTextContent("Your tree is empty"); + fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/tree?node=recursion"); + }); +}); + +describe("QuizHome — the resume strip", () => { + it("names the concept and resumes the attempt", () => { + const { actions } = mount({ + home: { resumable: { attempt: attemptDetail(), session: null, answered: 2 } }, + }); + + const strip = screen.getByTestId("quiz-resume-strip"); + expect(strip).toHaveTextContent("You left a quiz on Recursion — 2 of 5 answered"); + + fireEvent.click(within(strip).getByTestId("quiz-resume")); + expect(actions.resume).toHaveBeenCalledWith("attempt-1"); + }); + + it("discards it client-side and reloads (R-3: there is no abandon endpoint)", () => { + const { home } = mount({ + home: { resumable: { attempt: attemptDetail(), session: null, answered: 2 } }, + }); + + fireEvent.click(screen.getByTestId("quiz-resume-discard")); + + expect(window.localStorage.getItem(DISMISSED_KEY)).toContain("attempt-1"); + expect(home.refresh).toHaveBeenCalled(); + }); + + it("is absent when nothing is resumable", () => { + mount(); + expect(screen.queryByTestId("quiz-resume-strip")).toBeNull(); + }); +}); + +describe("QuizHome — the due row", () => { + it("starts a capped queue of short attempts (R-4)", () => { + const { actions } = mount(); + + const row = screen.getByTestId("quiz-review-due"); + expect(row).toHaveTextContent("7 concepts across 2 courses"); + + fireEvent.click(row); + + const [request, config] = (actions.start as ReturnType).mock.calls[0]; + expect(request.intent).toBe("review"); + expect(request.scope.kind).toBe("due"); + // Seven concepts are due; a session runs at most QUEUE_MAX of them. + expect(request.scope.queue).toHaveLength(5); + // Weakest first, and the first one is what generates. + expect(request.scope.queue[0]).toBe("determinants"); + expect(request.conceptId).toBe("determinants"); + expect(config.count).toBe(QUEUE_COUNT); + }); + + it("is hidden when nothing is due", () => { + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ home: { nodes: mastered } }); + expect(screen.queryByTestId("quiz-review-due")).toBeNull(); + }); +}); + +describe("QuizHome — the concept dialog", () => { + it("opens off an alternative and starts with the config chosen in it", () => { + const { actions } = mount(); + + // The two alternatives are the next-weakest after the primary. + fireEvent.click(screen.getByTestId("quiz-alternative-determinants")); + + const dialog = screen.getByTestId("quiz-concept-dialog"); + expect(dialog).toHaveTextContent("Determinants"); + + // Every length offered is one `/config` named. + const lengths = within(dialog).getByTestId("quiz-seg-count"); + expect(lengths).toHaveTextContent("2 questions"); + expect(lengths).toHaveTextContent("4 questions"); + expect(lengths).not.toHaveTextContent("5 questions"); + expect(lengths).not.toHaveTextContent("10 questions"); + + fireEvent.click(within(dialog).getByTestId("quiz-seg-count-4")); + fireEvent.click(within(dialog).getByTestId("quiz-seg-difficulty-fierce")); + expect(within(dialog).getByTestId("quiz-concept-start")).toHaveTextContent("Start · 4 fierce"); + + fireEvent.click(within(dialog).getByTestId("quiz-concept-start")); + + expect(actions.start).toHaveBeenCalledWith( + { + intent: "practice", + scope: { kind: "concept", conceptId: "determinants" }, + conceptId: "determinants", + courseId: "c-math", + }, + { count: 4, difficulty: "fierce", feedback: "at-end" }, + ); + // Started from a dialog, so the choices are remembered (§5 B1.6). + expect(window.localStorage.getItem(PREFS_KEY)).toContain("fierce"); + }); + + it("opens off a pick-list row", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + + const list = screen.getByTestId("quiz-pick-list"); + expect(within(list).getByText("CS101 · Intro to Computer Science")).toBeInTheDocument(); + + fireEvent.click(within(list).getByTestId("quiz-pick-base-cases")); + expect(screen.getByTestId("quiz-concept-dialog")).toHaveTextContent("Base cases"); + }); + + it("does not restate the meta line in the stand-in definition", () => { + // The dialog's meta already opens with the course code and carries the + // tier; the built sentence used to repeat both back (M6). + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + fireEvent.click(within(screen.getByTestId("quiz-pick-list")).getByTestId("quiz-pick-base-cases")); + + const dialog = screen.getByTestId("quiz-concept-dialog"); + expect(dialog).toHaveTextContent("CS101 · 52% · learning"); + expect(dialog).toHaveTextContent("1 connected concept on your tree."); + expect(dialog).not.toHaveTextContent("CS101 · learning · 1 connected concept"); + }); + + it("collapses the pick list again", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + fireEvent.click(screen.getByTestId("quiz-pick-back")); + expect(screen.queryByTestId("quiz-pick-list")).toBeNull(); + expect(screen.getByTestId("quiz-proposal")).toBeInTheDocument(); + }); +}); + +describe("QuizHome — the adjust dialog", () => { + it("marks the link active while open and writes the choices back on Done", () => { + const { actions } = mount(); + + const link = screen.getByTestId("quiz-adjust"); + expect(link).not.toHaveAttribute("data-active"); + + fireEvent.click(link); + expect(screen.getByTestId("quiz-adjust")).toHaveAttribute("data-active", "true"); + expect(actions.configure).toHaveBeenCalledWith(true); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(dialog).toHaveTextContent("Recursion · CS101"); + fireEvent.click(within(dialog).getByTestId("quiz-seg-difficulty-fierce")); + fireEvent.click(within(dialog).getByTestId("quiz-adjust-done")); + + expect(actions.setConfig).toHaveBeenCalledWith({ + count: 2, + difficulty: "fierce", + feedback: "at-end", + }); + expect(actions.start).not.toHaveBeenCalled(); + expect(screen.queryByTestId("quiz-adjust-dialog")).toBeNull(); + }); + + it("explains what the Answers choice changes", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(dialog).toHaveTextContent("Answers stay hidden while you work"); + + fireEvent.click(within(dialog).getByTestId("quiz-seg-feedback-as-you-go")); + expect(dialog).toHaveTextContent("After each answer you'll see whether it was right"); + }); + + it("starts with its own config", () => { + const { actions } = mount(); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + fireEvent.click(within(dialog).getByTestId("quiz-seg-count-4")); + fireEvent.click(within(dialog).getByTestId("quiz-adjust-start")); + + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "recursion" }), + { count: 4, difficulty: "gentle", feedback: "at-end" }, + ); + }); + + it("picks up settings that land after it was opened, instead of starting stale ones", () => { + // Open Adjust before `GET /api/quiz/config` resolves and the `SET_CONFIG` + // that follows moves `session.config` underneath the dialog. The draft used + // to keep the pre-config values, so Start ran a quiz the card behind it had + // already stopped describing. + const home = buildHome(); + const actions = buildActions(); + const screenFor = (s: QuizSession) => ( + + ); + const { rerender } = render(screenFor(session())); + + fireEvent.click(screen.getByTestId("quiz-adjust")); + expect(screen.getByTestId("quiz-adjust-start")).toHaveTextContent("Start · 2 gentle"); + + rerender( + screenFor(session({ config: { count: 4, difficulty: "fierce", feedback: "at-end" } })), + ); + expect(screen.getByTestId("quiz-adjust-start")).toHaveTextContent("Start · 4 fierce"); + + fireEvent.click(screen.getByTestId("quiz-adjust-start")); + expect(actions.start).toHaveBeenCalledWith(expect.objectContaining({ conceptId: "recursion" }), { + count: 4, + difficulty: "fierce", + feedback: "at-end", + }); + }); + + it("keeps a choice made in the dialog when nothing outside it moved", () => { + // The other half of the effect above: re-seeding must not fire on a plain + // re-render, or the settings row would snap back while it is being used. + const { actions, home, view } = mount(); + fireEvent.click(screen.getByTestId("quiz-adjust")); + fireEvent.click(within(screen.getByTestId("quiz-adjust-dialog")).getByTestId("quiz-seg-count-4")); + + view.rerender( + , + ); + expect(screen.getByTestId("quiz-adjust-start")).toHaveTextContent("Start · 4 gentle"); + }); +}); + +describe("QuizHome — arrival", () => { + it("makes a `?concept=` link the proposal, with its own reason", () => { + const { actions } = mount({ + entry: entry({ concept: "matrices", source: { kind: "tree", conceptId: "matrices" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Matrices"); + expect(proposal).toHaveTextContent("From your tree"); + + fireEvent.click(screen.getByTestId("quiz-start")); + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "matrices", courseId: "c-math" }), + SESSION_CONFIG, + ); + }); + + it("resolves a legacy `?topic=` name the same way", () => { + // The other half of the deep-link path: `topic` is a concept NAME, matched + // case-insensitively (the tree's and dashboard's old links). + const { actions } = mount({ + entry: entry({ topic: "matrices", source: { kind: "link" } }), + }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Matrices"); + expect(toast.info).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("quiz-start")); + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "matrices", courseId: "c-math" }), + SESSION_CONFIG, + ); + }); + + it("says where a note's concept came from", () => { + mount({ + entry: entry({ concept: "matrices", source: { kind: "notes", noteId: "n1" } }), + }); + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("From your note"); + }); + + it("drops the 'Also worth a look' heading when there is nothing under it", () => { + // A deep link onto a tree with nothing left to review: the card stands, but + // there are no alternatives and nothing is due. + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ + home: { nodes: mastered }, + entry: entry({ concept: "matrices", source: { kind: "tree" } }), + }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Matrices"); + expect(screen.queryByText("Also worth a look")).toBeNull(); + // …and the way out of the dead end is still there. + expect(screen.getByTestId("quiz-pick-open")).toBeInTheDocument(); + }); + + it("says so once when the link names something outside the semester", () => { + const { view } = mount({ + entry: entry({ concept: "not-in-this-term", source: { kind: "link" } }), + }); + + expect(toast.info).toHaveBeenCalledTimes(1); + expect(toast.info).toHaveBeenCalledWith("That concept isn't in your current semester"); + // …and the ordinary home renders underneath. + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Recursion"); + + view.rerender( + , + ); + expect(toast.info).toHaveBeenCalledTimes(1); + }); + + it("turns `?course=` into a course queue", () => { + const { actions } = mount({ + entry: entry({ course: "c-cs", source: { kind: "tree", returnTo: "/tree" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Practice CS101"); + expect(proposal).toHaveTextContent("4 concepts due"); + expect(proposal).toHaveTextContent(`${QUEUE_COUNT} questions each, gentle`); + + fireEvent.click(screen.getByTestId("quiz-start")); + const [request, config] = (actions.start as ReturnType).mock.calls[0]; + expect(request.scope).toEqual({ + kind: "course", + courseId: "c-cs", + queue: ["tail-recursion", "recursion", "stack-frames", "base-cases"], + }); + expect(config.count).toBe(QUEUE_COUNT); + }); + + it("turns `?scope=due` into the due card", () => { + const { actions } = mount({ entry: entry({ scope: "due", source: { kind: "dashboard" } }) }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Review everything due"); + expect(proposal).toHaveTextContent("7 concepts across 2 courses · starting with the 5 weakest"); + + fireEvent.click(screen.getByTestId("quiz-start")); + const [request] = (actions.start as ReturnType).mock.calls[0]; + expect(request.intent).toBe("review"); + expect(request.scope.queue).toHaveLength(5); + }); +}); + +describe("QuizHome — the states with no proposal", () => { + it("sends a student with no courses somewhere they can add one", () => { + mount({ home: { nodes: [], courses: [], edges: [] } }); + + const empty = screen.getByTestId("quiz-empty-state"); + expect(empty).toHaveTextContent("Add a course to start quizzing"); + expect(within(empty).getByRole("link")).toHaveAttribute("href", "/dashboard"); + }); + + it("offers both ways to grow an empty tree", () => { + mount({ home: { nodes: [], edges: [] } }); + + const empty = screen.getByTestId("quiz-empty-state"); + expect(empty).toHaveTextContent("Your tree is empty"); + const hrefs = within(empty) + .getAllByRole("link") + .map(a => a.getAttribute("href")); + expect(hrefs).toEqual(["/library", "/learn"]); + }); + + it("still offers the list when everything is mastered", () => { + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ home: { nodes: mastered } }); + + expect(screen.getByTestId("quiz-empty-state")).toHaveTextContent("Nothing needs review"); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + expect(screen.getByTestId("quiz-pick-list")).toBeInTheDocument(); + }); + + it("offers a retry when the load failed", () => { + const { home } = mount({ + home: { + status: "error", + error: { code: "NETWORK", message: "You look offline.", retryable: true }, + }, + }); + + expect(screen.getByTestId("quiz-home-error")).toHaveTextContent("You look offline."); + fireEvent.click(screen.getByTestId("quiz-home-retry")); + expect(home.refresh).toHaveBeenCalled(); + }); + + it("shows no options at all until `/config` lands", () => { + mount({ config: null }); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(within(dialog).queryByTestId("quiz-seg-count")).toBeNull(); + expect(within(dialog).queryByTestId("quiz-seg-difficulty")).toBeNull(); + // The one list that is a client concept (R-2) is always there. + expect(within(dialog).getByTestId("quiz-seg-feedback")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/quiz/home/QuizHome.tsx b/frontend/src/components/quiz/home/QuizHome.tsx new file mode 100644 index 00000000..fc4c18c7 --- /dev/null +++ b/frontend/src/components/quiz/home/QuizHome.tsx @@ -0,0 +1,665 @@ +"use client"; + +/** + * Quiz home (§5 B1) — `phase: home | configuring`. + * + * One proposal you can start in a single click, two alternatives, the whole + * due set as one row, and a way to find anything else. The old screen opened + * on an empty course/concept dropdown and asked the student to know what they + * wanted; this one arrives with an answer and lets them disagree. + * + * Everything on it is derived, never invented: the ranking is + * `lib/quiz/proposals` (a cited mirror of `graph_service.get_recommendations`, + * R-7), the meta and rationale lines are its formatters, the option lists come + * off `GET /api/quiz/config`, and the marks are the tree's own arithmetic + * through `ConceptNode` / `ConceptNeighbourhood`. + * + * The card has three shapes, chosen by how you arrived (§6): a concept (the + * default, and every `?concept=` / `?topic=` deep link), a course (`?course=`) + * and the due set (`?scope=due`). The last two run as a queue of single-concept + * attempts, because `/generate` is per concept (R-4). + */ + +import React from "react"; +import { Button, EmptyState, InlineBanner } from "@/components/ui"; +import { Skeleton } from "@/components/Skeleton"; +import { useToast } from "@/components/ToastProvider"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { apiToGraphNode } from "@/lib/data"; +import { siblingsFor } from "@/lib/graph/neighbourhood"; +import { cancelTarget } from "@/lib/quiz/exits"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import { savePrefs } from "@/lib/quiz/prefs"; +import { + colorFor, + entrySelection, + latestCompletedAttempt, + metaLine, + queueFor, + rationaleFor, + type Candidate, +} from "@/lib/quiz/proposals"; +import { QUEUE_COUNT, QUEUE_MAX, dismissAttempt } from "@/lib/quiz/session"; +import type { EntryRequest } from "@/lib/quiz/source"; +import type { QuizConfig, QuizSession, SourceKind } from "@/lib/quiz/types"; +import { fallbackDefinition, type QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import { AdjustDialog } from "./AdjustDialog"; +import { ConceptDialog } from "./ConceptDialog"; +import { PickList } from "./PickList"; +import { accentStyle } from "./accent"; +import "./home.css"; + +export interface QuizHomeProps { + userId: string; + home: QuizHomeData; + config: QuizConfig | null; + entry: EntryRequest; + session: QuizSession; + actions: QuizActions; +} + +/** The card's three shapes (§5 B1.2). `course` and `due` run as queues. */ +type CardMode = "concept" | "course" | "due"; + +/** The home neighbourhood canvas, per §3. */ +const CANVAS = { width: 320, height: 204, scale: 2 } as const; + +/** The mark beside the concept name — `node`, so it carries the soft glow. */ +const CARD_NODE_SIZE = 26; +/** The dot on an "Also worth a look" row. */ +const ALT_DOT_SIZE = 11; + +/** Why a deep-linked concept is the one on offer (§5 B1.2). */ +const DEEP_LINK_RATIONALE: Partial> = { + tree: "From your tree", + notes: "From your note", +}; +const DEFAULT_DEEP_LINK_RATIONALE = "Suggested for you"; + +/** §6: a link into a term the student isn't looking at. */ +const UNRESOLVED_COPY = "That concept isn't in your current semester"; + +export function QuizHome({ userId, home, config, entry, session, actions }: QuizHomeProps) { + const toast = useToast(); + const [picking, setPicking] = React.useState(false); + const [adjustOpen, setAdjustOpen] = React.useState(false); + const [dialogNodeId, setDialogNodeId] = React.useState(null); + + // ── What the card is about ──────────────────────────────────────────── + + const selection = React.useMemo( + () => entrySelection(entry, home.nodes, home.courses), + [entry, home.nodes, home.courses], + ); + + // A deep link naming something outside the active semester says so once and + // then gets out of the way — the ordinary home renders underneath (§6). + const toasted = React.useRef(false); + React.useEffect(() => { + if (toasted.current || home.status !== "ready" || !selection.unresolved) return; + toasted.current = true; + toast.info(UNRESOLVED_COPY); + }, [home.status, selection.unresolved, toast]); + + const courseQueue = React.useMemo( + () => (entry.course ? queueFor("course", home.nodes, entry.course) : []), + [entry.course, home.nodes], + ); + const dueQueue = React.useMemo(() => queueFor("due", home.nodes), [home.nodes]); + + const mode: CardMode = selection.conceptId + ? "concept" + : entry.course && courseQueue.length > 0 + ? "course" + : entry.scope === "due" && dueQueue.length > 0 + ? "due" + : "concept"; + + /** + * A concept as the card and the dialogs want it. Ranked candidates come with + * their rationale already resolved; anything else (a mastered concept picked + * out of the list, say) is built the same way `rankCandidates` would. + */ + const candidateFor = React.useCallback( + (nodeId: string | null | undefined): Candidate | null => { + if (!nodeId) return null; + const ranked = home.candidates.find(c => c.node.id === nodeId); + if (ranked) return ranked; + const node = home.nodes.find(n => n.id === nodeId); + if (!node) return null; + const course = home.courses.find(c => c.course_id === node.course_id) ?? null; + const lastAttempt = latestCompletedAttempt(node.id, home.attempts); + return { + node, + course, + color: colorFor(node, course), + rationale: rationaleFor(node, lastAttempt), + ...(lastAttempt ? { lastAttempt } : {}), + }; + }, + [home.candidates, home.nodes, home.courses, home.attempts], + ); + + const card = React.useMemo(() => { + if (mode === "course") return candidateFor(courseQueue[0]); + if (mode === "due") return candidateFor(dueQueue[0]); + return candidateFor(selection.conceptId) ?? home.primary; + }, [mode, candidateFor, courseQueue, dueQueue, selection.conceptId, home.primary]); + + const entryCourse = React.useMemo( + () => home.courses.find(c => c.course_id === entry.course) ?? null, + [home.courses, entry.course], + ); + + // ── The little constellation ────────────────────────────────────────── + // + // `siblingsFor` works on the adapted `lib/data` node shape (colour resolved, + // `name` rather than `concept_name`) — the same one the tree feeds its graph, + // and the one `QuizScreen` already adapts for the results screen. + const viewNodes = React.useMemo( + () => home.nodes.map(n => apiToGraphNode(n, home.courses)), + [home.nodes, home.courses], + ); + + const siblingsOf = React.useCallback( + (nodeId: string | undefined) => (nodeId ? siblingsFor(nodeId, viewNodes, home.edges) : []), + [viewNodes, home.edges], + ); + + /** The `n` in "{Course} · {tier} · {n} connected concepts" (R-8's fallback). */ + const connectedTo = React.useCallback( + (nodeId: string | undefined) => + nodeId ? home.edges.filter(e => e.source === nodeId || e.target === nodeId).length : 0, + [home.edges], + ); + + const cardSiblings = React.useMemo(() => siblingsOf(card?.node.id), [siblingsOf, card]); + + // ── Copy ────────────────────────────────────────────────────────────── + + const accent = card?.color ?? null; + const queued = mode !== "concept"; + const conceptName = card?.node.concept_name ?? ""; + const courseCode = (mode === "course" ? entryCourse : card?.course)?.course_code ?? ""; + + const title = + mode === "course" + ? `Practice ${courseCode}` + : mode === "due" + ? "Review everything due" + : conceptName; + + const meta = + mode === "course" + ? `${courseQueue.length} concepts due` + : mode === "due" + ? `${home.due.count} concepts across ${home.due.courseCount} courses · starting with the ${Math.min(home.due.count, QUEUE_MAX)} weakest` + : card + ? metaLine(card.node) + : ""; + + // Only a deep link explains itself on the card; the ranked proposal's reason + // lives on the alternatives rows, where there is something to compare against. + const rationale = + mode === "concept" && selection.conceptId && card + ? DEEP_LINK_RATIONALE[entry.source.kind] ?? DEFAULT_DEEP_LINK_RATIONALE + : null; + + // R-8's sentence, fetched by the hook for the concept the CARD shows — a deep + // link included (A2 fix round 5). The built sentence stands in while the call + // is in flight or after it fails. The two queued shapes show no definition at + // all: it describes one concept and would read as a caption for the wrong + // thing under "Practice CS101". + const definition = queued + ? null + : home.cardDescription ?? (card ? fallbackDefinition(card, connectedTo(card.node.id)) : ""); + + const feedbackSuffix = session.config.feedback === "as-you-go" ? " · answers as you go" : ""; + const configLine = queued + ? `${QUEUE_COUNT} questions each, ${session.config.difficulty}${feedbackSuffix}` + : `${session.config.count} questions, ${session.config.difficulty}${feedbackSuffix}`; + + // ── Starting ────────────────────────────────────────────────────────── + + const startConcept = React.useCallback( + (candidate: Candidate, cfg: SessionConfig) => { + actions.start( + { + intent: "practice", + scope: { kind: "concept", conceptId: candidate.node.id }, + conceptId: candidate.node.id, + courseId: candidate.node.course_id ?? null, + }, + cfg, + ); + }, + [actions], + ); + + const startQueue = React.useCallback( + (kind: "course" | "due", queue: string[], cfg: SessionConfig, courseId?: string) => { + if (queue.length === 0) return; + const first = queue[0]; + actions.start( + { + intent: kind === "due" ? "review" : "practice", + scope: + kind === "due" + ? { kind: "due", queue } + : { kind: "course", courseId: courseId ?? "", queue }, + conceptId: first, + courseId: home.nodes.find(n => n.id === first)?.course_id ?? null, + }, + cfg, + ); + }, + [actions, home.nodes], + ); + + /** The card's own Start, in whichever shape it currently has. */ + const startCard = React.useCallback( + (cfg: SessionConfig) => { + if (mode === "course") return startQueue("course", courseQueue, cfg, entry.course); + if (mode === "due") return startQueue("due", dueQueue, cfg); + if (card) startConcept(card, cfg); + }, + [mode, startQueue, courseQueue, dueQueue, entry.course, card, startConcept], + ); + + /** Remember the choices a dialog was started with (§5 B1.6). `setConfig` + * persists on its own; `start` does not. */ + const remember = (cfg: SessionConfig) => + savePrefs({ count: cfg.count, difficulty: cfg.difficulty, feedback: cfg.feedback }); + + // A queued session is QUEUE_COUNT questions per concept by default (R-4). + // That is what the card starts with AND what its Adjust dialog opens on — + // otherwise "Start · 5 medium" would quietly run three-question attempts. + const effectiveConfig: SessionConfig = queued + ? { ...session.config, count: QUEUE_COUNT } + : session.config; + + // ── Dialogs ─────────────────────────────────────────────────────────── + + const dialogCandidate = React.useMemo( + () => candidateFor(dialogNodeId), + [candidateFor, dialogNodeId], + ); + + const openAdjust = (open: boolean) => { + setAdjustOpen(open); + // `configuring` is the machine's own name for "the adjust dialog is open". + actions.configure(open); + }; + + const openConcept = (nodeId: string) => { + if (adjustOpen) openAdjust(false); + setDialogNodeId(nodeId); + }; + + // ── Regions ─────────────────────────────────────────────────────────── + + const resumable = home.resumable; + const resumeName = resumable + ? home.nodes.find(n => n.id === resumable.attempt.concept_node_id)?.concept_name + ?? home.attempts.find(a => a.quiz_id === resumable.attempt.quiz_id)?.concept_name + ?? "a concept" + : ""; + const resumeTotal = resumable + ? resumable.attempt.questions.length || resumable.attempt.total || 0 + : 0; + + // A true full-bleed band: it renders OUTSIDE the content column (A2's + // `.quiz-body--home` is padding-free for exactly this), so `InlineBanner`'s + // own page padding and bottom rule reach both edges under the TopBar with + // nothing here overriding them. + const resumeStrip = resumable ? ( + + + + + } + > + {`You left a quiz on ${resumeName} — ${resumable.answered} of ${resumeTotal} answered`} + + ) : null; + + const conceptCount = React.useMemo( + () => home.nodes.filter(n => !n.is_subject_root).length, + [home.nodes], + ); + + /** + * The eyebrow-and-Cancel row (§5 B1.8). It sits above whatever the body turns + * out to be rather than inside the proposal, so the three no-card states — + * empty tree, no courses, everything mastered, a failed load — still have a + * way back to wherever the student came from, and nothing below it moves as + * the load resolves. + */ + const cardHead = (eyebrow: string | null) => ( +
+ {eyebrow &&
{eyebrow}
} + +
+ ); + + const proposal = card ? ( +
+
+
+
+ {!queued && ( + + )} + {title} +
+ + {meta &&

{meta}

} + {rationale &&

{rationale}

} + {definition &&

{definition}

} +

{configLine}

+ +
+ + +
+
+ +
+
+ +
+
+
+ ) : ( + // Every concept is mastered: there is nothing to propose, but the list is + // still there, so this is a signpost rather than a dead end. + setPicking(true)}> + Pick something specific → + + } + /> + ); + + const alternatives = ( + <> + {home.alternatives.map(alt => ( + + ))} + + {home.due.count > 0 && ( + + )} + + ); + + const skeleton = ( +
+
+ +
+ + +
+ + + + + +
+
+
+ +
+
+ ); + + const errorCard = ( +
+

We couldn't load your tree

+

{home.error?.message}

+
+ +
+
+ ); + + /** Which arrival the screen is rendering. Named once so the head row above + * the body can know whether it has an eyebrow to carry. */ + const view = + home.status === "error" + ? "error" + : home.status === "loading" + ? "loading" + : home.courses.length === 0 + ? "no-courses" + : conceptCount === 0 + ? "empty-tree" + : picking + ? "picking" + : "home"; + + // "Also worth a look" is a heading for the rows under it; with no + // alternatives AND nothing due (a deep link to a mastered concept, say) it + // would sit between two rules with nothing in between. + const hasMore = home.alternatives.length > 0 || home.due.count > 0; + + const body = () => { + if (view === "error") return errorCard; + if (view === "loading") return skeleton; + + if (view === "no-courses") { + return ( + + ); + } + + if (view === "empty-tree") { + return ( + +
+ Go to your library + + + Talk to the tutor + + + } + /> + ); + } + + if (view === "picking") { + return setPicking(false)} />; + } + + return ( + <> + {proposal} + {card && ( + <> +
+ {hasMore && ( + <> +
+ Also worth a look +
+ {alternatives} +
+ + )} +
+ +
+ + )} + + ); + }; + + return ( +
+ {resumeStrip} + + {/* A2's `.quiz-body--home` is padding-free so the strip above can bleed; + everything else gets the column and the page padding back here. The + two are NESTED rather than stacked on one element: `box-sizing: + border-box` is global, so a single div carrying both would eat the + 64px of page padding out of the 780px measure and set the card at 716. + The design puts the padding outside the column. */} +
+
+ {/* The pick list has its own `← Back`; a Cancel beside it would be a + second escape from a screen the student just navigated INTO. */} + {view !== "picking" && cardHead(view === "home" && card ? "Ready for you" : null)} + {body()} +
+
+ + {dialogCandidate && ( + setDialogNodeId(null)} + onStart={cfg => { + setDialogNodeId(null); + remember(cfg); + startConcept(dialogCandidate, cfg); + }} + /> + )} + + {adjustOpen && card && ( + { + openAdjust(false); + actions.setConfig(cfg); + }} + onClose={() => openAdjust(false)} + onStart={cfg => { + openAdjust(false); + remember(cfg); + startCard(cfg); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/quiz/home/QuizSettings.tsx b/frontend/src/components/quiz/home/QuizSettings.tsx new file mode 100644 index 00000000..e795c719 --- /dev/null +++ b/frontend/src/components/quiz/home/QuizSettings.tsx @@ -0,0 +1,110 @@ +"use client"; + +/** + * The three pick-one rows both quiz-home dialogs carry: Length, Difficulty, + * Answers (§5 B1.5 / B1.6). + * + * One component rather than two copies because the Concept dialog and the + * Adjust dialog render the identical control set — the design draws them + * identically too, down to the 96px label gutter. + * + * NOTHING here enumerates counts or difficulties: the two option lists come + * off `GET /api/quiz/config` (§2) and the only hardcoded list in the quiz is + * `FEEDBACK_MODES`, which is a client concept with no server list to read + * (R-2). While `/config` is still in flight the rows keep their labels and + * show a skeleton where the options will land, so the dialog doesn't reflow + * when it arrives. + */ + +import React from "react"; +import { SegmentedControl } from "@/components/ui"; +import { Skeleton } from "@/components/Skeleton"; +import { FEEDBACK_LABELS, FEEDBACK_MODES } from "@/lib/quiz/prefs"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import type { QuizConfig } from "@/lib/quiz/types"; + +export interface QuizSettingsProps { + /** `null` until `/api/quiz/config` resolves. */ + config: QuizConfig | null; + value: SessionConfig; + onChange: (next: SessionConfig) => void; + /** Drops the top margin when the rows already follow a rule. */ + flush?: boolean; +} + +/** Width of the placeholder that stands in for a row of options. */ +const SKELETON_WIDTH = 220; +const SKELETON_HEIGHT = 14; + +function Row({ + label, + children, +}: { + label: string; + children: (labelId: string) => React.ReactNode; +}) { + const labelId = React.useId(); + return ( +
+ + {label} + + {children(labelId)} +
+ ); +} + +export function QuizSettings({ config, value, onChange, flush = false }: QuizSettingsProps) { + const pending = ; + + return ( +
+ + {labelId => + config ? ( + ({ + value: n, + label: `${n} questions`, + }))} + value={value.count} + onChange={count => onChange({ ...value, count })} + labelledBy={labelId} + testid="quiz-seg-count" + /> + ) : ( + pending + ) + } + + + + {labelId => + config ? ( + ({ value: d, label: d }))} + value={value.difficulty} + onChange={difficulty => onChange({ ...value, difficulty })} + labelledBy={labelId} + testid="quiz-seg-difficulty" + /> + ) : ( + pending + ) + } + + + + {labelId => ( + ({ value: m, label: FEEDBACK_LABELS[m] }))} + value={value.feedback} + onChange={feedback => onChange({ ...value, feedback })} + labelledBy={labelId} + testid="quiz-seg-feedback" + /> + )} + +
+ ); +} diff --git a/frontend/src/components/quiz/home/accent.ts b/frontend/src/components/quiz/home/accent.ts new file mode 100644 index 00000000..962e4162 --- /dev/null +++ b/frontend/src/components/quiz/home/accent.ts @@ -0,0 +1,18 @@ +import type { CSSProperties } from "react"; + +/** + * Bind the course accent for a subtree — R-1's one styling exemption ("binding + * a CSS custom property to runtime data"), collected here so quiz home has a + * single auditable site for it. + * + * `QuizScreen` already sets `--quiz-accent` on `.quiz-root`, which covers + * everything in the page tree. The two dialogs do NOT live in that tree: + * `Dialog` portals its panel to `document.body`, where the accent would fall + * all the way back to `var(--accent)` and the segmented underlines would paint + * in the app's green instead of the course's colour. Re-binding it on the + * dialog root is what keeps a portalled panel looking like the screen it came + * from. + */ +export function accentStyle(color: string | null | undefined): CSSProperties | undefined { + return color ? ({ "--quiz-accent": color } as CSSProperties) : undefined; +} diff --git a/frontend/src/components/quiz/home/home.css b/frontend/src/components/quiz/home/home.css new file mode 100644 index 00000000..c7f3dffc --- /dev/null +++ b/frontend/src/components/quiz/home/home.css @@ -0,0 +1,437 @@ +/* Quiz home (§5 B1) — the proposal card, the alternatives, the pick list and + * the two settings dialogs. + * + * R-1: class names only, tokens only. Every geometry constant the design draws + * — the measures, the three display sizes, and the whole vertical rhythm — is + * declared ONCE in the block below and referenced everywhere else. The only + * bare lengths further down are the 1px hairlines, which are the house idiom + * (`quiz.css`, `globals.css`). + * + * The rhythm tokens are FIXED rather than density-scaled `--pad-*`. An earlier + * pass took the 16px steps from `--pad-md` and left the 12/14/18/34px ones + * hardcoded, so the card half-responded to the density setting and half didn't + * — the worst of both. The prototype is a fixed-geometry drawing and the screen + * now reproduces it whole; if the lead would rather the screen breathe with + * density, these tokens are the one place to redefine. + * + * The token block is applied to BOTH roots: `.quiz-home` (in the page tree) and + * `.quiz-home-dialog` (portalled to by `Dialog`, so it inherits nothing + * from the screen). That portal is also why the dialogs re-bind `--quiz-accent` + * themselves — outside `.quiz-root` the accent would fall all the way back to + * the app's own green and the underlines would stop being the course colour. + */ + +.quiz-home, +.quiz-home-dialog { + /* ── Measures ─────────────────────────────────────────────────────── */ + /* The definition paragraph is set to a reading measure rather than the + column, exactly as the design draws it. */ + --quiz-home-def-width: 380px; + --quiz-home-dialog-def-width: 340px; + --quiz-home-pick-width: 620px; + --quiz-home-card-min: 340px; + --quiz-home-dialog-card-min: 280px; + --quiz-home-setting-label-w: 96px; + + /* ── Display sizes the --fs-* ramp has no step for ────────────────── */ + /* Pinned here rather than bolted onto the global scale (that scale is + documented as derived from an audit, not invented — see globals.css). */ + --quiz-home-name-fs: 28px; + --quiz-home-dialog-name-fs: 22px; + --quiz-home-pick-title-fs: 24px; + --quiz-home-def-fs: 13.5px; + + /* ── The card's rhythm ────────────────────────────────────────────── */ + --quiz-home-card-gap: 24px; + --quiz-home-head-gap: 16px; + --quiz-home-name-gap: 14px; + --quiz-home-name-top: 16px; + --quiz-home-meta-top: 12px; + --quiz-home-rationale-top: 2px; + --quiz-home-def-top: 14px; + --quiz-home-config-top: 16px; + --quiz-home-actions-gap: 18px; + --quiz-home-actions-top: 28px; + --quiz-home-skeleton-gap: 12px; + + /* ── Sections and rows ────────────────────────────────────────────── */ + --quiz-home-rule-top: 36px; + --quiz-home-rule-bottom: 26px; + --quiz-home-eyebrow-gap: 6px; + --quiz-home-row-pad: 13px 8px; + --quiz-home-row-bleed: -8px; + --quiz-home-row-gap: 12px; + --quiz-home-pick-open-top: 18px; + + /* ── The pick list ────────────────────────────────────────────────── */ + --quiz-home-pick-eyebrow-top: 24px; + --quiz-home-pick-title-top: 12px; + --quiz-home-group-gap: 8px; + --quiz-home-group-top: 34px; + --quiz-home-group-bottom: 2px; + + /* ── The dialogs ──────────────────────────────────────────────────── */ + --quiz-home-dialog-gap: 22px; + --quiz-home-dialog-main-top: 4px; + --quiz-home-dialog-subtitle-top: 4px; + --quiz-home-dialog-subtitle-gap: 18px; + --quiz-home-dialog-meta-top: 8px; + --quiz-home-settings-top: 20px; + --quiz-home-setting-gap: 8px; + --quiz-home-setting-pad: 12px 0 4px; + --quiz-home-note-min: 34px; + --quiz-home-note-top: 8px; + --quiz-home-footer-top: 20px; + --quiz-home-footer-pad: 18px; + + /* The review-due row has no mastery, so its dot is the one mark drawn in CSS + rather than by (whose diameters are props, not tokens). The + ring is an 8px circle centred in the 11px box the other row dots occupy, + as the design draws it (r=4 in an 11-wide viewBox). */ + --quiz-home-dot-sm: 11px; + --quiz-home-dot-hollow: 8px; + + /* The inline course code on a row is tracked tighter than `.label-micro`. */ + --quiz-home-code-tracking: 0.08em; +} + +/* ── Shell ──────────────────────────────────────────────────────────── */ + +.quiz-home { + display: flex; + flex-direction: column; +} + +/* The resume strip is a genuine full-bleed band under the TopBar: `QuizScreen` + renders home WITHOUT the centred column (`.quiz-body--home` is padding-free, + see quiz.css), so the strip reaches both edges on its own and everything + below it is wrapped by the screen in `.quiz-col--home .quiz-inset--home`. + `InlineBanner` brings its own `12px var(--pad-xl)` padding and bottom rule — + nothing here restyles it. */ + +.quiz-home__rule { + border: 0; + border-top: 1px solid var(--border); + margin: var(--quiz-home-rule-top) 0 var(--quiz-home-rule-bottom); +} + +.quiz-home__rule--tight { + margin: var(--quiz-home-rule-bottom) 0 0; +} + +/* The eyebrow of a section that follows a rule; `.quiz-eyebrow`'s own gap is + the app's, the design's is tighter. */ +.quiz-home__section-eyebrow { + margin-bottom: var(--quiz-home-eyebrow-gap); +} + +/* ── The proposal card ──────────────────────────────────────────────── */ + +.quiz-home__card { + display: flex; + align-items: stretch; + gap: var(--quiz-home-card-gap); +} + +.quiz-home__card-main { + flex: 1; + min-width: var(--quiz-home-card-min); +} + +/* The eyebrow and Cancel span the WHOLE column, not just the card's text, so + Cancel sits at the top-right of the screen (§5 B1.8) rather than floating in + the gutter between the text and the neighbourhood. The row renders in every + state — proposal, loading, empty, error — so Cancel is never unreachable and + nothing below it shifts as the state resolves. */ +.quiz-home__card-head { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: var(--quiz-home-head-gap); +} + +.quiz-home__card-head .quiz-eyebrow { + flex: 1; + min-width: 0; + margin-bottom: 0; +} + +.quiz-home__name { + display: flex; + align-items: center; + gap: var(--quiz-home-name-gap); + margin-top: var(--quiz-home-name-top); +} + +.quiz-home__name-text { + font-size: var(--quiz-home-name-fs); + color: var(--text); +} + +.quiz-home__meta { + margin: var(--quiz-home-meta-top) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home__rationale { + margin: var(--quiz-home-rationale-top) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home__definition { + margin: var(--quiz-home-def-top) 0 0; + max-width: var(--quiz-home-def-width); + font-size: var(--quiz-home-def-fs); + line-height: 1.6; + color: var(--text-dim); + text-wrap: pretty; +} + +.quiz-home__config { + margin: var(--quiz-home-config-top) 0 0; + font-size: var(--fs-base); + color: var(--text-dim); +} + +.quiz-home__actions { + display: flex; + align-items: center; + gap: var(--quiz-home-actions-gap); + margin-top: var(--quiz-home-actions-top); +} + +/* The vertical hairline between the card and its neighbourhood. */ +.quiz-home__card-divider { + width: 1px; + flex-shrink: 0; + background: var(--border); +} + +.quiz-home__neighbourhood { + flex-shrink: 0; + align-self: center; +} + +/* ── Rows: alternatives, review-due, pick list ──────────────────────── */ + +.quiz-home__row { + display: flex; + align-items: baseline; + gap: var(--quiz-home-row-gap); + padding: var(--quiz-home-row-pad); + /* The rows bleed 8px past the column so their hover/focus surface extends + under the text, exactly as the design draws it. */ + margin: 0 var(--quiz-home-row-bleed); + width: calc(100% - 2 * var(--quiz-home-row-bleed)); + border: 0; + border-radius: var(--r-sm); + background: none; + text-align: left; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.quiz-home__row:hover { + background: color-mix(in srgb, var(--text) 3%, transparent); +} + +/* A ruled row keeps its radius: the hover surface stays rounded against the + rule, which is what the design draws. */ +.quiz-home__row--ruled { + border-bottom: 1px solid var(--border); +} + +.quiz-home__row-mark { + flex-shrink: 0; + align-self: center; + display: inline-flex; +} + +/* The review-due row has no mastery to draw, so its dot is an outline — an 8px + ring centred in the same 11px box the other row dots fill. */ +.quiz-home__row-mark--hollow { + width: var(--quiz-home-dot-sm); + height: var(--quiz-home-dot-sm); + align-items: center; + justify-content: center; +} + +.quiz-home__row-mark--hollow::before { + content: ""; + width: var(--quiz-home-dot-hollow); + height: var(--quiz-home-dot-hollow); + border: 1px solid var(--text-muted); + border-radius: var(--r-full); +} + +.quiz-home__row-name { + font-size: var(--fs-lg); + color: var(--text); +} + +.quiz-home__row-code { + font-family: var(--font-mono); + font-size: var(--fs-2xs); + letter-spacing: var(--quiz-home-code-tracking); + color: var(--text-muted); +} + +.quiz-home__row-spacer { + flex: 1; +} + +.quiz-home__row-meta { + font-size: var(--fs-md); + color: var(--text-muted); + text-align: right; +} + +.quiz-home__pick-open { + margin-top: var(--quiz-home-pick-open-top); +} + +/* ── Pick something specific ────────────────────────────────────────── */ + +.quiz-home__pick { + max-width: var(--quiz-home-pick-width); +} + +.quiz-home__pick-title { + margin: var(--quiz-home-pick-title-top) 0 0; + font-size: var(--quiz-home-pick-title-fs); + line-height: 1.3; + color: var(--text); +} + +.quiz-home__pick-eyebrow { + margin-top: var(--quiz-home-pick-eyebrow-top); +} + +.quiz-home__pick-group { + display: flex; + align-items: center; + gap: var(--quiz-home-group-gap); + margin: var(--quiz-home-group-top) 0 var(--quiz-home-group-bottom); +} + +/* ── Empty, loading and error ───────────────────────────────────────── */ + +.quiz-home__skeleton { + display: flex; + align-items: stretch; + gap: var(--quiz-home-card-gap); +} + +.quiz-home__skeleton-main { + flex: 1; + min-width: var(--quiz-home-card-min); + display: flex; + flex-direction: column; + gap: var(--quiz-home-skeleton-gap); +} + +.quiz-home__skeleton-name { + display: flex; + align-items: center; + gap: var(--quiz-home-name-gap); +} + +/* ── The two dialogs ────────────────────────────────────────────────── */ + +.quiz-home-dialog__title { + margin: 0; + font-size: var(--fs-xl); + font-weight: 700; + color: var(--text); +} + +.quiz-home-dialog__subtitle { + margin: var(--quiz-home-dialog-subtitle-top) 0 var(--quiz-home-dialog-subtitle-gap); + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__card { + display: flex; + align-items: stretch; + gap: var(--quiz-home-dialog-gap); +} + +.quiz-home-dialog__main { + flex: 1; + min-width: var(--quiz-home-dialog-card-min); + padding-top: var(--quiz-home-dialog-main-top); +} + +.quiz-home-dialog__name { + margin: 0; + font-size: var(--quiz-home-dialog-name-fs); + line-height: 1.2; + color: var(--text); +} + +.quiz-home-dialog__meta { + margin: var(--quiz-home-dialog-meta-top) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__rationale { + margin: var(--quiz-home-rationale-top) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__definition { + margin: var(--quiz-home-def-top) 0 0; + max-width: var(--quiz-home-dialog-def-width); + font-size: var(--quiz-home-def-fs); + line-height: 1.6; + color: var(--text-dim); + text-wrap: pretty; +} + +.quiz-home-dialog__note { + min-height: var(--quiz-home-note-min); + padding-top: var(--quiz-home-note-top); + margin: 0; + font-size: var(--fs-sm); + color: var(--text-muted); + text-wrap: pretty; +} + +.quiz-home-dialog__footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--quiz-home-footer-top); + padding-top: var(--quiz-home-footer-pad); + border-top: 1px solid var(--border); +} + +/* ── Settings rows (shared by both dialogs) ─────────────────────────── */ + +.quiz-home-settings { + margin-top: var(--quiz-home-settings-top); + border-top: 1px solid var(--border); +} + +/* Inside the adjust dialog the rows follow a subtitle, which already carries + the gap; the rule above them is the only separation needed. */ +.quiz-home-settings--flush { + margin-top: 0; +} + +.quiz-home-settings__row { + display: flex; + align-items: baseline; + gap: var(--quiz-home-setting-gap); + padding: var(--quiz-home-setting-pad); +} + +.quiz-home-settings__label { + width: var(--quiz-home-setting-label-w); + flex-shrink: 0; +} diff --git a/frontend/src/components/quiz/index.ts b/frontend/src/components/quiz/index.ts new file mode 100644 index 00000000..a0a4499c --- /dev/null +++ b/frontend/src/components/quiz/index.ts @@ -0,0 +1,8 @@ +export { QuizScreen } from "./QuizScreen"; +export { QuizHome, type QuizHomeProps } from "./home/QuizHome"; +export { + QuizQuestion, + type QuizQuestionProps, + type QuizConceptSummary, +} from "./question/QuizQuestion"; +export { QuizResults, type QuizResultsProps } from "./results/QuizResults"; diff --git a/frontend/src/components/quiz/question/AskPanel.test.tsx b/frontend/src/components/quiz/question/AskPanel.test.tsx new file mode 100644 index 00000000..8e31082e --- /dev/null +++ b/frontend/src/components/quiz/question/AskPanel.test.tsx @@ -0,0 +1,336 @@ +// @vitest-environment jsdom +/** + * The tutor handoff, on its own (R-6 / R1 §F). What matters here is the SEAM: + * the two-call seeding pattern, the follow-ups landing on the same session, + * the failure ladder, and the session being left open on close. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { AskPanel, composeAskMessage, type AskSeed } from "./AskPanel"; + +const api = vi.hoisted(() => ({ + startSessionStream: vi.fn(), + startSession: vi.fn(), + streamChat: vi.fn(), + sendChat: vi.fn(), + endSession: vi.fn(), + shouldFallBackToJson: vi.fn(() => true), +})); +vi.mock("@/lib/api", () => api); + +vi.mock("next/dynamic", () => ({ + default: () => + function MarkdownStub({ children }: { children: React.ReactNode }) { + return
{children}
; + }, +})); + +const SEED: AskSeed = { + stem: "What is the purpose of a base case?", + chosenLabel: "C", + chosenText: "It increases the recursion depth", + correctLabel: "B", + correctText: "It stops the recursion", + explanation: "Without one the calls never end.", +}; + +function renderPanel(over: Partial> = {}) { + const onClose = vi.fn(); + const view = render( + , + ); + return { ...view, onClose }; +} + +/** The session id the tutor opened with. */ +const SESSION = "tutor-session-1"; + +beforeEach(() => { + vi.clearAllMocks(); + api.shouldFallBackToJson.mockReturnValue(true); + api.startSessionStream.mockResolvedValue({ session_id: SESSION, reply: "Hi." }); + api.startSession.mockResolvedValue({ session_id: SESSION, initial_message: "Hi." }); + api.streamChat.mockResolvedValue({ reply: "The base case is the exit." }); + api.sendChat.mockResolvedValue({ reply: "The base case is the exit (json)." }); +}); + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +describe("composeAskMessage", () => { + it("says what happened, in the order the tutor needs it", () => { + expect(composeAskMessage(SEED)).toBe( + [ + "I got this quiz question wrong and want to understand why.", + "", + "Question: What is the purpose of a base case?", + "I chose C: It increases the recursion depth", + "The correct answer is B: It stops the recursion", + "Explanation given: Without one the calls never end.", + "", + "Help me understand why.", + ].join("\n"), + ); + }); +}); + +describe("AskPanel", () => { + it("opens a session on the concept, then sends the composed context", async () => { + renderPanel(); + + await waitFor(() => expect(api.startSessionStream).toHaveBeenCalledTimes(1)); + // (userId, topic, mode, useSharedContext, courseId, ...) + expect(api.startSessionStream.mock.calls[0].slice(0, 5)).toEqual([ + "user-1", + "Recursion", + "socratic", + true, + "course-cs101", + ]); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + expect(api.streamChat.mock.calls[0][0]).toBe(SESSION); + expect(api.streamChat.mock.calls[0][2]).toBe(composeAskMessage(SEED)); + + // The greeting from the start call is deliberately not rendered. + expect(screen.queryByText("Hi.")).toBeNull(); + await screen.findByText("The base case is the exit."); + // ...and the seed itself is on screen as static context. + expect(screen.getByTestId("quiz-ask-seed")).toHaveTextContent("You chose C"); + expect(screen.getByTestId("quiz-ask-seed")).toHaveTextContent("The answer is B"); + }); + + it("renders tokens as they stream in", async () => { + api.streamChat.mockImplementation(async (...args: unknown[]) => { + const handlers = args[6] as { onToken?: (d: string) => void }; + handlers.onToken?.("Think about "); + handlers.onToken?.("the exit condition."); + return { reply: "Think about the exit condition." }; + }); + + renderPanel(); + await screen.findByText("Think about the exit condition."); + }); + + it("sends a follow-up on the SAME session and never re-opens one", async () => { + renderPanel(); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByTestId("quiz-ask-input"), { + target: { value: "So what happens without it?" }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-send")); + }); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(2)); + expect(api.startSessionStream).toHaveBeenCalledTimes(1); + expect(api.streamChat.mock.calls[1][0]).toBe(SESSION); + expect(api.streamChat.mock.calls[1][2]).toBe("So what happens without it?"); + expect(screen.getByText("So what happens without it?")).toBeInTheDocument(); + }); + + it("falls back to the JSON route when the stream produced nothing", async () => { + api.streamChat.mockRejectedValueOnce(new Error("stream died")); + renderPanel(); + + await waitFor(() => expect(api.sendChat).toHaveBeenCalledTimes(1)); + expect(api.sendChat.mock.calls[0][0]).toBe(SESSION); + await screen.findByText("The base case is the exit (json)."); + expect(screen.queryByTestId("quiz-ask-retry")).toBeNull(); + }); + + it("surfaces a non-retryable failure inline, and Retry re-sends it", async () => { + api.shouldFallBackToJson.mockReturnValue(false); + api.streamChat.mockRejectedValueOnce(new Error("tool writes already landed")); + renderPanel(); + + const retry = await screen.findByTestId("quiz-ask-retry"); + expect(screen.getByRole("alert")).toHaveTextContent("tool writes already landed"); + expect(api.sendChat).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(retry); + }); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(2)); + // The session opened for the first attempt is reused, not replaced. + expect(api.startSessionStream).toHaveBeenCalledTimes(1); + expect(api.streamChat.mock.calls[1][2]).toBe(composeAskMessage(SEED)); + }); + + it("keeps a half-written answer when the stream dies mid-sentence (ADR 0020)", async () => { + api.streamChat.mockImplementationOnce(async (...args: unknown[]) => { + const handlers = args[6] as { onToken?: (d: string) => void }; + handlers.onToken?.("A base case is "); + handlers.onToken?.("the branch that"); + throw new Error("the tutor was interrupted"); + }); + + renderPanel(); + + // The partial the student was already reading stays on screen, marked. It + // is NOT blinked out and replaced by an error strip. + await screen.findByText("A base case is the branch that"); + expect(screen.getByText(/Interrupted/)).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("the tutor was interrupted"); + // Tokens appeared, so the JSON rung is skipped: never silently re-run a + // turn the student has already partly read. + expect(api.sendChat).not.toHaveBeenCalled(); + + // Retry drops the fragment rather than stacking a whole answer under it. + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-retry")); + }); + await screen.findByText("The base case is the exit."); + expect(screen.queryByText("A base case is the branch that")).toBeNull(); + expect(screen.queryByText(/Interrupted/)).toBeNull(); + expect(screen.queryByTestId("quiz-ask-retry")).toBeNull(); + }); + + it("aborts an in-flight stream on unmount", async () => { + let signal: AbortSignal | undefined; + api.streamChat.mockImplementationOnce(async (...args: unknown[]) => { + signal = (args[6] as { signal?: AbortSignal }).signal; + return new Promise(() => {}); // never settles — the tab goes away first + }); + + const { unmount } = renderPanel(); + await waitFor(() => expect(signal).toBeDefined()); + expect(signal!.aborted).toBe(false); + + unmount(); + expect(signal!.aborted).toBe(true); + }); + + it("aborts an in-flight stream when the panel is closed, and re-asks on reopen", async () => { + // Nobody is reading a stream behind a closed sheet, and its reply would + // land in state on a panel that is gone. + let signal: AbortSignal | undefined; + api.streamChat.mockImplementationOnce(async (...args: unknown[]) => { + signal = (args[6] as { signal?: AbortSignal }).signal; + return new Promise(() => {}); // still mid-sentence when the sheet closes + }); + + const onClose = vi.fn(); + const props = { + onClose, + userId: "user-1", + conceptName: "Recursion", + courseId: "course-cs101", + courseLabel: "CS101", + seed: SEED, + }; + const { rerender } = render(); + await waitFor(() => expect(signal).toBeDefined()); + expect(signal!.aborted).toBe(false); + + await act(async () => { + rerender(); + }); + expect(signal!.aborted).toBe(true); + + // The turn never produced an answer, so reopening the SAME question asks it + // again rather than showing an empty thread with no way to get one. + await act(async () => { + rerender(); + }); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(2)); + expect(api.streamChat.mock.calls[1][2]).toBe(composeAskMessage(SEED)); + await screen.findByText("The base case is the exit."); + }); + + it("leaves a finished turn alone when the panel closes and reopens", async () => { + const onClose = vi.fn(); + const props = { + onClose, + userId: "user-1", + conceptName: "Recursion", + courseId: "course-cs101", + courseLabel: "CS101", + seed: SEED, + }; + const { rerender } = render(); + await screen.findByText("The base case is the exit."); + + await act(async () => { + rerender(); + }); + await act(async () => { + rerender(); + }); + + // Nothing was in flight to abort, so the conversation is where it was — no + // second session, no repeated question. + expect(api.startSessionStream).toHaveBeenCalledTimes(1); + expect(api.streamChat).toHaveBeenCalledTimes(1); + expect(screen.getByText("The base case is the exit.")).toBeInTheDocument(); + }); + + it("uses the JSON start route when the streamed one falls over", async () => { + api.startSessionStream.mockRejectedValueOnce(new Error("no stream")); + renderPanel(); + + await waitFor(() => expect(api.startSession).toHaveBeenCalledTimes(1)); + expect(api.startSession.mock.calls[0].slice(0, 4)).toEqual([ + "user-1", + "Recursion", + "socratic", + "course-cs101", + ]); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + }); + + it("leaves the tutor session open on close, and hands focus back", async () => { + const trigger = document.createElement("button"); + trigger.setAttribute("data-testid", "outside-trigger"); + document.body.appendChild(trigger); + const returnFocusTo = { current: trigger }; + + const { rerender, onClose } = renderPanel({ returnFocusTo }); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender( + , + ); + }); + + expect(api.endSession).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it("does nothing at all until it is opened", () => { + renderPanel({ open: false }); + expect(api.startSessionStream).not.toHaveBeenCalled(); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + }); +}); diff --git a/frontend/src/components/quiz/question/AskPanel.tsx b/frontend/src/components/quiz/question/AskPanel.tsx new file mode 100644 index 00000000..ef6cbfbf --- /dev/null +++ b/frontend/src/components/quiz/question/AskPanel.tsx @@ -0,0 +1,418 @@ +"use client"; + +/** + * "Ask about this" — the tutor, OVER the quiz (R-6). + * + * This is the behavioural point of the redesign. The old screen navigated to + * `/learn?topic=…`, which abandoned the attempt: the questions were gone, the + * answers already given were gone, and there was no way back. Here the tutor + * arrives in a `Sheet` on top of the question; closing it puts the student + * back on the exact same item with the exact same verdict on screen. The panel + * owns all of its own state and dispatches no machine events — the attempt + * cannot notice it happened. + * + * SEEDING (R1 §F). `StartSessionBody` has no context field: `topic` is the + * session's display name, it is encrypted-stored, and it is matched against + * course codes and concept names to find the course grounding — so dumping a + * question stem into it would produce a garbage session title AND lose the + * grounding. The only pattern that exists is two calls: open the session on + * the concept name (with `course_id`, which is what actually grounds the graph + * block and RAG), then send the composed context as the first message. The + * tutor's greeting from that first call is deliberately never rendered — the + * student asked about a question, not for a hello. + * + * The session is LEFT OPEN on close (no `end-session`): it stays in the + * tutor's session list, which is where a student who wants to keep going will + * look for it. The accumulating-sessions cost is recorded as a seam in §8. + * + * Failure handling mirrors `Learn.tsx`'s ladder, minus the parts that only + * make sense in a transcript: a stream that produced nothing falls back to the + * JSON route transparently, a stream that failed AFTER tokens (or one the + * backend marked non-retryable, or a 413) surfaces an inline error with Retry. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import { Sheet } from "@/components/ui"; +import { + sendChat, + shouldFallBackToJson, + startSession, + startSessionStream, + streamChat, +} from "@/lib/api"; + +// The same renderer the tutor uses, loaded the same way `ChatPanel` loads it: +// `MarkdownChat` statically imports mermaid, katex and highlight.js, and a +// static import here would pull all of that into the quiz route's bundle. +const MarkdownChat = dynamic( + () => import("@/components/chat/MarkdownChat").then(m => m.MarkdownChat), + { ssr: false, loading: () => null }, +); + +/** The tutor mode a "why was I wrong" question wants. */ +const TUTOR_MODE = "socratic"; + +/** Everything the tutor needs to know about the item that was missed. */ +export interface AskSeed { + stem: string; + chosenLabel: string; + chosenText: string; + correctLabel: string; + correctText: string; + explanation: string; +} + +export interface AskPanelProps { + open: boolean; + onClose: () => void; + userId: string; + conceptName: string; + courseId: string | null; + courseLabel?: string; + seed: AskSeed; + /** Focused after the panel closes. `Sheet` restores focus on its own; this + * is the explicit target for a caller whose trigger re-renders (B3's + * missed-list rows), and it runs after Sheet's restore, so it wins. */ + returnFocusTo?: React.RefObject; + testid?: string; +} + +interface AskTurn { + id: number; + role: "user" | "assistant"; + text: string; + /** ADR 0020: the stream was cut off after producing this much. The partial + * stays in the thread, marked; Retry drops it and starts the answer over. */ + interrupted?: boolean; +} + +/** + * The first message. Exported because it is the actual contract with the + * tutor — a test that pins the wording is pinning what the model is told. + */ +export function composeAskMessage(seed: AskSeed): string { + return [ + "I got this quiz question wrong and want to understand why.", + "", + `Question: ${seed.stem}`, + `I chose ${seed.chosenLabel}: ${seed.chosenText}`, + `The correct answer is ${seed.correctLabel}: ${seed.correctText}`, + `Explanation given: ${seed.explanation}`, + "", + "Help me understand why.", + ].join("\n"); +} + +export function AskPanel({ + open, + onClose, + userId, + conceptName, + courseId, + courseLabel, + seed, + returnFocusTo, + testid = "quiz-ask-panel", +}: AskPanelProps) { + const [turns, setTurns] = useState([]); + const [streaming, setStreaming] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(""); + + // The tutor session, held in a ref: nothing renders off it, and a follow-up + // fired from a stale closure must still reach the session that was opened. + const sessionIdRef = useRef(null); + const abortRef = useRef(null); + // Whether a turn is still running. The `busy` state can't answer that for the + // close effect below: an effect reads the value from the render that closed + // the panel, and a turn can settle between the two. + const inFlightRef = useRef(false); + // False from the unmount cleanup onward. Aborting stops the request; this + // stops the settle path that follows an abort from writing state into a + // component that is already gone. + const mountedRef = useRef(true); + // Monotonic run token — a superseded turn's late resolution is dropped. + const runRef = useRef(0); + const idRef = useRef(0); + // What Retry re-sends. + const lastMessageRef = useRef(""); + // The seed the current conversation was opened with. + const lastSeededRef = useRef(null); + + const seedMessage = useMemo(() => composeAskMessage(seed), [seed]); + + const nextId = () => ++idRef.current; + + const runTurn = useCallback( + async (message: string) => { + const token = ++runRef.current; + // A new turn supersedes whatever was in flight; two streams writing into + // the same partial-text state would interleave. + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + inFlightRef.current = true; + lastMessageRef.current = message; + setBusy(true); + setError(null); + setStreaming(""); + let sawToken = false; + // The partial reply so far. `streaming` state can't serve here: it is + // cleared before the JSON leg and again in `finally`, and ADR 0020 needs + // the text to outlive both. + let partial = ""; + + try { + let sid = sessionIdRef.current; + if (!sid) { + try { + const started = await startSessionStream( + userId, + conceptName, + TUTOR_MODE, + true, + courseId ?? undefined, + undefined, + { signal: controller.signal }, + ); + sid = started.session_id ?? null; + } catch (err) { + if (controller.signal.aborted) return; + if (!shouldFallBackToJson(err)) throw err; + // The greeting is thrown away either way, so the JSON route is a + // straight substitute here — there is no partial text to lose. + const started = await startSession( + userId, + conceptName, + TUTOR_MODE, + courseId ?? undefined, + true, + ); + sid = started.session_id; + } + if (!sid) throw new Error("The tutor didn't open a session."); + if (runRef.current !== token) return; + sessionIdRef.current = sid; + } + + let reply: string; + try { + // `graph_update` events are deliberately unhandled: this panel is a + // read of the student's own mistake, not a study turn that should + // move the graph under the quiz. + const res = await streamChat(sid, userId, message, TUTOR_MODE, true, undefined, { + onToken: delta => { + if (delta.trim()) sawToken = true; + partial += delta; + setStreaming(prev => (prev ?? "") + delta); + }, + signal: controller.signal, + }); + reply = res.reply || ""; + } catch (err) { + if (controller.signal.aborted) return; + // Tokens already on screen, or a failure the JSON route would repeat + // identically (#151a) — surface it rather than silently re-running. + // The partial survives via the outer catch. + if (sawToken || !shouldFallBackToJson(err)) throw err; + setStreaming(null); + const res = await sendChat(sid, userId, message, TUTOR_MODE, true); + reply = res.reply || ""; + } + + if (runRef.current !== token || !mountedRef.current) return; + setTurns(t => [...t, { id: nextId(), role: "assistant", text: reply }]); + } catch (err) { + if (controller.signal.aborted || runRef.current !== token || !mountedRef.current) return; + // ADR 0020, and the whole reason `partial` exists: half an answer the + // student was already reading must not blink out and be replaced by an + // error strip. It stays in the thread, marked unfinished. Retry drops + // it (`retry` below) so the second attempt doesn't read as a sequel. + if (partial.trim()) { + setTurns(t => [ + ...t, + { id: nextId(), role: "assistant", text: partial, interrupted: true }, + ]); + } + setError(err instanceof Error ? err.message : "The tutor is unavailable."); + } finally { + if (runRef.current === token) { + inFlightRef.current = false; + if (mountedRef.current) { + setBusy(false); + setStreaming(null); + } + } + } + }, + [userId, conceptName, courseId], + ); + + // Seed on open, and re-seed when the question changes (B3 opens the same + // panel for each missed item). Keyed on the composed message rather than on + // `open`, so closing and reopening the SAME question keeps the conversation. + useEffect(() => { + if (!open) return; + if (lastSeededRef.current === seedMessage) return; + lastSeededRef.current = seedMessage; + sessionIdRef.current = null; + setTurns([]); + setStreaming(null); + setError(null); + setDraft(""); + void runTurn(seedMessage); + }, [open, seedMessage, runTurn]); + + // Closing the sheet ends the turn it was showing. + // + // The stream used to be left running ("shut the panel mid-sentence and find + // the finished answer waiting"), but nobody is reading it: the tokens are + // still generated and paid for, and the reply lands in state behind a closed + // sheet. Bumping the run token first means the aborted turn's late + // resolution is dropped rather than appended. Dropping the seed marker is the + // other half — an unfinished turn left no answer, so reopening the same + // question must ASK it again rather than show an empty thread with no way to + // get one. A turn that already finished is untouched: close and reopen still + // finds the conversation exactly where it was. + useEffect(() => { + if (open || !inFlightRef.current) return; + runRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + inFlightRef.current = false; + lastSeededRef.current = null; + setBusy(false); + setStreaming(null); + }, [open]); + + // Unmount: the same stop, plus the latch that keeps the settle path from + // setting state on a component that no longer exists. Assigning `true` in the + // effect body (rather than relying on the ref's initial value) is what makes + // it survive StrictMode's mount → cleanup → mount. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortRef.current?.abort(); + }; + }, []); + + // Focus returns to whatever opened the panel. `Sheet`'s own restore already + // does this for a trigger that stays mounted; this effect runs after that + // cleanup, so an explicit target wins. + const wasOpenRef = useRef(false); + useEffect(() => { + if (open) { + wasOpenRef.current = true; + return; + } + if (!wasOpenRef.current) return; + wasOpenRef.current = false; + returnFocusTo?.current?.focus(); + }, [open, returnFocusTo]); + + /** + * Re-send the turn that failed. The interrupted partial is dropped first: + * the retry produces a whole answer, and leaving the fragment above it would + * read as the first half of the same reply. + */ + const retry = () => { + setTurns(t => (t.length > 0 && t[t.length - 1].interrupted ? t.slice(0, -1) : t)); + void runTurn(lastMessageRef.current); + }; + + const send = (e: React.FormEvent) => { + e.preventDefault(); + const text = draft.trim(); + if (!text || busy) return; + setDraft(""); + setTurns(t => [...t, { id: nextId(), role: "user", text }]); + void runTurn(text); + }; + + const subtitle = courseLabel ? `${conceptName} · ${courseLabel}` : conceptName; + + return ( + +
+
+

{seed.stem}

+
+ You chose {seed.chosenLabel} · + {seed.chosenText} +
+
+ The answer is {seed.correctLabel} · + {seed.correctText} +
+ {seed.explanation && ( +

{seed.explanation}

+ )} + Asking the tutor about {subtitle}. +
+ +
+ {turns.map(turn => + turn.role === "user" ? ( +

+ {turn.text} +

+ ) : ( +
+ {turn.text} + {turn.interrupted && ( +

Interrupted — the tutor didn't finish.

+ )} +
+ ), + )} + {streaming !== null && ( +
+ {streaming ? ( + {streaming} + ) : ( +

Thinking…

+ )} +
+ )} + {error && ( +
+ {error} + +
+ )} +
+ +
+ setDraft(e.target.value)} + placeholder="Ask a follow-up…" + aria-label="Ask a follow-up" + data-testid="quiz-ask-input" + /> + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/question/LeaveDialog.tsx b/frontend/src/components/quiz/question/LeaveDialog.tsx new file mode 100644 index 00000000..ef929829 --- /dev/null +++ b/frontend/src/components/quiz/question/LeaveDialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * The one door out of a live attempt (§5 B2). + * + * Nothing else in the question screen navigates: the machine refuses `EXIT` + * from `active`/`answered` (invariant 1), so leaving has to come through here + * and land on `paused`. The dialog exists to make that deliberate — and to say + * the true thing about it, which is that the answers already given are safe. + * + * "Keep going" is the primary and takes focus: the safe choice is the default + * one, and Escape / the backdrop / the close button all resolve to it. + */ + +import React, { useRef } from "react"; +import Dialog from "@/components/Dialog"; + +export interface LeaveDialogProps { + open: boolean; + /** Escape, the backdrop, the close button and "Keep going" all land here. */ + onCancel: () => void; + onConfirm: () => void; +} + +export function LeaveDialog({ open, onCancel, onConfirm }: LeaveDialogProps) { + // Without this the overlay focuses the first focusable node in the panel, + // which is Dialog's own close button. The safe action should be the one + // under the student's fingers. + const keepGoingRef = useRef(null); + + return ( + +
+

+ Your answers so far are saved. You can pick it up again from Quiz home. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/question/QuizQuestion.test.tsx b/frontend/src/components/quiz/question/QuizQuestion.test.tsx new file mode 100644 index 00000000..b1da41a0 --- /dev/null +++ b/frontend/src/components/quiz/question/QuizQuestion.test.tsx @@ -0,0 +1,645 @@ +// @vitest-environment jsdom +/** + * What this screen has to get right, in the order it matters (§5 B2): + * + * 1. the attempt is never orphaned — "Ask about this" opens OVER the question + * and closing it leaves the question DOM byte-identical, + * 2. the keyboard map works without a mouse, + * 3. the verdict is announced, not just painted, + * 4. nothing moves between states. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizItem, QuizSession, WireQuestion } from "@/lib/quiz/types"; +import { QuizQuestion, type QuizConceptSummary } from "./QuizQuestion"; + +const toastApi = vi.hoisted(() => ({ + show: vi.fn(), + dismiss: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +})); +vi.mock("@/components/ToastProvider", () => ({ useToast: () => toastApi })); + +const api = vi.hoisted(() => ({ + startSessionStream: vi.fn(), + startSession: vi.fn(), + streamChat: vi.fn(), + sendChat: vi.fn(), + shouldFallBackToJson: vi.fn(() => true), +})); +vi.mock("@/lib/api", () => api); + +// MarkdownChat is lazy-loaded (mermaid + katex + highlight.js); render its +// text so assertions see the reply synchronously. +vi.mock("next/dynamic", () => ({ + default: () => + function MarkdownStub({ children }: { children: React.ReactNode }) { + return
{children}
; + }, +})); + +// ── fixtures ────────────────────────────────────────────────────────────── + +const CONCEPT: QuizConceptSummary = { + id: "node-recursion", + name: "Recursion", + courseCode: "CS101", + color: "#7b4b99", + tier: "struggling", + mastery: 0.29, +}; + +function question(id: number): WireQuestion { + return { + id, + question: `What is the purpose of a base case in a recursive function? (${id})`, + options: [ + { label: "A", text: "It makes the function run faster by caching results" }, + { label: "B", text: "It stops the recursion by returning without another call" }, + { label: "C", text: "It increases the recursion depth available" }, + { label: "D", text: "It converts the recursion into a loop at compile time" }, + ], + difficulty: "medium", + }; +} + +function item(index: number, over: Partial = {}): QuizItem { + return { + index, + question: question(index + 1), + selectedIndex: null, + verdict: null, + flagged: false, + ...over, + }; +} + +function makeSession(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: CONCEPT.id }, + source: { kind: "nav" }, + config: { count: 3, difficulty: "medium", feedback: "as-you-go" }, + conceptId: CONCEPT.id, + courseId: "course-cs101", + attemptId: "attempt-1", + items: [item(0), item(1), item(2)], + cursor: 0, + queueIndex: 0, + phase: "active", + error: null, + result: null, + xp: null, + deliveredShort: false, + ...over, + }; +} + +function makeActions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function renderScreen(session: QuizSession, actions: QuizActions = makeActions()) { + const view = render( + , + ); + return { ...view, actions }; +} + +const root = () => screen.getByTestId("quiz-panel"); + +beforeEach(() => { + vi.clearAllMocks(); + api.shouldFallBackToJson.mockReturnValue(true); + api.startSessionStream.mockResolvedValue({ session_id: "tutor-1", reply: "Hello." }); + api.streamChat.mockResolvedValue({ reply: "Because the base case is the exit." }); +}); + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +// ── the screen ──────────────────────────────────────────────────────────── + +describe("QuizQuestion — the question", () => { + it("renders the stem, the options and the progress rail", () => { + renderScreen(makeSession({ cursor: 1, items: [item(0, { verdict: { isCorrect: true, correctIndex: 1, explanation: "" }, selectedIndex: 1 }), item(1), item(2)] })); + + expect(screen.getByText(/purpose of a base case/)).toBeInTheDocument(); + expect(screen.getByTestId("quiz-answer-options")).toHaveAttribute("role", "radiogroup"); + for (const label of ["A", "B", "C", "D"]) { + expect(screen.getByTestId(`quiz-answer-option-${label}`)).toBeInTheDocument(); + } + expect(screen.getByTestId("quiz-progress")).toHaveAttribute("aria-label", "Question 2 of 3"); + expect(screen.getByText("Recursion · CS101")).toBeInTheDocument(); + expect(screen.getByText("MEDIUM")).toBeInTheDocument(); + }); + + it("gives the radiogroup exactly one tab stop — the selection, else the first row", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("tabindex", "0"); + expect(screen.getByTestId("quiz-answer-option-C")).toHaveAttribute("tabindex", "-1"); + + rerender( + , + ); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("tabindex", "-1"); + expect(screen.getByTestId("quiz-answer-option-C")).toHaveAttribute("tabindex", "0"); + }); + + it("reserves the mark slot in every state, so revealing a verdict reflows nothing", () => { + const { container, rerender } = renderScreen(makeSession()); + const marks = () => container.querySelectorAll(".answer-option__mark"); + + expect(marks()).toHaveLength(4); + marks().forEach(mark => { + expect(mark).toHaveAttribute("aria-hidden", "true"); + expect(mark.textContent).toBe(""); + }); + + rerender( + , + ); + // Same four slots, still every one of them present. + expect(marks()).toHaveLength(4); + marks().forEach(mark => expect(mark).toHaveAttribute("aria-hidden", "true")); + }); +}); + +describe("QuizQuestion — keyboard", () => { + it("selects with the letter keys and the number keys", () => { + const { actions } = renderScreen(makeSession()); + + fireEvent.keyDown(root(), { key: "c" }); + expect(actions.select).toHaveBeenCalledWith(2); + + fireEvent.keyDown(root(), { key: "2" }); + expect(actions.select).toHaveBeenCalledWith(1); + + // Past the end of the option list is not a selection. + actions.select = vi.fn(); + fireEvent.keyDown(root(), { key: "f" }); + expect(actions.select).not.toHaveBeenCalled(); + }); + + it("moves the selection with the arrow keys, wrapping", () => { + const { actions } = renderScreen(makeSession({ items: [item(0, { selectedIndex: 3 }), item(1), item(2)] })); + + fireEvent.keyDown(screen.getByTestId("quiz-answer-options"), { key: "ArrowDown" }); + expect(actions.select).toHaveBeenCalledWith(0); + + fireEvent.keyDown(screen.getByTestId("quiz-answer-options"), { key: "ArrowUp" }); + expect(actions.select).toHaveBeenCalledWith(2); + }); + + it("Enter submits while active and advances once the verdict is showing", () => { + const { actions, rerender } = renderScreen( + makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }), + ); + + fireEvent.keyDown(root(), { key: "Enter" }); + expect(actions.submitAnswer).toHaveBeenCalledTimes(1); + + const answered = makeActions(); + rerender( + , + ); + fireEvent.keyDown(root(), { key: "Enter" }); + expect(answered.next).toHaveBeenCalledTimes(1); + }); + + it("listens on the screen root, not on window", () => { + const { actions } = renderScreen(makeSession()); + // The same keypress that selects when it reaches the root must do nothing + // when the quiz doesn't own the focus. + fireEvent.keyDown(document.body, { key: "c" }); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(actions.select).not.toHaveBeenCalled(); + expect(actions.requestLeave).not.toHaveBeenCalled(); + }); + + it("leaves Space to the answer row's own activation", () => { + const { actions } = renderScreen( + makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }), + ); + const row = screen.getByTestId("quiz-answer-option-C"); + + // `fireEvent` returns false when the handler called preventDefault. Enter + // is the footer's action and IS pre-empted; Space must not be, or the + // row's native button activation never runs. + expect(fireEvent.keyDown(row, { key: " " })).toBe(true); + expect(fireEvent.keyDown(row, { key: "Enter" })).toBe(false); + expect(actions.submitAnswer).toHaveBeenCalledTimes(1); + + // ...and what the browser does on Space is a click. + fireEvent.click(row); + expect(actions.select).toHaveBeenCalledWith(2); + }); + + it("Enter does nothing while no answer is chosen", () => { + const { actions } = renderScreen(makeSession()); + fireEvent.keyDown(root(), { key: "Enter" }); + expect(actions.submitAnswer).not.toHaveBeenCalled(); + }); + + it("Escape asks to leave, and the dialog's Leave confirms it", () => { + const { actions, rerender } = renderScreen(makeSession()); + + fireEvent.keyDown(root(), { key: "Escape" }); + expect(actions.requestLeave).toHaveBeenCalledTimes(1); + + const leaving = makeActions(); + rerender( + , + ); + expect(screen.getByTestId("quiz-leave-dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("quiz-leave-confirm")); + expect(leaving.confirmLeave).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("quiz-leave-cancel")); + expect(leaving.cancelLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe("QuizQuestion — the verdict", () => { + const answeredSession = () => + makeSession({ + phase: "answered", + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }); + + it("marks correct / chosen-wrong / muted and moves focus to the feedback line", () => { + renderScreen(answeredSession()); + + expect(screen.getByTestId("quiz-answer-option-B").className).toContain("answer-option--correct"); + expect(screen.getByTestId("quiz-answer-option-C").className).toContain( + "answer-option--chosen-wrong", + ); + expect(screen.getByTestId("quiz-answer-option-A").className).toContain("answer-option--muted"); + expect(screen.getByTestId("quiz-answer-option-D").className).toContain("answer-option--muted"); + + const feedback = screen.getByTestId("quiz-review-verdict"); + expect(feedback).toHaveTextContent("Not quite — the answer is B."); + expect(feedback).toHaveTextContent("The base case ends it."); + expect(document.activeElement).toBe(feedback); + }); + + it("never reveals anything in at-end mode", () => { + renderScreen( + makeSession({ + config: { count: 3, difficulty: "medium", feedback: "at-end" }, + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }), + ); + expect(screen.getByTestId("quiz-review-verdict")).toHaveTextContent(""); + expect(screen.getByTestId("quiz-answer-option-B").className).not.toContain( + "answer-option--correct", + ); + }); + + it("switches the one footer button's label and testid in place", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.getByTestId("quiz-submit-answer")).toHaveTextContent("Submit"); + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + expect(screen.queryByTestId("quiz-next")).toBeNull(); + + const props = (session: QuizSession) => ( + + ); + + rerender(props(makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }))); + expect(screen.getByTestId("quiz-submit-answer")).toBeEnabled(); + + rerender(props(answeredSession())); + expect(screen.queryByTestId("quiz-submit-answer")).toBeNull(); + expect(screen.getByTestId("quiz-next")).toHaveTextContent("Next"); + + rerender( + props( + makeSession({ + phase: "answered", + cursor: 2, + items: [ + item(0), + item(1), + item(2, { + selectedIndex: 0, + verdict: { isCorrect: true, correctIndex: 0, explanation: "" }, + }), + ], + }), + ), + ); + expect(screen.getByTestId("quiz-next")).toHaveTextContent("See results"); + + rerender(props(makeSession({ phase: "submitting" }))); + expect(screen.getByTestId("quiz-submit-answer")).toHaveTextContent("Scoring…"); + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + }); + + it("holds Submit while the hook says a call is in flight", () => { + const actions = makeActions(); + const session = makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }); + const { rerender } = render( + , + ); + expect(screen.getByTestId("quiz-submit-answer")).toBeEnabled(); + + rerender( + , + ); + // Same phase, same selection — only `pending` moved, and it is enough. + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("aria-disabled", "true"); + + fireEvent.keyDown(root(), { key: "Enter" }); + fireEvent.keyDown(root(), { key: "d" }); + expect(actions.submitAnswer).not.toHaveBeenCalled(); + expect(actions.select).not.toHaveBeenCalled(); + }); +}); + +describe("QuizQuestion — flag and generating", () => { + it("flags with a toast and reflects the flag in aria-pressed", () => { + const { actions, rerender } = renderScreen(makeSession()); + const flag = screen.getByTestId("quiz-flag"); + expect(flag).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(flag); + expect(actions.flag).toHaveBeenCalledTimes(1); + expect(toastApi.show).toHaveBeenCalledWith("Noted — thanks."); + + rerender( + , + ); + expect(screen.getByTestId("quiz-flag")).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows the skeleton while the quiz is being written, and says so", () => { + const { container } = renderScreen( + makeSession({ phase: "generating", items: [], attemptId: null }), + ); + expect(screen.getByTestId("quiz-generating")).toBeInTheDocument(); + expect(screen.getByText("Writing your quiz…")).toBeInTheDocument(); + expect(container.querySelectorAll(".quiz-question__skeleton-row")).toHaveLength(4); + expect(screen.queryByTestId("quiz-answer-options")).toBeNull(); + }); + + it("says so exactly once when fewer questions arrived than were asked for", () => { + const short = makeSession({ deliveredShort: true, items: [item(0), item(1)] }); + const { rerender } = renderScreen(short); + expect(toastApi.show).toHaveBeenCalledWith("Only 2 questions were ready for this concept."); + expect(toastApi.show).toHaveBeenCalledTimes(1); + + // Every re-render of the same attempt — a selection, a verdict, a flag — + // must not re-announce it. + rerender( + , + ); + rerender( + , + ); + expect(toastApi.show).toHaveBeenCalledTimes(1); + }); +}); + +// ── the whole point ─────────────────────────────────────────────────────── + +describe("QuizQuestion — Ask about this never orphans the attempt", () => { + const answeredSession = () => + makeSession({ + phase: "answered", + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }); + + it("is offered only once there is a verdict", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.queryByTestId("quiz-ask")).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId("quiz-ask")).toBeInTheDocument(); + }); + + it("opens the tutor over the question, seeds it, and leaves the question untouched", async () => { + const { container, actions } = renderScreen(answeredSession()); + const optionsBefore = screen.getByTestId("quiz-answer-options").innerHTML; + const stemBefore = container.querySelector(".quiz-question__stem")!.innerHTML; + + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask")); + }); + + const panel = await screen.findByTestId("quiz-ask-panel"); + expect(panel).toHaveAttribute("aria-modal", "true"); + + // Two-call seeding (R1 §F): the session is opened on the concept name with + // the course id, then the context arrives as the first message. + await waitFor(() => expect(api.startSessionStream).toHaveBeenCalledTimes(1)); + expect(api.startSessionStream.mock.calls[0].slice(0, 5)).toEqual([ + "user-1", + "Recursion", + "socratic", + true, + "course-cs101", + ]); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + const seedMessage = api.streamChat.mock.calls[0][2] as string; + expect(seedMessage).toContain("I got this quiz question wrong and want to understand why."); + expect(seedMessage).toContain("Question: What is the purpose of a base case"); + expect(seedMessage).toContain("I chose C: It increases the recursion depth available"); + expect(seedMessage).toContain( + "The correct answer is B: It stops the recursion by returning without another call", + ); + expect(seedMessage).toContain("Explanation given: The base case ends it."); + expect(seedMessage).toContain("Help me understand why."); + + // The seeded context is on screen, and so is the reply. + expect(screen.getByTestId("quiz-ask-seed")).toBeInTheDocument(); + await waitFor(() => + expect(panel).toHaveTextContent("Because the base case is the exit."), + ); + + // Nothing about the attempt moved — not one machine event, of any kind. + Object.values(actions).forEach(fn => expect(fn).not.toHaveBeenCalled()); + expect(screen.getByTestId("quiz-answer-options").innerHTML).toBe(optionsBefore); + + // Closing puts the student back exactly where they were. + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + }); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + expect(screen.getByTestId("quiz-answer-options").innerHTML).toBe(optionsBefore); + expect(container.querySelector(".quiz-question__stem")!.innerHTML).toBe(stemBefore); + expect(document.activeElement).toBe(screen.getByTestId("quiz-ask")); + }); + + it("ignores the answer shortcuts while the panel has the keyboard", async () => { + const { actions } = renderScreen(answeredSession()); + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask")); + }); + await screen.findByTestId("quiz-ask-panel"); + + fireEvent.keyDown(screen.getByTestId("quiz-ask-input"), { key: "a" }); + fireEvent.keyDown(screen.getByTestId("quiz-ask-input"), { key: "Escape" }); + expect(actions.select).not.toHaveBeenCalled(); + expect(actions.requestLeave).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/quiz/question/QuizQuestion.tsx b/frontend/src/components/quiz/question/QuizQuestion.tsx new file mode 100644 index 00000000..51a98c9c --- /dev/null +++ b/frontend/src/components/quiz/question/QuizQuestion.tsx @@ -0,0 +1,487 @@ +"use client"; + +/** + * The question screen (§5 B2) — everything between "we have a quiz" and "we + * have a score": `generating`, `active`, `answered`, `confirm-leave`, + * `submitting`. + * + * Two things it is built around. + * + * NOTHING ORPHANS THE ATTEMPT. There is exactly one exit — the leave dialog — + * and it goes through `CONFIRM_LEAVE`, which persists the session before it + * navigates. "Ask about this" opens the tutor in a `Sheet` ON TOP of the + * question rather than navigating to `/learn`, which is what the old screen + * did and what silently threw away every answer given so far. Closing the + * sheet leaves the question exactly as it was, down to the focus ring. + * + * NOTHING MOVES WHEN A STATE CHANGES. The stem reserves its height, the + * verdict line reserves its height, every answer row reserves the ✓/✕ slot and + * the 2px selection bar, the flag link is always rendered (R-11), and the + * footer's primary button is ONE element whose label and testid swap in place + * (Submit → Next → See results → Scoring…). A quiz that jumps under the cursor + * between "choose" and "submit" is how you mis-click an answer. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AnswerOption, Badge, Button, ProgressDots, type AnswerState } from "@/components/ui"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { Skeleton } from "@/components/Skeleton"; +import { useToast } from "@/components/ToastProvider"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizConfig, QuizSession } from "@/lib/quiz/types"; +import { AskPanel, type AskSeed } from "./AskPanel"; +import { LeaveDialog } from "./LeaveDialog"; +import "./question.css"; + +export interface QuizConceptSummary { + id: string; + name: string; + courseCode: string; + color: string; + tier: string; + mastery: number; +} + +export interface QuizQuestionProps { + session: QuizSession; + actions: QuizActions; + config: QuizConfig | null; + concept: QuizConceptSummary; + userId: string; + courseId: string | null; + /** + * A quiz call is in flight (`useQuizSession`'s `pending`). + * + * The phase cannot say this: it stays `active` for the whole `/answer` round + * trip, so `phase !== "active"` is false exactly when the request IS running. + * Submit needs this to show its in-flight state honestly. It is not a + * correctness guard — the hook already refuses a second `/answer` for the same + * item, and the reducer drops a duplicate response (#537 A2 fix round 2). + * Optional so the current render keeps compiling until it reads this. + */ + pending?: boolean; +} + +/** The header mark, at the size §3 pins for a `dot`. */ +const HEADER_DOT = 15; +/** Placeholder row widths while the quiz is written — four is the modal count. */ +const SKELETON_ROWS = [72, 86, 64, 79]; +/** `A`…`F` — the answer shortcuts. Six covers every count `/config` offers and + * degrades harmlessly on a question with fewer options. */ +const SHORTCUT_LETTERS = "abcdef"; +const SHORTCUT_DIGITS = "123456"; + +/** Which option a keypress means, or null if the key isn't a shortcut. */ +export function shortcutIndex(key: string): number | null { + if (key.length !== 1) return null; + const letter = SHORTCUT_LETTERS.indexOf(key.toLowerCase()); + if (letter >= 0) return letter; + const digit = SHORTCUT_DIGITS.indexOf(key); + return digit >= 0 ? digit : null; +} + +/** Keystrokes belong to a field, not to the quiz, when one is focused. React + * portals bubble events through the React TREE, so the AskPanel's composer + * sits "inside" this screen as far as the handler is concerned. */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +export function QuizQuestion({ + session, + actions, + concept, + userId, + courseId, + pending = false, +}: QuizQuestionProps) { + const toast = useToast(); + + const rootRef = useRef(null); + const optionsRef = useRef(null); + const feedbackRef = useRef(null); + const askRef = useRef(null); + const shortToastRef = useRef(null); + + /** + * The tutor sheet, remembered per QUESTION rather than as a bare boolean: a + * new item derives it closed, because the panel is seeded from one verdict + * and carrying it across would be answering the wrong question. + */ + const [askForCursor, setAskForCursor] = useState(null); + + const { phase, cursor, items } = session; + const item = items[cursor]; + const total = items.length; + const options = item?.question.options ?? []; + const selectedIndex = item?.selectedIndex ?? null; + const answeredCount = items.filter(i => i.verdict !== null).length; + const isLast = cursor >= total - 1; + // R-2: every answer is recorded server-side either way; the feedback mode + // only decides whether the verdict is ever shown. + const showVerdict = session.config.feedback === "as-you-go" && item?.verdict != null; + + /** + * A quiz call is in flight. Straight off the hook (`useQuizSession`'s + * `pending`, on the props since A2's fix round 4): the phase can't say it, + * because it stays `active` for the whole `/answer` round trip — and the + * hook is the only thing that knows about the calls it refuses (a missing + * `attemptId`, a duplicate press), so a screen-local latch would stay stuck + * on exactly those. + */ + const busy = pending; + // Derived, so a new question is the only thing that closes the sheet. + const askOpen = askForCursor === cursor; + const selectable = phase === "active" && !busy; + + // A blank accent would reach `shadeFor` as an unparseable colour and pass + // straight through; the CSS variable degrades to the app accent instead. + const accent = concept.color || "var(--quiz-accent, var(--accent))"; + const difficulty = (item?.question.difficulty ?? session.config.difficulty ?? "").toUpperCase(); + const railTotal = total || session.config.count; + + // ── Effects ────────────────────────────────────────────────────────── + + // The keyboard map is attached to the screen root, so the root has to hold + // focus for it to hear anything before the student clicks. Moving focus onto + // each new question is also the right announcement. + useEffect(() => { + if (phase !== "active") return; + rootRef.current?.focus(); + }, [phase, cursor]); + + // ...and when the verdict lands, focus moves to the line that carries it, so + // a screen reader hears the result. Nothing else on the screen moves. + useEffect(() => { + if (!showVerdict) return; + feedbackRef.current?.focus(); + }, [showVerdict, cursor]); + + // One toast per attempt, on arrival (§5 B2). + useEffect(() => { + const attemptId = session.attemptId; + if (!session.deliveredShort || !attemptId || total === 0) return; + if (shortToastRef.current === attemptId) return; + shortToastRef.current = attemptId; + toast.show(`Only ${total} questions were ready for this concept.`); + }, [session.deliveredShort, session.attemptId, total, toast]); + + // ── Actions ────────────────────────────────────────────────────────── + + const submit = useCallback(() => { + if (phase !== "active" || busy || selectedIndex === null) return; + actions.submitAnswer(); + }, [actions, busy, phase, selectedIndex]); + + const flag = () => { + const wasFlagged = item?.flagged ?? false; + actions.flag(); + // Un-flagging is a correction, not a report — thanking for it reads as a + // bug. Only raising the flag says anything. + if (!wasFlagged) toast.show("Noted — thanks."); + }; + + const focusOption = (index: number) => { + optionsRef.current?.querySelectorAll('[role="radio"]')[index]?.focus(); + }; + + /** The single footer button: one element, three jobs. */ + const primary = useMemo(() => { + if (phase === "submitting") { + return { + label: "Scoring…", + // The element keeps the identity of the press that started the + // scoring: Next/See results in as-you-go, Submit on the last at-end item. + testid: showVerdict ? "quiz-next" : "quiz-submit-answer", + enabled: false, + activate: () => {}, + }; + } + if (showVerdict) { + return { + label: isLast ? "See results" : "Next", + testid: "quiz-next", + enabled: phase === "answered", + activate: () => actions.next(), + }; + } + return { + label: "Submit", + testid: "quiz-submit-answer", + enabled: phase === "active" && !busy && selectedIndex !== null, + activate: submit, + }; + }, [actions, busy, isLast, phase, selectedIndex, showVerdict, submit]); + + // ── Keyboard (§5 B2) ───────────────────────────────────────────────── + + const onRootKeyDown = (e: React.KeyboardEvent) => { + // The sheet and the dialog own the keyboard while they are open — and both + // are portals, whose events still bubble through the React tree to here. + if (askOpen || phase === "confirm-leave") return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (isTypingTarget(e.target)) return; + + if (e.key === "Escape") { + if (phase !== "active" && phase !== "answered") return; + e.preventDefault(); + actions.requestLeave(); + return; + } + + if (e.key === "Enter") { + // Enter is the footer's action everywhere on this screen, including on + // an answer row — `AnswerOption` is a + {phase === "answered" && ( + + )} +
+ +
+ +
+ + +
+
+ +
+ + actions.cancelLeave()} + onConfirm={() => actions.confirmLeave()} + /> + + setAskForCursor(null)} + userId={userId} + conceptName={concept.name} + courseId={courseId} + courseLabel={concept.courseCode || undefined} + seed={askSeed} + returnFocusTo={askRef} + /> +
+ ); +} diff --git a/frontend/src/components/quiz/question/question.css b/frontend/src/components/quiz/question/question.css new file mode 100644 index 00000000..15f9da31 --- /dev/null +++ b/frontend/src/components/quiz/question/question.css @@ -0,0 +1,386 @@ +/* The question screen (§5 B2) — the one place a student is mid-attempt. + * + * Every rule here is a class over tokens (R-1). The design's own geometry + * constants are declared ONCE in the block below and referenced everywhere + * else, so no rule carries a bare measurement that a retune would have to + * hunt for. Values come from `` in the prototype. + * + * The course accent arrives as `--quiz-accent`, bound on the screen root by + * `QuizScreen`; everything reads it through `var(--quiz-accent, var(--accent))` + * so an unset accent degrades to the app's own. + */ + +.quiz-question { + /* ── Geometry the design pins ─────────────────────────────────────── */ + --quiz-q-rail-top: 8px; /* the rail's optical alignment with the header */ + --quiz-q-stem-fs: 24px; /* the --fs-* ramp has no 24px step */ + --quiz-q-stem-mw: 560px; /* the reading measure of the stem */ + --quiz-q-stem-my: 48px; /* the air above and below it */ + --quiz-q-stem-mh: 72px; /* reserved, so a one-line stem doesn't jump */ + --quiz-q-feedback-mh: 20px; /* reserved for the verdict line */ + --quiz-q-feedback-mt: 18px; + --quiz-q-aside-mt: 10px; + --quiz-q-skeleton-row: 55px; /* one `AnswerOption` row, while generating */ + --quiz-q-skeleton-stem: 26px; + --quiz-q-skeleton-line: 13px; + /* The skeleton row's left inset is `AnswerOption`'s own (`padding: 17px 16px + 17px 14px`, globals.css), so the placeholder starts where the answer text + will. Not `--answer-letter-w`: that is the letter column's WIDTH, which is + the same number today by coincidence, not by construction. */ + --quiz-q-skeleton-inset: 14px; + + display: flex; + align-items: stretch; + /* The footer sits at the bottom of the viewport, not under the stem: the + column below grows into whatever height is going. */ + min-height: 100%; + /* The root is a programmatic focus target for the keyboard map (it is + `tabIndex={-1}`, never in the tab order), so it must not paint a ring. */ + outline: none; +} + +/* The ONE rule here that names a class from the shared `quiz.css`. + * + * Every other screen's wrapper is exactly its content column. This one is + * FLANKED — a 64px progress rail on the left, a 64px spacer on the right — so + * for the reading column itself to come out at `--quiz-col-question` the + * wrapper has to be that much wider, and it has to stretch to full height for + * the footer to reach the bottom. Scoped with `:has()` to a wrapper that + * actually holds this screen, so the error card (which shares the `question` + * layout) and every other screen are untouched. Where `:has()` is unsupported + * the layout degrades to a narrower reading column and a footer that follows + * the content — never to a broken one. */ +.quiz-col--question:has(.quiz-question) { + max-width: calc(var(--quiz-col-question) + 2 * var(--quiz-rail)); + align-self: stretch; +} + +.quiz-question__rail { + width: var(--quiz-rail); + flex-shrink: 0; + display: flex; + justify-content: center; + padding-top: var(--quiz-q-rail-top); +} + +/* Mirrored on the right so the reading column stays optically centred. */ +.quiz-question__spacer { + width: var(--quiz-rail); + flex-shrink: 0; +} + +.quiz-question__col { + flex: 1; + min-width: 0; + max-width: var(--quiz-col-question); + margin: 0 auto; + display: flex; + flex-direction: column; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ + +.quiz-question__header { + display: flex; + align-items: center; + gap: var(--pad-sm); +} + +.quiz-question__concept { + font-size: var(--fs-md); + color: var(--text-dim); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quiz-question__header-gap { + flex: 1; +} + +/* ── Stem ───────────────────────────────────────────────────────────── */ + +.quiz-question__stem { + font-size: var(--quiz-q-stem-fs); + line-height: 1.5; + color: var(--text); + margin: var(--quiz-q-stem-my) 0; + max-width: var(--quiz-q-stem-mw); + min-height: var(--quiz-q-stem-mh); + text-wrap: pretty; +} + +/* ── Options ────────────────────────────────────────────────────────── */ + +/* The rows bring their own bottom rule; the group supplies the top one. */ +.quiz-question__options { + border-top: 1px solid var(--border); +} + +/* ── Feedback ───────────────────────────────────────────────────────── */ + +/* Height is reserved whether or not there is a verdict, so revealing one + moves nothing above it. It takes focus when the verdict lands (so a screen + reader hears it), which is why it must not paint a ring of its own. */ +.quiz-question__feedback { + min-height: var(--quiz-q-feedback-mh); + margin-top: var(--quiz-q-feedback-mt); + font-size: var(--fs-md); + color: var(--text-dim); + outline: none; +} + +.quiz-question__explanation { + margin: var(--pad-sm) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); + max-width: var(--quiz-q-stem-mw); +} + +/* ── Aside: flag, then Ask ──────────────────────────────────────────── */ + +/* The flag comes FIRST so that "Ask about this" appearing with the verdict + doesn't push it sideways — the flag is always rendered (R-11), Ask only + once there is something to ask about. */ +.quiz-question__aside { + display: flex; + align-items: center; + gap: var(--pad-md); + margin-top: var(--quiz-q-aside-mt); +} + +/* The prototype's flag link is 12px, one step below the body text around it. + `.btn.btn--link` (0,2,0) sets 13px, so this has to be equally specific to + land — a bare `.quiz-question__flag` is a dead rule. */ +.btn.quiz-question__flag { + font-size: var(--fs-sm); +} + +/* ── Footer ─────────────────────────────────────────────────────────── */ + +/* The one flexible row: it eats the leftover height so Leave/Submit sit at + the bottom of the screen rather than under the last option. */ +.quiz-question__grow { + flex: 1; + min-height: var(--pad-xl); +} + +.quiz-question__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--pad-sm); +} + +/* ── Keyboard hint ──────────────────────────────────────────────────── */ + +/* The canonical visually-hidden recipe. The 1px here is not a design + measurement — it is what keeps the node in the accessibility tree while + painting nothing. */ +.quiz-question__hint { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* ── Generating ─────────────────────────────────────────────────────── */ + +.quiz-question__generating-copy { + margin: var(--pad-md) 0 0; + color: var(--text-muted); +} + +/* The stem's placeholder keeps the real stem's box, so the skeleton and the + question that replaces it occupy the same space. */ +.quiz-question__stem--skeleton { + display: flex; + flex-direction: column; + gap: var(--pad-sm); + justify-content: center; +} + +.quiz-question__skeleton-options { + border-top: 1px solid var(--border); +} + +.quiz-question__skeleton-row { + display: flex; + align-items: center; + height: var(--quiz-q-skeleton-row); + padding: 0 var(--pad-md) 0 var(--quiz-q-skeleton-inset); + border-bottom: 1px solid var(--border); +} + +/* ── Leave dialog ───────────────────────────────────────────────────── */ + +.quiz-leave-dialog__body { + margin: 0 0 var(--pad-lg); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-leave-dialog__actions { + display: flex; + justify-content: flex-end; + gap: var(--pad-sm); +} + +/* ── Ask panel ──────────────────────────────────────────────────────── */ + +/* The panel is portalled to , so it is NOT inside `.quiz-question` and + inherits none of its tokens. Its own geometry is declared here, on its own + root, for the same reason. (`--quiz-accent` is likewise out of scope from + here and falls through to `--accent`, which is the honest degradation: the + sheet floats over the app, not inside the course-tinted screen.) */ +.quiz-ask { + --quiz-ask-label-gap: 6px; /* between "You chose C ·" and the answer text */ + --quiz-ask-input-pad-y: 8px; /* the composer's field, matching `.btn` height */ + + display: flex; + flex-direction: column; + gap: var(--pad-md); + min-height: 100%; +} + +/* The panel's own visually-hidden line. Deliberately NOT + `.quiz-question__hint`: that one is the question screen's keyboard hint, and + restyling it must not silently restyle the sheet. Same canonical recipe — + the 1px/-1px here are what keep a node in the accessibility tree while + painting nothing, not design measurements. */ +.quiz-ask__sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* The seeded context, restated as static cards so the student can see exactly + what the tutor was told — nothing here is a message they can edit. */ +.quiz-ask__seed { + display: flex; + flex-direction: column; + gap: var(--pad-sm); + padding-bottom: var(--pad-md); + border-bottom: 1px solid var(--border); +} + +.quiz-ask__seed-stem { + font-size: var(--fs-lg); + margin: 0; + color: var(--text); + text-wrap: pretty; +} + +.quiz-ask__seed-card { + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + background: var(--bg-subtle); + border-left: var(--answer-bar-w) solid var(--border-strong); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-ask__seed-card--chosen { + border-left-color: var(--state-struggle); +} + +.quiz-ask__seed-card--correct { + border-left-color: var(--quiz-accent, var(--accent)); +} + +.quiz-ask__seed-label { + color: var(--text-muted); + margin-right: var(--quiz-ask-label-gap); +} + +.quiz-ask__seed-explanation { + margin: 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-ask__thread { + display: flex; + flex-direction: column; + gap: var(--pad-md); + flex: 1; + min-height: 0; +} + +.quiz-ask__turn--user { + align-self: flex-end; + max-width: 90%; + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + background: var(--bg-soft); + font-size: var(--fs-md); + color: var(--text); +} + +.quiz-ask__turn--assistant { + font-size: var(--fs-md); + color: var(--text); +} + +.quiz-ask__pending { + font-size: var(--fs-md); + color: var(--text-muted); +} + +/* ADR 0020: a reply the student was already reading survives the failure that + cut it off, marked as unfinished rather than silently passed off as whole. */ +.quiz-ask__interrupted { + margin: var(--pad-sm) 0 0; + font-size: var(--fs-sm); + color: var(--text-muted); +} + +.quiz-ask__error { + display: flex; + align-items: center; + gap: var(--pad-sm); + flex-wrap: wrap; + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + border: 1px solid var(--border); + border-left: var(--answer-bar-w) solid var(--state-struggle); + background: var(--bg-panel); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-ask__composer { + display: flex; + align-items: center; + gap: var(--pad-sm); + padding-top: var(--pad-md); + border-top: 1px solid var(--border); +} + +.quiz-ask__input { + flex: 1; + min-width: 0; + padding: var(--quiz-ask-input-pad-y) var(--pad-md); + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--bg-panel); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--fs-md); +} + +.quiz-ask__input:focus { + border-color: var(--border-strong); +} diff --git a/frontend/src/components/quiz/quiz.css b/frontend/src/components/quiz/quiz.css new file mode 100644 index 00000000..ebc0c6ea --- /dev/null +++ b/frontend/src/components/quiz/quiz.css @@ -0,0 +1,138 @@ +/* Shared layout for every quiz screen (§5 "Common"). + * + * Per-screen rules live beside their screen (`home/home.css`, `question/ + * question.css`, `results/results.css`); this file holds only what all three + * share — the three content-column widths, the page paddings, and the root that + * binds the course accent. + * + * Every value is a token. The design's own geometry constants (the column + * widths and the three page paddings) are declared ONCE here as tokens and + * referenced everywhere else, so no screen carries a bare px measurement. + * + * `--quiz-accent` is the one thing bound at runtime: the screen root sets it + * from the active concept's course colour with an inline custom property (the + * single inline-style exemption in R-1). Everything downstream reads + * `var(--quiz-accent, var(--accent))`, so an unset accent degrades to the app's + * own, never to a hardcoded colour. + */ + +.quiz-root { + --quiz-col-home: 780px; + --quiz-col-question: 680px; + --quiz-col-results: 640px; + + --quiz-pad-home: 52px var(--pad-xl) 32px; + --quiz-pad-question: 56px var(--pad-xl) 36px; + --quiz-pad-results: 28px var(--pad-xl) 32px; + + /* The rail the question screen's progress dots sit in, mirrored on the right + so the content column stays optically centred. */ + --quiz-rail: 64px; + + /* `display` and `flex-direction` are NOT set here: `FullHeightScreen` writes + both inline and inline wins, so restating them would be a rule that never + applies. This class carries the tokens and the sizing only. */ + flex: 1; + min-height: 0; +} + +/* The scrolling body under the TopBar. Each phase supplies its own padding + token; the column width is set by the screen inside. */ +.quiz-body { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + justify-content: center; + align-items: flex-start; +} + +/* Home is the exception: `QuizScreen` renders it WITHOUT the centred column so + the resume strip can span the full width under the TopBar, which it cannot do + inside a padded, max-width parent. That makes the home screen responsible for + its own layout, and it gets the column and the page padding back by NESTING + the two: + +
<- page padding, full width +
<- the 780px measure + ...everything below the resume strip +
+
+ + Nested, not stacked on one element. `box-sizing: border-box` is global, so a + single div carrying both classes resolves `max-width: 780px` INCLUSIVE of the + 64px of page padding and the card measures 716px instead of 780px. The design + puts the padding outside the column, so the DOM has to as well. + + Question and results are unchanged: `QuizScreen` still wraps those in + `.quiz-col` itself and the padding lives on `.quiz-body--*`, where it is on a + different element for the same reason. */ +.quiz-body--home { padding: 0; display: block; } +.quiz-body--question { padding: var(--quiz-pad-question); } +.quiz-body--results { padding: var(--quiz-pad-results); } + +/* The page padding home would have had. Must be the PARENT of + `.quiz-col--home`, never a sibling class on it — see above. */ +.quiz-inset--home { padding: var(--quiz-pad-home); } + +.quiz-col { + width: 100%; + margin: 0 auto; +} + +.quiz-col--home { max-width: var(--quiz-col-home); } +.quiz-col--question { max-width: var(--quiz-col-question); } +.quiz-col--results { max-width: var(--quiz-col-results); } + +/* Section rule used between blocks on every screen. */ +.quiz-divider { + border: 0; + border-top: 1px solid var(--border); + margin: var(--pad-lg) 0; +} + +.quiz-eyebrow { + color: var(--text-muted); + margin-bottom: var(--pad-sm); +} + +/* Nothing is signalled by colour alone, so the error card carries a heading as + well as its tint. */ +.quiz-error { + max-width: var(--quiz-col-question); + margin: 0 auto; + padding: var(--pad-lg); + border: 1px solid var(--border); + border-left: 2px solid var(--state-struggle); + border-radius: var(--r-lg); + background: var(--bg-panel); +} + +.quiz-error__title { + font-size: var(--fs-lg); + margin: 0 0 var(--pad-sm); +} + +.quiz-error__body { + font-size: var(--fs-md); + color: var(--text-dim); + margin: 0 0 var(--pad-md); +} + +.quiz-error__actions { + display: flex; + gap: var(--pad-sm); +} + +.quiz-error__request-id { + display: block; + margin-top: var(--pad-sm); + font-size: var(--fs-2xs); + color: var(--text-muted); +} + +/* The signed-out line `QuizScreen` renders in place of the phase switch. */ +.quiz-signin-note { + color: var(--text-muted); + font-size: var(--fs-md); +} diff --git a/frontend/src/components/quiz/results/MissedList.tsx b/frontend/src/components/quiz/results/MissedList.tsx new file mode 100644 index 00000000..3cd9f00e --- /dev/null +++ b/frontend/src/components/quiz/results/MissedList.tsx @@ -0,0 +1,158 @@ +"use client"; + +/** + * The "to look at" list on the results screen (§5 B3). + * + * One row per wrong answer: the stem, what was chosen against what was right, a + * disclosure for the explanation and "Ask about this". It is a separate file + * from `QuizResults` because the JOIN it does is the interesting part — + * `SubmitResult.results[]` carries labels and ids but no stems, and the stems + * only exist on the session's `items`. `buildMissedItems` is that join, exported + * so a test can pin it without rendering. + * + * The explanation disclosure is the one place on this screen that changes + * height. Everywhere else reserves its space; here the expansion IS the + * interaction, and reserving three lines of prose per row for something most + * students won't open would cost more than the shift does. + */ + +import React from "react"; +import { Button } from "@/components/ui"; +import type { QuizSession } from "@/lib/quiz/types"; + +export interface MissedItem { + /** `SubmitResult.results[].question_id` — a string on the wire, and the + * testid suffix (`quiz-missed-{id}`). */ + questionId: string; + /** "" when the question is no longer in the session's items. */ + stem: string; + /** "" when the item was submitted unanswered. */ + chosenLabel: string; + chosenText: string; + correctLabel: string; + correctText: string; + explanation: string; +} + +/** + * The wrong answers, in the order the server scored them, joined to their + * stems. + * + * `SubmitResult.results[].question_id` is `str(q["id"])` server-side + * (`quiz.py:1749`) while `WireQuestion.id` is a number, so the join coerces — + * a `===` on the raw values would silently match nothing and render a list of + * stemless rows. `selected` / `correct_answer` are option LABELS, not texts, + * which is why the option texts are looked up here rather than read off the + * result. + */ +export function buildMissedItems(session: QuizSession): MissedItem[] { + const result = session.result; + if (!result) return []; + + return result.results + .filter(r => !r.correct) + .map(r => { + const question = session.items.find( + item => String(item.question.id) === String(r.question_id), + )?.question; + const textFor = (label: string) => + question?.options.find(o => o.label === label)?.text ?? ""; + + return { + questionId: String(r.question_id), + stem: question?.question ?? "", + chosenLabel: r.selected, + chosenText: textFor(r.selected), + correctLabel: r.correct_answer, + correctText: textFor(r.correct_answer), + explanation: r.explanation, + }; + }); +} + +/** "One to look at" / "3 to look at" — the design's own eyebrow. */ +export function missedLabel(count: number): string { + return count === 1 ? "One to look at" : `${count} to look at`; +} + +export interface MissedListProps { + items: MissedItem[]; + /** Opens the AskPanel seeded from this row. The trigger is handed back so the + * screen can hold it as the panel's `returnFocusTo`. */ + onAsk: (item: MissedItem, trigger: HTMLElement) => void; + testid?: string; +} + +export function MissedList({ items, onAsk, testid = "quiz-missed-list" }: MissedListProps) { + const [expanded, setExpanded] = React.useState>({}); + + if (items.length === 0) return null; + + // The eyebrow doubles as the region's accessible name: a `
` without + // one is not exposed as a landmark, which would leave "One to look at" as + // loose text rather than the heading of the block it introduces. + const headingId = `${testid}-heading`; + + return ( +
+
+ {missedLabel(items.length)} +
+ {items.map(item => { + const open = expanded[item.questionId] === true; + const panelId = `quiz-missed-explanation-${item.questionId}`; + return ( +
+ {item.stem &&

{item.stem}

} +

+ {item.chosenLabel + ? `You chose ${item.chosenLabel} · the answer is ${item.correctLabel}` + : `No answer · the answer is ${item.correctLabel}`} +

+
+ {item.explanation && ( + + )} + +
+ {/* Always in the DOM so `aria-controls` never points at nothing — + an IDREF to a missing element is invalid ARIA and some screen + readers announce the control as broken. `hidden` occupies no + space, so the expand behaves exactly as before. */} + {item.explanation && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/quiz/results/QuizResults.test.tsx b/frontend/src/components/quiz/results/QuizResults.test.tsx new file mode 100644 index 00000000..e329ccca --- /dev/null +++ b/frontend/src/components/quiz/results/QuizResults.test.tsx @@ -0,0 +1,444 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; +import { markRadius } from "@/components/graph/ConceptNode"; +import { __resetReducedMotionStoreForTests } from "@/lib/usePrefersReducedMotion"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizItem, QuizSession, SubmitResult } from "@/lib/quiz/types"; +import { QuizResults } from "./QuizResults"; +import { buildMissedItems } from "./MissedList"; + +// B2 owns `question/AskPanel`; this screen only has to open it with the right +// seed. The mock renders the seed so the assertion is on the props, not on +// whatever the real sheet does with them. +vi.mock("../question/AskPanel", () => ({ + AskPanel: (props: { open: boolean; seed: Record; conceptName: string }) => + props.open ? ( +
+ {JSON.stringify(props.seed)} +
+ ) : null, +})); + +// The reduced-motion MEDIA query is forced off in this file so the +// `prefersReducedMotion` PROP is the only thing under test (the repo's vitest +// setup defaults the query to `matches: true`, which would mask it). +beforeEach(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + cleanup(); + __resetReducedMotionStoreForTests(); + vi.restoreAllMocks(); +}); + +const CONCEPT = { + id: "recursion", + name: "Recursion", + courseCode: "CS101", + color: "#7b4b99", + tier: "struggling", + mastery: 0.29, +}; + +const STEMS: Record = { + 101: "What is a base case?", + 102: "What happens if a recursive function has no base case?", + 103: "Which call pattern is tail recursive?", +}; + +function item(id: number): QuizItem { + return { + index: id - 101, + question: { + id, + question: STEMS[id], + difficulty: "medium", + options: [ + { label: "A", text: "It returns immediately" }, + { label: "B", text: "It recurses forever and overflows the stack" }, + { label: "C", text: "It is optimised away" }, + { label: "D", text: "Nothing at all" }, + ], + }, + selectedIndex: 0, + verdict: null, + flagged: false, + }; +} + +const RESULT: SubmitResult = { + score: 2, + total: 3, + mastery_before: 0.29, + mastery_after: 0.46, + results: [ + { question_id: "101", selected: "B", correct: true, correct_answer: "B", explanation: "Yes." }, + { + question_id: "102", + selected: "A", + correct: false, + correct_answer: "B", + explanation: "Without a base case the recursion never stops.", + }, + { question_id: "103", selected: "C", correct: true, correct_answer: "C", explanation: "Right." }, + ], +}; + +function session(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + source: { kind: "tree", conceptId: "recursion" }, + config: { count: 3, difficulty: "medium", feedback: "at-end" }, + conceptId: "recursion", + courseId: "cs101", + attemptId: "attempt-1", + items: [item(101), item(102), item(103)], + cursor: 2, + queueIndex: 0, + phase: "results", + error: null, + result: RESULT, + xp: { before: 300, after: 330, streak: 12 }, + deliveredShort: false, + ...over, + }; +} + +function actions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function renderResults( + over: Partial = {}, + opts: { + prefersReducedMotion?: boolean; + acts?: QuizActions; + nextConcept?: { id: string; name: string } | null; + } = {}, +) { + const acts = opts.acts ?? actions(); + const view = render( + , + ); + return { ...view, acts }; +} + +/** The growth circle's drawn radius and its scale ratio — 1 once grown. */ +function growthMark(container: HTMLElement) { + const body = container.querySelector(".concept-node__body--growth")!; + return { + r: Number(body.getAttribute("r")), + grow: Number(body.getAttribute("style")?.match(/--concept-grow:\s*([\d.]+)/)?.[1]), + }; +} + +const PERFECT: Partial = { + result: { + ...RESULT, + score: 3, + results: RESULT.results.map(r => ({ ...r, correct: true, selected: r.correct_answer })), + }, +}; + +describe("QuizResults", () => { + it("shows the score, the mastery delta and the XP line", () => { + renderResults(); + expect(screen.getByTestId("quiz-results-score")).toHaveTextContent("2 of 3 correct"); + // tierBefore is the wire tier off the concept; tierAfter is the ONE value + // derived from a score (R-12). + expect(screen.getByTestId("quiz-results-mastery")).toHaveTextContent( + "29% → 46% · struggling → learning", + ); + expect(screen.getByTestId("quiz-results-xp")).toHaveTextContent("+30 XP · 12-day streak"); + }); + + it("omits the XP line entirely when the gamification read failed (R-9)", () => { + renderResults({ xp: null }); + expect(screen.queryByTestId("quiz-results-xp")).toBeNull(); + expect(screen.getByTestId("quiz-results-score")).toBeInTheDocument(); + }); + + it("names the growth in the canvas's accessible label", () => { + renderResults(); + expect(screen.getByTestId("quiz-results-graph")).toHaveAttribute( + "aria-label", + "Recursion node grew from 29% to 46% mastery", + ); + }); + + it("says the node moved, not grew, when mastery went down", () => { + renderResults({ result: { ...RESULT, mastery_after: 0.2 } }); + expect(screen.getByTestId("quiz-results-graph")).toHaveAttribute( + "aria-label", + "Recursion node moved from 29% to 20% mastery", + ); + }); + + it("lists only the wrong answers, joined to their stems", () => { + renderResults(); + const list = screen.getByTestId("quiz-missed-list"); + expect(list).toHaveTextContent("One to look at"); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent(STEMS[102]); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent( + "You chose A · the answer is B", + ); + expect(screen.queryByTestId("quiz-missed-101")).toBeNull(); + expect(screen.queryByTestId("quiz-missed-103")).toBeNull(); + }); + + it("joins the string question_id off the wire to the numeric one on the item", () => { + // The wire sends `str(q["id"])` while `WireQuestion.id` is a number — a + // strict === would match nothing and render a stemless row. + const missed = buildMissedItems(session()); + expect(missed).toHaveLength(1); + expect(missed[0]).toMatchObject({ + questionId: "102", + stem: STEMS[102], + chosenLabel: "A", + chosenText: "It returns immediately", + correctLabel: "B", + correctText: "It recurses forever and overflows the stack", + }); + }); + + it("toggles the explanation disclosure and reveals the text", () => { + renderResults(); + const toggle = screen.getByTestId("quiz-missed-explain-102"); + // The panel is always in the DOM (so `aria-controls` resolves) and hidden + // until asked for, so visibility — not presence — is the assertion. + const panel = document.getElementById(toggle.getAttribute("aria-controls")!)!; + expect(panel).toHaveTextContent("Without a base case the recursion never stops."); + + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(panel).not.toBeVisible(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(panel).toBeVisible(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(panel).not.toBeVisible(); + }); + + it("names the missed region from its eyebrow", () => { + renderResults(); + const list = screen.getByTestId("quiz-missed-list"); + const heading = document.getElementById(list.getAttribute("aria-labelledby")!); + expect(heading).toHaveTextContent("One to look at"); + }); + + it("opens the AskPanel seeded from the row that asked", () => { + renderResults(); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + + fireEvent.click(screen.getByTestId("quiz-missed-ask-102")); + const panel = screen.getByTestId("quiz-ask-panel"); + expect(panel).toHaveAttribute("data-concept", "Recursion"); + expect(JSON.parse(panel.textContent ?? "{}")).toEqual({ + stem: STEMS[102], + chosenLabel: "A", + chosenText: "It returns immediately", + correctLabel: "B", + correctText: "It recurses forever and overflows the stack", + explanation: "Without a base case the recursion never stops.", + }); + }); + + it("replaces the review section with the perfect-run line", () => { + renderResults(PERFECT); + expect(screen.getByTestId("quiz-results-perfect")).toHaveTextContent( + "Nothing to review — every answer was right. Recursion keeps growing on your tree.", + ); + expect(screen.queryByTestId("quiz-missed-list")).toBeNull(); + expect(screen.getByTestId("quiz-again")).toBeInTheDocument(); + expect(screen.queryByTestId("quiz-practise-missed")).toBeNull(); + }); + + it("restarts the same concept on the session's own config", () => { + const { acts } = renderResults(PERFECT); + fireEvent.click(screen.getByTestId("quiz-again")); + // No config override: `useQuizSession.start` defaults it to the live + // session's config, so omitting it IS "the same quiz again" — passing + // prefs here would silently change the length or difficulty. + expect(acts.start).toHaveBeenCalledTimes(1); + expect(acts.start).toHaveBeenCalledWith({ + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + conceptId: "recursion", + courseId: "cs101", + }); + }); + + it("offers the next concept while the queue has more, and calls nextInQueue", () => { + const { acts } = renderResults({ + scope: { kind: "course", courseId: "cs101", queue: ["recursion", "base-cases"] }, + queueIndex: 0, + }); + expect(screen.queryByTestId("quiz-practise-missed")).toBeNull(); + fireEvent.click(screen.getByTestId("quiz-next-concept")); + expect(acts.nextInQueue).toHaveBeenCalledTimes(1); + }); + + it("names the next concept when the graph could resolve it", () => { + renderResults( + { + scope: { kind: "course", courseId: "cs101", queue: ["recursion", "base-cases"] }, + queueIndex: 0, + }, + { nextConcept: { id: "base-cases", name: "Base cases" } }, + ); + expect(screen.getByTestId("quiz-next-concept")).toHaveTextContent("Next: Base cases →"); + }); + + it("still offers the queue exit unnamed when the next id can't be named", () => { + // `nextConcept` is a LABEL: a next id outside the scoped graph must not + // hide the exit, and must not get an invented name. + renderResults( + { + scope: { kind: "due", queue: ["recursion", "off-graph"] }, + queueIndex: 0, + }, + { nextConcept: null }, + ); + expect(screen.getByTestId("quiz-next-concept")).toHaveTextContent("Next concept →"); + }); + + it("falls back to practising the missed questions, and calls practiseMissed", () => { + const { acts } = renderResults(); + const button = screen.getByTestId("quiz-practise-missed"); + expect(button).toHaveTextContent("Practise the one you missed"); + fireEvent.click(button); + expect(acts.practiseMissed).toHaveBeenCalledTimes(1); + }); + + it("says 'ones' — uncounted — when more than one was missed", () => { + // The prototype's `practiseLabel`: never a count. The count is already the + // eyebrow above the list. + renderResults({ + result: { + ...RESULT, + score: 1, + results: RESULT.results.map((r, i) => (i === 0 ? { ...r, correct: false } : r)), + }, + }); + expect(screen.getByTestId("quiz-missed-list")).toHaveTextContent("2 to look at"); + expect(screen.getByTestId("quiz-practise-missed")).toHaveTextContent( + "Practise the ones you missed", + ); + }); + + it("says so when an item was submitted unanswered", () => { + // `selected` is "" on the wire for a skipped item; "You chose · …" would + // read as a rendering bug. + const missed = buildMissedItems( + session({ + result: { + ...RESULT, + results: RESULT.results.map((r, i) => (i === 1 ? { ...r, selected: "" } : r)), + }, + }), + ); + expect(missed[0]).toMatchObject({ chosenLabel: "", chosenText: "" }); + + renderResults({ + result: { + ...RESULT, + results: RESULT.results.map((r, i) => (i === 1 ? { ...r, selected: "" } : r)), + }, + }); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent("No answer · the answer is B"); + }); + + it("labels the secondary exit from the source and calls exit()", () => { + const { acts } = renderResults({ source: { kind: "notes", noteId: "n1" } }); + const back = screen.getByTestId("quiz-back-to-source"); + expect(back).toHaveTextContent("Back to your note"); + fireEvent.click(back); + expect(acts.exit).toHaveBeenCalledWith(); + }); + + it("sends Done to quiz home", () => { + const { acts } = renderResults(); + fireEvent.click(screen.getByTestId("quiz-done")); + expect(acts.exit).toHaveBeenCalledWith("/quiz"); + }); + + it("labels a review attempt as focused on what was missed (R-5)", () => { + renderResults({ + intent: "review", + scope: { kind: "missed", conceptId: "recursion", missedCount: 1 }, + }); + expect(screen.getByText("Focused on what you missed")).toBeInTheDocument(); + }); + + it("renders the grown node at its end state immediately under reduced motion", () => { + const { container } = renderResults({}, { prefersReducedMotion: true }); + // The end state, first paint: the after-radius, at full scale — no + // transition to run. + const mark = growthMark(container); + expect(mark.r).toBeCloseTo(markRadius(0.46, false, 2.5), 5); + expect(mark.grow).toBe(1); + }); + + it("grows once and does not replay when the screen re-renders", async () => { + // With motion on, the mark starts at the BEFORE ratio and grows to 1. The + // headline behaviour of this screen is that it then stays there: a + // disclosure toggle (or opening the AskPanel) must not restart it. + const { container } = renderResults({}, { prefersReducedMotion: false }); + expect(growthMark(container).grow).toBeCloseTo( + markRadius(0.29, false, 2.5) / markRadius(0.46, false, 2.5), + 3, + ); + await waitFor(() => expect(growthMark(container).grow).toBe(1)); + + fireEvent.click(screen.getByTestId("quiz-missed-explain-102")); + expect(screen.getByTestId("quiz-missed-explain-102")).toHaveAttribute("aria-expanded", "true"); + + const after = growthMark(container); + expect(after.grow).toBe(1); + expect(after.r).toBeCloseTo(markRadius(0.46, false, 2.5), 5); + }); +}); diff --git a/frontend/src/components/quiz/results/QuizResults.tsx b/frontend/src/components/quiz/results/QuizResults.tsx new file mode 100644 index 00000000..ca1604e1 --- /dev/null +++ b/frontend/src/components/quiz/results/QuizResults.tsx @@ -0,0 +1,247 @@ +"use client"; + +/** + * The results screen (§5 B3). + * + * The point of the screen is the first thing on it: the concept's node, grown + * from the mastery it had to the mastery it has, inside a still fragment of the + * tree it lives on. Everything below is the receipt — the score, the XP line + * when we know it, the questions worth another look, and the three ways out. + * + * Two things it deliberately does NOT do: + * - It never recomputes a tier from a score except for the ONE value that has + * no tier on the wire, `mastery_after` (R-12). The "before" tier is the + * server's `mastery_tier`, read off the concept. + * - It never dispatches `sapling:graph-changed`. That belongs to + * `useQuizSession`, which knows a submit actually landed; a component + * firing it on render would repeat it on every re-render of one result + * (§4 amendment). + */ + +import React from "react"; +import { Button } from "@/components/ui"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import { tierFor } from "@/lib/graph/nodeStyle"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { useUser } from "@/context/UserContext"; +import { queueOf } from "@/lib/quiz/machine"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizSession } from "@/lib/quiz/types"; +import { sourceLabel } from "@/lib/quiz/exits"; +import { AskPanel } from "../question/AskPanel"; +import type { QuizConceptSummary } from "../question/QuizQuestion"; +import { MissedList, buildMissedItems, type MissedItem } from "./MissedList"; +import "./results.css"; + +/** The results canvas, §3's third `ConceptNeighbourhood` preset. */ +const CANVAS = { width: 640, height: 212, scale: 2.5 } as const; + +export interface QuizResultsProps { + session: QuizSession; + actions: QuizActions; + concept: QuizConceptSummary; + neighbourhood: { siblings: NeighbourNode[] }; + prefersReducedMotion: boolean; + /** + * The next concept in the scope queue, named — for the "Next: {concept}" + * primary exit. `QuizScreen` resolves it from the loaded graph + * (`proposals.nextConceptInQueue`), which is the only place that has one. + * + * `null` means "no name available", which covers a finished queue AND a next + * id that isn't in the scoped graph. It is a LABEL only: whether that exit + * renders at all stays `queueOf(session.scope).length > session.queueIndex + 1`. + */ + nextConcept?: { id: string; name: string } | null; +} + +const pct = (value: number) => Math.round(value * 100); + +export function QuizResults({ + session, + actions, + concept, + neighbourhood, + prefersReducedMotion, + nextConcept, +}: QuizResultsProps) { + // AskPanel needs the viewer's id and the seam (`QuizResultsProps`) is fixed, + // so it comes off the same context `QuizScreen` reads. The context has a + // default value, so this is safe outside a provider (an empty id, which the + // panel treats as "not signed in") rather than a throw. + const { userId } = useUser(); + const [asking, setAsking] = React.useState(null); + // The row that opened the panel, so closing it lands back on that button + // rather than on the document (AskPanel's `returnFocusTo`). + const askTrigger = React.useRef(null); + + const result = session.result; + // `results` is the only phase that renders this screen and the reducer sets + // `result` on the way in, so this is a guard, not a state. + if (!result) return null; + + const before = result.mastery_before; + const after = result.mastery_after; + const tierBefore = concept.tier; + const tierAfter = tierFor(after); + // "grew" is the whole promise of the screen; a mastery that went down still + // gets an honest verb rather than a cheerful one. + const verb = after >= before ? "grew" : "moved"; + const growthLabel = `${concept.name} node ${verb} from ${pct(before)}% to ${pct(after)}% mastery`; + + const missed = buildMissedItems(session); + const isPerfect = missed.length === 0; + + const queue = queueOf(session.scope); + const hasNext = session.queueIndex + 1 < queue.length; + + const xpDelta = session.xp ? session.xp.after - session.xp.before : null; + + // The course accent, already resolved by `QuizScreen`. An unresolved one + // falls through to the app accent inside the mark rather than to a literal. + const courseColor = concept.color || "var(--quiz-accent, var(--accent))"; + + return ( +
+ {/* Keyed on the attempt so the node grows exactly once per result: a + re-render (opening a disclosure, the AskPanel) must not replay it, + and the NEXT attempt's result must. */} +
+ +
+ + {/* R-5: the repetition guard means a "practise what you missed" attempt + asks different questions. Say so rather than let it read as a bug. */} + {session.scope.kind === "missed" && ( +
Focused on what you missed
+ )} + +

{concept.name}

+

+ {pct(before)}% → {pct(after)}% · {tierBefore} → {tierAfter} +

+ +
+ + {result.score} of {result.total} correct + + {/* R-9: submit returns no deltas, so the XP line is a separate read. + If either half of it failed it is omitted — never invented. */} + {xpDelta !== null && session.xp && ( + + +{xpDelta} XP · {session.xp.streak}-day streak + + )} +
+ + {isPerfect ? ( +

+ Nothing to review — every answer was right. {concept.name} keeps growing on your tree. +

+ ) : ( + { + askTrigger.current = trigger; + setAsking(item); + }} + /> + )} + +
+ +
+ {hasNext ? ( + + ) : missed.length > 0 ? ( + + ) : ( + + )} + + + + + + +
+ + {/* One panel for the whole list — the same sheet the question screen + opens (R-6), seeded from whichever row asked. */} + {asking && ( + setAsking(null)} + userId={userId} + conceptName={concept.name} + courseId={session.courseId} + courseLabel={concept.courseCode || undefined} + returnFocusTo={askTrigger} + seed={{ + stem: asking.stem, + chosenLabel: asking.chosenLabel, + chosenText: asking.chosenText, + correctLabel: asking.correctLabel, + correctText: asking.correctText, + explanation: asking.explanation, + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/quiz/results/results.css b/frontend/src/components/quiz/results/results.css new file mode 100644 index 00000000..5396b7a3 --- /dev/null +++ b/frontend/src/components/quiz/results/results.css @@ -0,0 +1,179 @@ +/* The results screen (§5 B3). + * + * Class names only (R-1): the ONE inline style anywhere under + * `components/quiz/**` is `QuizScreen`'s `--quiz-accent` binding, and every + * colour below reads `var(--quiz-accent, var(--accent))` so an unset accent + * degrades to the app's own rather than to a literal. + * + * The design's own geometry — the vertical rhythm of the column, the missed + * item's accent bar and inset, the stem's 17px/1.45 — is declared ONCE here as + * tokens on `.quiz-results` and referenced below, so no rule carries a bare px + * measurement. Everything else is a `--fs-*` / `--pad-*` / colour token. + */ + +.quiz-results { + /* Vertical rhythm, read off the prototype's RESULTS section. */ + --quiz-results-gap-name: 18px; + --quiz-results-gap-delta: 8px; + --quiz-results-gap-rule: 26px; + --quiz-results-rule-pad-y: 13px; + --quiz-results-rule-pad-x: 4px; + --quiz-results-gap-block: 26px; + --quiz-results-gap-perfect: 34px; + --quiz-results-gap-divider: 30px; + --quiz-results-gap-exits: 18px; + --quiz-results-exits-gap: 10px; + + /* The missed item: a 2px accent bar, its text inset past it. */ + --quiz-results-bar: 2px; + --quiz-results-item-inset: 16px; + --quiz-results-item-pad-y: 2px; + --quiz-results-item-gap: 12px; + --quiz-results-line-gap: 6px; + + /* The missed stem sits between --fs-xl (18) and --fs-lg (16); the design's + 17px/1.45 is its own step, so it is a token rather than a rounded token. */ + --quiz-results-stem: 17px; + --quiz-results-stem-lh: 1.45; + + /* The perfect-run sentence is set narrow so it reads as a sentence, centred + under a 640px column. */ + --quiz-results-perfect-w: 400px; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +/* The neighbourhood canvas is 640px wide by preset — the same width as the + column — so it only ever needs to shrink, never to be scrolled. */ +.quiz-results__graph { + max-width: 100%; +} + +.quiz-results__graph > svg { + max-width: 100%; + height: auto; +} + +/* R-5: a review attempt says so, above the name it is about. */ +.quiz-results__eyebrow { + margin-top: var(--quiz-results-gap-name); +} + +.quiz-results__name { + font-size: var(--fs-3xl); + color: var(--text); + margin: var(--quiz-results-gap-name) 0 0; + text-align: center; +} + +.quiz-results__eyebrow + .quiz-results__name { + margin-top: var(--quiz-results-gap-delta); +} + +.quiz-results__delta { + font-size: var(--fs-md); + color: var(--text-muted); + margin: var(--quiz-results-gap-delta) 0 0; +} + +/* The score rule: two plain text spans between two hairlines. Nothing is + signalled by colour alone — this is a sentence, not a badge. */ +.quiz-results__rule { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--pad-md); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: var(--quiz-results-rule-pad-y) var(--quiz-results-rule-pad-x); + margin-top: var(--quiz-results-gap-rule); +} + +.quiz-results__score { + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-results__xp { + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-results__perfect { + font-size: var(--fs-md); + color: var(--text-muted); + margin-top: var(--quiz-results-gap-perfect); + max-width: var(--quiz-results-perfect-w); + text-align: center; + text-wrap: pretty; +} + +.quiz-results__divider { + width: 100%; + height: 1px; + background: var(--border); + border: 0; + margin: var(--quiz-results-gap-divider) 0 0; +} + +.quiz-results__exits { + width: 100%; + display: flex; + align-items: center; + gap: var(--quiz-results-exits-gap); + padding-top: var(--quiz-results-gap-exits); +} + +/* Pushes "Done" to the far right without giving it a different alignment + context from the two buttons on the left. */ +.quiz-results__exits-spacer { + flex: 1; +} + +/* ── The missed list ──────────────────────────────────────────────── */ + +.quiz-missed { + width: 100%; + margin-top: var(--quiz-results-gap-block); +} + +.quiz-missed__item { + border-left: var(--quiz-results-bar) solid var(--quiz-accent, var(--accent)); + padding: var(--quiz-results-item-pad-y) 0 var(--quiz-results-item-pad-y) + var(--quiz-results-item-inset); + margin-top: var(--quiz-results-item-gap); +} + +.quiz-missed__stem { + font-size: var(--quiz-results-stem); + line-height: var(--quiz-results-stem-lh); + color: var(--text); + margin: 0; + text-wrap: pretty; +} + +.quiz-missed__line { + font-size: var(--fs-md); + color: var(--text-muted); + margin: var(--quiz-results-line-gap) 0 0; +} + +.quiz-missed__actions { + display: flex; + align-items: center; + gap: var(--pad-md); + margin-top: var(--quiz-results-item-gap); +} + +/* The one intentional expand on this screen: nothing is reserved for it, so + the list is compact until a student asks for the reasoning. */ +.quiz-missed__explanation { + font-size: var(--fs-md); + color: var(--text-dim); + margin: var(--quiz-results-line-gap) 0 0; + text-wrap: pretty; +} diff --git a/frontend/src/components/screens/Dashboard.quiz.test.tsx b/frontend/src/components/screens/Dashboard.quiz.test.tsx new file mode 100644 index 00000000..d6b64c4e --- /dev/null +++ b/frontend/src/components/screens/Dashboard.quiz.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +/** + * Quiz entry-point wiring on the dashboard (#537 C2, contract §6): + * - The "Try this next" suggest card resolves `?suggest=` to its node + * id (same case-insensitive match `suggestNode` already does) and opens + * the quiz on that concept, carrying `{kind:"dashboard", returnTo:"/dashboard"}`. + * - The Learn-next panel's quiz CTA (legacy topnav layout) becomes "Review + * what's due", showing the due count and scoping the quiz to `scope=due`; + * with nothing due it falls back to a plain "Quiz" button/home href. + * - The default (sidenav) layout has no "Learn next" panel of its own, so + * the same due CTA is also surfaced there (`dashboard-review-due`). + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import type { EnrolledCourse } from "@/lib/api"; + +const push = vi.fn(); +let searchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + useSearchParams: () => searchParams, +})); + +vi.mock("@/context/UserContext", () => ({ + useUser: () => ({ userId: "u1", userReady: true, userName: "Ada" }), +})); + +vi.mock("@/lib/useIsMobile", () => ({ useIsMobile: vi.fn(() => false) })); + +// "topnav" renders the legacy Learn-next panel with the quiz CTA; the +// third describe block flips this to "sidebar" to exercise the layout with +// no Learn-next panel at all. +const layoutState = vi.hoisted(() => ({ pref: "topnav" })); +vi.mock("@/lib/useLayoutPref", () => ({ useLayoutPref: () => [layoutState.pref, vi.fn()] })); + +vi.mock("../graph/KnowledgeGraph", () => ({ KnowledgeGraph: () => null })); +vi.mock("../ManageCoursesModal", () => ({ ManageCoursesModal: () => null })); +vi.mock("../Skeleton", () => ({ DashboardSkeleton: () => null })); +vi.mock("../MiniStat", () => ({ MiniStat: () => null })); +vi.mock("../Icon", () => ({ Icon: () => null })); + +vi.mock("@/lib/api", () => ({ + getGraph: vi.fn(), + getCourses: vi.fn(), + getUpcomingAssignments: vi.fn(), + getSessions: vi.fn(), + getRecommendations: vi.fn(), +})); + +import { Dashboard } from "./Dashboard"; +import { + getCourses, + getGraph, + getRecommendations, + getSessions, + getUpcomingAssignments, +} from "@/lib/api"; +import { useIsMobile } from "@/lib/useIsMobile"; + +function course(code: string): EnrolledCourse { + return { + enrollment_id: `e-${code}`, + course_id: `c-${code}`, + course_code: code, + course_name: code, + school: "BU", + department: "CS", + color: null, + nickname: null, + node_count: 0, + enrolled_at: "2025-08-25", + term: "Spring 2026", + }; +} + +// Raw (pre-apiToGraphNode) node shape, matching `getGraph`'s wire response. +function apiNode(overrides: Record = {}) { + return { + id: "node-recursion", + concept_name: "Recursion", + mastery_score: 0.3, + mastery_tier: "struggling", + times_studied: 2, + last_studied_at: "2026-08-01T00:00:00Z", + subject: "CS-101", + course_id: "c-CS-101", + is_subject_root: false, + ...overrides, + }; +} + +beforeEach(() => { + layoutState.pref = "topnav"; + searchParams = new URLSearchParams(); + window.localStorage.clear(); + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + vi.mocked(useIsMobile).mockReturnValue(false); + vi.mocked(getUpcomingAssignments).mockResolvedValue({ assignments: [] }); + vi.mocked(getSessions).mockResolvedValue({ sessions: [] }); + vi.mocked(getRecommendations).mockResolvedValue({ recommendations: [] }); + vi.mocked(getCourses).mockResolvedValue({ courses: [course("CS-101")] }); + vi.mocked(getGraph).mockResolvedValue({ nodes: [apiNode()], edges: [], stats: {} }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("Dashboard — suggest card entry (?suggest=)", () => { + it("resolves the concept name to its node id and opens the quiz on that concept", async () => { + searchParams = new URLSearchParams("suggest=recursion"); // case-insensitive match + + render(); + + const startQuiz = await screen.findByText("Start quiz"); + fireEvent.click(startQuiz); + + expect(push).toHaveBeenCalledWith( + "/quiz?concept=node-recursion&from=dashboard&return=%2Fdashboard", + ); + }); +}); + +describe("Dashboard — review-what's-due CTA (legacy topnav layout)", () => { + it("shows the due count and scopes the quiz to scope=due", async () => { + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent).toContain("Review what's due"); + expect(cta.textContent).toContain("1"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); + + it("falls back to a plain 'Quiz' button/home href when nothing is due", async () => { + vi.mocked(getGraph).mockResolvedValue({ + nodes: [apiNode({ mastery_tier: "mastered" })], + edges: [], + stats: {}, + }); + + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent?.trim()).toBe("Quiz"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?from=dashboard&return=%2Fdashboard"); + }); +}); + +describe("Dashboard — review-what's-due CTA (default sidenav layout)", () => { + it("is also surfaced with no Learn-next panel present", async () => { + layoutState.pref = "sidebar"; + + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent).toContain("Review what's due"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); + + it("is reachable at mobile width too, as the single mounted instance", async () => { + layoutState.pref = "sidebar"; + vi.mocked(useIsMobile).mockReturnValue(true); + + render(); + + // Exactly one CTA in the DOM — the mobile row, not the (hidden) + // desktop quick-action row — so the shared testid never collides. + const ctas = await screen.findAllByTestId("dashboard-review-due"); + expect(ctas).toHaveLength(1); + expect(ctas[0].textContent).toContain("Review what's due"); + + fireEvent.click(ctas[0]); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); +}); diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index eda4d920..43ad74c9 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -25,6 +25,8 @@ import { import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; import { apiToGraphNode, learnHrefForNode, type GraphNode, type GraphEdge } from "@/lib/data"; import { IS_TEST_MODE, now } from "@/lib/testMode"; +import { buildQuizHref } from "@/lib/quiz/source"; +import { dueSet } from "@/lib/quiz/proposals"; const QUOTES = [ "Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci", @@ -210,6 +212,12 @@ export function Dashboard() { const [sessions, setSessions] = React.useState([]); const [assignments, setAssignments] = React.useState([]); const [recommendations, setRecommendations] = React.useState<{ concept_name: string; reason?: string }[]>([]); + // Mirrors the same tier-membership filter `/quiz`'s useQuizHome runs (R-7) — + // computed over the RAW (pre-apiToGraphNode) nodes because `dueSet` is typed + // against the wire `GraphNode` (concept_name/times_studied included), while + // this screen's own `nodes` state has already been through `apiToGraphNode` + // and dropped those fields. + const [due, setDue] = React.useState>({ conceptIds: [], count: 0, courseCount: 0 }); const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(null); const [activeDays, setActiveDays] = React.useState>(new Set()); @@ -277,9 +285,11 @@ export function Dashboard() { ]); const cs = coursesRes.courses || []; setCourses(cs); - const gNodes: GraphNode[] = (graphRes.nodes || []).map((n: ApiNode) => apiToGraphNode(n, cs)); + const rawNodes: ApiNode[] = graphRes.nodes || []; + const gNodes: GraphNode[] = rawNodes.map((n) => apiToGraphNode(n, cs)); setNodes(gNodes); setEdges((graphRes.edges || []).map(apiToGraphEdge)); + setDue(dueSet(rawNodes)); setStats({ streak: graphRes.stats?.streak ?? 0, mastered: graphRes.stats?.mastered ?? 0, @@ -492,10 +502,28 @@ export function Dashboard() { return c?.course_code || c?.course_name || null; }; + // The Learn-next panel's quiz CTA, and its sidenav-layout counterpart: + // "review everything due" when there's a due set, else a plain quiz home. + const reviewDueHref = due.count > 0 + ? buildQuizHref({ scope: "due" }, { kind: "dashboard", returnTo: "/dashboard" }) + : buildQuizHref({}, { kind: "dashboard", returnTo: "/dashboard" }); + const reviewDueLabel = due.count > 0 ? `Review what's due · ${due.count}` : "Quiz"; + const rightPanel = (
{!isMobile && (
+ {/* Sidenav layout has no "Learn next" panel of its own (that CTA + only lives in the legacy topnav's right column below), so the + same review-what's-due entry point is surfaced here instead. */} + - + )} +
- Start learning - + + +
) : null; @@ -714,7 +761,15 @@ export function Dashboard() { Try this next: {suggestNode.name} {suggestNode.subject && · {suggestNode.subject}}
- @@ -894,8 +949,13 @@ export function Dashboard() { ))}
- -
+ + Upload syllabus + + } + /> ); } diff --git a/frontend/src/components/screens/Quiz.tsx b/frontend/src/components/screens/Quiz.tsx deleted file mode 100644 index 037602e5..00000000 --- a/frontend/src/components/screens/Quiz.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import React, { Suspense, useEffect, useMemo, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { TopBar } from "../TopBar"; -import { FullHeightScreen } from "../FullHeightScreen"; -import { AIDisclaimerChip } from "../chat/AIDisclaimerChip"; -import { DisclaimerModal } from "../DisclaimerModal"; -import { QuizPanel } from "../QuizPanel"; -import { useUser } from "@/context/UserContext"; -import { useActiveSemester, courseInTerm } from "@/lib/useActiveSemester"; -import { getCourses, getGraph, type EnrolledCourse } from "@/lib/api"; -import type { GraphNode as ApiNode } from "@/lib/types"; - -type Concept = { id: string; name: string; course_id: string | null; course_code: string | null; term: string | null; terms: string[] | null }; - -export function Quiz() { - return ( - Loading…}> - - - ); -} - -function QuizInner() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { userId, userReady } = useUser(); - const [activeSemester, , semesterHydrated] = useActiveSemester(); - - const [concepts, setConcepts] = useState([]); - const [courses, setCourses] = useState([]); - const [loaded, setLoaded] = useState(false); - - // Waits for the active-semester read from localStorage before the first - // fetch, so returning users fetch scoped once instead of unscoped-then-scoped; - // re-runs when the active semester changes. - useEffect(() => { - if (!userReady || !userId || !semesterHydrated) return; - let cancelled = false; - (async () => { - try { - const [cRes, gRes] = await Promise.all([ - getCourses(userId).catch(() => ({ courses: [] as EnrolledCourse[] })), - getGraph(userId, activeSemester || undefined).catch(() => ({ nodes: [] as ApiNode[], edges: [], stats: {} })), - ]); - if (cancelled) return; - setCourses(cRes.courses ?? []); - const courseById = new Map((cRes.courses ?? []).map(c => [c.course_id, c])); - const nodes = (gRes.nodes ?? []) as ApiNode[]; - setConcepts( - nodes - .filter(n => !n.is_subject_root) - .map(n => ({ - id: n.id, - name: n.concept_name || "Concept", - course_id: n.course_id ?? null, - course_code: n.course_id ? (courseById.get(n.course_id)?.course_code ?? null) : null, - term: n.course_id ? (courseById.get(n.course_id)?.term ?? null) : null, - terms: n.course_id ? (courseById.get(n.course_id)?.terms ?? null) : null, - })), - ); - } catch (err) { - console.error("quiz bootstrap failed", err); - } finally { - if (!cancelled) setLoaded(true); - } - })(); - return () => { cancelled = true; }; - }, [userReady, userId, semesterHydrated, activeSemester]); - - // The graph fetch above is already scoped to the active semester, so this - // is defensive rather than load-bearing — matches the Tree/Learn pickers. - const scopedConcepts = useMemo( - () => (activeSemester ? concepts.filter(c => courseInTerm(c, activeSemester)) : concepts), - [concepts, activeSemester], - ); - - // Scope the course picker to the active semester too, so it stays consistent - // with the concept list the quiz draws from. - const scopedCourses = useMemo( - () => (activeSemester ? courses.filter(c => courseInTerm(c, activeSemester)) : courses), - [courses, activeSemester], - ); - - const topicParam = searchParams.get("topic"); - const conceptParam = searchParams.get("concept"); - - const initialConceptId = useMemo(() => { - if (conceptParam) return conceptParam; - if (!topicParam) return null; - const t = topicParam.trim().toLowerCase(); - return scopedConcepts.find(c => c.name.toLowerCase() === t)?.id ?? null; - }, [conceptParam, topicParam, scopedConcepts]); - - return ( - - - } - /> -
- {!userReady ? null : !userId ? ( -
Sign in to take a quiz.
- ) : loaded ? ( - router.push("/learn")} - /> - ) : null} -
-
- ); -} diff --git a/frontend/src/components/screens/Tree.quiz.test.tsx b/frontend/src/components/screens/Tree.quiz.test.tsx new file mode 100644 index 00000000..4c77a3a1 --- /dev/null +++ b/frontend/src/components/screens/Tree.quiz.test.tsx @@ -0,0 +1,329 @@ +// @vitest-environment jsdom +/** + * The tree's half of the quiz entry/exit thread (#537 §6, C1): + * + * 1. "Quick quiz" on a CONCEPT links by node id, tagged `from=tree`, and + * carries a `return` that comes back to this very node's open panel. + * Before #537 it linked by concept NAME — unique only within a course — + * and the quiz exited to a hardcoded `/learn` no matter how it was + * entered. + * 2. "Quick quiz" on a SUBJECT ROOT links to the abstract course. The old + * handler sent a bare `/quiz` because the quiz screen had no course entry + * at all; it does now. + * 3. `/tree?node=` opens that node's panel, and an unknown id is ignored + * in silence rather than erroring. + * 4. "Recent quizzes" shows at most five COMPLETED attempts for the SELECTED + * node, newest first — the attempts endpoint is user-scoped and unfiltered + * (gaps G2/G3), so every one of those filters is this component's job. + * + * The graph renderer is stubbed to a node list of buttons: this is about the + * panel and the links, and the real renderer needs a canvas. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { AttemptSummary } from "@/lib/quiz/types"; +import type { GraphNode } from "@/lib/data"; + +const push = vi.fn(); +const params = vi.hoisted(() => ({ value: new URLSearchParams() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + useSearchParams: () => params.value, +})); + +vi.mock("@/context/UserContext", () => ({ + useUser: () => ({ userId: "u1", userReady: true, userName: "Ada" }), +})); + +vi.mock("@/lib/useIsMobile", () => ({ useIsMobile: () => false })); + +// A clickable stand-in for the real graph: one button per node, so a test can +// "tap" a node exactly as the canvas would. +vi.mock("../graph/KnowledgeGraph", () => ({ + KnowledgeGraph: ({ + nodes, + onNodeClick, + }: { + nodes: GraphNode[]; + onNodeClick?: (n: GraphNode) => void; + }) => ( +
+ {nodes.map((n) => ( + + ))} +
+ ), +})); + +vi.mock("../Skeleton", () => ({ GraphPanelSkeleton: () => null })); +vi.mock("../Icon", () => ({ Icon: () => null })); + +vi.mock("@/lib/api", () => ({ + getGraph: vi.fn(), + getCourses: vi.fn(), + getSessions: vi.fn(), + addGraphNode: vi.fn(), + deleteGraphNode: vi.fn(), +})); + +vi.mock("@/lib/quiz/api", () => ({ listAttempts: vi.fn() })); + +import { Tree } from "./Tree"; +import { ToastProvider } from "../ToastProvider"; +import { getCourses, getGraph, getSessions } from "@/lib/api"; +import { listAttempts } from "@/lib/quiz/api"; + +const mockedGraph = vi.mocked(getGraph); +const mockedCourses = vi.mocked(getCourses); +const mockedSessions = vi.mocked(getSessions); +const mockedAttempts = vi.mocked(listAttempts); + +const COURSE_ID = "course-cs101"; +const ROOT_ID = `subject_root__${COURSE_ID}`; +const RECURSION = "node-recursion"; +const POINTERS = "node-pointers"; + +/** The wire shape `getGraph` returns, not the mapped `GraphNode`. */ +function apiNode(over: Partial> & { id: string; concept_name: string }) { + return { + subject: "Intro to CS", + mastery_tier: "learning", + mastery_score: 0.4, + course_id: COURSE_ID, + course_color: "#123456", + times_studied: 2, + last_studied_at: null, + is_subject_root: false, + ...over, + }; +} + +function attempt(over: Partial & { quiz_id: string }): AttemptSummary { + return { + status: "completed", + concept_node_id: RECURSION, + concept_name: "Recursion", + course_id: COURSE_ID, + score: 3, + total: 3, + difficulty: "medium", + mastery_before: 0.4, + mastery_after: 0.44, + mastery_delta: 0.04, + created_at: "2026-08-20T10:00:00Z", + completed_at: "2026-08-20T10:05:00Z", + ...over, + }; +} + +function renderTree() { + return render( + + + , + ); +} + +/** Opens a node's detail panel through the graph, the way a student does. */ +async function selectNode(user: ReturnType, id: string) { + await user.click(await screen.findByTestId(`stub-node-${id}`)); +} + +beforeEach(() => { + vi.clearAllMocks(); + params.value = new URLSearchParams(); + window.localStorage.clear(); + // jsdom has no ResizeObserver; the graph slot measures itself with one. + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + mockedCourses.mockResolvedValue({ + courses: [ + { + enrollment_id: "e1", + course_id: COURSE_ID, + course_code: "CS101", + course_name: "Intro to CS", + color: "#123456", + }, + ], + } as Awaited>); + mockedGraph.mockResolvedValue({ + nodes: [ + apiNode({ id: RECURSION, concept_name: "Recursion" }), + apiNode({ id: POINTERS, concept_name: "Pointers" }), + apiNode({ + id: ROOT_ID, + concept_name: "CS101 - Intro to CS", + mastery_tier: "subject_root", + is_subject_root: true, + }), + ], + edges: [], + } as unknown as Awaited>); + mockedSessions.mockResolvedValue({ sessions: [] } as Awaited>); + mockedAttempts.mockResolvedValue({ total: 0, limit: 100, offset: 0, attempts: [] }); +}); + +afterEach(cleanup); + +describe("Quick quiz links (§6)", () => { + it("sends a concept by id, tagged from=tree, returning to its own node", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + await user.click(screen.getByRole("button", { name: /quick quiz/i })); + + expect(push).toHaveBeenCalledWith( + `/quiz?concept=${RECURSION}&from=tree&return=${encodeURIComponent(`/tree?node=${RECURSION}`)}`, + ); + }); + + it("sends a subject root as its abstract course, returning to the tree", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, ROOT_ID); + + await user.click(screen.getByRole("button", { name: /quick quiz/i })); + + expect(push).toHaveBeenCalledWith( + `/quiz?course=${COURSE_ID}&from=tree&return=${encodeURIComponent("/tree")}`, + ); + }); +}); + +describe("?node= focus", () => { + it("opens the named node's panel", async () => { + params.value = new URLSearchParams(`node=${POINTERS}`); + renderTree(); + + // The panel's own heading — proof the node is selected, not merely painted. + expect(await screen.findByRole("heading", { name: "Pointers" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Recursion" })).not.toBeInTheDocument(); + }); + + it("ignores an id that is not in the graph", async () => { + params.value = new URLSearchParams("node=node-deleted-last-week"); + renderTree(); + + await screen.findByTestId("stub-graph"); + await waitFor(() => expect(mockedGraph).toHaveBeenCalled()); + expect(screen.queryByTestId("tree-node-recent-quizzes")).not.toBeInTheDocument(); + }); +}); + +describe("Recent quizzes", () => { + it("shows the five newest completed attempts for the selected node only", async () => { + mockedAttempts.mockResolvedValue({ + total: 8, + limit: 100, + offset: 0, + attempts: [ + // Deliberately out of order on the wire, and the fifth-newest first: + // the ordering under test is this component's, not the endpoint's. + attempt({ quiz_id: "q-5th", completed_at: "2026-08-05T10:00:00Z" }), + attempt({ quiz_id: "q-new", completed_at: "2026-08-21T10:00:00Z" }), + attempt({ quiz_id: "q-2", completed_at: "2026-08-19T10:00:00Z" }), + attempt({ quiz_id: "q-3", completed_at: "2026-08-18T10:00:00Z" }), + attempt({ quiz_id: "q-4", completed_at: "2026-08-17T10:00:00Z" }), + // Sixth-newest — over the cap, so it never renders. + attempt({ quiz_id: "q-6th", completed_at: "2026-08-01T10:00:00Z" }), + // Filtered out: another concept, and an unfinished attempt. + attempt({ quiz_id: "q-other-node", concept_node_id: POINTERS }), + attempt({ quiz_id: "q-in-progress", status: "in_progress", completed_at: null }), + ], + }); + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + const block = await screen.findByTestId("tree-node-recent-quizzes"); + const rows = await within(block).findAllByTestId(/^tree-node-recent-quiz-/); + + expect(rows.map((r) => r.getAttribute("data-testid"))).toEqual([ + "tree-node-recent-quiz-q-new", + "tree-node-recent-quiz-q-2", + "tree-node-recent-quiz-q-3", + "tree-node-recent-quiz-q-4", + "tree-node-recent-quiz-q-5th", + ]); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-6th")).not.toBeInTheDocument(); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-other-node")).not.toBeInTheDocument(); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-in-progress")).not.toBeInTheDocument(); + }); + + it("renders score and a signed whole-percent mastery delta", async () => { + mockedAttempts.mockResolvedValue({ + total: 3, + limit: 100, + offset: 0, + attempts: [ + attempt({ quiz_id: "q-up", score: 3, total: 3, mastery_delta: 0.04, completed_at: "2026-08-21T10:00:00Z" }), + attempt({ quiz_id: "q-down", score: 1, total: 4, mastery_delta: -0.02, completed_at: "2026-08-20T10:00:00Z" }), + attempt({ quiz_id: "q-null", score: 2, total: 3, mastery_delta: null, completed_at: "2026-08-19T10:00:00Z" }), + ], + }); + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + expect(await screen.findByTestId("tree-node-recent-quiz-q-up")).toHaveTextContent( + "3/3 · +4% mastery", + ); + expect(screen.getByTestId("tree-node-recent-quiz-q-down")).toHaveTextContent( + "1/4 · −2% mastery", + ); + expect(screen.getByTestId("tree-node-recent-quiz-q-null")).toHaveTextContent( + "2/3 · — mastery", + ); + }); + + it("says so when the concept has no attempts", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + const block = await screen.findByTestId("tree-node-recent-quizzes"); + await waitFor(() => expect(block).toHaveTextContent("No quizzes yet")); + // The Quick quiz button above IS the call to action; the empty state must + // not grow a second one. + expect(within(block).queryByRole("button")).not.toBeInTheDocument(); + }); + + it("never offers history on a subject root", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, ROOT_ID); + + await screen.findByRole("heading", { name: "CS101 - Intro to CS" }); + expect(screen.queryByTestId("tree-node-recent-quizzes")).not.toBeInTheDocument(); + expect(mockedAttempts).not.toHaveBeenCalled(); + }); + + it("refetches when a quiz submit announces a graph change", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + await waitFor(() => expect(mockedAttempts).toHaveBeenCalledTimes(1)); + + mockedAttempts.mockResolvedValue({ + total: 1, + limit: 100, + offset: 0, + attempts: [attempt({ quiz_id: "q-just-finished" })], + }); + window.dispatchEvent(new CustomEvent("sapling:graph-changed", { detail: {} })); + + expect(await screen.findByTestId("tree-node-recent-quiz-q-just-finished")).toBeInTheDocument(); + expect(mockedAttempts).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index f972f32b..a9d7ca77 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -17,9 +17,37 @@ import { useToast } from "../ToastProvider"; import { useConfirm } from "@/lib/useConfirm"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; import { apiToGraphNode, learnHrefForNode, type GraphNode, type GraphEdge } from "@/lib/data"; +import { buildQuizHref } from "@/lib/quiz/source"; +import { listAttempts } from "@/lib/quiz/api"; +import { relativeStudied } from "@/lib/quiz/relativeTime"; +import type { AttemptSummary } from "@/lib/quiz/types"; type Tier = "all" | "mastered" | "learning" | "struggling" | "unexplored"; +/** One page of history is enough to find a single concept's attempts: the + * endpoint has no concept filter (gap G2), so the page is filtered client-side + * and 100 rows covers far more than the five we show. */ +const ATTEMPT_HISTORY_LIMIT = 100; +/** Rows in the node panel's "Recent quizzes" block. */ +const RECENT_QUIZ_ROWS = 5; + +/** `mastery_delta` is a 0–1 fraction (`routes/quiz.py:1780`, + * `mastery_after - mastery_before`); the panel speaks in whole percent, the + * same unit as the Mastery tile above it. `null` is "the attempt recorded no + * move", not zero — say so with an em dash rather than inventing a number. */ +function formatMasteryDelta(delta: number | null | undefined): string { + if (delta === null || delta === undefined || Number.isNaN(delta)) return "—"; + const pct = Math.round(delta * 100); + return `${pct < 0 ? "−" : "+"}${Math.abs(pct)}%`; +} + +/** Completed attempts sort by when they FINISHED; `created_at` is the fallback + * for a row whose `completed_at` never landed. */ +function attemptTime(a: AttemptSummary): number { + const t = new Date(a.completed_at || a.created_at).getTime(); + return Number.isNaN(t) ? 0 : t; +} + const TIER_META: Record, { label: string; color: string }> = { mastered: { label: "Mastered", color: "var(--state-mastery)" }, learning: { label: "Learning", color: "var(--state-progress)" }, @@ -34,6 +62,10 @@ export function Tree() { const [activeSemester, , semesterHydrated] = useActiveSemester(); const isMobile = useIsMobile(); const suggest = search.get("suggest"); + // `?node=` — the quiz's return path (#537 R-10). `?suggest=` is the older, + // fuzzier twin (a concept NAME) and is untouched by this; they are written by + // different callers and are not expected together. + const focusNodeId = search.get("node"); const [courseFilter, setCourseFilter] = React.useState("all"); const [tier, setTier] = React.useState("all"); @@ -50,6 +82,12 @@ export function Tree() { const [sessions, setSessions] = React.useState([]); const [loading, setLoading] = React.useState(true); + // Quiz history for the node panel's "Recent quizzes" block. `null` = not + // fetched yet (the block says "Loading…", never the lie "No quizzes yet"); + // the ref records which user the cached page belongs to. + const [attempts, setAttempts] = React.useState(null); + const attemptsFor = React.useRef(null); + // Manual add-concept composer (#330) — only offered when a single course // is selected: courseFilter is a FILTER whose "all" value gives no course // to attribute the new node to. @@ -91,6 +129,43 @@ export function Tree() { } }, [userId, activeSemester]); + const loadAttempts = React.useCallback(async () => { + if (!userId) return; + attemptsFor.current = userId; + try { + const page = await listAttempts(userId, { limit: ATTEMPT_HISTORY_LIMIT }); + setAttempts(page.attempts || []); + } catch (err) { + // History is decoration on a panel that already works; a failed read + // must never blank the panel. Keep whatever we hold, and settle `null` + // to `[]` so the block stops claiming it is still loading. + console.error("tree attempt history load failed", err); + setAttempts((prev) => prev ?? []); + } + }, [userId]); + + // Fetched LAZILY, on the first concept-node selection: a student who only + // pans the graph never pays for a history page. Once per user for the + // screen's lifetime — the panel opens and closes constantly. + React.useEffect(() => { + if (!userId || !selected || selected.is_subject_root) return; + if (attemptsFor.current === userId) return; + setAttempts(null); + loadAttempts(); + }, [userId, selected, loadAttempts]); + + // A quiz submit announces itself (`lib/quiz/useQuizSession.ts`), which is the + // one moment this page's history goes stale while it is on screen. Only + // refresh a page we already hold; the lazy path above still owns the first + // fetch. + React.useEffect(() => { + const onGraphChanged = () => { + if (attemptsFor.current) loadAttempts(); + }; + window.addEventListener("sapling:graph-changed", onGraphChanged); + return () => window.removeEventListener("sapling:graph-changed", onGraphChanged); + }, [loadAttempts]); + // Manual add-concept (#330): create-or-merge server-side (the backend // dedups case/whitespace-insensitively), then reload so the canonical row // and its anchor edge render from DB truth. The selected node anchors the @@ -130,6 +205,26 @@ export function Tree() { if (target) setSelected(target); }, [suggest, nodes]); + // `?node=` focus (#537 §6): select that node exactly as a tap would, so + // the detail panel opens on desktop and the bottom sheet slides up on mobile + // off the same `selected` state. That IS the whole focus gesture — neither + // graph renderer exposes a camera, only `highlightId` (a paint), so there is + // nothing here to centre on. An unknown id is ignored in silence: a student + // returning from a quiz on a concept they have since deleted should land on + // an ordinary tree, not an error. + // Applied once per param VALUE, unlike `?suggest=`: a graph reload (add or + // delete a concept) rebuilds `nodes`, and re-opening a panel the student + // deliberately closed would be a haunting. + const focusApplied = React.useRef(null); + React.useEffect(() => { + if (!focusNodeId || !nodes.length) return; + if (focusApplied.current === focusNodeId) return; + const target = nodes.find(n => n.id === focusNodeId); + if (!target) return; + focusApplied.current = focusNodeId; + setSelected(target); + }, [focusNodeId, nodes]); + useScrollLock(fullscreen); React.useEffect(() => { @@ -169,6 +264,17 @@ export function Tree() { return sessions.filter(s => (s.topic || "").toLowerCase() === name); }, [selected, sessions]); + // `GET /api/quiz/attempts` is user-scoped and unfiltered (gaps G2/G3), so the + // concept and the completed-status filter both happen here. Subject roots + // have no attempts of their own — a course is never a `concept_node_id`. + const recentQuizzes = React.useMemo(() => { + if (!attempts || !selected || selected.is_subject_root) return []; + return attempts + .filter(a => a.concept_node_id === selected.id && a.status === "completed") + .sort((a, b) => attemptTime(b) - attemptTime(a)) + .slice(0, RECENT_QUIZ_ROWS); + }, [attempts, selected]); + const suggestId = React.useMemo(() => { if (!suggest) return undefined; const n = nodes.find(x => x.name.toLowerCase() === suggest.toLowerCase()); @@ -176,13 +282,32 @@ export function Tree() { }, [suggest, nodes]); const onLearn = (n: GraphNode) => router.push(learnHrefForNode(n)); - // Subject-root (course) nodes have no single quiz topic — open the picker - // rather than seeding the course name (mirrors the tutor fix, #319). The - // Quiz screen only reads `topic`/`concept`, so the old `course_id` param was - // dead and is dropped. - const onQuiz = (n: GraphNode) => router.push( - n.is_subject_root ? "/quiz" : `/quiz?topic=${encodeURIComponent(n.name)}`, - ); + + // Quick quiz (#537 §6). Two things changed from the `?topic=` link this + // replaces. First, a concept is addressed by ID: a name is only unique within + // a course, so the fuzzy link could seed the wrong node. Second, the link now + // carries where it came from, which is what lets every quiz exit come BACK + // here — to this node's open panel — instead of the hardcoded `/learn` that + // #537 found on every exit. + // + // Subject-root (course) nodes still have no single topic (the #319 fix), but + // "open the bare picker" is no longer the only honest answer: the quiz home + // takes an abstract `course` and proposes within it. The root carries that id + // directly (`graph_service.py` synthesizes `subject_root__{course_id}` with + // `course_id` set); the id is only unpicked as a belt-and-braces fallback. + const onQuiz = (n: GraphNode) => { + if (n.is_subject_root) { + const courseId = n.course_id || n.id.replace(/^subject_root__/, ""); + router.push(buildQuizHref({ course: courseId }, { kind: "tree", returnTo: "/tree" })); + return; + } + router.push( + buildQuizHref( + { concept: n.id }, + { kind: "tree", returnTo: `/tree?node=${encodeURIComponent(n.id)}`, conceptId: n.id }, + ), + ); + }; const del = useConfirm(async () => { if (!userId || !selected) return; @@ -248,6 +373,42 @@ export function Tree() { {del.armed ? "Click again to confirm" : "Delete concept"} )} + {!selected.is_subject_root && ( +
+
Recent quizzes
+ {attempts === null ? ( +
Loading…
+ ) : recentQuizzes.length === 0 ? ( + // No second CTA here on purpose: "Quick quiz" is a few pixels up + // and is the action this empty state would ask for. +
No quizzes yet
+ ) : ( + recentQuizzes.map(a => ( +
+ {/* Not a link: there is no finished-attempt review endpoint — + `GET /attempts/{id}` answers `resumable: false, questions: []` + for a completed row (gap G5). A row that navigated nowhere + would be a broken promise. */} + + {relativeStudied(a.completed_at || a.created_at)} + + + {a.score ?? "—"}/{a.total ?? "—"} · {formatMasteryDelta(a.mastery_delta)} mastery + +
+ )) + )} +
+ )} {sessionsForSelected.length > 0 && (
Sessions for this concept
diff --git a/frontend/src/components/ui/AnswerOption.test.tsx b/frontend/src/components/ui/AnswerOption.test.tsx new file mode 100644 index 00000000..c0b678cb --- /dev/null +++ b/frontend/src/components/ui/AnswerOption.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import fs from "node:fs"; +import path from "node:path"; +import { AnswerOption, type AnswerState } from "./AnswerOption"; + +afterEach(cleanup); + +const TEXT = "It stops the recursion by returning a result"; + +function setup(state: AnswerState, over: Partial> = {}) { + const onSelect = vi.fn(); + const utils = render( + , + ); + return { onSelect, ...utils }; +} + +describe("AnswerOption", () => { + it("is a radio carrying the letter and the text", () => { + setup("default"); + const row = screen.getByRole("radio"); + expect(row).toHaveAttribute("aria-checked", "false"); + expect(row).toHaveTextContent("B"); + expect(row).toHaveTextContent(TEXT); + expect(row).toHaveClass("answer-option", "answer-option--default"); + expect(row).toHaveAttribute("data-testid", "quiz-answer-option-B"); + }); + + it("is checked when selected, and when it was the chosen wrong answer", () => { + setup("selected"); + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "true"); + cleanup(); + setup("chosen-wrong"); + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "true"); + cleanup(); + setup("correct"); + // `correct` is the answer, not the student's pick. + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "false"); + }); + + it("selects on click and on Enter/Space, because it is a real button", () => { + const { onSelect } = setup("default"); + const row = screen.getByRole("radio"); + fireEvent.click(row); + fireEvent.keyDown(row, { key: "Enter" }); + fireEvent.keyUp(row, { key: " " }); + // jsdom doesn't synthesise the click for key events, so assert the type + // that actually carries them: a + ); +} diff --git a/frontend/src/components/ui/Button.test.tsx b/frontend/src/components/ui/Button.test.tsx new file mode 100644 index 00000000..0b17f798 --- /dev/null +++ b/frontend/src/components/ui/Button.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { Button } from "./Button"; +import { Sheet } from "./Sheet"; + +afterEach(cleanup); + +describe("Button — the link variant (#537)", () => { + it("carries .btn--link and stays a real button", () => { + render(); + const btn = screen.getByRole("button", { name: "adjust" }); + expect(btn).toHaveClass("btn", "btn--link"); + expect(btn).toHaveAttribute("type", "button"); + }); + + it("leaves the other variants alone", () => { + render( + <> + + + + , + ); + // secondary is the default and adds no modifier + expect(screen.getByRole("button", { name: "secondary" }).className).toBe("btn"); + expect(screen.getByRole("button", { name: "primary" })).toHaveClass("btn--primary"); + expect(screen.getByRole("button", { name: "ghost" })).toHaveClass("btn--ghost", "btn--sm"); + }); + + it("keeps a disabled primary in place, not hidden — the .btn--primary rule fades it", () => { + render( + <> + + + , + ); + // Both forms must reach the CSS: :disabled for the DOM attribute, and + // [aria-disabled] for the controls that stay focusable while inert. + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Score" })).toHaveAttribute( + "aria-disabled", + "true", + ); + for (const name of ["Submit", "Score"]) { + expect(screen.getByRole("button", { name })).toHaveClass("btn", "btn--primary"); + expect(screen.getByRole("button", { name })).toBeVisible(); + } + }); + + it("forwards its ref to the button element, so it can be a return-focus target", () => { + const ref = React.createRef(); + render( + , + ); + expect(ref.current).toBe(screen.getByRole("button", { name: "Ask about this" })); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it("is a usable initialFocusRef for an overlay, which is why the ref exists", () => { + function Harness() { + const ref = React.useRef(null); + return ( + <> + + {}} title="Ask about this" initialFocusRef={ref}> +

body

+
+ + ); + } + render(); + // The overlay only ever reads `.current`; proving the ref is populated with + // the real node is what makes it a legal target. + expect(screen.getByRole("button", { name: "Ask about this" })).toBeInstanceOf( + HTMLButtonElement, + ); + }); + + it("exposes the open-dialog state the quiz's `adjust` link needs", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "adjust" }); + expect(btn).toHaveAttribute("aria-pressed", "true"); + expect(btn).toHaveAttribute("data-active", "true"); + }); +}); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index cc8f60eb..fb63f211 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,24 +1,37 @@ "use client"; import React from "react"; -type Variant = "primary" | "secondary" | "ghost" | "danger"; +type Variant = "primary" | "secondary" | "ghost" | "danger" | "link"; type Size = "sm" | "md" | "lg" | "xl"; // Shared button primitive. Wraps the canonical .btn classes in globals.css so // every action button is one shape (6px) with consistent hover/transitions. -// - variant: primary (forest fill) | secondary (bordered, default) | ghost | danger +// - variant: primary (forest fill) | secondary (bordered, default) | ghost | +// danger | link // - size: sm | md (default) | lg (the hero size for de-pilled CTAs) // Pills are NOT a Button — use for segmented controls. -export function Button({ - variant = "secondary", - size = "md", - className, - type = "button", - ...props -}: React.ButtonHTMLAttributes & { +// +// `link` is a bare text button — no padding, border or background, muted until +// hover. It exists because "adjust", "Discard", "Pick something specific →" +// and "Done" are text, not chrome, and `ghost` keeps button padding so it +// still reads as a control (#537). `aria-pressed` / `data-active="true"` +// underlines it in the accent, which is how the quiz shows "this link's dialog +// is open". Leave `size` at its default on a link: `.btn--sm`/`--lg`/`--xl` +// still apply their padding, which is the shape `link` exists to shed. +// +// The ref is forwarded so callers can hold the DOM node: the quiz's question +// screen needs "Ask about this" as a return-focus target when its Sheet +// closes, and was otherwise forced to drop to a raw ` + } + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Upload syllabus" })); + expect(onUpload).toHaveBeenCalled(); + }); + + it("omits the parts it wasn't given", () => { + const { container } = render(); + expect(container.querySelector(".empty-state__body")).toBeNull(); + expect(container.querySelector(".empty-state__eyebrow")).toBeNull(); + expect(container.querySelector(".empty-state__action")).toBeNull(); + expect(container.querySelector(".empty-state__icon")).toBeNull(); + }); + + it("renders a decorative icon when asked", () => { + const { container } = render(); + const icon = container.querySelector(".empty-state__icon")!; + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon.querySelector("svg")).not.toBeNull(); + }); + + it("carries Gradebook's display-scale treatment under size=hero", () => { + const { container } = render(); + expect(container.querySelector(".empty-state")).toHaveClass("empty-state--hero"); + }); +}); + +/** + * The promotion out of `screens/Gradebook/Landing.tsx` has to render that + * screen unchanged, and the four values it was drawn at (56px title, 11px + * ss01 eyebrow, 17px body, a plain primary CTA at 10px/18px) now live in CSS + * that jsdom does not apply. So this pins both halves: the markup carries the + * classes the rules hang off, and the rules carry the values. + */ +describe("EmptyState — the Gradebook promotion renders identically", () => { + const GRADEBOOK = ( + + Upload syllabus + + } + /> + ); + + it("hangs every hero rule off the classes the old inline styles carried", () => { + const { container } = render(GRADEBOOK); + const root = container.querySelector(".empty-state")!; + expect(root).toHaveClass("empty-state--hero"); + // Eyebrow: `.label-micro` inside `--hero`, which is what restores 11px+ss01. + expect(root.querySelector(".empty-state__eyebrow")).toHaveClass("label-micro"); + // Title/body keep the type classes the old inline font-families named. + expect(root.querySelector(".empty-state__title")).toHaveClass("h-serif"); + expect(root.querySelector(".empty-state__body")).toHaveClass("body-serif"); + }); + + it("keeps the CTA a plain primary — btn--lg is 1px shorter and 100 heavier", () => { + render(GRADEBOOK); + const cta = screen.getByTestId("gradebook-upload-syllabus"); + expect(cta).toHaveClass("btn", "btn--primary"); + expect(cta).not.toHaveClass("btn--lg"); + expect(cta).not.toHaveClass("btn--sm"); + }); + + it("pins the four promoted values in globals.css", () => { + const css = fs.readFileSync( + path.resolve(__dirname, "../../app/globals.css"), + "utf8", + ); + // The tokens, at the values the screen was drawn at… + expect(css).toMatch(/--empty-hero-fs:\s*56px;/); + expect(css).toMatch(/--empty-hero-eyebrow-fs:\s*11px;/); + expect(css).toMatch(/--empty-hero-body-fs:\s*17px;/); + expect(css).toMatch(/--empty-hero-cta-pad:\s*10px 18px;/); + // …and the rules that actually spend them, including the ss01 `.mono` + // carried and `.label-micro` does not. + expect(css).toMatch( + /\.empty-state--hero \.empty-state__eyebrow \{[^}]*font-size:\s*var\(--empty-hero-eyebrow-fs\)[^}]*font-feature-settings:\s*"ss01"[^}]*\}/, + ); + expect(css).toMatch( + /\.empty-state--hero \.empty-state__body \{[^}]*font-size:\s*var\(--empty-hero-body-fs\)[^}]*\}/, + ); + expect(css).toMatch( + /\.empty-state--hero \.empty-state__action \.btn \{[^}]*padding:\s*var\(--empty-hero-cta-pad\)[^}]*\}/, + ); + }); +}); diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..0e2e480e --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,72 @@ +"use client"; +import React from "react"; +import { Icon } from "@/components/Icon"; + +/** + * EmptyState — "there's nothing here yet, and here's the way out" (#537). + * + * Promoted from the private component inside `screens/Gradebook/Landing.tsx`, + * which was the only real empty state in the app; the quiz's two ("add a + * course", "your tree is empty") would otherwise have been the second and + * third one-off. Landing now imports this. + * + * `size="hero"` is Landing's treatment — a display-scale title that owns a + * whole blank screen. `md` (the default) is the in-page version the quiz uses. + * An empty state is never a dead end: `action` is the way out, and callers are + * expected to supply one. + */ +export interface EmptyStateProps { + title: string; + body?: string; + /** A link out, or any control the caller would rather build itself. */ + action?: { label: string; href: string } | React.ReactNode; + /** Mono/uppercase line above the title. */ + eyebrow?: string; + /** Name from `components/Icon`. */ + icon?: string; + size?: "md" | "hero"; + testid?: string; +} + +function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } { + return ( + typeof a === "object" && + a !== null && + "href" in a && + typeof (a as { href?: unknown }).href === "string" + ); +} + +export function EmptyState({ + title, + body, + action, + eyebrow, + icon, + size = "md", + testid, +}: EmptyStateProps) { + return ( +
+ {icon && ( + + )} + {eyebrow &&
{eyebrow}
} +

{title}

+ {body &&

{body}

} + {action && ( +
+ {isHrefAction(action) ? ( + + {action.label} + + ) : ( + action + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/InlineBanner.test.tsx b/frontend/src/components/ui/InlineBanner.test.tsx new file mode 100644 index 00000000..f9523ead --- /dev/null +++ b/frontend/src/components/ui/InlineBanner.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { InlineBanner } from "./InlineBanner"; +import { Button } from "./Button"; + +afterEach(cleanup); + +describe("InlineBanner", () => { + it("announces itself — the strip appears without the user asking", () => { + render(You left a quiz on Recursion — 2 of 5 answered); + const banner = screen.getByRole("status"); + expect(banner).toHaveTextContent("You left a quiz on Recursion — 2 of 5 answered"); + expect(banner).toHaveClass("inline-banner", "inline-banner--accent"); + }); + + it("keeps its actions in their own slot, after the body", () => { + render( + + + + + } + > + You left a quiz on Recursion + , + ); + const banner = screen.getByTestId("quiz-resume-strip"); + expect(banner.querySelector(".inline-banner__body")).toHaveTextContent( + "You left a quiz on Recursion", + ); + const actions = banner.querySelector(".inline-banner__actions")!; + expect(actions.querySelectorAll("button")).toHaveLength(2); + expect(screen.getByTestId("quiz-resume-discard")).toHaveClass("btn--link"); + }); + + it("omits the actions slot entirely when there are none", () => { + const { container } = render(Nothing to do here); + expect(container.querySelector(".inline-banner__actions")).toBeNull(); + }); + + it("takes the neutral tone for strips that aren't about the course", () => { + render(Heads up); + expect(screen.getByRole("status")).toHaveClass("inline-banner--neutral"); + }); +}); diff --git a/frontend/src/components/ui/InlineBanner.tsx b/frontend/src/components/ui/InlineBanner.tsx new file mode 100644 index 00000000..98b2a3f3 --- /dev/null +++ b/frontend/src/components/ui/InlineBanner.tsx @@ -0,0 +1,32 @@ +"use client"; +import React from "react"; + +/** + * InlineBanner — a full-width strip under a page header (#537). + * + * The quiz's "You left a quiz on Recursion — 2 of 5 answered · Resume · + * Discard" line. There was no precedent for this anywhere in the app (Learn + * offers an ordinary "Resume session" button and nothing else), and Dashboard + * and Study both plausibly want the same shape later, so it lands here rather + * than inside the quiz. + * + * `role="status"` because it appears in response to state the user didn't just + * ask about — a resumable attempt found on another device should be announced, + * not silently painted. + */ +export interface InlineBannerProps { + children: React.ReactNode; + /** Right-aligned controls. Usually a secondary button and a link button. */ + actions?: React.ReactNode; + tone?: "accent" | "neutral"; + testid?: string; +} + +export function InlineBanner({ children, actions, tone = "accent", testid }: InlineBannerProps) { + return ( +
+
{children}
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/components/ui/ProgressDots.test.tsx b/frontend/src/components/ui/ProgressDots.test.tsx new file mode 100644 index 00000000..994ad88c --- /dev/null +++ b/frontend/src/components/ui/ProgressDots.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { ProgressDots } from "./ProgressDots"; + +afterEach(cleanup); + +const kinds = (container: HTMLElement) => + Array.from(container.querySelectorAll(".progress-dots__dot")).map((d) => + d.className.replace("progress-dots__dot progress-dots__dot--", ""), + ); + +describe("ProgressDots", () => { + it("is one labelled image, not a list of anonymous dots", () => { + render(); + const el = screen.getByRole("img", { name: "Question 3 of 5" }); + expect(el).toHaveClass("progress-dots", "progress-dots--column"); + }); + + it("marks answered, current and upcoming", () => { + const { container } = render( + , + ); + expect(kinds(container)).toEqual(["done", "done", "current", "todo", "todo"]); + }); + + it("shows 'you are here' over 'you answered this' when revisiting", () => { + const { container } = render( + , + ); + expect(kinds(container)).toEqual(["done", "current", "done", "done"]); + }); + + it("renders one dot per item at any count, and nothing at zero", () => { + const { container, rerender } = render( + , + ); + expect(kinds(container)).toHaveLength(3); + rerender(); + expect(kinds(container)).toHaveLength(0); + }); + + it("takes the row orientation Onboarding's step indicator wants", () => { + render( + , + ); + const el = screen.getByTestId("onboarding-progress"); + expect(el).toHaveClass("progress-dots--row"); + expect(el).not.toHaveClass("progress-dots--column"); + }); +}); diff --git a/frontend/src/components/ui/ProgressDots.tsx b/frontend/src/components/ui/ProgressDots.tsx new file mode 100644 index 00000000..923d4815 --- /dev/null +++ b/frontend/src/components/ui/ProgressDots.tsx @@ -0,0 +1,51 @@ +"use client"; +import React from "react"; + +/** + * ProgressDots — where you are in a fixed-length sequence (#537). + * + * The column variant is the design's "branch": a hairline rail with a dot per + * question — filled for answered, a hollow accent ring for the current one, + * a smaller hollow dot for what's ahead. Onboarding has the same grammar + * inline and horizontal (`Onboarding.tsx`), which is what `orientation="row"` + * is for; this is the shared version. + * + * One `role="img"` with a spoken label ("Question 3 of 5") rather than a list + * of nine anonymous dots: the dots are a picture of the label, and reading + * them out individually tells a screen-reader user nothing. + */ +export interface ProgressDotsProps { + total: number; + /** 0-based index of the item being worked on. */ + current: number; + /** How many items are answered — contiguous from 0. */ + answered: number; + orientation?: "column" | "row"; + ariaLabel: string; + testid?: string; +} + +export function ProgressDots({ + total, + current, + answered, + orientation = "column", + ariaLabel, + testid, +}: ProgressDotsProps) { + return ( +
+ {Array.from({ length: Math.max(0, total) }, (_, i) => { + // Current wins over answered: revisiting an answered item still shows + // "you are here", which is the question the rail exists to answer. + const kind = i === current ? "current" : i < answered ? "done" : "todo"; + return ; + })} +
+ ); +} diff --git a/frontend/src/components/ui/SegmentedControl.test.tsx b/frontend/src/components/ui/SegmentedControl.test.tsx new file mode 100644 index 00000000..a01d4760 --- /dev/null +++ b/frontend/src/components/ui/SegmentedControl.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { SegmentedControl } from "./SegmentedControl"; + +afterEach(cleanup); + +const OPTIONS = [ + { value: "easy", label: "easy" }, + { value: "medium", label: "medium" }, + { value: "hard", label: "hard" }, +]; + +function setup(over: Partial>> = {}) { + const onChange = vi.fn(); + const utils = render( + , + ); + return { onChange, ...utils }; +} + +describe("SegmentedControl", () => { + it("is a labelled radiogroup of radios with exactly one checked", () => { + setup(); + const group = screen.getByRole("radiogroup", { name: "Difficulty" }); + expect(group).toBeInTheDocument(); + const radios = screen.getAllByRole("radio"); + expect(radios).toHaveLength(3); + expect(radios.filter((r) => r.getAttribute("aria-checked") === "true")).toHaveLength(1); + expect(screen.getByRole("radio", { name: "medium" })).toHaveAttribute("aria-checked", "true"); + }); + + it("gives the group one tab stop — the selected option", () => { + setup(); + expect(screen.getByRole("radio", { name: "medium" })).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("radio", { name: "easy" })).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("radio", { name: "hard" })).toHaveAttribute("tabindex", "-1"); + }); + + it("puts the tab stop on the first option when nothing is selected yet", () => { + setup({ value: "" }); + expect(screen.getByRole("radio", { name: "easy" })).toHaveAttribute("tabindex", "0"); + }); + + it("selects on click", () => { + const { onChange } = setup(); + fireEvent.click(screen.getByRole("radio", { name: "hard" })); + expect(onChange).toHaveBeenCalledWith("hard"); + }); + + it("moves and selects with the arrow keys, wrapping at both ends", () => { + const { onChange } = setup(); + const group = screen.getByRole("radiogroup"); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + cleanup(); + + const last = setup({ value: "hard" }); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(last.onChange).toHaveBeenLastCalledWith("easy"); + }); + + it("treats up/down like left/right, and Home/End as the ends", () => { + const { onChange } = setup(); + const group = screen.getByRole("radiogroup"); + fireEvent.keyDown(group, { key: "ArrowDown" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + fireEvent.keyDown(group, { key: "ArrowUp" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + fireEvent.keyDown(group, { key: "Home" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + fireEvent.keyDown(group, { key: "End" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + }); + + it("moves focus with the selection so the keyboard user follows it", () => { + setup(); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(screen.getByRole("radio", { name: "hard" })); + }); + + it("skips a disabled option rather than landing on it", () => { + const { onChange } = setup({ + options: [ + { value: "easy", label: "easy" }, + { value: "medium", label: "medium" }, + { value: "hard", label: "hard", disabled: true }, + ], + }); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); // wrapped past `hard` + fireEvent.click(screen.getByRole("radio", { name: "hard" })); + expect(onChange).not.toHaveBeenCalledWith("hard"); + expect(screen.getByRole("radio", { name: "hard" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("suffixes each option's testid with its value", () => { + setup(); + expect(screen.getByTestId("quiz-seg-difficulty")).toBeInTheDocument(); + expect(screen.getByTestId("quiz-seg-difficulty-easy")).toBeInTheDocument(); + expect(screen.getByTestId("quiz-seg-difficulty-hard")).toBeInTheDocument(); + }); + + it("takes numeric values, which is how the question-count row uses it", () => { + const onChange = vi.fn(); + render( + ({ value: v, label: `${v} questions` }))} + value={5} + onChange={onChange} + ariaLabel="Length" + testid="quiz-seg-count" + />, + ); + fireEvent.click(screen.getByRole("radio", { name: "10 questions" })); + expect(onChange).toHaveBeenCalledWith(10); + expect(screen.getByTestId("quiz-seg-count-3")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/SegmentedControl.tsx b/frontend/src/components/ui/SegmentedControl.tsx new file mode 100644 index 00000000..7c60e778 --- /dev/null +++ b/frontend/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,121 @@ +"use client"; +import React from "react"; + +/** + * SegmentedControl — the design's mono/uppercase pick-one row (#537). + * + * NOT ``: that is the filled pill, a different visual language. This + * one is `.label-micro` type with a 2px accent underline on the selected + * option, which is the mechanic four screens already re-implement privately + * (Achievements, Admin, Learn's mobile tabs, FlashcardImportModal) — none of + * them shared, none of them mono. This is the shared one. + * + * Semantics are a real radiogroup: one tab stop for the whole control (roving + * tabindex), arrows move AND select, Home/End jump to the ends, and disabled + * options are skipped rather than focused-and-refused. + * + * The option list is always the caller's — the quiz reads its counts and + * difficulties off `GET /api/quiz/config` and never enumerates them in code. + */ +export interface SegmentedControlProps { + options: { value: V; label: string; disabled?: boolean }[]; + value: V; + onChange: (v: V) => void; + ariaLabel?: string; + labelledBy?: string; + testid?: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + ariaLabel, + labelledBy, + testid, +}: SegmentedControlProps) { + const refs = React.useRef(new Map()); + + const enabled = options.filter((o) => !o.disabled); + const selectedIndex = enabled.findIndex((o) => o.value === value); + // Nothing selected yet → the first enabled option carries the tab stop, so + // the control is always reachable. + const tabStop = selectedIndex >= 0 ? value : enabled[0]?.value; + + const move = (delta: number) => { + if (enabled.length === 0) return; + const from = selectedIndex >= 0 ? selectedIndex : 0; + const next = enabled[(from + delta + enabled.length) % enabled.length]; + onChange(next.value); + refs.current.get(next.value)?.focus(); + }; + + const jump = (index: number) => { + const target = enabled[index]; + if (!target) return; + onChange(target.value); + refs.current.get(target.value)?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + move(1); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + move(-1); + break; + case "Home": + e.preventDefault(); + jump(0); + break; + case "End": + e.preventDefault(); + jump(enabled.length - 1); + break; + default: + break; + } + }; + + return ( +
+ {options.map((option) => { + const checked = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/ui/Sheet.test.tsx b/frontend/src/components/ui/Sheet.test.tsx new file mode 100644 index 00000000..478c129e --- /dev/null +++ b/frontend/src/components/ui/Sheet.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import React from "react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { Sheet } from "./Sheet"; + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); + document.body.style.removeProperty("overflow-y"); + document.body.style.removeProperty("overflow-x"); +}); + +function open(over: Partial> = {}) { + const onClose = vi.fn(); + const utils = render( + +

Why is B the answer?

+ +
, + ); + return { onClose, ...utils }; +} + +describe("Sheet", () => { + it("is a modal dialog labelled by its own title", () => { + open(); + const panel = screen.getByRole("dialog", { name: "Ask about this" }); + expect(panel).toHaveAttribute("aria-modal", "true"); + expect(panel).toHaveClass("sheet", "sheet--right"); + expect(panel).toHaveTextContent("Why is B the answer?"); + }); + + it("renders into a portal on document.body, over the page", () => { + const { container } = open(); + // Nothing in the caller's own subtree… + expect(container.querySelector(".sheet")).toBeNull(); + // …but present in the document. + expect(document.body.querySelector(".sheet")).not.toBeNull(); + }); + + it("renders nothing at all when closed", () => { + render( + {}} title="Ask about this"> +

hidden

+
, + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("closes on Escape, on the close button, and on a backdrop click", () => { + const first = open(); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(first.onClose).toHaveBeenCalledTimes(1); + cleanup(); + + const second = open(); + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + expect(second.onClose).toHaveBeenCalledTimes(1); + cleanup(); + + const third = open(); + fireEvent.click(document.body.querySelector(".sheet-backdrop")!); + expect(third.onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close when the click lands inside the panel", () => { + const { onClose } = open(); + fireEvent.click(screen.getByRole("dialog")); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("names its close button and suffixes its testid", () => { + open(); + const close = screen.getByRole("button", { name: "Close" }); + expect(close).toHaveAttribute("data-testid", "quiz-ask-panel-close"); + }); + + it("traps Tab inside the panel", () => { + open(); + const panel = screen.getByRole("dialog"); + const focusable = Array.from(panel.querySelectorAll("button")); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + last.focus(); + fireEvent.keyDown(panel, { key: "Tab" }); + expect(document.activeElement).toBe(first); + + first.focus(); + fireEvent.keyDown(panel, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(last); + }); + + it("moves focus into the panel on open and restores it on close", () => { + vi.useFakeTimers(); + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const { unmount } = render( + {}} title="Ask about this"> + + , + ); + act(() => { + vi.advanceTimersByTime(50); + }); + expect(document.activeElement).not.toBe(opener); + expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true); + + unmount(); + expect(document.activeElement).toBe(opener); + opener.remove(); + vi.useRealTimers(); + }); + + it("locks the scrolling container while open and releases it on close", () => { + // The lock is Dialog's, inherited through useOverlayBehaviour — but §3 + // names it as a Sheet requirement, so it gets its own assertion. + const scroller = document.createElement("div"); + scroller.setAttribute("data-scroll-container", ""); + document.body.appendChild(scroller); + expect(scroller.style.getPropertyValue("overflow-y")).toBe(""); + + const { unmount } = render( + {}} title="Ask about this"> +

body

+
, + ); + expect(scroller.style.getPropertyValue("overflow-y")).toBe("hidden"); + expect(scroller.style.getPropertyValue("overflow-x")).toBe("hidden"); + + unmount(); + expect(scroller.style.getPropertyValue("overflow-y")).toBe(""); + scroller.remove(); + }); + + it("takes its width as a custom property so the rule stays in the stylesheet", () => { + open({ width: 560 }); + expect(screen.getByRole("dialog").style.getPropertyValue("--sheet-width")).toBe("560px"); + }); +}); diff --git a/frontend/src/components/ui/Sheet.tsx b/frontend/src/components/ui/Sheet.tsx new file mode 100644 index 00000000..7dff6952 --- /dev/null +++ b/frontend/src/components/ui/Sheet.tsx @@ -0,0 +1,93 @@ +"use client"; +import React from "react"; +import { createPortal } from "react-dom"; +import { useOverlayBehaviour } from "@/components/Dialog"; +import { Icon } from "@/components/Icon"; + +/** + * Sheet — a panel that opens OVER the page, anchored right (#537). + * + * The quiz's "Ask about this" needs the question to stay visible while the + * tutor answers, which nothing in the app did: the notetaker's chat aside and + * the tree's node panel are permanent grid columns, and `Dialog` is centred. + * + * Everything modal about it — portal, scroll-lock, focus trap, Escape, focus + * restore — is `Dialog`'s own `useOverlayBehaviour`, so the two can't drift. + * All that differs is the geometry and the entrance: the panel slides in from + * the right, and doesn't when the viewer asked for reduced motion (the global + * `prefers-reduced-motion` rule zeroes the transition). + */ +export interface SheetProps { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + /** Panel width in px. Capped at the viewport by the stylesheet. */ + width?: number; + side?: "right"; + initialFocusRef?: React.RefObject; + zIndex?: number; + testid?: string; +} + +export function Sheet({ + open, + onClose, + title, + children, + width = 480, + side = "right", + initialFocusRef, + zIndex = 100, + testid, +}: SheetProps) { + const titleId = `sheet-title-${React.useId()}`; + const { mounted, visible, panelRef, onKeyDown } = useOverlayBehaviour({ + open, + onClose, + initialFocusRef, + }); + + if (!mounted || !open) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

+ {title} +

+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx new file mode 100644 index 00000000..a85454e0 --- /dev/null +++ b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { QuizPrimitivesGallery } from "./QuizPrimitivesGallery"; + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +/** + * A smoke test for the harness itself. Its job is to keep the gallery from + * quietly rotting the next time a primitive's props change — a screenshot + * target nobody can mount is worse than none. + */ +describe("QuizPrimitivesGallery", () => { + it("mounts every primitive, with the course accent bound on the root", () => { + const { container } = render(); + const root = container.querySelector(".quiz-gallery")!; + expect(root.style.getPropertyValue("--quiz-accent")).toBe("#7b4b99"); + + expect(container.querySelectorAll(".btn").length).toBeGreaterThan(6); + expect(container.querySelectorAll(".seg").length).toBe(4); + expect(container.querySelectorAll(".answer-option").length).toBeGreaterThan(6); + expect(container.querySelectorAll(".progress-dots").length).toBe(5); + expect(container.querySelectorAll(".inline-banner").length).toBe(2); + expect(container.querySelectorAll(".empty-state").length).toBe(2); + expect(container.querySelectorAll(".concept-node").length).toBeGreaterThan(9); + expect(container.querySelectorAll(".concept-neighbourhood").length).toBe(5); + }); + + it("shows all five AnswerOption states side by side", () => { + const { container } = render(); + for (const state of ["default", "selected", "correct", "chosen-wrong", "muted"]) { + expect(container.querySelector(`.answer-option--${state}`)).not.toBeNull(); + } + }); + + it("opens the sheet, which portals out of the gallery", () => { + render(); + expect(screen.queryByTestId("gallery-sheet")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Ask about this" })); + expect(screen.getByTestId("gallery-sheet")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx new file mode 100644 index 00000000..61abfc45 --- /dev/null +++ b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx @@ -0,0 +1,398 @@ +"use client"; + +/** + * QuizPrimitivesGallery — every primitive, every state, on one page (#537). + * + * A harness, not a route and not a test: it exists so the shared primitives + * can be looked at in isolation at the shell's real content width before any + * screen consumes them, and so a design review has something to screenshot. + * Mount it anywhere (a scratch route, a story, a Playwright fixture page) — + * it takes no data and touches nothing. + * + * Light mode only: the app has no dark mode, deliberately (globals.css). + * + * `--quiz-accent` is set on the root here exactly as `QuizScreen` will set it + * from the active concept's course colour, so the accent-derived states + * (segmented underline, selection bar, progress dots, banner tint) render the + * way they will in the real screen rather than in the app's default green. + */ + +import React from "react"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { AnswerOption, type AnswerState } from "../AnswerOption"; +import { Button } from "../Button"; +import { EmptyState } from "../EmptyState"; +import { InlineBanner } from "../InlineBanner"; +import { ProgressDots } from "../ProgressDots"; +import { SegmentedControl } from "../SegmentedControl"; +import { Sheet } from "../Sheet"; +// The harness's own layout, co-located rather than shipped in globals.css. +import "./quizPrimitivesGallery.css"; + +/** The prototype's CS101 purple, so the gallery matches the design's screens. */ +const COURSE_COLOR = "#7b4b99"; + +const CENTRE = { id: "recursion", name: "Recursion", mastery: 0.29, tier: "struggling" }; + +const SIBLINGS: NeighbourNode[] = [ + { id: "base-cases", name: "Base cases", mastery: 0.52, tier: "learning", strength: 0.9 }, + { id: "stack-frames", name: "Stack frames", mastery: 0.3, tier: "struggling", strength: 0.7 }, + { id: "tail-recursion", name: "Tail recursion", mastery: 0.12, tier: "struggling", strength: 0.4 }, +]; + +const ANSWER_STATES: AnswerState[] = [ + "default", + "selected", + "correct", + "chosen-wrong", + "muted", +]; + +const ANSWER_TEXT: Record = { + default: "It makes the function run faster by caching the results of earlier calls", + selected: "It stops the recursion by returning a result without another recursive call", + correct: "It stops the recursion by returning a result without another recursive call", + "chosen-wrong": "It increases the recursion depth available on the call stack", + muted: "It converts the recursion into an iterative loop at compile time", +}; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function QuizPrimitivesGallery() { + const [count, setCount] = React.useState(5); + const [difficulty, setDifficulty] = React.useState("medium"); + const [feedback, setFeedback] = React.useState("at-end"); + const [picked, setPicked] = React.useState("B"); + const [sheetOpen, setSheetOpen] = React.useState(false); + + return ( +
+

Quiz primitives

+ +
+ + + + + + + + + + + + + + + + {/* The DOM attribute, and the aria-only form the quiz's Submit uses + so it stays focusable and announced while it can't be pressed. */} + + + + +
+ +
+ + ({ value: v, label: `${v} questions` }))} + value={count} + onChange={setCount} + ariaLabel="Length" + testid="gallery-seg-count" + /> + + + ({ value: v, label: v }))} + value={difficulty} + onChange={setDifficulty} + ariaLabel="Difficulty" + testid="gallery-seg-difficulty" + /> + + + + + + {}} + ariaLabel="Difficulty with a disabled option" + /> + +
+ +
+
+ {ANSWER_STATES.map((state, i) => ( + {}} + /> + ))} + +
+ +
+ {["A", "B", "C"].map((letter, i) => ( + setPicked(letter)} + /> + ))} +
+
+
+ +
+ + + + + + + + + + + +
+ +
+ + + + + } + > + You left a quiz on Recursion — 2 of 5 answered + + + Only 3 questions were ready for this concept. + +
+ +
+ + + + setSheetOpen(false)} + title="Ask about this" + testid="gallery-sheet" + > +

+ What is the purpose of a base case in a recursive function? +

+

You chose B · The answer is B.

+ +
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + {/* The composition (`compact` +8px nudge vs `wide` dead-centre) is picked + from the canvas width, so these four rows show both without asking. */} +
+ + + + + + + {/* The results screen prints the concept's name below the canvas, so + the centre caption is dropped rather than said twice. */} + + + + + + + +
+
+ ); +} + +/** Four masteries, one per tier, so the opacity ramp is visible side by side. */ +const marks = { + recursion: { nodeId: "recursion", mastery: 0.29, tier: "struggling" }, + baseCases: { nodeId: "base-cases", mastery: 0.52, tier: "learning" }, + mastered: { nodeId: "determinants", mastery: 0.85, tier: "mastered" }, + unexplored: { nodeId: "tail-recursion", mastery: 0.05, tier: "unexplored" }, +} as const; diff --git a/frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css b/frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css new file mode 100644 index 00000000..1fa509fd --- /dev/null +++ b/frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css @@ -0,0 +1,50 @@ +/* QuizPrimitivesGallery — the harness's own layout (#537). + * + * Co-located and imported by the fixture, not appended to globals.css: no + * route mounts the gallery, so ~45 lines of dev-only rules have no business + * being parsed on every page load. The App Router allows a global stylesheet + * import from any component, which is the same mechanism R-1 gives the quiz's + * per-screen CSS. + * + * Tokens only, like the primitives it displays. Nothing here styles a + * primitive — the harness only arranges them. + */ + +.quiz-gallery { + max-width: 900px; + padding: var(--pad-xl); + display: flex; + flex-direction: column; + gap: 44px; +} +.quiz-gallery__title { margin: 0; font-size: var(--fs-4xl); } +.quiz-gallery__section { + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 20px; + border-top: 1px solid var(--border); +} +.quiz-gallery__heading { margin: 0; } +.quiz-gallery__row { + display: flex; + align-items: flex-start; + gap: var(--pad-lg); +} +.quiz-gallery__caption { + width: 200px; + flex-shrink: 0; + font-size: var(--fs-sm); + color: var(--text-muted); +} +.quiz-gallery__specimens { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; + min-width: 0; +} +.quiz-gallery__answers { + width: 100%; + border-top: 1px solid var(--border); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 6eeba134..6b86c858 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,5 +1,13 @@ -export { Button } from "./Button"; +export { Button, type ButtonProps } from "./Button"; export { Chip } from "./Chip"; export { Toggle } from "./Toggle"; export { Badge } from "./Badge"; export { FilterPills } from "./FilterPills"; +// Added for the quiz redesign (#537) — shared, not quiz-only: each one +// replaces a pattern that was already re-implemented privately elsewhere. +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl"; +export { AnswerOption, type AnswerOptionProps, type AnswerState } from "./AnswerOption"; +export { ProgressDots, type ProgressDotsProps } from "./ProgressDots"; +export { InlineBanner, type InlineBannerProps } from "./InlineBanner"; +export { Sheet, type SheetProps } from "./Sheet"; +export { EmptyState, type EmptyStateProps } from "./EmptyState"; diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index c893bb71..f0e0c560 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -208,3 +208,108 @@ describe('fetchJSON error shape', () => { expect((err as ApiError).message).toBe('HTTP 502'); }); }); + +/** + * The coded envelope (#537). Quiz routes answer with + * `{error: {code, message, request_id}, detail, request_id}` and a `Retry-After` + * header on 429. Before this, fetchJSON kept only the body text and the status, + * so QUIZ_RATE_LIMITED / QUIZ_DAILY_LIMIT_REACHED / QUIZ_GENERATION_TIMEOUT were + * indistinguishable to the UI (R1 §H, gap G12). + */ +describe('fetchJSON coded-error envelope', () => { + function codedResponse( + body: unknown, + status: number, + headers: Record = {}, + ): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + } + + it('lifts error.code and error.request_id onto the ApiError', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue( + codedResponse( + { + error: { + code: 'QUIZ_GENERATION_TIMEOUT', + message: 'Quiz generation timed out.', + request_id: 'req-1', + }, + detail: 'Quiz generation timed out.', + request_id: 'req-1', + }, + 502, + ), + ); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.code).toBe('QUIZ_GENERATION_TIMEOUT'); + expect(err.requestId).toBe('req-1'); + expect(err.status).toBe(502); + // The raw body still comes through as the message, unchanged. + expect(err.message).toContain('QUIZ_GENERATION_TIMEOUT'); + }); + + it('parses the whole body onto ApiError.body', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue( + codedResponse( + { error: { code: 'QUIZ_COUNT_OUT_OF_RANGE', message: 'Between 1 and 10.' } }, + 422, + ), + ); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.body).toEqual({ + error: { code: 'QUIZ_COUNT_OUT_OF_RANGE', message: 'Between 1 and 10.' }, + }); + }); + + it('reads whole-second Retry-After off a 429', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue( + codedResponse({ error: { code: 'QUIZ_RATE_LIMITED', message: 'Slow down.' } }, 429, { + 'Retry-After': '37', + }), + ); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.retryAfterSec).toBe(37); + }); + + it('ignores an HTTP-date Retry-After rather than guessing', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue( + codedResponse({}, 429, { 'Retry-After': 'Wed, 21 Oct 2026 07:28:00 GMT' }), + ); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.retryAfterSec).toBeUndefined(); + }); + + it('leaves the coded fields undefined for a legacy {detail} body', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue( + codedResponse({ detail: 'Exam not found.', request_id: 'req-legacy' }, 404), + ); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.code).toBeUndefined(); + expect(err.retryAfterSec).toBeUndefined(); + // The legacy shape still carries a top-level request_id; keep it. + expect(err.requestId).toBe('req-legacy'); + }); + + it('survives a non-JSON body (an HTML 502 from a proxy)', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType; + fetchMock.mockResolvedValue(new Response('502 Bad Gateway', { status: 502 })); + + const err = (await getCourses('u1').catch((e: unknown) => e)) as ApiError; + expect(err.body).toBeUndefined(); + expect(err.code).toBeUndefined(); + expect(err.message).toBe('502 Bad Gateway'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d71f6006..50138223 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,18 +24,88 @@ export const API_URL = ''; * * `message` stays the raw body for backward compatibility: callers that * stringify the error, or read `.message`, behave exactly as they did before. + * + * Since #537 it also carries what the coded envelope told us. Quiz routes + * answer with `{error: {code, message, request_id}, detail, request_id}` + * (backend/services/quiz_errors.py::quiz_error_body) plus a `Retry-After` + * header on 429; `code`/`requestId`/`retryAfterSec`/`body` surface that so + * `lib/quiz/errors.ts` can tell a rate limit from a daily cap from a generation + * timeout. All four are optional — routes outside the quiz router still answer + * with the legacy `{detail, request_id}` shape and leave them undefined. */ +export interface ApiErrorFields { + /** `error.code` off the coded envelope, when the route sent one. */ + code?: string; + /** `error.request_id` / top-level `request_id` — the support handle. */ + requestId?: string; + /** Whole-seconds `Retry-After`, when the response carried the header. */ + retryAfterSec?: number; + /** The parsed JSON body, when the body was JSON. */ + body?: unknown; +} + export class ApiError extends Error { readonly status: number; + readonly code?: string; + readonly requestId?: string; + readonly retryAfterSec?: number; + readonly body?: unknown; - constructor(message: string, status: number) { + constructor(message: string, status: number, fields: ApiErrorFields = {}) { super(message); this.name = 'ApiError'; this.status = status; + this.code = fields.code; + this.requestId = fields.requestId; + this.retryAfterSec = fields.retryAfterSec; + this.body = fields.body; + } +} + +function parseErrorBody(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed.startsWith('{')) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; } } -async function fetchJSON(path: string, options?: RequestInit): Promise { +/** `Retry-After` is either whole seconds or an HTTP date. Only the numeric form + * is honoured — a date needs clock-skew handling nobody here wants, and the one + * route that sets the header (quiz.py:1238) always sends seconds. */ +function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + const trimmed = header.trim(); + if (!trimmed) return undefined; + const seconds = Number(trimmed); + return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds) : undefined; +} + +/** Pulls the coded-envelope fields out of a parsed error body, tolerating both + * the quiz shape and the legacy `{detail, request_id}` one. */ +function errorFieldsFrom(body: unknown): { code?: string; requestId?: string } { + if (body === null || typeof body !== 'object') return {}; + const record = body as Record; + const nested = record.error; + const error = nested !== null && typeof nested === 'object' + ? (nested as Record) + : null; + const code = error && typeof error.code === 'string' ? error.code : undefined; + const requestId = [error?.request_id, record.request_id].find( + (v): v is string => typeof v === 'string' && v.length > 0, + ); + return { code, requestId }; +} + +/** + * The one sanctioned request helper: same-origin `API_URL` so the `sapling_session` + * cookie rides along (a cross-origin `NEXT_PUBLIC_API_URL` fetch drops it — the + * 2026-06-30 onboarding-loop bug). Exported since #537 so feature-scoped clients + * (`lib/quiz/api.ts`) can be thin wrappers over it instead of growing this file. + */ +export async function fetchJSON(path: string, options?: RequestInit): Promise { const res = await fetch(`${API_URL}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...options?.headers }, @@ -43,7 +113,12 @@ async function fetchJSON(path: string, options?: RequestInit): Promise { }); if (!res.ok) { const err = await res.text(); - throw new ApiError(err || `HTTP ${res.status}`, res.status); + const body = parseErrorBody(err); + throw new ApiError(err || `HTTP ${res.status}`, res.status, { + ...errorFieldsFrom(body), + retryAfterSec: parseRetryAfter(res.headers.get('Retry-After')), + body, + }); } return res.json(); } @@ -439,29 +514,7 @@ export const resumeSession = (sessionId: string) => messages: { id: string; role: string; content: string; created_at: string }[]; }>(`/api/learn/sessions/${sessionId}/resume`); -// Quiz -export interface QuizConfig { - num_questions: { min: number; max: number; options: number[] }; - difficulties: string[]; - question_types: string[]; -} - -// #540 A2: the backend is the single source of truth for selector values — -// QuizPanel builds its count/difficulty selects from this so the UI can -// never again offer a value the route rejects (e.g. the old "15 questions"). -export const fetchQuizConfig = () => fetchJSON('/api/quiz/config'); - -export const generateQuiz = (userId: string, conceptNodeId: string, numQuestions: number, difficulty: string, useSharedContext = true) => - fetchJSON<{ quiz_id: string; questions: any[]; requested_difficulty?: string; resolved_difficulty?: string; requested_count?: number; delivered_count?: number }>('/api/quiz/generate', { - method: 'POST', - body: JSON.stringify({ user_id: userId, concept_node_id: conceptNodeId, num_questions: numQuestions, difficulty, use_shared_context: useSharedContext }), - }); - -export const submitQuiz = (quizId: string, answers: any[]) => - fetchJSON<{ score: number; total: number; mastery_before: number; mastery_after: number; results: any[] }>('/api/quiz/submit', { - method: 'POST', - body: JSON.stringify({ quiz_id: quizId, answers }), - }); +// Quiz lives in `lib/quiz/api.ts` — its own client over all six quiz endpoints. // Calendar export interface Assignment { diff --git a/frontend/src/lib/errorMessage.test.ts b/frontend/src/lib/errorMessage.test.ts index c17b33bd..8c1aeac3 100644 --- a/frontend/src/lib/errorMessage.test.ts +++ b/frontend/src/lib/errorMessage.test.ts @@ -217,3 +217,47 @@ describe('ApiError integration', () => { .toBe("That's temporarily unavailable. Try again in a moment."); }); }); + +/** + * The coded envelope (#537). `error.message` is the student-readable sentence; + * top-level `detail` is legacy and, on a 422, a list of Pydantic dicts. + */ +describe('coded error envelope', () => { + it('prefers error.message over the legacy detail', () => { + const body = { + error: { code: 'QUIZ_RATE_LIMITED', message: 'Slow down a moment.', request_id: 'r1' }, + detail: 'Slow down a moment.', + request_id: 'r1', + }; + expect(extractErrorDetail({ ...body, status: 429 })) + .toEqual({ detail: 'Slow down a moment.', status: 429 }); + }); + + it('reads error.message when detail is the 422 error list', () => { + const err = new ApiError( + JSON.stringify({ + error: { code: 'QUIZ_COUNT_OUT_OF_RANGE', message: 'Quizzes can have between 1 and 10 questions.' }, + detail: [{ loc: ['body', 'num_questions'], msg: 'less than or equal to 10', type: 'x' }], + }), + 422, + ); + // Without the error.* preference this returned the Pydantic `msg` fragment. + expect(humanizeError(err, FALLBACK)).toBe('Quizzes can have between 1 and 10 questions.'); + }); + + it('reads the already-parsed ApiError.body without re-parsing the message', () => { + const err = new ApiError('(body text elided)', 409, { + code: 'QUIZ_ATTEMPT_ALREADY_COMPLETED', + body: { error: { code: 'QUIZ_ATTEMPT_ALREADY_COMPLETED', message: 'Already scored.' } }, + }); + expect(humanizeError(err, FALLBACK)).toBe('Already scored.'); + }); + + it('still falls through to status copy when error.message is unusable', () => { + const err = new ApiError('', 500, { + body: { error: { code: 'QUIZ_INTERNAL_ERROR', message: ' ' } }, + }); + expect(humanizeError(err, FALLBACK)) + .toBe('Something went wrong on our end. Try again in a moment.'); + }); +}); diff --git a/frontend/src/lib/errorMessage.ts b/frontend/src/lib/errorMessage.ts index 4f845fdb..8bd9552d 100644 --- a/frontend/src/lib/errorMessage.ts +++ b/frontend/src/lib/errorMessage.ts @@ -50,6 +50,13 @@ function parseJson(text: string): unknown { } function detailOf(body: Body): string | undefined { + // The coded envelope wins. Quiz routes answer with + // `{error: {code, message, request_id}, detail, request_id}` — `error.message` + // is the student-readable sentence, while top-level `detail` is legacy and, + // for a 422, a list of Pydantic error dicts (#537 / backend quiz_errors.py). + const coded = asRecord(body.error)?.message; + if (typeof coded === "string" && coded.trim()) return coded.trim(); + const detail = body.detail; if (typeof detail === "string") return detail.trim() || undefined; // FastAPI request-validation failures nest the message under detail[].msg. @@ -83,7 +90,13 @@ function statusFrom(err: unknown, body: Body | null): number | undefined { /** Pulls what the server actually told us out of a thrown error. */ export function extractErrorDetail(err: unknown): ErrorDetail { - const body = (err instanceof Error ? null : asRecord(err)) + // `ApiError.body` is the already-parsed response body (#537). Prefer it over + // re-parsing `message`, which is the same JSON as text. + const parsedBody = asRecord(err) && !Array.isArray(err) + ? asRecord((err as { body?: unknown }).body) + : null; + const body = parsedBody + ?? (err instanceof Error ? null : asRecord(err)) ?? asRecord(parseJson(rawMessage(err))); const out: ErrorDetail = {}; const detail = body ? detailOf(body) : undefined; diff --git a/frontend/src/lib/graph/neighbourhood.test.ts b/frontend/src/lib/graph/neighbourhood.test.ts new file mode 100644 index 00000000..668fbdce --- /dev/null +++ b/frontend/src/lib/graph/neighbourhood.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; +import { BACKFILL_STRENGTH, siblingsFor } from "./neighbourhood"; + +const node = (id: string, over: Partial = {}): GraphNode => ({ + id, + name: id, + subject: "CS101", + color: "#7b4b99", + mastery_tier: "learning", + mastery_score: 0.5, + course_id: "c1", + ...over, +}); + +const NODES: GraphNode[] = [ + node("subject_root__c1", { name: "CS101", is_subject_root: true, mastery_tier: "mastered" }), + node("recursion", { name: "Recursion", mastery_score: 0.29, mastery_tier: "struggling" }), + node("base-cases", { name: "Base cases", mastery_score: 0.52 }), + node("stack-frames", { name: "Stack frames", mastery_score: 0.3, mastery_tier: "struggling" }), + node("tail-recursion", { name: "Tail recursion", mastery_score: 0.12, mastery_tier: "struggling" }), + node("closures", { name: "Closures", mastery_score: 0.7 }), + node("eigenvalues", { name: "Eigenvalues", course_id: "c2", subject: "MATH210" }), +]; + +const EDGES: GraphEdge[] = [ + { source: "subject_root__c1", target: "recursion", strength: 0.7 }, + { source: "recursion", target: "base-cases", strength: 0.9 }, + { source: "stack-frames", target: "recursion", strength: 0.4 }, + { source: "recursion", target: "tail-recursion", strength: 0.6 }, +]; + +describe("siblingsFor", () => { + it("returns real neighbours by descending strength, in either edge direction", () => { + const sibs = siblingsFor("recursion", NODES, EDGES); + expect(sibs.map((s) => s.id)).toEqual(["base-cases", "tail-recursion", "stack-frames"]); + expect(sibs.map((s) => s.strength)).toEqual([0.9, 0.6, 0.4]); + }); + + it("excludes the synthetic subject-root hub, which is wired to every concept", () => { + const sibs = siblingsFor("recursion", NODES, EDGES, 5); + expect(sibs.map((s) => s.id)).not.toContain("subject_root__c1"); + }); + + it("carries the fields the mark needs off each neighbour", () => { + const [first] = siblingsFor("recursion", NODES, EDGES, 1); + expect(first).toEqual({ + id: "base-cases", + name: "Base cases", + mastery: 0.52, + tier: "learning", + strength: 0.9, + }); + }); + + it("backfills with same-course peers, ordered by hashSeed, when there aren't enough edges", () => { + // "closures" is the only unedged same-course concept left. + const sibs = siblingsFor("recursion", NODES, EDGES, 4); + expect(sibs.map((s) => s.id)).toEqual([ + "base-cases", + "tail-recursion", + "stack-frames", + "closures", + ]); + expect(sibs[3].strength).toBe(BACKFILL_STRENGTH); + }); + + it("orders the backfill by hashSeed, not by array order", () => { + const isolated = node("lonely", { name: "Lonely" }); + const peers = ["p-alpha", "p-beta", "p-gamma", "p-delta"].map((id) => node(id)); + const expected = [...peers] + .sort((a, b) => hashSeed(a.id) - hashSeed(b.id)) + .slice(0, 3) + .map((p) => p.id); + + const forward = siblingsFor("lonely", [isolated, ...peers], []); + const reversed = siblingsFor("lonely", [isolated, ...[...peers].reverse()], []); + + expect(forward.map((s) => s.id)).toEqual(expected); + // Same graph, different array order → same answer. + expect(reversed.map((s) => s.id)).toEqual(expected); + }); + + it("never backfills across courses", () => { + const sibs = siblingsFor("recursion", NODES, EDGES, 6); + expect(sibs.map((s) => s.id)).not.toContain("eigenvalues"); + // Five concepts in c1; the centre is one of them, so four are available. + expect(sibs).toHaveLength(4); + }); + + it("skips the backfill entirely when the centre has no course", () => { + const orphan = node("orphan", { course_id: "" }); + const others = [node("x", { course_id: "" }), node("y", { course_id: "" })]; + expect(siblingsFor("orphan", [orphan, ...others], [])).toEqual([]); + }); + + it("returns nothing for an unknown centre, an empty graph, or n <= 0", () => { + expect(siblingsFor("nope", NODES, EDGES)).toEqual([]); + expect(siblingsFor("recursion", [], [])).toEqual([]); + expect(siblingsFor("recursion", NODES, EDGES, 0)).toEqual([]); + }); + + it("keeps the strongest edge when a pair is joined more than once, and never self-links", () => { + const dupes: GraphEdge[] = [ + { source: "recursion", target: "base-cases", strength: 0.2 }, + { source: "base-cases", target: "recursion", strength: 0.8 }, + { source: "recursion", target: "recursion", strength: 1 }, + ]; + const sibs = siblingsFor("recursion", NODES, dupes, 1); + expect(sibs).toEqual([ + { id: "base-cases", name: "Base cases", mastery: 0.52, tier: "learning", strength: 0.8 }, + ]); + }); + + it("is deterministic across repeated calls", () => { + const a = siblingsFor("recursion", NODES, EDGES, 4); + const b = siblingsFor("recursion", NODES, EDGES, 4); + expect(a).toEqual(b); + }); +}); diff --git a/frontend/src/lib/graph/neighbourhood.ts b/frontend/src/lib/graph/neighbourhood.ts new file mode 100644 index 00000000..63677773 --- /dev/null +++ b/frontend/src/lib/graph/neighbourhood.ts @@ -0,0 +1,111 @@ +/** + * neighbourhood — which concepts to draw around a centre concept. + * + * There is no neighbourhood endpoint: `GET /api/graph/{user}` returns the + * whole `{nodes, edges}` and the caller derives the fragment. This module is + * that derivation, kept pure and deterministic so the little constellation on + * quiz home doesn't reshuffle between renders (#537). + * + * Two sources, in order: + * 1. Real neighbours — the other endpoint of any edge touching the centre, + * strongest first. `subject_root__*` hubs are excluded: the backend mints + * one per course and wires it to *every* concept in that course + * (graph_service.py), so they are structure, not siblings. + * 2. Backfill — same-course concepts, ordered by `hashSeed(id)`. A freshly + * extracted concept often has no edges at all, and an empty + * neighbourhood reads as "this concept is alone on your tree", which is + * a lie about the data rather than a fact about it. + */ + +import { hashSeed, type GraphEdge, type GraphNode } from "@/lib/data"; + +/** The synthetic per-course hub id minted by `graph_service.get_graph`. */ +const SUBJECT_ROOT_PREFIX = "subject_root__"; + +export interface NeighbourNode { + id: string; + name: string; + mastery: number; + tier: string; + /** Edge strength to the centre. Backfilled peers get the backend's fixed + * hub-spoke 0.7 so their edge renders at the same width as a real spoke. */ + strength: number; +} + +/** The strength `graph_service` gives every hub-spoke edge. */ +export const BACKFILL_STRENGTH = 0.7; + +const isRoot = (n: Pick) => + Boolean(n.is_subject_root) || n.id.startsWith(SUBJECT_ROOT_PREFIX); + +function toNeighbour(node: GraphNode, strength: number): NeighbourNode { + return { + id: node.id, + name: node.name, + mastery: node.mastery_score || 0, + tier: node.mastery_tier, + strength, + }; +} + +/** + * Up to `n` siblings for `centreId`, deterministic for a given graph. + * + * Returns fewer than `n` only when the course genuinely has fewer concepts. + * An unknown `centreId` yields an empty list rather than a random selection — + * the caller has nothing to draw a centre for either. + */ +export function siblingsFor( + centreId: string, + nodes: GraphNode[], + edges: GraphEdge[], + n = 3, +): NeighbourNode[] { + if (n <= 0) return []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const centre = byId.get(centreId); + if (!centre) return []; + + // 1. Real neighbours, strongest first. A pair can be joined by more than one + // edge, so keep the strongest per id; ties break on id for stability + // (Array.prototype.sort is stable, but the input edge order is not). + const strongest = new Map(); + for (const e of edges) { + const otherId = e.source === centreId ? e.target : e.target === centreId ? e.source : null; + if (!otherId || otherId === centreId) continue; + const other = byId.get(otherId); + if (!other || isRoot(other)) continue; + const strength = e.strength ?? 0; + const seen = strongest.get(otherId); + if (seen === undefined || strength > seen) strongest.set(otherId, strength); + } + + const picked: NeighbourNode[] = [...strongest.entries()] + .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .slice(0, n) + .map(([id, strength]) => toNeighbour(byId.get(id)!, strength)); + + if (picked.length >= n) return picked; + + // 2. Backfill with same-course peers the edges didn't already supply. + const taken = new Set(picked.map((p) => p.id)); + taken.add(centreId); + const peers = nodes + .filter( + (node) => + !taken.has(node.id) && + !isRoot(node) && + node.course_id === centre.course_id && + // A blank course_id matches every other blank one — that is noise, not + // a course. Skip the backfill entirely rather than invent a family. + Boolean(centre.course_id), + ) + .sort((a, b) => hashSeed(a.id) - hashSeed(b.id) || (a.id < b.id ? -1 : 1)); + + for (const peer of peers) { + if (picked.length >= n) break; + picked.push(toNeighbour(peer, BACKFILL_STRENGTH)); + } + + return picked; +} diff --git a/frontend/src/lib/graph/nodeStyle.test.ts b/frontend/src/lib/graph/nodeStyle.test.ts new file mode 100644 index 00000000..6f4acefd --- /dev/null +++ b/frontend/src/lib/graph/nodeStyle.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; +import { + GLOW, + NODE_STROKE_OPACITY, + TIER_OPACITY, + edgeWidthFor, + hexToHsl, + hslToHex, + opacityFor, + radiusFor, + shadeFor, + tierFor, + truncateLabel, +} from "./nodeStyle"; + +/** + * A frozen table, not a re-derivation. `shadeFor` is a bit-shift jitter whose + * only correctness criterion is "the same node keeps the same colour, on every + * screen, forever" — so the test that protects it has to be literals. A + * "cleanup" of the shifts that changes any of these is a visible regression on + * every graph in the app. + */ +const SHADE_GOLDEN = [ + { base: "#7a874f", id: "n1", css: "hsl(91 24% 33%)", hex: "#536840" }, + { base: "#7a874f", id: "recursion", css: "hsl(82 28% 36%)", hex: "#637642" }, + { base: "#3e6f8a", id: "eigenvalues", css: "hsl(209 46% 46%)", hex: "#4077ac" }, + { base: "#7b4b99", id: "base-cases", css: "hsl(297 20% 28%)", hex: "#543956" }, + { base: "#b4562c", id: "subject_root__c1", css: "hsl(43 39% 28%)", hex: "#63532c" }, + { base: "#3f8a7c", id: "a", css: "hsl(190 32% 28%)", hex: "#30575e" }, + { base: "#7a874f", id: "Fundamental theorem of calculus", css: "hsl(61 22% 49%)", hex: "#989961" }, +]; + +describe("shadeFor", () => { + it.each(SHADE_GOLDEN)("$base + $id → $css / $hex", ({ base, id, css, hex }) => { + expect(shadeFor(base, id)).toBe(css); + expect(shadeFor(base, id, "css")).toBe(css); + expect(shadeFor(base, id, "hex")).toBe(hex); + }); + + it("defaults to the css form the SVG renderers consume", () => { + expect(shadeFor("#7a874f", "n1")).toMatch(/^hsl\(/); + }); + + it("never returns the space-separated hsl() form for the 3D renderer", () => { + // Three.js's Color.setStyle rejects `hsl(h s% l%)` and paints BLACK + // (KnowledgeGraph3D documents this at its old local copy). + for (const { base, id } of SHADE_GOLDEN) { + expect(shadeFor(base, id, "hex")).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("passes a non-hex base straight through, shading disabled", () => { + expect(shadeFor("var(--c-sage)", "n1")).toBe("var(--c-sage)"); + expect(shadeFor("var(--c-sage)", "n1", "hex")).toBe("var(--c-sage)"); + }); + + it("is stable across calls and independent of the base for the seed", () => { + expect(shadeFor("#7a874f", "n1")).toBe(shadeFor("#7a874f", "n1")); + // Different ids on the same course must not collide. + expect(shadeFor("#7a874f", "n1")).not.toBe(shadeFor("#7a874f", "n2")); + }); +}); + +describe("hexToHsl / hslToHex", () => { + it("parses with and without the leading hash, and rejects everything else", () => { + expect(hexToHsl("#ffffff")).toEqual({ h: 0, s: 0, l: 100 }); + expect(hexToHsl("000000")).toEqual({ h: 0, s: 0, l: 0 }); + expect(hexToHsl("var(--accent)")).toBeNull(); + expect(hexToHsl("#abc")).toBeNull(); + }); + + it("round-trips a saturated hue", () => { + const hsl = hexToHsl("#3e6f8a")!; + expect(hslToHex(hsl.h, hsl.s, hsl.l)).toBe("#3e6f8a"); + }); +}); + +describe("radiusFor", () => { + it.each([ + [0, 8], + [0.25, 11], + [0.5, 14], + [1, 20], + ])("mastery %s → r %s", (mastery, r) => { + expect(radiusFor(mastery)).toBeCloseTo(r, 10); + }); + + it("pins subject roots at a flat 22 regardless of mastery", () => { + expect(radiusFor(0, true)).toBe(22); + expect(radiusFor(1, true)).toBe(22); + }); +}); + +describe("tierFor", () => { + // Mirrors backend/config.py::get_mastery_tier — 0.75 / 0.45 / 0.1, boundaries + // inclusive on the lower end. Pinned so the fifth client-side mirror can't drift. + it.each([ + [1, "mastered"], + [0.75, "mastered"], + [0.7499, "learning"], + [0.45, "learning"], + [0.4499, "struggling"], + [0.1, "struggling"], + [0.0999, "unexplored"], + [0, "unexplored"], + ])("score %s → %s", (score, tier) => { + expect(tierFor(score)).toBe(tier); + }); +}); + +describe("opacityFor / TIER_OPACITY", () => { + it("is the tree's ramp", () => { + expect(TIER_OPACITY).toEqual({ + mastered: 1, + learning: 0.78, + struggling: 0.55, + unexplored: 0.28, + }); + }); + + it.each(Object.entries(TIER_OPACITY))("tier %s → %s", (tier, op) => { + expect(opacityFor(tier)).toBe(op); + }); + + it("treats the wire's subject_root as fully opaque", () => { + expect(opacityFor("subject_root")).toBe(1); + }); + + it("falls back to 0.6 for an unrecognised tier, as the renderer's `|| 0.6` did", () => { + expect(opacityFor("nonsense")).toBe(0.6); + }); +}); + +describe("edgeWidthFor", () => { + it.each([ + [0.1, 0.62], + [0.3, 0.86], + [0.5, 1.1], + [0.7, 1.34], + [1, 1.7], + ])("strength %s → %s", (strength, width) => { + expect(edgeWidthFor(strength)).toBeCloseTo(width, 10); + }); + + it("substitutes 0.5 for a falsy strength, exactly as the renderer's `|| 0.5` did", () => { + // Including a literal 0 — the quirk is preserved on purpose, because the + // tree paints a zero-strength edge at 1.1 today and the golden pins it. + expect(edgeWidthFor(0)).toBeCloseTo(1.1, 10); + expect(edgeWidthFor(undefined as unknown as number)).toBeCloseTo(1.1, 10); + }); +}); + +describe("truncateLabel", () => { + it("leaves anything up to 18 characters alone", () => { + expect(truncateLabel("Recursion")).toBe("Recursion"); + expect(truncateLabel("123456789012345678")).toBe("123456789012345678"); + }); + + it("cuts to 17 characters plus an ellipsis past 18", () => { + expect(truncateLabel("1234567890123456789")).toBe("12345678901234567…"); + expect(truncateLabel("Fundamental theorem of calculus")).toBe("Fundamental theor…"); + }); + + it("honours a caller-supplied max", () => { + expect(truncateLabel("Recursion", 5)).toBe("Recu…"); + }); +}); + +describe("the mark's constants", () => { + it("keeps the resting stroke opacity and the glow geometry the tree uses", () => { + expect(NODE_STROKE_OPACITY).toBe(0.4); + expect(GLOW).toEqual({ pad: 8, opacity: 0.15, blur: 3 }); + }); +}); diff --git a/frontend/src/lib/graph/nodeStyle.ts b/frontend/src/lib/graph/nodeStyle.ts new file mode 100644 index 00000000..2b77b2cb --- /dev/null +++ b/frontend/src/lib/graph/nodeStyle.ts @@ -0,0 +1,161 @@ +/** + * nodeStyle — how a concept node is painted, as pure functions. + * + * Extracted verbatim from `KnowledgeGraph2D` / `KnowledgeGraph3D` (#537), + * which each carried their own byte-identical copy of `hexToHsl` + `shadeFor` + * and their own tier→opacity / mastery→radius tables. Both renderers now + * import from here, and so do the quiz surfaces (`ConceptNode`, + * `ConceptNeighbourhood`), so a retune moves the tree and the quiz together + * instead of drifting them apart. + * + * Nothing here touches React, d3 or the DOM: these are arithmetic, and the + * renderers evaluate them during render, outside the simulation tick. + * + * Two things this module deliberately does NOT own: + * - the force-collide radius (`is_subject_root ? 36 : 18 + mastery * 6`, + * KnowledgeGraph2D). That is a *layout* radius, not a visual one; the two + * have always differed and the simulation keeps it. + * - `hashSeed`. It already lives in `lib/data.ts` and is shared. Note the + * unrelated same-named DJB2 hash in `Gradebook/CourseCard.tsx` — importing + * this one there would reshuffle every course watermark. + */ + +import { hashSeed } from "@/lib/data"; + +export type MasteryTier = "mastered" | "learning" | "struggling" | "unexplored"; + +/** `#rrggbb` (with or without the hash) → HSL, or null for anything else — + * notably `var(--token)` strings, which callers pass straight through. */ +export function hexToHsl(hex: string): { h: number; s: number; l: number } | null { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + const r = parseInt(m[1].slice(0, 2), 16) / 255; + const g = parseInt(m[1].slice(2, 4), 16) / 255; + const b = parseInt(m[1].slice(4, 6), 16) / 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b), + l = (max + min) / 2; + let h = 0, + s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60; + else if (max === g) h = ((b - r) / d + 2) * 60; + else h = ((r - g) / d + 4) * 60; + } + return { h, s: s * 100, l: l * 100 }; +} + +export function hslToHex(h: number, s: number, l: number): string { + const sN = s / 100; + const lN = l / 100; + const c = (1 - Math.abs(2 * lN - 1)) * sN; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lN - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const to = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, "0"); + return `#${to(r)}${to(g)}${to(b)}`; +} + +/** + * Deterministic per-node shade derived from the course colour + node id. + * Keeps each course visually unified while giving every node its own tone, + * and produces identical output across pages because it depends only on the + * stable inputs (no per-screen overrides). + * + * `as` picks the output form, and the choice is load-bearing: + * - `"css"` (default) → `hsl(h s% l%)`, what the SVG renderers use. + * - `"hex"` → `#rrggbb`, what the 3D renderer MUST use. Three.js's + * `Color.setStyle` only accepts the comma-separated `hsl(h, s%, l%)` + * form; the modern space-separated string silently renders BLACK. + * + * A non-hex base (e.g. `var(--c-sage)`) can't be jittered, so it passes + * through unchanged — which silently disables shading. Callers that care + * resolve the colour to hex first (`apiToGraphNode` does). + */ +export function shadeFor(baseHex: string, nodeId: string, as: "css" | "hex" = "css"): string { + const hsl = hexToHsl(baseHex); + if (!hsl) return baseHex; + const seed = hashSeed(nodeId); + const dh = (seed % 51) - 25; + const ds = ((seed >> 5) % 17) - 8; + const dl = ((seed >> 10) % 25) - 12; + const h = (hsl.h + dh + 360) % 360; + const s = Math.max(20, Math.min(85, hsl.s + ds)); + const l = Math.max(28, Math.min(62, hsl.l + dl)); + if (as === "hex") return hslToHex(h, s, l); + return `hsl(${h.toFixed(0)} ${s.toFixed(0)}% ${l.toFixed(0)}%)`; +} + +/** Visual radius of a concept mark, in the tree's own units. Subject roots are + * a flat 22 — they anchor a family and don't encode mastery. */ +export function radiusFor(mastery: number, isRoot = false): number { + if (isRoot) return 22; + return 8 + (mastery || 0) * 12; +} + +/** + * score → tier. Mirrors `backend/config.py::get_mastery_tier` + * (MASTERY_MASTERED_MIN 0.75 / MASTERY_LEARNING_MIN 0.45 / + * MASTERY_STRUGGLING_MIN 0.1), pinned by the table test next to this file. + * + * Read the server's `mastery_tier` string wherever there is one — every graph + * node carries it. This exists for the one case that has a score and no tier: + * the quiz submit response's `mastery_after` (#537 R-12). + */ +export function tierFor(score: number): MasteryTier { + if (score >= 0.75) return "mastered"; + if (score >= 0.45) return "learning"; + if (score >= 0.1) return "struggling"; + return "unexplored"; +} + +/** How the mark encodes its tier. Mirrored by `e2e/graph.spec.ts`'s + * TIER_OPACITY — a retune updates both in the same PR. */ +export const TIER_OPACITY: Record = { + mastered: 1, + learning: 0.78, + struggling: 0.55, + unexplored: 0.28, +}; + +/** + * Tier → opacity, tolerant of the strings that actually arrive on the wire. + * `subject_root` reads as fully opaque (roots don't encode mastery); anything + * else unrecognised falls to 0.6, exactly as the renderer's `|| 0.6` did. + */ +export function opacityFor(tier: string): number { + if (tier === "subject_root") return 1; + return TIER_OPACITY[tier as MasteryTier] ?? 0.6; +} + +/** Edge stroke width from its 0..1 strength → 0.5 … 1.7. The backend's + * hub-spoke edges ship a fixed 0.7 (graph_service.py), i.e. 1.34. */ +export function edgeWidthFor(strength: number): number { + return 0.5 + (strength || 0.5) * 1.2; +} + +/** The tree's concept-label rule: anything longer than `max` is cut to + * `max - 1` characters plus an ellipsis. Mirrored by `e2e/graph.spec.ts`. */ +export function truncateLabel(name: string, max = 18): string { + return name.length > max ? name.slice(0, max - 1) + "…" : name; +} + +/** Resting stroke opacity of the node body. The tree raises it on hover (0.9) + * and while drag-pinned (1.0); static surfaces stay at rest. */ +export const NODE_STROKE_OPACITY = 0.4; + +/** The soft halo behind an `organism` node: a blurred disc at `r + pad`. */ +export const GLOW = { pad: 8, opacity: 0.15, blur: 3 } as const; diff --git a/frontend/src/lib/quiz/api.test.ts b/frontend/src/lib/quiz/api.test.ts new file mode 100644 index 00000000..5ee35128 --- /dev/null +++ b/frontend/src/lib/quiz/api.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + answerQuestion, + describeConcept, + fetchQuizConfig, + generateQuiz, + getAttempt, + listAttempts, + submitQuiz, +} from "./api"; + +const realFetch = globalThis.fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function fetchMock(): ReturnType { + return globalThis.fetch as unknown as ReturnType; +} + +function lastCall(): [string, RequestInit] { + const calls = fetchMock().mock.calls; + return calls[calls.length - 1] as [string, RequestInit]; +} + +beforeEach(() => { + globalThis.fetch = vi.fn() as unknown as typeof fetch; + fetchMock().mockResolvedValue(jsonResponse({})); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); +}); + +describe("fetchQuizConfig", () => { + it("GETs the unauthenticated config route", async () => { + fetchMock().mockResolvedValue( + jsonResponse({ + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], + }), + ); + const config = await fetchQuizConfig(); + expect(lastCall()[0]).toBe("/api/quiz/config"); + expect(config.num_questions.options).toEqual([3, 5, 10]); + }); +}); + +describe("generateQuiz", () => { + it("always sends include_answer_key: false (R-2 — the server grades)", async () => { + await generateQuiz({ + userId: "u1", + conceptNodeId: "c1", + numQuestions: 5, + difficulty: "medium", + }); + const [url, init] = lastCall(); + expect(url).toBe("/api/quiz/generate"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + user_id: "u1", + concept_node_id: "c1", + num_questions: 5, + difficulty: "medium", + include_answer_key: false, + }); + }); +}); + +describe("answerQuestion", () => { + it("posts the 0-based index pair plus the 1-based wire question id", async () => { + await answerQuestion("a-1", { questionIndex: 2, selectedIndex: 0, questionId: 3 }); + const [url, init] = lastCall(); + expect(url).toBe("/api/quiz/attempts/a-1/answer"); + expect(JSON.parse(init.body as string)).toEqual({ + question_index: 2, + selected_index: 0, + question_id: 3, + }); + }); + + it("omits time_ms and confidence — nothing reads them back (G10)", async () => { + await answerQuestion("a-1", { questionIndex: 0, selectedIndex: 1, questionId: 1 }); + const body = JSON.parse(lastCall()[1].body as string) as Record; + expect("time_ms" in body).toBe(false); + expect("confidence" in body).toBe(false); + }); + + it("url-encodes the attempt id", async () => { + await answerQuestion("a/1", { questionIndex: 0, selectedIndex: 0, questionId: 1 }); + expect(lastCall()[0]).toBe("/api/quiz/attempts/a%2F1/answer"); + }); +}); + +describe("submitQuiz", () => { + it("posts quiz_id plus the local answers as the belt-and-braces payload", async () => { + await submitQuiz("a-1", [{ question_id: 1, selected_label: "B" }]); + const [url, init] = lastCall(); + expect(url).toBe("/api/quiz/submit"); + expect(JSON.parse(init.body as string)).toEqual({ + quiz_id: "a-1", + answers: [{ question_id: 1, selected_label: "B" }], + }); + }); + + it("accepts an empty answers list (recorded rows win at reconciliation)", async () => { + await submitQuiz("a-1", []); + expect(JSON.parse(lastCall()[1].body as string).answers).toEqual([]); + }); +}); + +describe("listAttempts", () => { + it("scopes by user id and omits absent pagination params", async () => { + await listAttempts("u1"); + expect(lastCall()[0]).toBe("/api/quiz/attempts?user_id=u1"); + }); + + it("passes limit and offset through", async () => { + await listAttempts("u 1", { limit: 20, offset: 40 }); + expect(lastCall()[0]).toBe("/api/quiz/attempts?user_id=u+1&limit=20&offset=40"); + }); +}); + +describe("getAttempt", () => { + it("GETs the resume route", async () => { + fetchMock().mockResolvedValue(jsonResponse({ quiz_id: "a-1", resumable: true })); + const detail = await getAttempt("a-1"); + expect(lastCall()[0]).toBe("/api/quiz/attempts/a-1"); + expect(detail.quiz_id).toBe("a-1"); + }); +}); + +describe("describeConcept", () => { + it("unwraps the description and sends the course LABEL, not an id", async () => { + fetchMock().mockResolvedValue(jsonResponse({ description: "A way to solve X." })); + const out = await describeConcept("u1", "Recursion", "CS 330 — Algorithms"); + const [url, init] = lastCall(); + expect(url).toBe("/api/graph/u1/concept-description"); + expect(JSON.parse(init.body as string)).toEqual({ + concept: "Recursion", + course_label: "CS 330 — Algorithms", + }); + expect(out).toBe("A way to solve X."); + }); + + it("sends a null course label when none is known", async () => { + fetchMock().mockResolvedValue(jsonResponse({ description: "d" })); + await describeConcept("u1", "Recursion"); + expect(JSON.parse(lastCall()[1].body as string).course_label).toBeNull(); + }); +}); + +describe("every call is same-origin and cookie-bearing", () => { + it("sends credentials: include", async () => { + await fetchQuizConfig(); + const [url, init] = lastCall(); + expect(url.startsWith("/api/")).toBe(true); + expect(init.credentials).toBe("include"); + }); +}); diff --git a/frontend/src/lib/quiz/api.ts b/frontend/src/lib/quiz/api.ts new file mode 100644 index 00000000..b6f8ddbc --- /dev/null +++ b/frontend/src/lib/quiz/api.ts @@ -0,0 +1,133 @@ +/** + * The quiz client — thin `fetchJSON` wrappers over the six shipped quiz + * endpoints (`backend/routes/quiz.py`, mounted at `/api/quiz`). + * + * Three of them had no frontend caller at all before #537: `GET /attempts`, + * `GET /attempts/{id}` and `POST /attempts/{id}/answer` (R1 §H). They are what + * make resume, history and server-side grading possible, so the quiz leans on + * all three. + * + * This is the only quiz client. `lib/api.ts` carries the shared `fetchJSON` and + * the non-quiz routes; its own quiz wrappers went with `QuizPanel`. + */ + +import { fetchJSON, describeConcept as describeConceptRaw } from "@/lib/api"; +import type { + AnswerResult, + AttemptDetail, + AttemptsPage, + GenerateResult, + QuizConfig, + SubmitResult, +} from "./types"; + +/** `GET /api/quiz/config` — unauthenticated; the ONLY source of count/difficulty + * option lists. Never enumerate those values in client code. */ +export const fetchQuizConfig = (): Promise => + fetchJSON("/api/quiz/config"); + +/** + * `POST /api/quiz/generate`. + * + * `include_answer_key: false` is deliberate and load-bearing (R-2): with the + * default `true` the response carries per-option `correct` booleans and every + * explanation, which is what let the old panel grade client-side. The keyless + * projection forces every verdict through `answerQuestion`, where the server + * grades. Removing the flag entirely is #546. + * + * `use_shared_context` and `model_pref` are left at their server defaults — + * the redesign has no surface for either. + */ +export const generateQuiz = (p: { + userId: string; + conceptNodeId: string; + numQuestions: number; + difficulty: string; +}): Promise => + fetchJSON("/api/quiz/generate", { + method: "POST", + body: JSON.stringify({ + user_id: p.userId, + concept_node_id: p.conceptNodeId, + num_questions: p.numQuestions, + difficulty: p.difficulty, + include_answer_key: false, + }), + }); + +/** + * `POST /api/quiz/attempts/{id}/answer` — the server-side grader. Idempotent on + * `(attempt_id, question_index)`: replaying an answer returns the FIRST recorded + * response with `recorded: false` rather than revising it. + * + * `time_ms` and `confidence` are accepted by the route and stored, but nothing + * ever reads them back and the redesign surfaces neither, so they are + * deliberately omitted rather than sent as junk. + * TODO(#537-followup: per-question timing) — send them once something displays + * them (gap G10). + */ +export const answerQuestion = ( + attemptId: string, + p: { questionIndex: number; selectedIndex: number; questionId: number }, +): Promise => + fetchJSON(`/api/quiz/attempts/${encodeURIComponent(attemptId)}/answer`, { + method: "POST", + body: JSON.stringify({ + question_index: p.questionIndex, + selected_index: p.selectedIndex, + question_id: p.questionId, + }), + }); + +/** + * `POST /api/quiz/submit` — the only call that scores the attempt, moves + * mastery and pays XP. Per-question `/answer` calls do NOT complete an attempt + * (gap G7), so this is always called at the end. + * + * `answers` is belt-and-braces: the route reconciles against `quiz_responses` + * and a recorded row always wins, so `[]` scores correctly when every question + * went through `/answer`. We still send the local answers — they cover the + * questions whose `/answer` call was lost. + */ +export const submitQuiz = ( + attemptId: string, + answers: { question_id: number; selected_label: string }[], +): Promise => + fetchJSON("/api/quiz/submit", { + method: "POST", + body: JSON.stringify({ quiz_id: attemptId, answers }), + }); + +/** `GET /api/quiz/attempts` — paginated, user-scoped, newest first. There is no + * concept/status filter param (gap G2/G3); filter the page client-side. */ +export const listAttempts = ( + userId: string, + p: { limit?: number; offset?: number } = {}, +): Promise => { + const params = new URLSearchParams({ user_id: userId }); + if (p.limit !== undefined) params.set("limit", String(p.limit)); + if (p.offset !== undefined) params.set("offset", String(p.offset)); + return fetchJSON(`/api/quiz/attempts?${params.toString()}`); +}; + +/** `GET /api/quiz/attempts/{id}` — resume. A completed or abandoned attempt + * answers 200 with `resumable: false` and `questions: []`, so this is not a + * results-review endpoint (gap G5). */ +export const getAttempt = (attemptId: string): Promise => + fetchJSON(`/api/quiz/attempts/${encodeURIComponent(attemptId)}`); + +/** + * `POST /api/graph/{user}/concept-description` — one LLM call for one concept. + * + * Reuses the client Learn's focus card already uses (`lib/api.ts::describeConcept`). + * The second argument the route takes is a human course *label*, not a course id + * — `build_message` hands the concept name and that label straight to the agent + * (routes/graph.py:145-190). Called for the primary proposal only (R-8); the + * callers fall back to a built sentence on failure rather than blocking. + */ +export const describeConcept = ( + userId: string, + conceptName: string, + courseLabel?: string, +): Promise => + describeConceptRaw(userId, conceptName, courseLabel).then(r => r.description); diff --git a/frontend/src/lib/quiz/errors.test.ts b/frontend/src/lib/quiz/errors.test.ts new file mode 100644 index 00000000..26804a57 --- /dev/null +++ b/frontend/src/lib/quiz/errors.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "@/lib/api"; +import { + QUIZ_ERROR_COPY, + QUIZ_ERROR_CODES, + describeQuizError, + type QuizErrorCode, +} from "./errors"; + +function apiError( + status: number, + extra: { code?: string; message?: string; requestId?: string; retryAfterSec?: number } = {}, +): ApiError { + const body = { + error: { + code: extra.code, + message: extra.message ?? "server sentence", + request_id: extra.requestId ?? null, + }, + detail: extra.message ?? "server sentence", + request_id: extra.requestId ?? null, + }; + return new ApiError(JSON.stringify(body), status, { + code: extra.code, + requestId: extra.requestId, + retryAfterSec: extra.retryAfterSec, + body, + }); +} + +describe("QUIZ_ERROR_COPY", () => { + it("covers every code with a non-empty sentence", () => { + for (const code of QUIZ_ERROR_CODES) { + expect(QUIZ_ERROR_COPY[code], code).toBeTruthy(); + expect(QUIZ_ERROR_COPY[code].trim().length, code).toBeGreaterThan(10); + } + }); + + it("pins the contract's final strings", () => { + expect(QUIZ_ERROR_COPY.QUIZ_RATE_LIMITED).toBe( + "You're quizzing fast — give it {n} seconds and try again.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_DAILY_LIMIT_REACHED).toBe( + "You've used today's quiz allowance. It resets tomorrow.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_GENERATION_TIMEOUT).toBe( + "Writing this quiz took too long. Try again — it usually works the second time.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_GENERATION_FAILED).toBe( + "We couldn't put a quiz together for this concept right now. Try again in a moment.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_CONCEPT_NOT_FOUND).toBe( + "That concept isn't on your tree any more. Pick another one.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_ATTEMPT_NOT_FOUND).toBe( + "We couldn't find that quiz. Start a new one.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_ATTEMPT_ALREADY_COMPLETED).toBe( + "This quiz was already scored. Your results are on your tree.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_ATTEMPT_ABANDONED).toBe( + "That quiz expired after a day. Start a fresh one.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_ATTEMPT_NOT_RESUMABLE).toBe( + "This quiz can't be resumed. Start a new one.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_QUESTION_INVALID).toBe( + "That answer didn't line up with the question. Reload and try again.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_DIFFICULTY_INVALID).toBe( + "Something about this request wasn't valid. Reload and try again.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_VALIDATION_ERROR).toBe( + QUIZ_ERROR_COPY.QUIZ_DIFFICULTY_INVALID, + ); + expect(QUIZ_ERROR_COPY.QUIZ_NOT_AUTHORIZED).toBe( + "Please sign in again to keep quizzing.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_INTERNAL_ERROR).toBe( + "Something went wrong on our side. Try again in a moment.", + ); + expect(QUIZ_ERROR_COPY.QUIZ_HTTP_ERROR).toBe(QUIZ_ERROR_COPY.QUIZ_INTERNAL_ERROR); + expect(QUIZ_ERROR_COPY.UNKNOWN).toBe(QUIZ_ERROR_COPY.QUIZ_INTERNAL_ERROR); + expect(QUIZ_ERROR_COPY.NETWORK).toBe( + "You look offline. Check your connection and try again.", + ); + }); +}); + +describe("describeQuizError — coded envelope", () => { + const cases: [QuizErrorCode, number][] = [ + ["QUIZ_DIFFICULTY_INVALID", 400], + ["QUIZ_QUESTION_INVALID", 400], + ["QUIZ_VALIDATION_ERROR", 422], + ["QUIZ_NOT_AUTHORIZED", 401], + ["QUIZ_CONCEPT_NOT_FOUND", 404], + ["QUIZ_ATTEMPT_NOT_FOUND", 404], + ["QUIZ_ATTEMPT_ALREADY_COMPLETED", 409], + ["QUIZ_ATTEMPT_ABANDONED", 409], + ["QUIZ_ATTEMPT_NOT_RESUMABLE", 409], + ["QUIZ_DAILY_LIMIT_REACHED", 429], + ["QUIZ_GENERATION_TIMEOUT", 502], + ["QUIZ_GENERATION_FAILED", 502], + ["QUIZ_INTERNAL_ERROR", 500], + ["QUIZ_HTTP_ERROR", 405], + ]; + + for (const [code, status] of cases) { + it(`maps ${code} to its copy`, () => { + const out = describeQuizError(apiError(status, { code })); + expect(out.code).toBe(code); + expect(out.message).toBe(QUIZ_ERROR_COPY[code]); + }); + } + + it("interpolates Retry-After into the rate-limit copy", () => { + const out = describeQuizError(apiError(429, { code: "QUIZ_RATE_LIMITED", retryAfterSec: 42 })); + expect(out.code).toBe("QUIZ_RATE_LIMITED"); + expect(out.message).toBe("You're quizzing fast — give it 42 seconds and try again."); + expect(out.retryAfterSec).toBe(42); + expect(out.retryable).toBe(true); + }); + + it("falls back to 60 seconds when Retry-After is missing", () => { + const out = describeQuizError(apiError(429, { code: "QUIZ_RATE_LIMITED" })); + expect(out.message).toBe("You're quizzing fast — give it 60 seconds and try again."); + expect(out.retryAfterSec).toBeUndefined(); + }); + + it("uses the server sentence verbatim for QUIZ_COUNT_OUT_OF_RANGE (it carries the bounds)", () => { + const out = describeQuizError( + apiError(422, { + code: "QUIZ_COUNT_OUT_OF_RANGE", + message: "Quizzes can have between 1 and 10 questions.", + }), + ); + expect(out.code).toBe("QUIZ_COUNT_OUT_OF_RANGE"); + expect(out.message).toBe("Quizzes can have between 1 and 10 questions."); + }); + + it("falls back to generic copy when QUIZ_COUNT_OUT_OF_RANGE has no server sentence", () => { + const err = new ApiError("", 422, { code: "QUIZ_COUNT_OUT_OF_RANGE" }); + expect(describeQuizError(err).message).toBe(QUIZ_ERROR_COPY.QUIZ_COUNT_OUT_OF_RANGE); + }); + + it("carries the request id through for support", () => { + const out = describeQuizError( + apiError(500, { code: "QUIZ_INTERNAL_ERROR", requestId: "req-9" }), + ); + expect(out.requestId).toBe("req-9"); + }); + + it("marks the retryable codes", () => { + const retryable: QuizErrorCode[] = [ + "QUIZ_RATE_LIMITED", + "QUIZ_GENERATION_TIMEOUT", + "QUIZ_GENERATION_FAILED", + "QUIZ_INTERNAL_ERROR", + "QUIZ_HTTP_ERROR", + "UNKNOWN", + "NETWORK", + ]; + for (const code of QUIZ_ERROR_CODES) { + const out = describeQuizError(apiError(500, { code })); + expect(out.retryable, code).toBe(retryable.includes(code)); + } + }); +}); + +describe("describeQuizError — uncoded responses", () => { + it("reads 401/403 as not-authorized", () => { + expect(describeQuizError(new ApiError("nope", 401)).code).toBe("QUIZ_NOT_AUTHORIZED"); + expect(describeQuizError(new ApiError("nope", 403)).code).toBe("QUIZ_NOT_AUTHORIZED"); + }); + + it("reads 429 as rate-limited", () => { + expect(describeQuizError(new ApiError("slow down", 429)).code).toBe("QUIZ_RATE_LIMITED"); + }); + + it("reads 5xx as an internal error", () => { + expect(describeQuizError(new ApiError("boom", 500)).code).toBe("QUIZ_INTERNAL_ERROR"); + expect(describeQuizError(new ApiError("boom", 503)).code).toBe("QUIZ_INTERNAL_ERROR"); + }); + + it("does not read an uncoded 404 as a domain state (QUIZ_HTTP_ERROR, per R1 §D)", () => { + expect(describeQuizError(new ApiError("missing", 404)).code).toBe("QUIZ_HTTP_ERROR"); + }); + + it("ignores a code the client does not know", () => { + const out = describeQuizError(apiError(500, { code: "QUIZ_BRAND_NEW_CODE" })); + expect(out.code).toBe("QUIZ_INTERNAL_ERROR"); + }); +}); + +describe("describeQuizError — transport failures", () => { + it("maps a TypeError to NETWORK", () => { + const out = describeQuizError(new TypeError("Failed to fetch")); + expect(out.code).toBe("NETWORK"); + expect(out.message).toBe(QUIZ_ERROR_COPY.NETWORK); + expect(out.retryable).toBe(true); + }); + + it("maps a fetch-worded plain Error to NETWORK", () => { + expect(describeQuizError(new Error("NetworkError when attempting to fetch resource")).code) + .toBe("NETWORK"); + expect(describeQuizError(new Error("network request failed")).code).toBe("NETWORK"); + }); + + it("maps anything else to UNKNOWN", () => { + expect(describeQuizError(new Error("something odd")).code).toBe("UNKNOWN"); + expect(describeQuizError(undefined).code).toBe("UNKNOWN"); + expect(describeQuizError("string throw").code).toBe("UNKNOWN"); + }); +}); diff --git a/frontend/src/lib/quiz/errors.ts b/frontend/src/lib/quiz/errors.ts new file mode 100644 index 00000000..ccd5913e --- /dev/null +++ b/frontend/src/lib/quiz/errors.ts @@ -0,0 +1,176 @@ +/** + * The one place a thrown quiz request becomes something a student can read. + * + * The backend already ships a machine-readable envelope on every `/api/quiz/*` + * path — `{error: {code, message, request_id}, detail, request_id}` plus a + * `Retry-After` header on 429 (`backend/services/quiz_errors.py::quiz_error_body`, + * applied by main.py's three global handlers). Until #537 the frontend threw all + * of that away and rendered one generic sentence per status, so + * `QUIZ_RATE_LIMITED`, `QUIZ_DAILY_LIMIT_REACHED` and `QUIZ_GENERATION_TIMEOUT` + * were indistinguishable (R1 §H, gap G12). + * + * `lib/api.ts::fetchJSON` now parses the envelope onto `ApiError`; this module + * turns that into `QuizError` — the shape the machine stores and the screens + * render. Copy is the contract's §4 table, verbatim. + */ + +import { ApiError } from "@/lib/api"; + +export type QuizErrorCode = + | "QUIZ_DIFFICULTY_INVALID" + | "QUIZ_QUESTION_INVALID" + | "QUIZ_COUNT_OUT_OF_RANGE" + | "QUIZ_VALIDATION_ERROR" + | "QUIZ_NOT_AUTHORIZED" + | "QUIZ_CONCEPT_NOT_FOUND" + | "QUIZ_ATTEMPT_NOT_FOUND" + | "QUIZ_ATTEMPT_ALREADY_COMPLETED" + | "QUIZ_ATTEMPT_ABANDONED" + | "QUIZ_ATTEMPT_NOT_RESUMABLE" + | "QUIZ_RATE_LIMITED" + | "QUIZ_DAILY_LIMIT_REACHED" + | "QUIZ_GENERATION_TIMEOUT" + | "QUIZ_GENERATION_FAILED" + | "QUIZ_INTERNAL_ERROR" + | "QUIZ_HTTP_ERROR" + | "NETWORK" + | "UNKNOWN"; + +export interface QuizError { + code: QuizErrorCode; + message: string; + retryable: boolean; + retryAfterSec?: number; + requestId?: string; +} + +const GENERIC_INVALID = "Something about this request wasn't valid. Reload and try again."; +const GENERIC_SERVER = "Something went wrong on our side. Try again in a moment."; + +export const QUIZ_ERROR_COPY: Record = { + // `{n}` is filled from `Retry-After`, else the default below. + QUIZ_RATE_LIMITED: "You're quizzing fast — give it {n} seconds and try again.", + QUIZ_DAILY_LIMIT_REACHED: "You've used today's quiz allowance. It resets tomorrow.", + QUIZ_GENERATION_TIMEOUT: + "Writing this quiz took too long. Try again — it usually works the second time.", + QUIZ_GENERATION_FAILED: + "We couldn't put a quiz together for this concept right now. Try again in a moment.", + QUIZ_CONCEPT_NOT_FOUND: "That concept isn't on your tree any more. Pick another one.", + QUIZ_ATTEMPT_NOT_FOUND: "We couldn't find that quiz. Start a new one.", + QUIZ_ATTEMPT_ALREADY_COMPLETED: "This quiz was already scored. Your results are on your tree.", + QUIZ_ATTEMPT_ABANDONED: "That quiz expired after a day. Start a fresh one.", + QUIZ_ATTEMPT_NOT_RESUMABLE: "This quiz can't be resumed. Start a new one.", + QUIZ_QUESTION_INVALID: "That answer didn't line up with the question. Reload and try again.", + // The server sentence wins for this one — it carries the real bounds, and the + // bounds live in `/api/quiz/config`, never in client code. This is only the + // fallback for a body that arrived without a message. + QUIZ_COUNT_OUT_OF_RANGE: "That quiz length isn't allowed. Pick a different number of questions.", + QUIZ_DIFFICULTY_INVALID: GENERIC_INVALID, + QUIZ_VALIDATION_ERROR: GENERIC_INVALID, + QUIZ_NOT_AUTHORIZED: "Please sign in again to keep quizzing.", + QUIZ_INTERNAL_ERROR: GENERIC_SERVER, + QUIZ_HTTP_ERROR: GENERIC_SERVER, + UNKNOWN: GENERIC_SERVER, + NETWORK: "You look offline. Check your connection and try again.", +}; + +/** Every code, in declaration order — handy for exhaustiveness tests. */ +export const QUIZ_ERROR_CODES = Object.keys(QUIZ_ERROR_COPY) as QuizErrorCode[]; + +const RETRYABLE: ReadonlySet = new Set([ + "QUIZ_RATE_LIMITED", + "QUIZ_GENERATION_TIMEOUT", + "QUIZ_GENERATION_FAILED", + "QUIZ_INTERNAL_ERROR", + "QUIZ_HTTP_ERROR", + "UNKNOWN", + "NETWORK", +]); + +/** Used when a 429 arrives without a `Retry-After` header (an in-process rate + * limiter behind more than one worker can do that — R1 §D). */ +const DEFAULT_RETRY_AFTER_SEC = 60; + +function isQuizErrorCode(value: unknown): value is QuizErrorCode { + return typeof value === "string" && value in QUIZ_ERROR_COPY; +} + +/** + * Status → code for responses that carry no `error.code`: everything off the + * quiz router (graph, gamification), and any proxy/edge error in front of it. + * + * Deliberately conservative. An uncoded 404 becomes `QUIZ_HTTP_ERROR`, not + * `QUIZ_ATTEMPT_NOT_FOUND` — R1 §D is explicit that `QUIZ_HTTP_ERROR` "must not + * be read as a domain state", and inventing one from a bare status is exactly + * that mistake in reverse. + */ +function codeForStatus(status: number | undefined): QuizErrorCode { + if (status === undefined) return "UNKNOWN"; + if (status === 401 || status === 403) return "QUIZ_NOT_AUTHORIZED"; + if (status === 429) return "QUIZ_RATE_LIMITED"; + if (status >= 500) return "QUIZ_INTERNAL_ERROR"; + if (status >= 400) return "QUIZ_HTTP_ERROR"; + return "UNKNOWN"; +} + +const NETWORK_WORDING = /failed to fetch|networkerror|network request failed|load failed/i; + +/** A transport failure never reached a server, so it has no status and no code. + * `fetch` rejects with a `TypeError` in every browser; the wording check is the + * safety net for the ones that wrap it. */ +function isNetworkFailure(err: unknown): boolean { + if (err instanceof ApiError) return false; + if (err instanceof TypeError) return true; + return err instanceof Error && NETWORK_WORDING.test(err.message); +} + +/** The server's own sentence for this error, when the envelope carried one. */ +function serverMessage(err: ApiError): string | undefined { + const body = err.body; + if (body === null || typeof body !== "object") return undefined; + const error = (body as { error?: unknown }).error; + if (error === null || typeof error !== "object") return undefined; + const message = (error as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? message.trim() : undefined; +} + +function copyFor(code: QuizErrorCode, err: ApiError | null, retryAfterSec?: number): string { + if (code === "QUIZ_RATE_LIMITED") { + return QUIZ_ERROR_COPY.QUIZ_RATE_LIMITED.replace( + "{n}", + String(retryAfterSec ?? DEFAULT_RETRY_AFTER_SEC), + ); + } + if (code === "QUIZ_COUNT_OUT_OF_RANGE") { + // The 422 handler rewrites this message to name the real min/max + // (main.py:227 → quiz_errors.validation_error_code), so it beats our copy. + return (err && serverMessage(err)) ?? QUIZ_ERROR_COPY.QUIZ_COUNT_OUT_OF_RANGE; + } + return QUIZ_ERROR_COPY[code]; +} + +/** + * Anything thrown by a quiz call → the error the UI renders. + * + * Never throws and never returns an empty message: an unrecognised rejection + * degrades to `UNKNOWN` with the generic server sentence. + */ +export function describeQuizError(err: unknown): QuizError { + if (isNetworkFailure(err)) { + return { code: "NETWORK", message: QUIZ_ERROR_COPY.NETWORK, retryable: true }; + } + + if (err instanceof ApiError) { + const code = isQuizErrorCode(err.code) ? err.code : codeForStatus(err.status); + const out: QuizError = { + code, + message: copyFor(code, err, err.retryAfterSec), + retryable: RETRYABLE.has(code), + }; + if (err.retryAfterSec !== undefined) out.retryAfterSec = err.retryAfterSec; + if (err.requestId !== undefined) out.requestId = err.requestId; + return out; + } + + return { code: "UNKNOWN", message: QUIZ_ERROR_COPY.UNKNOWN, retryable: true }; +} diff --git a/frontend/src/lib/quiz/exits.test.ts b/frontend/src/lib/quiz/exits.test.ts new file mode 100644 index 00000000..1128a5f3 --- /dev/null +++ b/frontend/src/lib/quiz/exits.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { cancelTarget, returnToSource, sourceLabel } from "./exits"; +import { initialSession } from "./machine"; +import type { QuizConfig, QuizPrefs, QuizSession, QuizSource } from "./types"; + +const CONFIG: QuizConfig = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; +const PREFS: QuizPrefs = { count: null, difficulty: null, feedback: "at-end" }; + +function sessionFrom(source: QuizSource, concept?: string): QuizSession { + return initialSession({ source, concept }, CONFIG, PREFS); +} + +describe("returnToSource", () => { + it("honours an explicit same-origin return", () => { + expect(returnToSource(sessionFrom({ kind: "tree", returnTo: "/tree?node=n1" }))) + .toBe("/tree?node=n1"); + expect(returnToSource(sessionFrom({ kind: "notes", returnTo: "/notetaker?note=n7" }))) + .toBe("/notetaker?note=n7"); + }); + + it("falls back to the tree focused on the concept", () => { + expect(returnToSource(sessionFrom({ kind: "link" }, "node-1"))).toBe("/tree?node=node-1"); + expect(returnToSource(sessionFrom({ kind: "nav" }))).toBe("/tree"); + }); + + it("encodes a concept id with URL-hostile characters", () => { + expect(returnToSource(sessionFrom({ kind: "link" }, "a b&c"))).toBe("/tree?node=a%20b%26c"); + }); + + it("re-checks the stored return — localStorage is not trusted input", () => { + const poisoned = sessionFrom({ kind: "tree", conceptId: "n1" }); + poisoned.source.returnTo = "https://evil.com"; + expect(returnToSource(poisoned)).toBe("/tree?node=n1"); + }); + + it("never lands on /learn", () => { + for (const kind of ["tree", "dashboard", "notes", "nav", "link", "quiz"] as const) { + expect(returnToSource(sessionFrom({ kind }))).not.toContain("/learn"); + } + }); +}); + +describe("sourceLabel", () => { + it("names the origin", () => { + expect(sourceLabel("tree")).toBe("Back to your tree"); + expect(sourceLabel("notes")).toBe("Back to your note"); + expect(sourceLabel("dashboard")).toBe("Back to dashboard"); + }); + + it("reads as the tree for every other origin, matching the fallback exit", () => { + expect(sourceLabel("nav")).toBe("Back to your tree"); + expect(sourceLabel("link")).toBe("Back to your tree"); + expect(sourceLabel("quiz")).toBe("Back to your tree"); + }); +}); + +describe("cancelTarget", () => { + it("returns to the origin when there was one", () => { + expect(cancelTarget(sessionFrom({ kind: "tree", returnTo: "/tree?node=n1" }))) + .toBe("/tree?node=n1"); + }); + + it("goes to the dashboard when the quiz was opened cold", () => { + expect(cancelTarget(sessionFrom({ kind: "nav" }))).toBe("/dashboard"); + }); +}); diff --git a/frontend/src/lib/quiz/exits.ts b/frontend/src/lib/quiz/exits.ts new file mode 100644 index 00000000..3b2bb11e --- /dev/null +++ b/frontend/src/lib/quiz/exits.ts @@ -0,0 +1,48 @@ +/** + * Where the quiz sends you when you leave it (R-10). + * + * The old screen pushed `/learn` for Cancel, Exit and Done alike, which dropped + * a student who arrived from the tree into a tutor session they never asked for. + * The rule now: go back where you came from, and if that is unknown, go to the + * tree focused on the concept you were quizzing. Nothing ever lands on `/learn` + * without a session. + */ + +import { isSafeReturnPath } from "./source"; +import type { QuizSession, SourceKind } from "./types"; + +/** The tree honours `?node=` by selecting that node in the detail panel (C1). */ +function treeHref(conceptId: string | undefined): string { + return conceptId ? `/tree?node=${encodeURIComponent(conceptId)}` : "/tree"; +} + +/** The destination for "Back", for the mid-quiz leave, and for Cancel. */ +export function returnToSource(session: QuizSession): string { + const { returnTo } = session.source; + // Re-checked at use, not only at parse: a session can come back off + // localStorage, where anything could have been written. + if (isSafeReturnPath(returnTo)) return returnTo; + return treeHref(session.source.conceptId || session.conceptId || undefined); +} + +const SOURCE_LABELS: Record = { + tree: "Back to your tree", + notes: "Back to your note", + dashboard: "Back to dashboard", + nav: "Back to your tree", + link: "Back to your tree", + quiz: "Back to your tree", +}; + +/** The label on the secondary exit. Anything unrecognised reads as the tree, + * which is where `returnToSource` falls back to as well. */ +export function sourceLabel(kind: SourceKind): string { + return SOURCE_LABELS[kind] ?? SOURCE_LABELS.tree; +} + +/** Where Cancel on quiz home goes: the origin if there was one, else the + * dashboard (§5 B1.8) — cancelling out of a quiz you never started should not + * drop you on the tree you didn't come from. */ +export function cancelTarget(session: QuizSession): string { + return isSafeReturnPath(session.source.returnTo) ? session.source.returnTo : "/dashboard"; +} diff --git a/frontend/src/lib/quiz/machine.test.ts b/frontend/src/lib/quiz/machine.test.ts new file mode 100644 index 00000000..0bff6fe1 --- /dev/null +++ b/frontend/src/lib/quiz/machine.test.ts @@ -0,0 +1,802 @@ +import { describe, expect, it } from "vitest"; +import { + canExit, + canSubmitAnswer, + defaultConfigFor, + errorReturnPhase, + firstUnansweredIndex, + initialSession, + reduce, + type QuizEvent, + type StartRequest, +} from "./machine"; +import type { EntryRequest } from "./source"; +import type { + AnswerResult, + AttemptDetail, + GenerateResult, + QuizConfig, + QuizPrefs, + QuizSession, + QuizSource, + SubmitResult, + WireQuestion, +} from "./types"; + +const CONFIG: QuizConfig = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +const PREFS: QuizPrefs = { count: null, difficulty: null, feedback: "at-end" }; + +const TREE_SOURCE: QuizSource = { + kind: "tree", + returnTo: "/tree?node=c1", + conceptId: "c1", +}; + +function entry(overrides: Partial = {}): EntryRequest { + return { source: TREE_SOURCE, ...overrides }; +} + +function question(id: number): WireQuestion { + return { + id, + question: `Q${id}?`, + options: [ + { label: "A", text: "a" }, + { label: "B", text: "b" }, + { label: "C", text: "c" }, + { label: "D", text: "d" }, + ], + difficulty: "medium", + }; +} + +function generated(count: number, requested = count): GenerateResult { + return { + quiz_id: "attempt-1", + questions: Array.from({ length: count }, (_, i) => question(i + 1)), + requested_difficulty: "medium", + resolved_difficulty: "medium", + requested_count: requested, + delivered_count: count, + }; +} + +function answered(index: number, isCorrect = true, recorded = true): AnswerResult { + return { + question_index: index, + question_id: index + 1, + is_correct: isCorrect, + correct_index: 1, + explanation: `because ${index}`, + next_question: null, + recorded, + }; +} + +const SUBMITTED: SubmitResult = { + score: 2, + total: 3, + mastery_before: 0.25, + mastery_after: 0.31, + results: [], +}; + +function startOf(conceptId = "c1"): StartRequest { + return { + intent: "practice", + scope: { kind: "concept", conceptId }, + conceptId, + courseId: "course-1", + }; +} + +/** home → generating → active, with `n` questions delivered. */ +function activeSession( + n = 3, + feedback: "as-you-go" | "at-end" = "at-end", + entryOverrides: Partial = {}, +): QuizSession { + const base = initialSession(entry(entryOverrides), CONFIG, { ...PREFS, feedback }); + const generating = reduce(base, { + type: "START", + start: startOf(), + config: base.config, + }); + return reduce(generating, { type: "GENERATED", result: generated(n) }); +} + +/** + * Answers every question. In at-end mode the last recorded answer lands on + * `submitting`; in as-you-go mode it stops on `answered` (the student still has + * to press "See results"), which is what the FINISH cases below need. + */ +function answerAll(session: QuizSession): QuizSession { + let s = session; + const last = session.items.length - 1; + for (let i = 0; i <= last; i += 1) { + s = reduce(s, { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(i) }); + if (s.phase === "answered" && i < last) s = reduce(s, { type: "NEXT" }); + } + return s; +} + +describe("defaultConfigFor", () => { + it("prefers 5 questions and medium difficulty when the config offers them", () => { + expect(defaultConfigFor(CONFIG, PREFS)).toEqual({ + count: 5, + difficulty: "medium", + feedback: "at-end", + }); + }); + + it("falls back to the middle option and the first difficulty otherwise", () => { + const odd: QuizConfig = { + num_questions: { min: 2, max: 8, options: [2, 4, 8] }, + difficulties: ["gentle", "brutal"], + question_types: ["multiple_choice"], + }; + expect(defaultConfigFor(odd, PREFS)).toEqual({ + count: 4, + difficulty: "gentle", + feedback: "at-end", + }); + }); + + it("lets stored prefs win over the config-derived defaults", () => { + expect(defaultConfigFor(CONFIG, { count: 10, difficulty: "hard", feedback: "as-you-go" })) + .toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + }); + + it("degrades to a single scalar default before /config resolves", () => { + const out = defaultConfigFor(null, PREFS); + expect(out.count).toBeGreaterThan(0); + expect(out.difficulty).toBeTruthy(); + }); +}); + +describe("initialSession", () => { + it("opens on home with the entry's source and concept", () => { + const s = initialSession(entry({ concept: "c1" }), CONFIG, PREFS); + expect(s.phase).toBe("home"); + expect(s.source).toEqual(TREE_SOURCE); + expect(s.conceptId).toBe("c1"); + expect(s.scope).toEqual({ kind: "concept", conceptId: "c1" }); + expect(s.items).toEqual([]); + expect(s.attemptId).toBeNull(); + expect(s.error).toBeNull(); + }); + + it("opens a due entry on an empty due queue (the home hook fills it)", () => { + const s = initialSession(entry({ scope: "due" }), CONFIG, PREFS); + expect(s.scope).toEqual({ kind: "due", queue: [] }); + }); + + it("opens a course entry on an empty course queue", () => { + const s = initialSession(entry({ course: "course-9" }), CONFIG, PREFS); + expect(s.scope).toEqual({ kind: "course", courseId: "course-9", queue: [] }); + expect(s.courseId).toBe("course-9"); + }); +}); + +describe("home ⇄ configuring", () => { + it("CONFIGURE opens and closes the config surface", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const configuring = reduce(home, { type: "CONFIGURE", open: true }); + expect(configuring.phase).toBe("configuring"); + expect(reduce(configuring, { type: "CONFIGURE", open: false }).phase).toBe("home"); + }); + + it("ignores CONFIGURE outside home/configuring", () => { + const active = activeSession(); + expect(reduce(active, { type: "CONFIGURE", open: true })).toBe(active); + }); +}); + +describe("START → GENERATED", () => { + it("clears the previous attempt when generating", () => { + const results = reduce(answerAll(activeSession(1)), { + type: "SUBMITTED", + result: SUBMITTED, + xp: null, + }); + expect(results.phase).toBe("results"); + + const generating = reduce(results, { type: "START", start: startOf("c2"), config: results.config }); + expect(generating.phase).toBe("generating"); + expect(generating.attemptId).toBeNull(); + expect(generating.items).toEqual([]); + expect(generating.result).toBeNull(); + expect(generating.cursor).toBe(0); + }); + + it("lands on active at cursor 0 with one item per question", () => { + const s = activeSession(3); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(0); + expect(s.attemptId).toBe("attempt-1"); + expect(s.items.map(i => i.index)).toEqual([0, 1, 2]); + expect(s.items.every(i => i.selectedIndex === null && i.verdict === null)).toBe(true); + }); + + it("flags a short delivery", () => { + const base = initialSession(entry(), CONFIG, PREFS); + const generating = reduce(base, { type: "START", start: startOf(), config: base.config }); + expect(reduce(generating, { type: "GENERATED", result: generated(3) }).deliveredShort).toBe(false); + expect(reduce(generating, { type: "GENERATED", result: generated(2, 5) }).deliveredShort).toBe(true); + }); + + it("treats a zero-question delivery as a generation failure", () => { + const base = initialSession(entry(), CONFIG, PREFS); + const generating = reduce(base, { type: "START", start: startOf(), config: base.config }); + const s = reduce(generating, { type: "GENERATED", result: generated(0, 5) }); + expect(s.phase).toBe("error"); + expect(s.error?.code).toBe("QUIZ_GENERATION_FAILED"); + }); + + it("GENERATE_FAILED lands on error whose dismissal returns home", () => { + const base = initialSession(entry(), CONFIG, PREFS); + const generating = reduce(base, { type: "START", start: startOf(), config: base.config }); + const failed = reduce(generating, { + type: "GENERATE_FAILED", + error: { code: "QUIZ_GENERATION_TIMEOUT", message: "slow", retryable: true }, + }); + expect(failed.phase).toBe("error"); + expect(errorReturnPhase(failed)).toBe("home"); + expect(reduce(failed, { type: "DISMISS_ERROR" }).phase).toBe("home"); + expect(reduce(failed, { type: "DISMISS_ERROR" }).error).toBeNull(); + }); + + it("ignores GENERATED outside generating", () => { + const active = activeSession(); + expect(reduce(active, { type: "GENERATED", result: generated(3) })).toBe(active); + }); +}); + +describe("answering — as-you-go", () => { + it("SELECT records the choice without advancing", () => { + const s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 2 }); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(0); + expect(s.items[0].selectedIndex).toBe(2); + }); + + it("ANSWER_RECORDED reveals the verdict and holds on answered", () => { + let s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0, false) }); + expect(s.phase).toBe("answered"); + expect(s.cursor).toBe(0); + expect(s.items[0].verdict).toEqual({ + isCorrect: false, + correctIndex: 1, + explanation: "because 0", + }); + }); + + it("NEXT advances from answered, and reaches submitting on the last item", () => { + let s = activeSession(2, "as-you-go"); + s = reduce(s, { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + s = reduce(s, { type: "NEXT" }); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(1); + + s = reduce(s, { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(1) }); + expect(s.phase).toBe("answered"); + expect(reduce(s, { type: "NEXT" }).phase).toBe("submitting"); + }); +}); + +describe("answering — at-end", () => { + it("ANSWER_RECORDED advances straight past the verdict", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(1); + // The verdict is still stored — only its display is deferred. + expect(s.items[0].verdict).not.toBeNull(); + }); + + it("goes to submitting when the last answer is recorded", () => { + let s = reduce(activeSession(1, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.phase).toBe("submitting"); + }); +}); + +describe("submitting and results", () => { + it("SUBMITTED carries the result and the XP delta into results", () => { + const s = reduce(answerAll(activeSession(2)), { + type: "SUBMITTED", + result: SUBMITTED, + xp: { before: 100, after: 130, streak: 4 }, + }); + expect(s.phase).toBe("results"); + expect(s.result).toEqual(SUBMITTED); + expect(s.xp).toEqual({ before: 100, after: 130, streak: 4 }); + }); + + it("SUBMIT_FAILED lands on an error that returns to submitting", () => { + const s = reduce(answerAll(activeSession(2)), { + type: "SUBMIT_FAILED", + error: { code: "QUIZ_ATTEMPT_ALREADY_COMPLETED", message: "scored", retryable: false }, + }); + expect(s.phase).toBe("error"); + expect(s.error?.code).toBe("QUIZ_ATTEMPT_ALREADY_COMPLETED"); + expect(errorReturnPhase(s)).toBe("submitting"); + expect(reduce(s, { type: "DISMISS_ERROR" }).phase).toBe("submitting"); + }); + + it("ANSWER_FAILED returns to active, not to home", () => { + const s = reduce(reduce(activeSession(3), { type: "SELECT", index: 1 }), { + type: "ANSWER_FAILED", + error: { code: "NETWORK", message: "offline", retryable: true }, + }); + expect(s.phase).toBe("error"); + expect(errorReturnPhase(s)).toBe("active"); + expect(reduce(s, { type: "DISMISS_ERROR" }).phase).toBe("active"); + }); +}); + +describe("leaving and resuming", () => { + it("REQUEST_LEAVE / CANCEL_LEAVE round-trips back to active", () => { + const active = activeSession(3); + const confirming = reduce(active, { type: "REQUEST_LEAVE" }); + expect(confirming.phase).toBe("confirm-leave"); + expect(reduce(confirming, { type: "CANCEL_LEAVE" }).phase).toBe("active"); + }); + + it("CANCEL_LEAVE returns to answered when the current verdict is showing", () => { + let s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + const confirming = reduce(s, { type: "REQUEST_LEAVE" }); + expect(confirming.phase).toBe("confirm-leave"); + expect(reduce(confirming, { type: "CANCEL_LEAVE" }).phase).toBe("answered"); + }); + + it("CONFIRM_LEAVE parks the session on paused", () => { + const paused = reduce(reduce(activeSession(3), { type: "REQUEST_LEAVE" }), { + type: "CONFIRM_LEAVE", + }); + expect(paused.phase).toBe("paused"); + expect(paused.attemptId).toBe("attempt-1"); + }); +}); + +describe("RESUME", () => { + function detail(selected: number[]): AttemptDetail { + return { + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2), question(3)], + responses: selected.map((selected_index, question_index) => ({ + question_index, + selected_index, + is_correct: true, + answered_at: "2026-08-22T10:00:00Z", + })), + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + }; + } + + it("lands on the first unanswered item", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const s = reduce(home, { type: "RESUME", detail: detail([1, 2]), stored: null }); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(2); + expect(s.attemptId).toBe("attempt-1"); + expect(s.items[0].selectedIndex).toBe(1); + expect(s.items[2].selectedIndex).toBeNull(); + }); + + it("restores verdicts and scope from the stored session", () => { + let mid = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + mid = reduce(mid, { type: "ANSWER_RECORDED", result: answered(0) }); + const stored = reduce(reduce(mid, { type: "REQUEST_LEAVE" }), { type: "CONFIRM_LEAVE" }); + + const home = initialSession(entry(), CONFIG, PREFS); + const s = reduce(home, { type: "RESUME", detail: detail([1]), stored }); + expect(s.items[0].verdict).toEqual({ + isCorrect: true, + correctIndex: 1, + explanation: "because 0", + }); + expect(s.config.feedback).toBe("as-you-go"); + expect(s.cursor).toBe(1); + }); + + it("shows the last verdict again when every question is already answered", () => { + let mid = activeSession(3, "as-you-go"); + for (let i = 0; i < 3; i += 1) { + mid = reduce(mid, { type: "SELECT", index: 1 }); + mid = reduce(mid, { type: "ANSWER_RECORDED", result: answered(i) }); + if (i < 2) mid = reduce(mid, { type: "NEXT" }); + } + const home = initialSession(entry(), CONFIG, PREFS); + const s = reduce(home, { type: "RESUME", detail: detail([1, 1, 1]), stored: mid }); + expect(s.cursor).toBe(2); + expect(s.phase).toBe("answered"); + }); + + it("ignores a stored session that belongs to another attempt", () => { + const other = { ...activeSession(3), attemptId: "attempt-other" }; + const home = initialSession(entry(), CONFIG, PREFS); + const s = reduce(home, { type: "RESUME", detail: detail([1]), stored: other }); + expect(s.items[0].verdict).toBeNull(); + expect(s.source).toEqual(TREE_SOURCE); + }); +}); + +describe("results exits", () => { + function resultsSession(scope: QuizSession["scope"]): QuizSession { + const base = { ...answerAll(activeSession(3)), scope }; + return reduce(base, { type: "SUBMITTED", result: SUBMITTED, xp: null }); + } + + it("PRACTISE_MISSED generates a review attempt on the same concept", () => { + const s = reduce(resultsSession({ kind: "concept", conceptId: "c1" }), { + type: "PRACTISE_MISSED", + missedCount: 2, + numQuestions: 2, + }); + expect(s.phase).toBe("generating"); + expect(s.intent).toBe("review"); + expect(s.scope).toEqual({ kind: "missed", conceptId: "c1", missedCount: 2 }); + expect(s.config.count).toBe(2); + expect(s.config.difficulty).toBe("medium"); + }); + + it("NEXT_IN_QUEUE advances to the next concept in the queue", () => { + const s = reduce(resultsSession({ kind: "due", queue: ["c1", "c2", "c3"] }), { + type: "NEXT_IN_QUEUE", + }); + expect(s.phase).toBe("generating"); + expect(s.queueIndex).toBe(1); + expect(s.conceptId).toBe("c2"); + }); + + it("EXIT from results resets to a clean home session", () => { + const s = reduce(resultsSession({ kind: "concept", conceptId: "c1" }), { type: "EXIT" }); + expect(s.phase).toBe("home"); + expect(s.result).toBeNull(); + expect(s.items).toEqual([]); + expect(s.attemptId).toBeNull(); + }); +}); + +describe("FLAG", () => { + it("toggles the current item and stays in the same phase", () => { + const active = activeSession(3); + const flagged = reduce(active, { type: "FLAG" }); + expect(flagged.phase).toBe("active"); + expect(flagged.items[0].flagged).toBe(true); + expect(reduce(flagged, { type: "FLAG" }).items[0].flagged).toBe(false); + }); + + it("is a no-op when there is no current item", () => { + const home = initialSession(entry(), CONFIG, PREFS); + expect(reduce(home, { type: "FLAG" })).toBe(home); + }); +}); + +describe("FINISH", () => { + it("submits once every question is answered", () => { + const s = answerAll(activeSession(2, "as-you-go")); + // answerAll leaves the last item on `answered` in as-you-go mode. + expect(reduce(s, { type: "FINISH" }).phase).toBe("submitting"); + }); + + it("is ignored while questions remain unanswered", () => { + const s = activeSession(3); + expect(reduce(s, { type: "FINISH" })).toBe(s); + }); +}); + +describe("FAILED and SET_CONFIG (the two events beyond §4's list)", () => { + const ABANDONED = { + code: "QUIZ_ATTEMPT_ABANDONED" as const, + message: "expired", + retryable: false, + }; + + it("FAILED surfaces a resume failure on quiz home", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const failed = reduce(home, { type: "FAILED", error: ABANDONED }); + expect(failed.phase).toBe("error"); + expect(failed.error).toEqual(ABANDONED); + expect(reduce(failed, { type: "DISMISS_ERROR" }).phase).toBe("home"); + }); + + it("FAILED never clobbers a live attempt", () => { + const active = activeSession(3); + expect(reduce(active, { type: "FAILED", error: ABANDONED })).toBe(active); + const confirming = reduce(active, { type: "REQUEST_LEAVE" }); + expect(reduce(confirming, { type: "FAILED", error: ABANDONED })).toBe(confirming); + }); + + it("FAILED does not overwrite an error already on screen", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const first = reduce(home, { type: "FAILED", error: ABANDONED }); + const second = reduce(first, { + type: "FAILED", + error: { code: "NETWORK", message: "offline", retryable: true }, + }); + expect(second).toBe(first); + }); + + it("SET_CONFIG holds an Adjust-dialog choice made without starting", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const tuned = reduce(home, { + type: "SET_CONFIG", + config: { count: 10, difficulty: "hard", feedback: "as-you-go" }, + }); + expect(tuned.config).toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + expect(tuned.phase).toBe("home"); + }); + + it("SET_CONFIG is ignored mid-quiz — the attempt is already generated", () => { + const active = activeSession(3); + expect( + reduce(active, { + type: "SET_CONFIG", + config: { count: 10, difficulty: "hard", feedback: "as-you-go" }, + }), + ).toBe(active); + }); +}); + +// ── The six invariants (§4) ──────────────────────────────────────────────── + +describe("invariant 1 — nothing walks out of a live quiz by accident", () => { + const LEAVERS: QuizEvent[] = [ + { type: "EXIT" }, + { type: "CONFIGURE", open: true }, + { type: "GENERATED", result: generated(3) }, + { type: "PRACTISE_MISSED", missedCount: 1, numQuestions: 1 }, + { type: "NEXT_IN_QUEUE" }, + { type: "RESUME", detail: { ...({} as AttemptDetail) }, stored: null }, + { type: "SUBMITTED", result: SUBMITTED, xp: null }, + { type: "DISMISS_ERROR" }, + { type: "CANCEL_LEAVE" }, + { type: "FAILED", error: { code: "NETWORK", message: "offline", retryable: true } }, + { type: "SET_CONFIG", config: { count: 10, difficulty: "hard", feedback: "as-you-go" } }, + { type: "START", start: startOf("c9"), config: { count: 3, difficulty: "easy", feedback: "at-end" } }, + { type: "NEXT" }, + { type: "FINISH" }, + ]; + + it("no event takes active to home or to an exit", () => { + const active = activeSession(3); + for (const event of LEAVERS) { + const next = reduce(active, event); + expect(next.phase, `${event.type} from active`).toBe("active"); + } + }); + + it("no event takes answered to home or to an exit", () => { + let s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.phase).toBe("answered"); + for (const event of LEAVERS) { + // NEXT is the submit path out of `answered`, not a way to walk out — its + // own transitions are pinned in "answering — as-you-go" above. + if (event.type === "NEXT") continue; + expect(reduce(s, event).phase, `${event.type} from answered`).toBe("answered"); + } + }); + + it("the only ways out are CONFIRM_LEAVE and the submit path", () => { + const active = activeSession(1); + expect( + reduce(reduce(active, { type: "REQUEST_LEAVE" }), { type: "CONFIRM_LEAVE" }).phase, + ).toBe("paused"); + + let s = reduce(active, { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.phase).toBe("submitting"); + }); + + it("canExit is false for every live phase", () => { + const active = activeSession(3); + expect(canExit(active)).toBe(false); + expect(canExit(reduce(active, { type: "REQUEST_LEAVE" }))).toBe(false); + expect(canExit({ ...active, phase: "submitting" })).toBe(false); + expect(canExit({ ...active, phase: "home" })).toBe(true); + expect(canExit({ ...active, phase: "results" })).toBe(true); + expect(canExit({ ...active, phase: "paused" })).toBe(true); + }); +}); + +describe("invariant 2 — source survives the whole session", () => { + const NOTE_SOURCE: QuizSource = { + kind: "notes", + returnTo: "/notetaker?note=n7", + conceptId: "c1", + noteId: "n7", + }; + + it("is identical on entry, on paused, on results and on exit", () => { + const start = initialSession({ source: NOTE_SOURCE, concept: "c1" }, CONFIG, PREFS); + expect(start.source).toEqual(NOTE_SOURCE); + + const active = reduce( + reduce(start, { type: "START", start: startOf(), config: start.config }), + { type: "GENERATED", result: generated(2) }, + ); + expect(active.source).toEqual(NOTE_SOURCE); + + const paused = reduce(reduce(active, { type: "REQUEST_LEAVE" }), { type: "CONFIRM_LEAVE" }); + expect(paused.source).toEqual(NOTE_SOURCE); + + const results = reduce(answerAll(active), { type: "SUBMITTED", result: SUBMITTED, xp: null }); + expect(results.source).toEqual(NOTE_SOURCE); + + expect(reduce(results, { type: "EXIT" }).source).toEqual(NOTE_SOURCE); + }); + + it("survives a queue hop and a practise-missed restart", () => { + const start = initialSession({ source: NOTE_SOURCE, scope: "due" }, CONFIG, PREFS); + const active = reduce( + reduce(start, { + type: "START", + start: { intent: "practice", scope: { kind: "due", queue: ["c1", "c2"] }, conceptId: "c1", courseId: null }, + config: start.config, + }), + { type: "GENERATED", result: generated(1) }, + ); + const results = reduce(answerAll(active), { type: "SUBMITTED", result: SUBMITTED, xp: null }); + expect(reduce(results, { type: "NEXT_IN_QUEUE" }).source).toEqual(NOTE_SOURCE); + expect( + reduce(results, { type: "PRACTISE_MISSED", missedCount: 1, numQuestions: 1 }).source, + ).toEqual(NOTE_SOURCE); + }); +}); + +describe("invariant 3 — answered then unmounted resumes where it left off", () => { + it("restores the cursor to the first unanswered item and keeps the source", () => { + // Answer question 1, then the tab goes away mid-quiz. + let s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + const stored = s; + expect(stored.source).toEqual(TREE_SOURCE); + + // A fresh mount: a brand-new home session, then RESUME off the wire. + const fresh = initialSession(entry(), CONFIG, PREFS); + const resumed = reduce(fresh, { + type: "RESUME", + stored, + detail: { + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2), question(3)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + }, + }); + + expect(resumed.phase).toBe("active"); + expect(resumed.cursor).toBe(1); + expect(resumed.source).toEqual(TREE_SOURCE); + expect(firstUnansweredIndex(resumed.items)).toBe(1); + }); +}); + +describe("invariant 4 — SELECT is ignored once the verdict is showing", () => { + it("keeps the recorded choice", () => { + let s = reduce(activeSession(3, "as-you-go"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.phase).toBe("answered"); + + const after = reduce(s, { type: "SELECT", index: 3 }); + expect(after).toBe(s); + expect(after.items[0].selectedIndex).toBe(1); + }); + + it("SUBMIT_ANSWER is gated by canSubmitAnswer, and never moves the session", () => { + const s = activeSession(3); + expect(canSubmitAnswer(s)).toBe(false); + + const selected = reduce(s, { type: "SELECT", index: 0 }); + expect(canSubmitAnswer(selected)).toBe(true); + + // SUBMIT_ANSWER is state-neutral BY DESIGN (the request is the hook's job), + // so it must not move the session in either direction. + expect(reduce(s, { type: "SUBMIT_ANSWER" })).toBe(s); + expect(reduce(selected, { type: "SUBMIT_ANSWER" })).toBe(selected); + }); + + it("canSubmitAnswer is false when the cursor points at no item at all", () => { + const empty = { ...activeSession(1), items: [], cursor: 0 }; + expect(canSubmitAnswer(empty)).toBe(false); + const past = { ...activeSession(1), cursor: 9 }; + expect(canSubmitAnswer(past)).toBe(false); + }); +}); + +describe("a stale or duplicate ANSWER_RECORDED never drags the cursor backwards", () => { + it("ignores a response for an item the student has already passed", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.cursor).toBe(1); + + // The duplicate from a double-click lands late. At-end would otherwise put + // the cursor back to index 0 + 1, re-asking a question already answered. + const after = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(after).toBe(s); + expect(after.cursor).toBe(1); + }); + + it("ignores a second response for the CURRENT item once it has a verdict", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + s = reduce(s, { type: "SELECT", index: 2 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(1) }); + expect(s.cursor).toBe(2); + + const replay = reduce(s, { type: "ANSWER_RECORDED", result: answered(1, false) }); + expect(replay).toBe(s); + expect(s.items[1].verdict?.isCorrect).toBe(true); + }); + + it("still advances a first response with recorded:false (invariant 6 intact)", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0, true, false) }); + expect(s.cursor).toBe(1); + }); +}); + +describe("invariant 5 — NEXT_IN_QUEUE past the end is ignored", () => { + it("stops at the last queue entry", () => { + const base = { ...answerAll(activeSession(1)), scope: { kind: "due" as const, queue: ["c1", "c2"] } }; + const results = reduce(base, { type: "SUBMITTED", result: SUBMITTED, xp: null }); + + const second = reduce(results, { type: "NEXT_IN_QUEUE" }); + expect(second.queueIndex).toBe(1); + + const atEnd = { ...results, queueIndex: 1 }; + expect(reduce(atEnd, { type: "NEXT_IN_QUEUE" })).toBe(atEnd); + }); + + it("is ignored for a scope with no queue at all", () => { + const results = reduce(answerAll(activeSession(1)), { + type: "SUBMITTED", + result: SUBMITTED, + xp: null, + }); + expect(reduce(results, { type: "NEXT_IN_QUEUE" })).toBe(results); + }); +}); + +describe("invariant 6 — an unrecorded replay still advances", () => { + it("treats recorded:false exactly like recorded:true", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0, true, false) }); + expect(s.phase).toBe("active"); + expect(s.cursor).toBe(1); + expect(s.items[0].verdict).not.toBeNull(); + }); + + it("still reaches submitting on the last item", () => { + let s = reduce(activeSession(1, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0, false, false) }); + expect(s.phase).toBe("submitting"); + }); +}); diff --git a/frontend/src/lib/quiz/machine.ts b/frontend/src/lib/quiz/machine.ts new file mode 100644 index 00000000..27027f77 --- /dev/null +++ b/frontend/src/lib/quiz/machine.ts @@ -0,0 +1,543 @@ +/** + * The quiz session state machine — a pure reducer, no React, no network. + * + * Every effect (generate / answer / submit / navigate / persist) lives in + * `useQuizSession`; this file only decides what the session looks like after an + * event. That split is what makes the six invariants in §4 of the contract + * testable at all: "nothing walks out of a live quiz by accident" is a property + * of the transition table, not of a component tree. + * + * Unhandled events return the SAME object, so a caller can compare by identity + * to see whether an event was accepted. + */ + +import { QUIZ_ERROR_COPY } from "./errors"; +import type { EntryRequest } from "./source"; +import type { + AnswerResult, + AttemptDetail, + GenerateResult, + Phase, + QuizConfig, + QuizIntent, + QuizItem, + QuizPrefs, + QuizScope, + QuizSession, + SubmitResult, + WireQuestion, +} from "./types"; +import type { QuizError } from "./errors"; + +/** What a "start this quiz" affordance has to say: the target and its framing. */ +export interface StartRequest { + intent: QuizIntent; + scope: QuizScope; + conceptId: string; + courseId: string | null; +} + +export type SessionConfig = QuizSession["config"]; + +export type QuizEvent = + | { type: "CONFIGURE"; open: boolean } + | { type: "START"; start: StartRequest; config: SessionConfig } + | { type: "GENERATED"; result: GenerateResult } + | { type: "GENERATE_FAILED"; error: QuizError } + | { type: "SELECT"; index: number } + | { type: "SUBMIT_ANSWER" } + | { type: "ANSWER_RECORDED"; result: AnswerResult } + | { type: "ANSWER_FAILED"; error: QuizError } + | { type: "NEXT" } + | { type: "REQUEST_LEAVE" } + | { type: "CANCEL_LEAVE" } + | { type: "CONFIRM_LEAVE" } + | { type: "RESUME"; detail: AttemptDetail; stored: QuizSession | null } + | { type: "FINISH" } + | { type: "SUBMITTED"; result: SubmitResult; xp: QuizSession["xp"] } + | { type: "SUBMIT_FAILED"; error: QuizError } + /** `numQuestions` is already clamped to `/config`'s min/max by the caller — + * the reducer must never know those bounds. */ + | { type: "PRACTISE_MISSED"; missedCount: number; numQuestions: number } + | { type: "NEXT_IN_QUEUE" } + | { type: "EXIT" } + | { type: "FLAG" } + | { type: "DISMISS_ERROR" } + // ── Two events beyond §4's list, both forced by effects that have nowhere + // else to land. Documented as such rather than smuggled in. + /** + * A failure with no phase of its own: `resume` 409s on an attempt the 24h + * sweep abandoned, or the initial load falls over. §4 lists a failure event + * for generate, answer and submit but not for these, and swallowing a + * `QUIZ_ATTEMPT_ABANDONED` would leave the resume strip permanently broken + * with no explanation. Only accepted from a phase with nothing live. + */ + | { type: "FAILED"; error: QuizError } + /** + * The Adjust dialog's "Done" (§5 B1.6) changes length/difficulty/feedback + * WITHOUT starting a quiz, so the choice has to live somewhere before the + * next `START` carries it. Only accepted where no attempt is in flight. + */ + | { type: "SET_CONFIG"; config: SessionConfig }; + +/** + * The count used before `GET /api/quiz/config` resolves. Not an option list — + * the pickers render nothing until the real config arrives — just the scalar the + * "5 questions, medium" line shows for the first paint. It matches the server's + * own default (`GenerateQuizBody.num_questions = 5`). + */ +const PRE_CONFIG_COUNT = 5; +/** Likewise `GenerateQuizBody.difficulty = "medium"`. */ +const PRE_CONFIG_DIFFICULTY = "medium"; +const DEFAULT_FEEDBACK: SessionConfig["feedback"] = "at-end"; + +/** Session config from stored prefs, falling back to config-derived defaults + * (§5 B1): 5 questions if offered else the middle option; "medium" if offered + * else the first difficulty. */ +export function defaultConfigFor(config: QuizConfig | null, prefs: QuizPrefs): SessionConfig { + const options = config?.num_questions.options ?? []; + const difficulties = config?.difficulties ?? []; + const count = + prefs.count + ?? (options.includes(PRE_CONFIG_COUNT) + ? PRE_CONFIG_COUNT + : options[Math.floor(options.length / 2)] ?? PRE_CONFIG_COUNT); + const difficulty = + prefs.difficulty + ?? (difficulties.includes(PRE_CONFIG_DIFFICULTY) + ? PRE_CONFIG_DIFFICULTY + : difficulties[0] ?? PRE_CONFIG_DIFFICULTY); + return { count, difficulty, feedback: prefs.feedback ?? DEFAULT_FEEDBACK }; +} + +function scopeForEntry(entry: EntryRequest): QuizScope { + if (entry.scope === "due") return { kind: "due", queue: [] }; + if (entry.course) return { kind: "course", courseId: entry.course, queue: [] }; + return { kind: "concept", conceptId: entry.concept ?? "" }; +} + +/** + * The session a fresh mount starts from: quiz home, nothing generated, the + * entry's source already recorded so every later exit can honour it. + * + * Queues are left empty — `useQuizHome` fills them once the graph has loaded, + * because the reducer has no access to the node list. + */ +export function initialSession( + entry: EntryRequest, + config: QuizConfig | null, + prefs: QuizPrefs, +): QuizSession { + return { + intent: "practice", + scope: scopeForEntry(entry), + source: entry.source, + config: defaultConfigFor(config, prefs), + conceptId: entry.concept ?? "", + courseId: entry.course ?? null, + attemptId: null, + items: [], + cursor: 0, + queueIndex: 0, + phase: "home", + error: null, + result: null, + xp: null, + deliveredShort: false, + }; +} + +// ── Pure predicates the screens and the hook share ───────────────────────── + +export function queueOf(scope: QuizScope): string[] { + return scope.kind === "course" || scope.kind === "due" ? scope.queue : []; +} + +export function firstUnansweredIndex(items: QuizItem[]): number { + return items.findIndex(i => i.selectedIndex === null); +} + +export function isLastItem(session: QuizSession, index = session.cursor): boolean { + return index >= session.items.length - 1; +} + +/** Submitting an answer needs a live question with a chosen option. */ +export function canSubmitAnswer(session: QuizSession): boolean { + if (session.phase !== "active") return false; + const item = session.items[session.cursor]; + return item !== undefined && item.selectedIndex !== null; +} + +/** Leaving for another screen. False for every phase where an attempt is live — + * those exit through the leave dialog (CONFIRM_LEAVE) or the submit path. */ +export function canExit(session: QuizSession): boolean { + return session.phase !== "active" + && session.phase !== "answered" + && session.phase !== "confirm-leave" + && session.phase !== "generating" + && session.phase !== "submitting"; +} + +/** + * Where `DISMISS_ERROR` puts the student back. + * + * Derived rather than stored, so `QuizSession` stays exactly the §2 shape. The + * three failures are distinguishable from the session alone: a generate failure + * has no attempt and no items; a submit failure has a verdict on every item + * (the submit path is only reachable once the last answer is recorded); an + * answer failure leaves the current item without one. + */ +export function errorReturnPhase(session: QuizSession): Phase { + if (session.attemptId === null || session.items.length === 0) return "home"; + return session.items.every(i => i.verdict !== null) ? "submitting" : "active"; +} + +// ── The reducer ──────────────────────────────────────────────────────────── + +function itemsFor(questions: WireQuestion[]): QuizItem[] { + return questions.map((question, index) => ({ + index, + question, + selectedIndex: null, + verdict: null, + flagged: false, + })); +} + +/** The state every fresh generation starts from: attempt, items, verdicts, + * result and XP all cleared, so a failure can never show the previous quiz. */ +function generatingFrom( + session: QuizSession, + patch: Partial, +): QuizSession { + return { + ...session, + ...patch, + phase: "generating", + attemptId: null, + items: [], + cursor: 0, + error: null, + result: null, + xp: null, + deliveredShort: false, + }; +} + +function withItem(session: QuizSession, index: number, patch: Partial): QuizSession { + return { + ...session, + items: session.items.map((item, i) => (i === index ? { ...item, ...patch } : item)), + }; +} + +/** Where an answered item goes next: hold on the verdict, step forward, or + * finish. `at-end` never stops on `answered` — that's the whole of R-2's + * client-side half. */ +function advanceAfterAnswer(session: QuizSession, index: number): QuizSession { + if (session.config.feedback === "as-you-go") { + return { ...session, phase: "answered", cursor: index }; + } + return isLastItem(session, index) + ? { ...session, phase: "submitting", cursor: index } + : { ...session, phase: "active", cursor: index + 1 }; +} + +export function reduce(session: QuizSession, event: QuizEvent): QuizSession { + switch (event.type) { + case "CONFIGURE": { + if (session.phase !== "home" && session.phase !== "configuring") return session; + return { ...session, phase: event.open ? "configuring" : "home" }; + } + + case "START": { + if (session.phase !== "home" && session.phase !== "configuring" && session.phase !== "results") { + return session; + } + // `source` is deliberately not settable here: invariant 2 (source is + // identical on entry and on every terminal transition) holds structurally + // because no event can overwrite it except RESUME's stored restore. + return generatingFrom(session, { + intent: event.start.intent, + scope: event.start.scope, + conceptId: event.start.conceptId, + courseId: event.start.courseId, + config: event.config, + queueIndex: 0, + }); + } + + case "GENERATED": { + if (session.phase !== "generating") return session; + const { result } = event; + if (result.questions.length === 0) { + // The route 502s rather than serving an empty quiz (R1 §C), so this is + // belt and braces — but a blank, control-less panel is exactly the #184 + // bug, and it is cheap to make impossible. + return { + ...session, + phase: "error", + error: { + code: "QUIZ_GENERATION_FAILED", + message: QUIZ_ERROR_COPY.QUIZ_GENERATION_FAILED, + retryable: true, + }, + }; + } + return { + ...session, + phase: "active", + attemptId: result.quiz_id, + items: itemsFor(result.questions), + cursor: 0, + error: null, + deliveredShort: result.delivered_count < result.requested_count, + }; + } + + case "GENERATE_FAILED": { + if (session.phase !== "generating") return session; + return { ...session, phase: "error", error: event.error }; + } + + case "SELECT": { + if (session.phase !== "active") return session; + if (!session.items[session.cursor]) return session; + return withItem(session, session.cursor, { selectedIndex: event.index }); + } + + case "SUBMIT_ANSWER": { + // State-neutral by design: the network call is the hook's job and the + // "pending" look is local to the footer button. `canSubmitAnswer` is the + // predicate that actually gates it, in the hook and on the button alike. + return session; + } + + case "ANSWER_RECORDED": { + if (session.phase !== "active") return session; + const index = event.result.question_index; + const target = session.items[index]; + if (!target) return session; + // A response for an item already left behind can only be a duplicate or a + // late arrival. `advanceAfterAnswer` positions the cursor from the + // RESPONDED index, so honouring one would drag the quiz backwards under + // the student — re-revealing an old verdict in as-you-go, or re-asking a + // question in at-end. `/answer` is idempotent server-side, so there is + // nothing to reconcile: drop it. + if (index < session.cursor || target.verdict !== null) return session; + // `recorded: false` is an idempotent replay or a lost race — the answer + // still stands, so it advances exactly like a fresh record (invariant 6). + const scored = withItem(session, index, { + verdict: { + isCorrect: event.result.is_correct, + correctIndex: event.result.correct_index, + explanation: event.result.explanation, + }, + }); + return advanceAfterAnswer(scored, index); + } + + case "ANSWER_FAILED": { + if (session.phase !== "active") return session; + return { ...session, phase: "error", error: event.error }; + } + + case "NEXT": { + if (session.phase !== "answered") return session; + return isLastItem(session) + ? { ...session, phase: "submitting" } + : { ...session, phase: "active", cursor: session.cursor + 1 }; + } + + case "FINISH": { + if (session.phase !== "active" && session.phase !== "answered") return session; + if (session.items.length === 0) return session; + if (session.items.some(i => i.selectedIndex === null)) return session; + return { ...session, phase: "submitting" }; + } + + case "REQUEST_LEAVE": { + if (session.phase !== "active" && session.phase !== "answered") return session; + return { ...session, phase: "confirm-leave" }; + } + + case "CANCEL_LEAVE": { + if (session.phase !== "confirm-leave") return session; + // A verdict on the current item means it was on screen when the dialog + // opened — in `at-end` mode the cursor always sits on a fresh item. + const showing = session.items[session.cursor]?.verdict != null; + return { ...session, phase: showing ? "answered" : "active" }; + } + + case "CONFIRM_LEAVE": { + if (session.phase !== "confirm-leave") return session; + return { ...session, phase: "paused" }; + } + + case "RESUME": { + if (session.phase !== "paused" && session.phase !== "home") return session; + return resumeFrom(session, event.detail, event.stored); + } + + case "SUBMITTED": { + if (session.phase !== "submitting") return session; + return { ...session, phase: "results", result: event.result, xp: event.xp, error: null }; + } + + case "SUBMIT_FAILED": { + if (session.phase !== "submitting") return session; + return { ...session, phase: "error", error: event.error }; + } + + case "PRACTISE_MISSED": { + if (session.phase !== "results") return session; + // A new attempt on the same concept (R-5). The repetition guard means the + // questions differ; the UI labels that honestly. + return generatingFrom(session, { + intent: "review", + scope: { + kind: "missed", + conceptId: session.conceptId, + missedCount: event.missedCount, + }, + config: { ...session.config, count: event.numQuestions }, + }); + } + + case "NEXT_IN_QUEUE": { + if (session.phase !== "results") return session; + const queue = queueOf(session.scope); + const next = session.queueIndex + 1; + if (next >= queue.length) return session; + return generatingFrom(session, { + queueIndex: next, + conceptId: queue[next], + // A `course` queue stays inside its course. A `due` queue spans them, + // and the reducer has no node list to look the new one up in — so the + // honest answer is "unknown" rather than the previous concept's course. + // Nothing on screen reads this: the screens resolve the course (and the + // accent) from the graph by concept id. + courseId: session.scope.kind === "course" ? session.courseId : null, + }); + } + + case "EXIT": { + if (!canExit(session)) return session; + // A genuinely clean home session. `source` survives (invariant 2) and so + // do the student's config choices; the TARGET does not. "Done" stays on + // /quiz, so a session still pointing at the concept just finished would + // keep the accent — and anything else that prefers the session over the + // proposal — pinned to a quiz that is over. + // + // Callers must therefore read the exit destination from the session + // BEFORE dispatching this (`returnToSource` needs `conceptId`). + return { + ...session, + phase: "home", + scope: { kind: "concept", conceptId: "" }, + conceptId: "", + courseId: null, + attemptId: null, + items: [], + cursor: 0, + queueIndex: 0, + error: null, + result: null, + xp: null, + deliveredShort: false, + }; + } + + case "FLAG": { + const item = session.items[session.cursor]; + if (!item) return session; + return withItem(session, session.cursor, { flagged: !item.flagged }); + } + + case "DISMISS_ERROR": { + if (session.phase !== "error") return session; + return { ...session, phase: errorReturnPhase(session), error: null }; + } + + case "FAILED": { + // Never over a live attempt — an in-flight quiz has ANSWER_FAILED / + // SUBMIT_FAILED, which keep the items so DISMISS_ERROR can go back to them. + if (!canExit(session)) return session; + if (session.phase === "error") return session; + return { ...session, phase: "error", error: event.error }; + } + + case "SET_CONFIG": { + if (session.phase !== "home" && session.phase !== "configuring" && session.phase !== "results") { + return session; + } + return { ...session, config: event.config }; + } + + default: + return session; + } +} + +/** + * Rebuilds a live session off `GET /api/quiz/attempts/{id}` plus whatever the + * browser had stored. + * + * The wire is the authority on which questions exist and which are answered; + * the stored session is the only place the verdicts, the scope, the queue and + * the original source survive (the resume payload carries `is_correct` but no + * `correct_index` and no explanation, and the questions are keyless). A stored + * record for a DIFFERENT attempt is ignored entirely. + */ +function resumeFrom( + session: QuizSession, + detail: AttemptDetail, + stored: QuizSession | null, +): QuizSession { + const matching = stored && stored.attemptId === detail.quiz_id ? stored : null; + const storedById = new Map((matching?.items ?? []).map(i => [i.question.id, i])); + const selectedByIndex = new Map( + (detail.responses ?? []).map(r => [r.question_index, r.selected_index]), + ); + + const items: QuizItem[] = (detail.questions ?? []).map((question, index) => { + const previous = storedById.get(question.id); + const selected = selectedByIndex.get(index); + return { + index, + question, + selectedIndex: selected ?? previous?.selectedIndex ?? null, + verdict: selected === undefined ? null : previous?.verdict ?? null, + flagged: previous?.flagged ?? false, + }; + }); + + const unanswered = firstUnansweredIndex(items); + // Every question already answered but the attempt never submitted (the last + // `/answer` landed and then the tab went away). Park on the final verdict so + // "See results" is one press away, rather than deadlocking on an empty cursor. + const cursor = unanswered >= 0 ? unanswered : Math.max(items.length - 1, 0); + const phase: Phase = + unanswered >= 0 ? "active" : items[cursor]?.verdict != null ? "answered" : "active"; + + return { + ...session, + intent: matching?.intent ?? session.intent, + scope: matching?.scope ?? { kind: "concept", conceptId: detail.concept_node_id }, + source: matching?.source ?? session.source, + config: matching + ? matching.config + : { ...session.config, difficulty: detail.difficulty || session.config.difficulty }, + conceptId: detail.concept_node_id || matching?.conceptId || session.conceptId, + courseId: matching?.courseId ?? session.courseId, + queueIndex: matching?.queueIndex ?? 0, + attemptId: detail.quiz_id, + items, + cursor, + phase, + error: null, + result: null, + xp: null, + deliveredShort: matching?.deliveredShort ?? false, + }; +} diff --git a/frontend/src/lib/quiz/prefs.ts b/frontend/src/lib/quiz/prefs.ts new file mode 100644 index 00000000..41bb74fa --- /dev/null +++ b/frontend/src/lib/quiz/prefs.ts @@ -0,0 +1,72 @@ +/** + * Remembered quiz settings: length, difficulty, and when the verdict shows. + * + * Feedback mode is a client-only concept (R-2, gap G1) — `/api/quiz/config` + * offers counts, difficulties and question types, and nothing else. The two + * modes are therefore the ONE list this codebase is allowed to write down; + * counts and difficulties must always come off the config endpoint. + * + * Stored values are validated against the live config at read time, so a + * remembered "15 questions" from before the ceiling moved can never be sent. + */ + +import { PREFS_KEY } from "./session"; +import type { FeedbackMode, QuizConfig, QuizPrefs } from "./types"; + +/** The only hardcoded option list in the quiz (R-2). */ +export const FEEDBACK_MODES: readonly FeedbackMode[] = ["as-you-go", "at-end"] as const; + +export const FEEDBACK_LABELS: Record = { + "as-you-go": "As you go", + "at-end": "At the end", +}; + +export const DEFAULT_PREFS: QuizPrefs = { count: null, difficulty: null, feedback: "at-end" }; + +function isFeedbackMode(value: unknown): value is FeedbackMode { + return typeof value === "string" && (FEEDBACK_MODES as readonly string[]).includes(value); +} + +/** + * Reads stored prefs. `config` is optional: pass it and a remembered value the + * server would now reject is dropped back to "no preference" rather than + * silently producing a 400 on the next generate. + */ +export function loadPrefs(config?: QuizConfig | null): QuizPrefs { + let raw: unknown = null; + try { + if (typeof window !== "undefined") { + const text = window.localStorage.getItem(PREFS_KEY); + raw = text ? JSON.parse(text) : null; + } + } catch { + raw = null; + } + if (raw === null || typeof raw !== "object") return { ...DEFAULT_PREFS }; + + const stored = raw as Partial; + const count = typeof stored.count === "number" && Number.isFinite(stored.count) + ? stored.count + : null; + const difficulty = typeof stored.difficulty === "string" && stored.difficulty + ? stored.difficulty + : null; + + return { + count: config && count !== null && !config.num_questions.options.includes(count) ? null : count, + difficulty: + config && difficulty !== null && !config.difficulties.includes(difficulty) + ? null + : difficulty, + feedback: isFeedbackMode(stored.feedback) ? stored.feedback : DEFAULT_PREFS.feedback, + }; +} + +export function savePrefs(prefs: QuizPrefs): void { + try { + if (typeof window === "undefined") return; + window.localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + } catch { + // A forgotten preference is a downgrade, never a failure. + } +} diff --git a/frontend/src/lib/quiz/proposals.test.ts b/frontend/src/lib/quiz/proposals.test.ts new file mode 100644 index 00000000..54bd8742 --- /dev/null +++ b/frontend/src/lib/quiz/proposals.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from "vitest"; +import type { EnrolledCourse } from "@/lib/api"; +import type { GraphNode } from "@/lib/types"; +import { + DUE_TIERS, + alternativesOf, + colorFor, + dueSet, + entrySelection, + groupByCourse, + isDue, + latestCompletedAttempt, + metaLine, + nextConceptInQueue, + primaryOf, + queueFor, + rankCandidates, + rationaleFor, +} from "./proposals"; +import { QUEUE_MAX } from "./session"; +import type { AttemptSummary, QuizScope, QuizSession } from "./types"; + +const NOW = new Date(2026, 7, 22, 9); // 22 Aug 2026, local + +function node(over: Partial & { id: string }): GraphNode { + return { + concept_name: over.id, + mastery_score: 0.3, + mastery_tier: "struggling", + times_studied: 1, + last_studied_at: null, + subject: "CS", + course_id: "course-a", + ...over, + }; +} + +function course(over: Partial & { course_id: string }): EnrolledCourse { + return { + enrollment_id: `e-${over.course_id}`, + course_code: "CS 101", + course_name: "Intro", + school: "BU", + department: "CS", + color: "#123456", + nickname: null, + node_count: 0, + enrolled_at: "2026-01-01T00:00:00Z", + term: "Fall 2026", + ...over, + }; +} + +function attempt(over: Partial & { quiz_id: string; concept_node_id: string }): AttemptSummary { + return { + status: "completed", + concept_name: over.concept_node_id, + course_id: "course-a", + score: 2, + total: 5, + difficulty: "medium", + mastery_before: 0.2, + mastery_after: 0.26, + mastery_delta: 0.06, + created_at: "2026-08-20T10:00:00Z", + completed_at: "2026-08-20T10:05:00Z", + ...over, + }; +} + +function iso(y: number, m: number, d: number): string { + return new Date(y, m - 1, d, 12).toISOString(); +} + +describe("isDue — the mirrored membership filter", () => { + it("matches the three tiers get_recommendations asks for", () => { + expect(DUE_TIERS).toEqual(["struggling", "learning", "unexplored"]); + for (const tier of DUE_TIERS) { + expect(isDue(node({ id: "n", mastery_tier: tier as GraphNode["mastery_tier"] }))).toBe(true); + } + }); + + it("excludes mastered and subject roots", () => { + expect(isDue(node({ id: "n", mastery_tier: "mastered" }))).toBe(false); + expect(isDue(node({ id: "n", mastery_tier: "subject_root" }))).toBe(false); + expect(isDue(node({ id: "n", mastery_tier: "struggling", is_subject_root: true }))).toBe(false); + }); +}); + +describe("rankCandidates", () => { + const nodes = [ + node({ id: "n-mid", mastery_score: 0.44, mastery_tier: "learning" }), + node({ id: "n-mastered", mastery_score: 0.9, mastery_tier: "mastered" }), + node({ id: "n-new", mastery_score: 0, mastery_tier: "unexplored", times_studied: 0 }), + node({ id: "n-low", mastery_score: 0.12, mastery_tier: "struggling" }), + node({ id: "n-root", mastery_tier: "subject_root", is_subject_root: true }), + ]; + + it("orders by mastery ascending, mastered and roots dropped", () => { + const ranked = rankCandidates(nodes, [], [], NOW); + expect(ranked.map(c => c.node.id)).toEqual(["n-new", "n-low", "n-mid"]); + }); + + it("breaks ties deterministically so cards never reshuffle", () => { + const tied = [ + node({ id: "b", mastery_score: 0.2 }), + node({ id: "a", mastery_score: 0.2 }), + ]; + expect(rankCandidates(tied, [], [], NOW).map(c => c.node.id)).toEqual(["a", "b"]); + expect(rankCandidates(tied.slice().reverse(), [], [], NOW).map(c => c.node.id)) + .toEqual(["a", "b"]); + }); + + it("does not mutate the caller's node array", () => { + const input = nodes.slice(); + rankCandidates(input, [], [], NOW); + expect(input.map(n => n.id)).toEqual(nodes.map(n => n.id)); + }); + + it("joins the course, the colour and the last completed attempt", () => { + const courses = [course({ course_id: "course-a", color: "#abcdef", course_code: "CS 330" })]; + const attempts = [attempt({ quiz_id: "q1", concept_node_id: "n-low", score: 1, total: 4 })]; + const ranked = rankCandidates(nodes, courses, attempts, NOW); + const low = ranked.find(c => c.node.id === "n-low"); + expect(low?.course?.course_code).toBe("CS 330"); + expect(low?.color).toBe("#abcdef"); + expect(low?.lastAttempt?.quiz_id).toBe("q1"); + expect(low?.rationale).toBe("12% · missed 3 last time"); + }); +}); + +describe("primaryOf / alternativesOf", () => { + const candidates = () => + rankCandidates( + [ + node({ id: "never", mastery_score: 0, mastery_tier: "unexplored", times_studied: 0 }), + node({ id: "studied", mastery_score: 0.29, times_studied: 3, last_studied_at: iso(2026, 8, 18) }), + node({ id: "third", mastery_score: 0.4, mastery_tier: "learning", times_studied: 1 }), + node({ id: "fourth", mastery_score: 0.5, mastery_tier: "learning", times_studied: 1 }), + ], + [], + [], + NOW, + ); + + it("prefers the weakest concept the student has actually opened (R-7)", () => { + // The raw mastery_score.asc order puts `never` (0.0) first. + expect(candidates()[0].node.id).toBe("never"); + expect(primaryOf(candidates())?.node.id).toBe("studied"); + }); + + it("falls back to the literal first when nothing has been studied", () => { + const fresh = rankCandidates( + [node({ id: "a", mastery_score: 0, mastery_tier: "unexplored", times_studied: 0 })], + [], + [], + NOW, + ); + expect(primaryOf(fresh)?.node.id).toBe("a"); + }); + + it("returns null when there is nothing to propose", () => { + expect(primaryOf([])).toBeNull(); + expect(alternativesOf([], null)).toEqual([]); + }); + + it("gives two alternatives and never repeats the primary", () => { + const c = candidates(); + const primary = primaryOf(c); + const alts = alternativesOf(c, primary); + expect(alts).toHaveLength(2); + expect(alts.map(a => a.node.id)).not.toContain(primary?.node.id); + expect(alts.map(a => a.node.id)).toEqual(["never", "third"]); + }); +}); + +describe("metaLine", () => { + it("reads score, tier and when it was last studied", () => { + expect( + metaLine( + node({ id: "n", mastery_score: 0.29, mastery_tier: "struggling", last_studied_at: iso(2026, 8, 18) }), + NOW, + ), + ).toBe("29% · struggling · last studied 4 days ago"); + }); + + it("says so when it has never been studied", () => { + expect( + metaLine(node({ id: "n", mastery_score: 0, mastery_tier: "unexplored", last_studied_at: null }), NOW), + ).toBe("0% · unexplored · not studied yet"); + }); +}); + +describe("rationaleFor", () => { + it("names a never-studied concept", () => { + expect( + rationaleFor(node({ id: "n", mastery_score: 0.12, times_studied: 0, last_studied_at: null }), undefined, NOW), + ).toBe("12% · not studied yet"); + }); + + it("prefers the misses from the last completed attempt", () => { + expect( + rationaleFor( + node({ id: "n", mastery_score: 0.44, last_studied_at: iso(2026, 8, 13) }), + attempt({ quiz_id: "q", concept_node_id: "n", score: 2, total: 5 }), + NOW, + ), + ).toBe("44% · missed 3 last time"); + }); + + it("falls through to the days line for a clean sweep", () => { + expect( + rationaleFor( + node({ id: "n", mastery_score: 0.31, last_studied_at: iso(2026, 8, 13) }), + attempt({ quiz_id: "q", concept_node_id: "n", score: 5, total: 5 }), + NOW, + ), + ).toBe("31% · not reviewed in 9 days"); + }); + + it("reads naturally for today and yesterday", () => { + expect(rationaleFor(node({ id: "n", mastery_score: 0.5, last_studied_at: iso(2026, 8, 22) }), undefined, NOW)) + .toBe("50% · reviewed today"); + expect(rationaleFor(node({ id: "n", mastery_score: 0.5, last_studied_at: iso(2026, 8, 21) }), undefined, NOW)) + .toBe("50% · reviewed yesterday"); + }); + + it("does not claim 'not studied yet' for a studied node with no timestamp", () => { + expect(rationaleFor(node({ id: "n", mastery_score: 0.2, times_studied: 4, last_studied_at: null }), undefined, NOW)) + .toBe("20% · not reviewed recently"); + }); +}); + +describe("latestCompletedAttempt", () => { + const attempts = [ + attempt({ quiz_id: "old", concept_node_id: "n", completed_at: "2026-08-01T00:00:00Z" }), + attempt({ quiz_id: "new", concept_node_id: "n", completed_at: "2026-08-20T00:00:00Z" }), + attempt({ quiz_id: "other", concept_node_id: "m", completed_at: "2026-08-21T00:00:00Z" }), + attempt({ quiz_id: "open", concept_node_id: "n", status: "in_progress", score: null, total: null, + completed_at: null, created_at: "2026-08-22T00:00:00Z" }), + ]; + + it("picks the newest completed attempt for that node only", () => { + expect(latestCompletedAttempt("n", attempts)?.quiz_id).toBe("new"); + }); + + it("ignores in-progress and abandoned rows — they carry no score", () => { + expect(latestCompletedAttempt("z", attempts)).toBeUndefined(); + expect(latestCompletedAttempt("n", [attempts[3]])).toBeUndefined(); + }); +}); + +describe("dueSet", () => { + it("counts the whole scoped graph, not the capped recommendation list", () => { + const nodes = Array.from({ length: 8 }, (_, i) => + node({ id: `n${i}`, mastery_score: i / 10, course_id: i < 5 ? "course-a" : "course-b" })); + nodes.push(node({ id: "done", mastery_tier: "mastered", mastery_score: 0.95 })); + + const due = dueSet(nodes); + expect(due.count).toBe(8); + expect(due.courseCount).toBe(2); + expect(due.conceptIds[0]).toBe("n0"); + }); + + it("is empty for a graph with nothing to work on", () => { + expect(dueSet([node({ id: "n", mastery_tier: "mastered" })])) + .toEqual({ conceptIds: [], count: 0, courseCount: 0 }); + }); + + it("does not count a null course id as a course", () => { + expect(dueSet([node({ id: "n", course_id: null })]).courseCount).toBe(0); + }); +}); + +describe("queueFor", () => { + const nodes = Array.from({ length: 8 }, (_, i) => + node({ id: `n${i}`, mastery_score: i / 10, course_id: i < 6 ? "course-a" : "course-b" })); + + it("caps a due queue at QUEUE_MAX, weakest first", () => { + const queue = queueFor("due", nodes); + expect(queue).toHaveLength(QUEUE_MAX); + expect(queue).toEqual(["n0", "n1", "n2", "n3", "n4"]); + }); + + it("scopes a course queue to that course", () => { + expect(queueFor("course", nodes, "course-b")).toEqual(["n6", "n7"]); + }); + + it("is empty for an unknown course", () => { + expect(queueFor("course", nodes, "nope")).toEqual([]); + }); +}); + +describe("groupByCourse", () => { + const nodes = [ + node({ id: "zebra", concept_name: "Zebra", course_id: "course-a" }), + node({ id: "apple", concept_name: "Apple", course_id: "course-a" }), + node({ id: "beta", concept_name: "Beta", course_id: "course-b" }), + node({ id: "root", concept_name: "CS", course_id: "course-a", is_subject_root: true }), + node({ id: "orphan", concept_name: "Orphan", course_id: null }), + ]; + const courses = [ + course({ course_id: "course-b", course_code: "MA 225" }), + course({ course_id: "course-a", course_code: "CS 330" }), + course({ course_id: "course-empty", course_code: "AA 100" }), + ]; + + it("groups by course, courses by code and concepts by name", () => { + const groups = groupByCourse(nodes, courses); + expect(groups.map(g => g.course.course_code)).toEqual(["CS 330", "MA 225"]); + expect(groups[0].nodes.map(n => n.concept_name)).toEqual(["Apple", "Zebra"]); + }); + + it("omits subject roots and courses with no concepts", () => { + const groups = groupByCourse(nodes, courses); + expect(groups.flatMap(g => g.nodes).map(n => n.id)).not.toContain("root"); + expect(groups.map(g => g.course.course_id)).not.toContain("course-empty"); + }); +}); + +describe("entrySelection", () => { + const nodes = [ + node({ id: "n1", concept_name: "Recursion", course_id: "course-a" }), + node({ id: "n2", concept_name: "Big-O", course_id: "course-b" }), + node({ id: "root", concept_name: "Recursion", course_id: "course-a", is_subject_root: true }), + ]; + const courses = [course({ course_id: "course-a" }), course({ course_id: "course-b" })]; + + it("resolves a concept id and its course", () => { + expect(entrySelection({ concept: "n2" }, nodes, courses)) + .toEqual({ conceptId: "n2", courseId: "course-b", unresolved: false }); + }); + + it("resolves the legacy topic form by name, case-insensitively", () => { + expect(entrySelection({ topic: " recursion " }, nodes, courses)) + .toEqual({ conceptId: "n1", courseId: "course-a", unresolved: false }); + }); + + it("flags a link pointing outside the current scope rather than ignoring it", () => { + expect(entrySelection({ concept: "gone" }, nodes, courses)) + .toEqual({ conceptId: null, courseId: null, unresolved: true }); + expect(entrySelection({ topic: "Monads" }, nodes, courses).unresolved).toBe(true); + }); + + it("prefers the precise concept id over the fuzzy topic", () => { + expect(entrySelection({ concept: "n2", topic: "Recursion" }, nodes, courses).conceptId) + .toBe("n2"); + }); + + it("passes a course-only entry straight through", () => { + expect(entrySelection({ course: "course-b" }, nodes, courses)) + .toEqual({ conceptId: null, courseId: "course-b", unresolved: false }); + expect(entrySelection({}, nodes, courses)) + .toEqual({ conceptId: null, courseId: null, unresolved: false }); + }); + + it("never resolves to a subject root", () => { + // Both the root and a real concept are called "Recursion"; the leaf wins. + expect(entrySelection({ topic: "Recursion" }, nodes, courses).conceptId).toBe("n1"); + expect(entrySelection({ concept: "root" }, nodes, courses).unresolved).toBe(true); + }); +}); + +describe("nextConceptInQueue", () => { + const nodes = [ + node({ id: "n1", concept_name: "Recursion" }), + node({ id: "n2", concept_name: "Big-O" }), + node({ id: "n3", concept_name: " Tail calls " }), + ]; + + function session(scope: QuizScope, queueIndex: number): QuizSession { + return { scope, queueIndex } as QuizSession; + } + + it("names the concept after the current one in a due queue", () => { + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "n2"] }, 0), nodes)) + .toEqual({ id: "n2", name: "Big-O" }); + }); + + it("names it in a course queue too, and trims the label", () => { + const scope: QuizScope = { kind: "course", courseId: "course-a", queue: ["n1", "n3"] }; + expect(nextConceptInQueue(session(scope, 0), nodes)).toEqual({ id: "n3", name: "Tail calls" }); + }); + + it("returns null at the end of the queue", () => { + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "n2"] }, 1), nodes)).toBeNull(); + expect(nextConceptInQueue(session({ kind: "due", queue: [] }, 0), nodes)).toBeNull(); + }); + + it("returns null for a scope that has no queue at all", () => { + expect(nextConceptInQueue(session({ kind: "concept", conceptId: "n1" }, 0), nodes)).toBeNull(); + expect(nextConceptInQueue(session({ kind: "missed", conceptId: "n1", missedCount: 2 }, 0), nodes)) + .toBeNull(); + }); + + it("returns null rather than guessing when the next id is not in the loaded graph", () => { + // It is a LABEL. The exit's existence is decided from the queue, which needs + // no graph — so an unnamed button beats an invented name. + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "off-scope"] }, 0), nodes)) + .toBeNull(); + expect(nextConceptInQueue( + session({ kind: "due", queue: ["n1", "blank"] }, 0), + [...nodes, node({ id: "blank", concept_name: " " })], + )).toBeNull(); + }); +}); + +describe("colorFor", () => { + it("prefers the node's own course colour, then the course record", () => { + expect(colorFor(node({ id: "n", course_color: "#111111" }), course({ course_id: "course-a", color: "#222222" }))) + .toBe("#111111"); + expect(colorFor(node({ id: "n" }), course({ course_id: "course-a", color: "#222222" }))) + .toBe("#222222"); + }); + + it("falls back to a deterministic palette entry", () => { + const a = colorFor(node({ id: "n", course_id: "course-a" }), null); + const b = colorFor(node({ id: "m", course_id: "course-a" }), null); + expect(a).toMatch(/^#/); + expect(a).toBe(b); + }); +}); diff --git a/frontend/src/lib/quiz/proposals.ts b/frontend/src/lib/quiz/proposals.ts new file mode 100644 index 00000000..a842f2af --- /dev/null +++ b/frontend/src/lib/quiz/proposals.ts @@ -0,0 +1,322 @@ +/** + * What the quiz offers you when you arrive with nothing in mind. + * + * This is a deliberate client-side MIRROR of the backend's one ranking rule: + * + * backend/services/graph_service.py:914-945 — get_recommendations() + * filters: mastery_tier in (struggling, learning, unexplored) + * order: mastery_score.asc (lowest mastery first) + * limit: 5 + * + * The endpoint itself (`GET /api/graph/{user}/recommendations`) returns only + * `{concept_name, reason}` — no node id, no course, no raw score, no + * `last_studied_at` — which is not enough to render a card, join to an attempt, + * or build a queue (R4 §2). The full node list is already loaded for the + * "pick something specific" list, so the rule is reproduced over that instead of + * fetching a second, thinner answer. The codebase has precedent for mirroring + * with a citation (`Learn.tsx::tierForScore` mirrors `config.py::get_mastery_tier`). + * + * TODO(#537-followup: enriched /recommendations) — once the endpoint returns + * node_id / course_id / mastery_score / last_studied_at, delete this mirror and + * join on the response instead. + * + * One presentation-layer tie-break sits on top (R-7): the primary slot prefers + * the first candidate the student has actually studied. `unexplored` nodes score + * 0.0, so a raw `mastery_score.asc` sort always puts never-opened concepts ahead + * of the struggling ones — a "you're at 29% here, last studied 4 days ago" card + * is a better opening move than "here is a concept you have never seen". The + * ordering itself is untouched. + */ + +import { paletteFor } from "@/lib/data"; +import { resolveInitialSelection, type QuizConcept } from "@/lib/quizSelection"; +import type { EnrolledCourse } from "@/lib/api"; +import type { GraphNode } from "@/lib/types"; +import { queueOf } from "./machine"; +import { daysAgo, relativeStudied } from "./relativeTime"; +import { QUEUE_MAX } from "./session"; +import type { AttemptSummary, QuizSession } from "./types"; + +/** The tiers `get_recommendations` considers worth suggesting. `mastered` and + * `subject_root` are excluded by the same filter. */ +export const DUE_TIERS: readonly string[] = ["struggling", "learning", "unexplored"] as const; + +/** The endpoint's own cap. Applied where parity with it matters; `dueSet` covers + * the whole graph on purpose (R-7). */ +export const RECOMMENDATION_LIMIT = 5; + +export interface Candidate { + node: GraphNode; + course: EnrolledCourse | null; + color: string; + rationale: string; + lastAttempt?: AttemptSummary; +} + +/** The membership half of the rule: is this node one the student owes time to? */ +export function isDue(node: GraphNode): boolean { + return !node.is_subject_root && DUE_TIERS.includes(node.mastery_tier); +} + +function byMasteryAsc(a: GraphNode, b: GraphNode): number { + if (a.mastery_score !== b.mastery_score) return a.mastery_score - b.mastery_score; + // The backend's secondary order is unspecified; sort by id so a redraw with + // identical scores never reshuffles the cards under the student's cursor. + return a.id.localeCompare(b.id); +} + +function courseFor(node: GraphNode, courses: EnrolledCourse[]): EnrolledCourse | null { + if (!node.course_id) return null; + return courses.find(c => c.course_id === node.course_id) ?? null; +} + +/** Same resolution order as `lib/data.ts::apiToGraphNode`, so the quiz's accent + * matches the colour the graph already draws this node with. */ +export function colorFor(node: GraphNode, course: EnrolledCourse | null): string { + return node.course_color + || node.color + || course?.color + || paletteFor(course?.course_id || node.course_id || node.subject); +} + +/** The most recent COMPLETED attempt on a node — the only one that carries a + * score, and therefore the only one that can say "missed 3 last time". */ +export function latestCompletedAttempt( + nodeId: string, + attempts: AttemptSummary[], +): AttemptSummary | undefined { + let best: AttemptSummary | undefined; + for (const a of attempts) { + if (a.concept_node_id !== nodeId) continue; + if (a.status !== "completed" || a.score === null || a.total === null) continue; + const stamp = a.completed_at ?? a.created_at; + const bestStamp = best ? best.completed_at ?? best.created_at : ""; + if (!best || stamp > bestStamp) best = a; + } + return best; +} + +function pct(score: number): number { + return Math.round(score * 100); +} + +/** "29% · struggling · last studied 4 days ago" (§5 B1.2). */ +export function metaLine(node: GraphNode, now: Date = new Date()): string { + const studied = relativeStudied(node.last_studied_at, now); + const when = studied === "not studied yet" ? studied : `last studied ${studied}`; + return `${pct(node.mastery_score)}% · ${node.mastery_tier} · ${when}`; +} + +/** + * The one-line "why this one" on an alternative row. + * + * Never-studied says so; otherwise a recent completed attempt with misses is the + * most actionable thing we know; otherwise how long it has been. + */ +export function rationaleFor( + node: GraphNode, + lastAttempt?: AttemptSummary, + now: Date = new Date(), +): string { + const p = `${pct(node.mastery_score)}%`; + const days = daysAgo(node.last_studied_at, now); + if (days === null && !node.times_studied) return `${p} · not studied yet`; + + if (lastAttempt && lastAttempt.score !== null && lastAttempt.total !== null) { + const missed = lastAttempt.total - lastAttempt.score; + if (missed > 0) return `${p} · missed ${missed} last time`; + } + + if (days === null) return `${p} · not reviewed recently`; + if (days === 0) return `${p} · reviewed today`; + if (days === 1) return `${p} · reviewed yesterday`; + return `${p} · not reviewed in ${days} days`; +} + +/** + * Every concept worth proposing, weakest first, each with its course, colour and + * rationale already resolved. NOT capped — `get_recommendations`' `limit=5` is a + * transport cap on a thin payload, while callers here need the whole set for the + * due count and the queue. Slice to `RECOMMENDATION_LIMIT` where parity matters. + */ +export function rankCandidates( + nodes: GraphNode[], + courses: EnrolledCourse[], + attempts: AttemptSummary[], + now: Date = new Date(), +): Candidate[] { + return nodes + .filter(isDue) + .sort(byMasteryAsc) + .map(node => { + const course = courseFor(node, courses); + const lastAttempt = latestCompletedAttempt(node.id, attempts); + return { + node, + course, + color: colorFor(node, course), + rationale: rationaleFor(node, lastAttempt, now), + ...(lastAttempt ? { lastAttempt } : {}), + }; + }); +} + +/** The card at the top of quiz home: the weakest concept the student has + * actually opened, falling back to the weakest overall (R-7). */ +export function primaryOf(candidates: Candidate[]): Candidate | null { + return candidates.find(c => c.node.times_studied > 0) ?? candidates[0] ?? null; +} + +/** "Also worth a look" — the next couple, never repeating the primary. */ +export function alternativesOf( + candidates: Candidate[], + primary: Candidate | null, + n = 2, +): Candidate[] { + return candidates.filter(c => c.node.id !== primary?.node.id).slice(0, n); +} + +/** + * "Review everything due": the same membership filter over the WHOLE scoped + * graph, not the capped recommendation list. There is no spaced-repetition or + * days-since concept anywhere in the backend (R4 §3), so "due" means exactly + * "in one of the three tiers that need work" — nothing is invented here. + */ +export function dueSet(nodes: GraphNode[]): { + conceptIds: string[]; + count: number; + courseCount: number; +} { + const due = nodes.filter(isDue).sort(byMasteryAsc); + const courses = new Set(due.map(n => n.course_id).filter((id): id is string => Boolean(id))); + return { + conceptIds: due.map(n => n.id), + count: due.length, + courseCount: courses.size, + }; +} + +/** + * The concept ids a multi-concept session works through, weakest first, capped + * at `QUEUE_MAX`. `/generate` is per concept (R-4), so "practice this course" + * and "review everything due" are both queues of single-concept attempts. + */ +export function queueFor( + scope: "course" | "due", + nodes: GraphNode[], + courseId?: string, +): string[] { + const scoped = scope === "course" + ? nodes.filter(n => n.course_id === courseId) + : nodes; + return scoped.filter(isDue).sort(byMasteryAsc).slice(0, QUEUE_MAX).map(n => n.id); +} + +/** + * The concept a queued session moves to next, named. + * + * The queue holds ids and the results screen is handed only the concept it just + * quizzed, so "Next: {concept}" cannot be built from the session alone — this + * resolves the id against the loaded graph. + * + * `null` covers both "the queue is finished" and "the next id isn't in the + * loaded (scoped) graph, so we can't name it". That is deliberate: it is a LABEL, + * and a caller must never use it to decide whether the exit exists — that stays + * `queueOf(scope).length > queueIndex + 1`, which needs no graph. Guessing a name + * would be worse than an unnamed button. + */ +export function nextConceptInQueue( + session: QuizSession, + nodes: GraphNode[], +): { id: string; name: string } | null { + const id = queueOf(session.scope)[session.queueIndex + 1]; + if (!id) return null; + const name = nodes.find(n => n.id === id)?.concept_name?.trim(); + return name ? { id, name } : null; +} + +/** What a deep link actually points at, once resolved against the loaded graph. */ +export interface EntrySelection { + conceptId: string | null; + courseId: string | null; + /** The link named a concept or topic that isn't in the current scope. §6 wants + * a toast and an ordinary home, not a silently ignored link. */ + unresolved: boolean; +} + +/** + * Resolves `?concept=` / `?topic=` against the scoped graph. + * + * The id path goes through `quizSelection.resolveInitialSelection` — the same + * resolver the old picker used, which already answers "unknown id → nothing + * selected" rather than pre-selecting a node that isn't there. `topic` is the + * fuzzy legacy form (a concept NAME, from the tree's and dashboard's old links), + * matched case-insensitively; `concept` wins when both are present. + */ +export function entrySelection( + entry: { concept?: string; topic?: string; course?: string }, + nodes: GraphNode[], + courses: EnrolledCourse[] = [], +): EntrySelection { + const byCourseId = new Map(courses.map(c => [c.course_id, c])); + const concepts: QuizConcept[] = nodes + .filter(n => !n.is_subject_root) + .map(n => ({ + id: n.id, + name: n.concept_name, + course_id: n.course_id ?? null, + course_code: n.course_id ? byCourseId.get(n.course_id)?.course_code ?? null : null, + })); + + if (entry.concept) { + const resolved = resolveInitialSelection(concepts, entry.concept); + return { + conceptId: resolved.conceptId, + courseId: resolved.courseId ?? entry.course ?? null, + unresolved: resolved.conceptId === null, + }; + } + + if (entry.topic) { + const wanted = entry.topic.trim().toLowerCase(); + const match = concepts.find(c => c.name.trim().toLowerCase() === wanted); + return { + conceptId: match?.id ?? null, + courseId: match?.course_id ?? entry.course ?? null, + unresolved: !match, + }; + } + + return { conceptId: null, courseId: entry.course ?? null, unresolved: false }; +} + +/** + * The "pick something specific" list: every concept, grouped under its course. + * + * Courses sort by code and concepts by name — this is a browse surface, not a + * ranking, so alphabetical beats weakest-first for finding a known name (the + * same choice `quizSelection.ts` already makes for its picker). Courses with no + * concepts are omitted: unlike the old dropdown, an empty group here would be a + * heading with nothing under it. + */ +export function groupByCourse( + nodes: GraphNode[], + courses: EnrolledCourse[], +): { course: EnrolledCourse; nodes: GraphNode[] }[] { + const byCourse = new Map(); + for (const node of nodes) { + if (node.is_subject_root || !node.course_id) continue; + const bucket = byCourse.get(node.course_id); + if (bucket) bucket.push(node); + else byCourse.set(node.course_id, [node]); + } + return courses + .filter(c => byCourse.has(c.course_id)) + .sort((a, b) => a.course_code.localeCompare(b.course_code)) + .map(course => ({ + course, + nodes: (byCourse.get(course.course_id) ?? []) + .slice() + .sort((a, b) => a.concept_name.localeCompare(b.concept_name)), + })); +} diff --git a/frontend/src/lib/quiz/relativeTime.test.ts b/frontend/src/lib/quiz/relativeTime.test.ts new file mode 100644 index 00000000..2feb478d --- /dev/null +++ b/frontend/src/lib/quiz/relativeTime.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { daysAgo, relativeStudied } from "./relativeTime"; + +// Local-midnight arithmetic, so the fixtures are built in local time too. +function at(year: number, month: number, day: number, hour = 12): Date { + return new Date(year, month - 1, day, hour); +} + +const NOW = at(2026, 8, 22, 9); + +describe("daysAgo", () => { + it("counts calendar days, not 24-hour blocks", () => { + // 11pm the previous evening is ten hours ago, but it is yesterday. + expect(daysAgo(at(2026, 8, 21, 23).toISOString(), NOW)).toBe(1); + expect(daysAgo(at(2026, 8, 22, 1).toISOString(), NOW)).toBe(0); + expect(daysAgo(at(2026, 8, 18, 8).toISOString(), NOW)).toBe(4); + }); + + it("clamps a future timestamp to today", () => { + expect(daysAgo(at(2026, 8, 25).toISOString(), NOW)).toBe(0); + }); + + it("returns null for absent or unparseable input", () => { + expect(daysAgo(null, NOW)).toBeNull(); + expect(daysAgo(undefined, NOW)).toBeNull(); + expect(daysAgo("", NOW)).toBeNull(); + expect(daysAgo("not a date", NOW)).toBeNull(); + }); +}); + +describe("relativeStudied", () => { + it("names today and yesterday", () => { + expect(relativeStudied(at(2026, 8, 22, 1).toISOString(), NOW)).toBe("today"); + expect(relativeStudied(at(2026, 8, 21, 23).toISOString(), NOW)).toBe("yesterday"); + }); + + it("counts days beyond that", () => { + expect(relativeStudied(at(2026, 8, 18).toISOString(), NOW)).toBe("4 days ago"); + expect(relativeStudied(at(2026, 8, 13).toISOString(), NOW)).toBe("9 days ago"); + }); + + it("says so when a concept was never studied", () => { + expect(relativeStudied(null, NOW)).toBe("not studied yet"); + }); +}); diff --git a/frontend/src/lib/quiz/relativeTime.ts b/frontend/src/lib/quiz/relativeTime.ts new file mode 100644 index 00000000..90adf7b2 --- /dev/null +++ b/frontend/src/lib/quiz/relativeTime.ts @@ -0,0 +1,41 @@ +/** + * "last studied 4 days ago" — the phrase the quiz home's meta lines are built + * from. + * + * The frontend had no relative-time formatter at all before #537 (the tree + * printed a raw `toLocaleString()` or the word "never", R4 §3). Counting is + * done in CALENDAR days, not 24-hour blocks: something studied at 11pm + * yesterday reads "yesterday" this morning, which is what a person means. + */ + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function startOfDay(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** + * Whole calendar days between `iso` and `now` (0 = today, 1 = yesterday). + * Returns `null` for an absent or unparseable timestamp. A future timestamp + * clamps to 0 rather than going negative — clock skew is not a story to tell. + */ +export function daysAgo(iso: string | null | undefined, now: Date = new Date()): number | null { + if (!iso) return null; + const then = new Date(iso); + if (Number.isNaN(then.getTime())) return null; + const days = Math.round((startOfDay(now) - startOfDay(then)) / MS_PER_DAY); + return days < 0 ? 0 : days; +} + +/** The phrase for a `last_studied_at`: "today" | "yesterday" | "N days ago" | + * "not studied yet". */ +export function relativeStudied( + iso: string | null | undefined, + now: Date = new Date(), +): string { + const days = daysAgo(iso, now); + if (days === null) return "not studied yet"; + if (days === 0) return "today"; + if (days === 1) return "yesterday"; + return `${days} days ago`; +} diff --git a/frontend/src/lib/quiz/session.test.ts b/frontend/src/lib/quiz/session.test.ts new file mode 100644 index 00000000..61e0f651 --- /dev/null +++ b/frontend/src/lib/quiz/session.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DISMISSED_KEY, + QUEUE_COUNT, + QUEUE_MAX, + STORAGE_KEY, + clearDismissed, + clearSession, + dismissAttempt, + isDismissed, + loadSession, + persistSession, + saveSession, + shouldPersist, +} from "./session"; +import { initialSession } from "./machine"; +import { DEFAULT_PREFS, FEEDBACK_MODES, loadPrefs, savePrefs } from "./prefs"; +import { PREFS_KEY } from "./session"; +import type { Phase, QuizConfig, QuizSession } from "./types"; + +const CONFIG: QuizConfig = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +function session(phase: Phase): QuizSession { + const base = initialSession({ source: { kind: "tree" }, concept: "c1" }, CONFIG, DEFAULT_PREFS); + return { ...base, phase, attemptId: "attempt-1" }; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +describe("constants", () => { + it("caps a queued session at get_recommendations' own limit", () => { + expect(QUEUE_MAX).toBe(5); + expect(QUEUE_COUNT).toBe(3); + }); +}); + +describe("saveSession / loadSession", () => { + it("round-trips a session", () => { + const s = session("active"); + saveSession(s); + expect(loadSession()).toEqual(s); + }); + + it("returns null when nothing is stored", () => { + expect(loadSession()).toBeNull(); + }); + + it("returns null for a corrupted record rather than throwing", () => { + window.localStorage.setItem(STORAGE_KEY, "{not json"); + expect(loadSession()).toBeNull(); + }); + + it("returns null for a record that isn't a session", () => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ hello: "world" })); + expect(loadSession()).toBeNull(); + }); + + it("survives a storage setter that throws (quota, private mode)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + expect(() => saveSession(session("active"))).not.toThrow(); + }); + + it("survives a storage getter that throws", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + expect(loadSession()).toBeNull(); + }); + + it("clears", () => { + saveSession(session("active")); + clearSession(); + expect(loadSession()).toBeNull(); + }); +}); + +describe("persistSession", () => { + it("saves from generating onward", () => { + const persisted: Phase[] = [ + "generating", + "active", + "answered", + "confirm-leave", + "submitting", + "paused", + "error", + ]; + for (const phase of persisted) { + expect(shouldPersist(session(phase)), phase).toBe(true); + window.localStorage.clear(); + persistSession(session(phase)); + expect(loadSession()?.phase, phase).toBe(phase); + } + }); + + it("does not save on home, configuring or results", () => { + for (const phase of ["home", "configuring", "results"] as Phase[]) { + expect(shouldPersist(session(phase)), phase).toBe(false); + window.localStorage.clear(); + persistSession(session(phase)); + expect(loadSession(), phase).toBeNull(); + } + }); + + it("LEAVES a paused record alone instead of clearing it", () => { + // The regression the browser lane found: quiz home mounts a fresh + // `home`-phase session and its config effect persists it, which used to + // delete the paused attempt the resume strip was about to offer. Storage is + // cleared at exactly two moments (SUBMITTED and EXIT), both by an explicit + // `clearSession()` — never as a side effect of a phase. + const paused = { ...session("paused"), cursor: 2 }; + saveSession(paused); + + for (const phase of ["home", "configuring", "results"] as Phase[]) { + persistSession(session(phase)); + expect(loadSession(), phase).toEqual(paused); + } + }); + + it("never persists a session that has no attempt to come back to", () => { + // The second door into the same loss: a resume whose GET /attempts/{id} + // fails, and a START whose generation fails, both land on a phase that IS + // in the persisted set while `attemptId` is still null. + for (const phase of ["generating", "error"] as Phase[]) { + const attemptless = { ...session(phase), attemptId: null, items: [] }; + expect(shouldPersist(attemptless), phase).toBe(false); + } + }); + + it("leaves the paused record intact when a resume fails before it has an attempt", () => { + const paused = { ...session("paused"), cursor: 2 }; + saveSession(paused); + + // What `runResume`'s catch produces: FAILED applied to the fresh mount's + // home-phase session, which has no attempt of its own. + persistSession({ ...session("error"), attemptId: null, items: [] }); + expect(loadSession()).toEqual(paused); + + // …and what a fresh START that never got an attempt id produces. + persistSession({ ...session("generating"), attemptId: null, items: [] }); + expect(loadSession()).toEqual(paused); + }); +}); + +describe("dismissed attempts", () => { + it("remembers a discard and forgets it on clear", () => { + expect(isDismissed("a-1")).toBe(false); + dismissAttempt("a-1"); + expect(isDismissed("a-1")).toBe(true); + clearDismissed(); + expect(isDismissed("a-1")).toBe(false); + }); + + it("de-duplicates and caps the list", () => { + dismissAttempt("a-1"); + dismissAttempt("a-1"); + expect(JSON.parse(window.localStorage.getItem(DISMISSED_KEY) as string)).toEqual(["a-1"]); + + for (let i = 0; i < 60; i += 1) dismissAttempt(`x-${i}`); + const stored = JSON.parse(window.localStorage.getItem(DISMISSED_KEY) as string) as string[]; + expect(stored.length).toBe(50); + expect(stored[0]).toBe("x-59"); + }); + + it("ignores an empty id and a corrupted list", () => { + dismissAttempt(""); + expect(window.localStorage.getItem(DISMISSED_KEY)).toBeNull(); + window.localStorage.setItem(DISMISSED_KEY, '"nope"'); + expect(isDismissed("a-1")).toBe(false); + }); +}); + +describe("prefs", () => { + it("has exactly the two feedback modes and nothing else hardcoded", () => { + expect(FEEDBACK_MODES).toEqual(["as-you-go", "at-end"]); + }); + + it("defaults to no remembered count/difficulty and at-end feedback", () => { + expect(loadPrefs()).toEqual({ count: null, difficulty: null, feedback: "at-end" }); + }); + + it("round-trips", () => { + savePrefs({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + expect(loadPrefs()).toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + }); + + it("drops a remembered value the live config no longer offers", () => { + savePrefs({ count: 15, difficulty: "impossible", feedback: "as-you-go" }); + expect(loadPrefs(CONFIG)).toEqual({ count: null, difficulty: null, feedback: "as-you-go" }); + // Without a config to check against, the stored value is left alone. + expect(loadPrefs()).toEqual({ count: 15, difficulty: "impossible", feedback: "as-you-go" }); + }); + + it("keeps a remembered value the config still offers", () => { + savePrefs({ count: 3, difficulty: "adaptive", feedback: "at-end" }); + expect(loadPrefs(CONFIG)).toEqual({ count: 3, difficulty: "adaptive", feedback: "at-end" }); + }); + + it("falls back for a corrupted or hostile record", () => { + window.localStorage.setItem(PREFS_KEY, "{not json"); + expect(loadPrefs()).toEqual(DEFAULT_PREFS); + window.localStorage.setItem(PREFS_KEY, JSON.stringify({ count: "five", feedback: "psychic" })); + expect(loadPrefs()).toEqual(DEFAULT_PREFS); + }); +}); diff --git a/frontend/src/lib/quiz/session.ts b/frontend/src/lib/quiz/session.ts new file mode 100644 index 00000000..584222cd --- /dev/null +++ b/frontend/src/lib/quiz/session.ts @@ -0,0 +1,174 @@ +/** + * Session persistence — the half of leave-and-resume the backend can't do. + * + * `GET /api/quiz/attempts/{id}` restores which questions exist and which were + * answered, but the resume payload is keyless and carries no `correct_index` + * and no explanation, and the server has never heard of the session's scope, + * queue, feedback mode or origin. Those live here, in localStorage, keyed by + * nothing but "the one quiz this browser is in the middle of". + * + * Every read and write is wrapped: a private window, a full quota or a + * disabled-storage browser must degrade to "no saved session", never to a + * thrown error mid-quiz. + */ + +import type { QuizSession } from "./types"; + +/** Max concepts in one multi-concept session — `get_recommendations`' own limit + * (`graph_service.py:914-945`, `limit=5`). The generation rate limit is 8/300s, + * so five 3-question attempts fit inside one session comfortably. */ +export const QUEUE_MAX = 5; +/** Questions per attempt in a queued session (R-4). */ +export const QUEUE_COUNT = 3; + +export const STORAGE_KEY = "sapling_quiz_session"; +export const PREFS_KEY = "sapling_quiz_prefs"; +export const DISMISSED_KEY = "sapling_quiz_dismissed"; + +/** Cap on remembered discards, so the key can't grow without bound. */ +const DISMISSED_MAX = 50; + +function storage(): Storage | null { + try { + if (typeof window === "undefined") return null; + return window.localStorage; + } catch { + return null; + } +} + +function readJson(key: string): T | null { + try { + const raw = storage()?.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function writeJson(key: string, value: unknown): void { + try { + storage()?.setItem(key, JSON.stringify(value)); + } catch { + // Quota, private mode, storage disabled. Losing the resume record is a + // downgrade, not a failure — the attempt itself is safe server-side. + } +} + +function removeKey(key: string): void { + try { + storage()?.removeItem(key); + } catch { + // See above. + } +} + +/** Cheap shape check, so a stale or hand-edited record can't crash a mount. */ +function looksLikeSession(value: unknown): value is QuizSession { + if (value === null || typeof value !== "object") return false; + const s = value as Partial; + return typeof s.phase === "string" + && Array.isArray(s.items) + && typeof s.cursor === "number" + && typeof s.source === "object" + && s.source !== null; +} + +export function saveSession(session: QuizSession): void { + writeJson(STORAGE_KEY, session); +} + +export function loadSession(): QuizSession | null { + const parsed = readJson(STORAGE_KEY); + return looksLikeSession(parsed) ? parsed : null; +} + +export function clearSession(): void { + removeKey(STORAGE_KEY); +} + +/** + * Phases worth remembering. Nothing before `generating` has an attempt to come + * back to, and once the attempt is scored the results live in memory and the + * record is cleared — a stale `results` session would otherwise reopen a quiz + * the student already finished. + */ +const PERSISTED_PHASES: ReadonlySet = new Set([ + "generating", + "active", + "answered", + "confirm-leave", + "submitting", + "paused", + "error", +]); + +/** + * …and only once there is an attempt to come back to. + * + * The phase set alone is not enough. `error` is in it (a failure mid-quiz must + * still be resumable), but a session can reach `error` with no attempt at all: + * a resume whose `GET /attempts/{id}` never answered (dropped connection, a 401 + * after a token refresh, a 5xx) applies FAILED to the freshly-mounted + * `home`-phase session, and a `START` that fails generation does the same from + * `generating`. Both would then write `{phase:"error", attemptId:null, items:[]}` + * over the paused record they were trying to open — the verdicts, scope, + * feedback mode and origin that live nowhere else, destroyed by the failure to + * read them. The attempt is still `in_progress` server-side, so the strip + * re-finds the row and the student resumes into a quiz with question one blank. + * + * A session with no attempt has nothing to resume, so skipping the write costs + * nothing and keeps the stored record the property of whichever session + * actually owns an attempt. + */ +export function shouldPersist(session: QuizSession): boolean { + return PERSISTED_PHASES.has(session.phase) && session.attemptId !== null; +} + +/** + * Save if this phase is worth remembering, and otherwise LEAVE THE RECORD ALONE. + * + * It used to clear on any non-live phase, which quietly deleted the very thing + * quiz home exists to offer: mounting home starts a fresh `home`-phase session, + * the config effect applies `SET_CONFIG` the moment `/api/quiz/config` resolves, + * and every accepted event is persisted — so the first thing the resume strip + * did was destroy the paused attempt's verdicts, scope and origin, all of which + * live nowhere else (R-3; the wire's resume payload has `is_correct` but no + * `correct_index` and no explanation). The browser lane caught it: a resumed + * quiz showed question one as unanswered, and a dashboard-sourced quiz exited to + * the tree. + * + * Clearing is not a phase's business. §4 names exactly two moments — SUBMITTED + * and EXIT — and `useQuizSession` calls `clearSession()` explicitly at both. + */ +export function persistSession(session: QuizSession): void { + if (shouldPersist(session)) saveSession(session); +} + +// ── Discarded attempts ───────────────────────────────────────────────────── +// +// There is no abandon endpoint (gap G4): an attempt only closes via submit or +// the 24h TTL sweep. "Discard" therefore hides the row client-side and leaves +// the server row to expire. +// TODO(#537-followup: abandon endpoint) — replace this with a real +// `POST /api/quiz/attempts/{id}/abandon` so a discard is visible on any device. + +function readDismissed(): string[] { + const parsed = readJson(DISMISSED_KEY); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; +} + +export function dismissAttempt(id: string): void { + if (!id) return; + const existing = readDismissed().filter(v => v !== id); + writeJson(DISMISSED_KEY, [id, ...existing].slice(0, DISMISSED_MAX)); +} + +export function isDismissed(id: string): boolean { + return readDismissed().includes(id); +} + +export function clearDismissed(): void { + removeKey(DISMISSED_KEY); +} diff --git a/frontend/src/lib/quiz/source.test.ts b/frontend/src/lib/quiz/source.test.ts new file mode 100644 index 00000000..05652dc7 --- /dev/null +++ b/frontend/src/lib/quiz/source.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { buildQuizHref, isSafeReturnPath, parseEntry } from "./source"; + +function parse(query: string) { + return parseEntry(new URLSearchParams(query)); +} + +describe("parseEntry — targets", () => { + it("reads a concept deep link", () => { + expect(parse("concept=node-1&from=tree&return=%2Ftree%3Fnode%3Dnode-1")).toEqual({ + concept: "node-1", + source: { kind: "tree", returnTo: "/tree?node=node-1", conceptId: "node-1" }, + }); + }); + + it("reads a topic (concept NAME) deep link", () => { + expect(parse("topic=Recursion")).toEqual({ + topic: "Recursion", + source: { kind: "link" }, + }); + }); + + it("reads a course scope", () => { + expect(parse("course=course-9&from=tree&return=%2Ftree")).toEqual({ + course: "course-9", + source: { kind: "tree", returnTo: "/tree" }, + }); + }); + + it("reads the due scope and ignores any other scope value", () => { + expect(parse("scope=due&from=dashboard").scope).toBe("due"); + expect(parse("scope=everything").scope).toBeUndefined(); + }); + + it("reads an attempt to resume", () => { + expect(parse("attempt=a-1").attempt).toBe("a-1"); + }); + + it("carries the note id from the notetaker", () => { + expect(parse("concept=c1&from=notes¬e=n7&return=%2Fnotetaker%3Fnote%3Dn7").source).toEqual({ + kind: "notes", + returnTo: "/notetaker?note=n7", + conceptId: "c1", + noteId: "n7", + }); + }); +}); + +describe("parseEntry — source kind", () => { + it("uses `from` when it names a known kind", () => { + for (const kind of ["tree", "dashboard", "notes", "nav", "link", "quiz"]) { + expect(parse(`from=${kind}`).source.kind).toBe(kind); + } + }); + + it("treats a legacy deep link with no `from` as a link", () => { + expect(parse("concept=c1").source.kind).toBe("link"); + expect(parse("topic=Recursion").source.kind).toBe("link"); + }); + + it("treats a bare /quiz as nav", () => { + expect(parse("").source.kind).toBe("nav"); + }); + + it("falls back rather than trusting an unknown `from`", () => { + expect(parse("from=evil&concept=c1").source.kind).toBe("link"); + expect(parse("from=evil").source.kind).toBe("nav"); + }); + + it("ignores blank params", () => { + // A whitespace-only concept is no target at all — the explicit `from` still + // stands, but nothing is deep-linked and no conceptId lands on the source. + expect(parse("concept=%20%20&from=tree")).toEqual({ source: { kind: "tree" } }); + expect(parse("concept=%20%20")).toEqual({ source: { kind: "nav" } }); + }); +}); + +describe("isSafeReturnPath", () => { + it("accepts a same-origin path", () => { + expect(isSafeReturnPath("/tree")).toBe(true); + expect(isSafeReturnPath("/tree?node=abc#x")).toBe(true); + }); + + it("rejects anything that could leave the origin", () => { + expect(isSafeReturnPath("https://evil.com")).toBe(false); + expect(isSafeReturnPath("//evil.com")).toBe(false); + expect(isSafeReturnPath("/\\evil.com")).toBe(false); + expect(isSafeReturnPath("javascript:alert(1)")).toBe(false); + expect(isSafeReturnPath("tree")).toBe(false); + expect(isSafeReturnPath("")).toBe(false); + expect(isSafeReturnPath(null)).toBe(false); + expect(isSafeReturnPath(undefined)).toBe(false); + }); + + it("rejects control characters that could hide the rest of the string", () => { + expect(isSafeReturnPath("/tree\nhttps://evil.com")).toBe(false); + expect(isSafeReturnPath("/tree\u0000")).toBe(false); + }); +}); + +describe("parseEntry — return safety", () => { + it("drops an off-origin return rather than following it", () => { + expect(parse("concept=c1&from=tree&return=https%3A%2F%2Fevil.com").source.returnTo) + .toBeUndefined(); + expect(parse("concept=c1&from=tree&return=%2F%2Fevil.com").source.returnTo).toBeUndefined(); + }); +}); + +describe("buildQuizHref", () => { + it("builds the tree concept link from §6", () => { + expect( + buildQuizHref( + { concept: "node-1" }, + { kind: "tree", returnTo: "/tree?node=node-1", conceptId: "node-1" }, + ), + ).toBe("/quiz?concept=node-1&from=tree&return=%2Ftree%3Fnode%3Dnode-1"); + }); + + it("builds the course link from a subject root", () => { + expect(buildQuizHref({ course: "course-9" }, { kind: "tree", returnTo: "/tree" })) + .toBe("/quiz?course=course-9&from=tree&return=%2Ftree"); + }); + + it("builds the review-everything-due link", () => { + expect(buildQuizHref({ scope: "due" }, { kind: "dashboard", returnTo: "/dashboard" })) + .toBe("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); + + it("builds the notetaker link, note id included", () => { + expect( + buildQuizHref( + { concept: "c1" }, + { kind: "notes", returnTo: "/notetaker?note=n7", noteId: "n7" }, + ), + ).toBe("/quiz?concept=c1&from=notes&return=%2Fnotetaker%3Fnote%3Dn7¬e=n7"); + }); + + it("builds a resume link", () => { + expect(buildQuizHref({ attempt: "a-1" }, { kind: "quiz" })).toBe("/quiz?attempt=a-1&from=quiz"); + }); + + it("refuses to encode an off-origin return", () => { + const href = buildQuizHref({ concept: "c1" }, { kind: "tree", returnTo: "https://evil.com" }); + expect(href).toBe("/quiz?concept=c1&from=tree"); + }); + + it("round-trips through parseEntry", () => { + const source = { kind: "tree" as const, returnTo: "/tree?node=n1", conceptId: "n1" }; + const href = buildQuizHref({ concept: "n1" }, source); + const entry = parseEntry(new URLSearchParams(href.slice(href.indexOf("?")))); + expect(entry.concept).toBe("n1"); + expect(entry.source).toEqual(source); + }); +}); diff --git a/frontend/src/lib/quiz/source.ts b/frontend/src/lib/quiz/source.ts new file mode 100644 index 00000000..1899caac --- /dev/null +++ b/frontend/src/lib/quiz/source.ts @@ -0,0 +1,116 @@ +/** + * Where a quiz was entered from, and how to get back. + * + * Before #537 the quiz screen exited to a hardcoded `/learn` no matter how it + * was reached — Cancel, mid-quiz Exit and Done all pushed the same route, and + * nothing carried the origin at all (R5 §C.1). `source` is that missing thread: + * every entry point encodes it in the URL, `parseEntry` reads it back, and + * `exits.ts` turns it into a destination. + * + * The `return` param is a same-origin PATH and nothing else. Anything carrying a + * scheme or a host is dropped rather than sanitised — an attacker-supplied + * `?return=` is otherwise an open redirect off a link a student would trust. + */ + +import type { QuizSource, SourceKind } from "./types"; + +export interface EntryRequest { + /** A concept node id — the precise deep link. */ + concept?: string; + /** A concept NAME — the fuzzy legacy deep link; resolved against the graph. */ + topic?: string; + /** An abstract course id — "practice on this course". */ + course?: string; + /** The only scope value: "review everything due". */ + scope?: "due"; + /** An attempt id to resume. */ + attempt?: string; + source: QuizSource; +} + +const SOURCE_KINDS: readonly SourceKind[] = [ + "tree", + "dashboard", + "notes", + "nav", + "link", + "quiz", +]; + +function isSourceKind(value: string | null): value is SourceKind { + return value !== null && (SOURCE_KINDS as readonly string[]).includes(value); +} + +/** + * A same-origin path we are willing to `router.push`. + * + * Must start with a single `/`. `//evil.com` is protocol-relative and leaves the + * origin; `/\evil.com` is the same trick with a backslash, which some parsers + * normalise; anything with a scheme is obviously external. Control characters + * are rejected because they can hide the rest of the string from a naive check. + */ +export function isSafeReturnPath(value: string | null | undefined): value is string { + if (!value) return false; + if (!value.startsWith("/")) return false; + if (value.startsWith("//") || value.startsWith("/\\")) return false; + if (/[\u0000-\u001f\u007f]/.test(value)) return false; + return true; +} + +function trimmed(params: URLSearchParams, key: string): string | undefined { + const value = params.get(key)?.trim(); + return value ? value : undefined; +} + +/** + * Reads the quiz entry out of the URL. + * + * `from`/`return`/`note` describe the origin; `concept`/`topic`/`course`/ + * `scope`/`attempt` describe the target. A link with a target but no `from` is + * a legacy deep link (`{kind: "link"}`, §6); a bare `/quiz` is nav. + */ +export function parseEntry(params: URLSearchParams): EntryRequest { + const concept = trimmed(params, "concept"); + const topic = trimmed(params, "topic"); + const course = trimmed(params, "course"); + const attempt = trimmed(params, "attempt"); + const scope = trimmed(params, "scope") === "due" ? ("due" as const) : undefined; + const noteId = trimmed(params, "note"); + const returnTo = trimmed(params, "return"); + + const from = params.get("from"); + const hasTarget = Boolean(concept || topic || course || attempt || scope); + const kind: SourceKind = isSourceKind(from) ? from : hasTarget ? "link" : "nav"; + + const source: QuizSource = { kind }; + if (isSafeReturnPath(returnTo)) source.returnTo = returnTo; + if (concept) source.conceptId = concept; + if (noteId) source.noteId = noteId; + + const entry: EntryRequest = { source }; + if (concept) entry.concept = concept; + if (topic) entry.topic = topic; + if (course) entry.course = course; + if (scope) entry.scope = scope; + if (attempt) entry.attempt = attempt; + return entry; +} + +/** The href every inbound caller links to (§6). Exactly one target field is + * meaningful; the rest are ignored in the order below. */ +export function buildQuizHref( + target: { concept?: string; course?: string; scope?: "due"; attempt?: string }, + source: QuizSource, +): string { + const params = new URLSearchParams(); + if (target.concept) params.set("concept", target.concept); + else if (target.course) params.set("course", target.course); + else if (target.scope) params.set("scope", target.scope); + else if (target.attempt) params.set("attempt", target.attempt); + + params.set("from", source.kind); + if (isSafeReturnPath(source.returnTo)) params.set("return", source.returnTo); + if (source.noteId) params.set("note", source.noteId); + + return `/quiz?${params.toString()}`; +} diff --git a/frontend/src/lib/quiz/types.ts b/frontend/src/lib/quiz/types.ts new file mode 100644 index 00000000..d6cc118f --- /dev/null +++ b/frontend/src/lib/quiz/types.ts @@ -0,0 +1,184 @@ +/** + * The shared vocabulary of the quiz redesign (#537) — §2 of + * `docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md`. + * + * Wire shapes here mirror what `backend/routes/quiz.py` actually returns; they + * are NOT aspirational. Anything the backend does not send (feedback mode, the + * session/scope model, the phase machine) is marked as a client concept. + */ + +import type { QuizError } from "./errors"; + +/** `GET /api/quiz/config` — the ONLY source of option lists (never enumerate + * counts or difficulties in code; `services/quiz_config.py::quiz_config_payload`). */ +export interface QuizConfig { + num_questions: { min: number; max: number; options: number[] }; + difficulties: string[]; + question_types: string[]; +} + +/** Client concept (R-2): the backend records every answer as it happens; this + * only decides when the verdict is shown. There is no `/config` list for it. */ +export type FeedbackMode = "as-you-go" | "at-end"; + +/** Persisted under `sapling_quiz_prefs`. `null` means "no stored preference — + * fall back to the config-derived default". */ +export interface QuizPrefs { + count: number | null; + difficulty: string | null; + feedback: FeedbackMode; +} + +/** An answer option as the keyless projection sends it (`include_answer_key: false` + * strips `correct`; quiz.py::_strip_answer_key). */ +export interface WireOption { + label: string; + text: string; +} + +export interface WireQuestion { + id: number; + question: string; + options: WireOption[]; + concept_tested?: string; + difficulty: string; +} + +export interface GenerateResult { + quiz_id: string; + questions: WireQuestion[]; + requested_difficulty: string; + resolved_difficulty: string; + requested_count: number; + delivered_count: number; +} + +export interface AnswerResult { + question_index: number; + question_id: number; + is_correct: boolean; + /** -1 for a malformed item with no correct option (quiz.py:1637-1647). */ + correct_index: number; + explanation: string; + next_question: WireQuestion | null; + /** false = idempotent replay or a lost race; the answer still stands. */ + recorded: boolean; +} + +export interface SubmitResult { + score: number; + total: number; + mastery_before: number; + mastery_after: number; + results: { + question_id: string; + selected: string; + correct: boolean; + correct_answer: string; + explanation: string; + }[]; +} + +export type AttemptStatus = "completed" | "abandoned" | "in_progress"; + +export interface AttemptSummary { + quiz_id: string; + status: AttemptStatus; + concept_node_id: string; + concept_name: string; + course_id: string | null; + score: number | null; + total: number | null; + difficulty: string; + mastery_before: number | null; + mastery_after: number | null; + mastery_delta: number | null; + created_at: string; + completed_at: string | null; +} + +export interface AttemptsPage { + total: number; + limit: number; + offset: number; + attempts: AttemptSummary[]; +} + +export interface AttemptDetail { + quiz_id: string; + status: AttemptStatus; + resumable: boolean; + difficulty: string; + concept_node_id: string; + /** Always keyless; `[]` once the attempt is no longer resumable. */ + questions: WireQuestion[]; + responses: { + question_index: number; + selected_index: number; + is_correct: boolean; + answered_at: string; + }[]; + score: number | null; + total: number | null; + created_at: string; +} + +export type SourceKind = "tree" | "dashboard" | "notes" | "nav" | "link" | "quiz"; + +export interface QuizSource { + kind: SourceKind; + returnTo?: string; + conceptId?: string; + noteId?: string; +} + +export type QuizIntent = "practice" | "review"; + +/** Multi-concept scopes run as a queue of single-concept attempts (R-4): + * `/generate` is per `concept_node_id`, so a course/due session is a queue. */ +export type QuizScope = + | { kind: "concept"; conceptId: string } + | { kind: "course"; courseId: string; queue: string[] } + | { kind: "due"; queue: string[] } + | { kind: "missed"; conceptId: string; missedCount: number }; + +export interface QuizItem { + index: number; + question: WireQuestion; + selectedIndex: number | null; + verdict: { isCorrect: boolean; correctIndex: number; explanation: string } | null; + flagged: boolean; +} + +export type Phase = + | "home" + | "configuring" + | "generating" + | "active" + | "answered" + | "confirm-leave" + | "submitting" + | "results" + | "paused" + | "error"; + +export interface QuizSession { + intent: QuizIntent; + scope: QuizScope; + source: QuizSource; + config: { count: number; difficulty: string; feedback: FeedbackMode }; + conceptId: string; + courseId: string | null; + attemptId: string | null; + items: QuizItem[]; + /** Index of the current item. */ + cursor: number; + /** Position in `scope.queue` (0 for concept/missed scopes). */ + queueIndex: number; + phase: Phase; + error: QuizError | null; + result: SubmitResult | null; + xp: { before: number; after: number; streak: number } | null; + /** `delivered_count < requested_count` on the generate that produced `items`. */ + deliveredShort: boolean; +} diff --git a/frontend/src/lib/quiz/useGamificationDelta.ts b/frontend/src/lib/quiz/useGamificationDelta.ts new file mode 100644 index 00000000..4c60366d --- /dev/null +++ b/frontend/src/lib/quiz/useGamificationDelta.ts @@ -0,0 +1,69 @@ +"use client"; + +/** + * The XP/streak line on the results screen (R-9). + * + * `POST /api/quiz/submit` pays XP through `award_xp_safe` and bumps the streak + * inside `apply_graph_update`, but returns neither (gap G8) — the response is + * score/total/mastery only. The only way to show "+30 XP · 4-day streak" is to + * read `GET /api/gamification/me` before the session and again after the submit + * and subtract. + * + * Both reads are best-effort. If either fails the whole line is omitted rather + * than showing a delta we'd have had to invent. + */ + +import { useCallback, useRef, useState } from "react"; +import { fetchGamificationMe } from "@/lib/api"; +import type { GamificationMe } from "@/lib/types"; +import type { QuizSession } from "./types"; + +export interface GamificationDelta { + /** The pre-quiz snapshot, for anything that wants to render it live. */ + before: GamificationMe | null; + /** Take the "before" reading. Call at session start; never throws. */ + snapshotBefore(): Promise; + /** Take the "after" reading. Call once the submit has landed; never throws. */ + readAfter(): Promise<{ xp: number; streak: number } | null>; + /** The two composed into the session's `xp` field — `null` if either read + * failed, or if no snapshot was taken. */ + deltaAfterSubmit(): Promise; +} + +export function useGamificationDelta(userId: string): GamificationDelta { + const [before, setBefore] = useState(null); + // The ref is what the async submit chain reads: a closure captured at render + // time would still be holding the pre-snapshot `null`. + const beforeRef = useRef(null); + + const snapshotBefore = useCallback(async () => { + if (!userId) return; + try { + const me = await fetchGamificationMe(userId); + beforeRef.current = me; + setBefore(me); + } catch { + beforeRef.current = null; + setBefore(null); + } + }, [userId]); + + const readAfter = useCallback(async () => { + if (!userId) return null; + try { + const me = await fetchGamificationMe(userId); + return { xp: me.total_xp, streak: me.streak }; + } catch { + return null; + } + }, [userId]); + + const deltaAfterSubmit = useCallback(async () => { + const start = beforeRef.current; + const end = await readAfter(); + if (!start || !end) return null; + return { before: start.total_xp, after: end.xp, streak: end.streak }; + }, [readAfter]); + + return { before, snapshotBefore, readAfter, deltaAfterSubmit }; +} diff --git a/frontend/src/lib/quiz/useQuizConfig.test.ts b/frontend/src/lib/quiz/useQuizConfig.test.ts new file mode 100644 index 00000000..fef5282e --- /dev/null +++ b/frontend/src/lib/quiz/useQuizConfig.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; + +const quizApi = vi.hoisted(() => ({ fetchQuizConfig: vi.fn() })); +vi.mock("./api", () => quizApi); + +const gamification = vi.hoisted(() => ({ fetchGamificationMe: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, fetchGamificationMe: gamification.fetchGamificationMe }; +}); + +import { resetQuizConfigCache, useQuizConfig } from "./useQuizConfig"; +import { useGamificationDelta } from "./useGamificationDelta"; + +const CONFIG = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +function me(total_xp: number, streak: number) { + return { + level: 3, next_level: 4, stage: "sprout", total_xp, xp_into_level: 10, xp_for_level: 100, + level_pct: 10, streak, longest_streak: streak, daily_goal_xp: 50, today_xp: 10, + earned_count: 1, total_count: 10, + }; +} + +beforeEach(() => { + resetQuizConfigCache(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useQuizConfig", () => { + it("fetches once and shares the result across mounts", async () => { + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + const a = renderHook(() => useQuizConfig()); + const b = renderHook(() => useQuizConfig()); + await waitFor(() => expect(a.result.current.config).toEqual(CONFIG)); + await waitFor(() => expect(b.result.current.config).toEqual(CONFIG)); + expect(quizApi.fetchQuizConfig).toHaveBeenCalledTimes(1); + }); + + it("maps a failure to quiz error copy and lets the next mount retry", async () => { + quizApi.fetchQuizConfig.mockRejectedValueOnce(new ApiError("down", 500)); + const first = renderHook(() => useQuizConfig()); + await waitFor(() => expect(first.result.current.error).not.toBeNull()); + expect(first.result.current.error?.code).toBe("QUIZ_INTERNAL_ERROR"); + + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + const second = renderHook(() => useQuizConfig()); + await waitFor(() => expect(second.result.current.config).toEqual(CONFIG)); + }); +}); + +describe("useGamificationDelta", () => { + it("subtracts the two reads", async () => { + gamification.fetchGamificationMe + .mockResolvedValueOnce(me(100, 3)) + .mockResolvedValueOnce(me(130, 4)); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await waitFor(() => expect(result.current.before?.total_xp).toBe(100)); + await expect(result.current.deltaAfterSubmit()).resolves.toEqual({ + before: 100, + after: 130, + streak: 4, + }); + }); + + it("returns null when the before read failed — never an invented delta", async () => { + gamification.fetchGamificationMe.mockRejectedValueOnce(new Error("down")); + gamification.fetchGamificationMe.mockResolvedValueOnce(me(130, 4)); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await expect(result.current.deltaAfterSubmit()).resolves.toBeNull(); + }); + + it("returns null when the after read failed", async () => { + gamification.fetchGamificationMe.mockResolvedValueOnce(me(100, 3)); + gamification.fetchGamificationMe.mockRejectedValueOnce(new Error("down")); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await expect(result.current.deltaAfterSubmit()).resolves.toBeNull(); + }); + + it("does nothing without a user id", async () => { + const { result } = renderHook(() => useGamificationDelta("")); + await result.current.snapshotBefore(); + await expect(result.current.readAfter()).resolves.toBeNull(); + expect(gamification.fetchGamificationMe).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizConfig.ts b/frontend/src/lib/quiz/useQuizConfig.ts new file mode 100644 index 00000000..05392426 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizConfig.ts @@ -0,0 +1,55 @@ +"use client"; + +/** + * `GET /api/quiz/config` — fetched once per page load and shared. + * + * The config endpoint is the single source of truth for the count and + * difficulty option lists (#540 A2): the UI must never offer a value the route + * would reject. It is unauthenticated, immutable for the life of a deploy and + * tiny, so one in-flight promise is cached at module scope and every consumer + * shares it. `resetQuizConfigCache` exists for tests. + */ + +import { useEffect, useState } from "react"; +import { fetchQuizConfig } from "./api"; +import { describeQuizError, type QuizError } from "./errors"; +import type { QuizConfig } from "./types"; + +let cached: Promise | null = null; + +function load(): Promise { + if (!cached) { + cached = fetchQuizConfig().catch(err => { + // Don't cache a failure — the next mount should be allowed to try again. + cached = null; + throw err; + }); + } + return cached; +} + +export function resetQuizConfigCache(): void { + cached = null; +} + +export function useQuizConfig(): { config: QuizConfig | null; error: QuizError | null } { + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + load().then( + value => { + if (!cancelled) setConfig(value); + }, + err => { + if (!cancelled) setError(describeQuizError(err)); + }, + ); + return () => { + cancelled = true; + }; + }, []); + + return { config, error }; +} diff --git a/frontend/src/lib/quiz/useQuizHome.test.ts b/frontend/src/lib/quiz/useQuizHome.test.ts new file mode 100644 index 00000000..8c6c3914 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizHome.test.ts @@ -0,0 +1,364 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import type { GraphNode } from "@/lib/types"; + +const quizApi = vi.hoisted(() => ({ + fetchQuizConfig: vi.fn(), + generateQuiz: vi.fn(), + answerQuestion: vi.fn(), + submitQuiz: vi.fn(), + getAttempt: vi.fn(), + listAttempts: vi.fn(), + describeConcept: vi.fn(), +})); +vi.mock("./api", () => quizApi); + +const coreApi = vi.hoisted(() => ({ getCourses: vi.fn(), getGraph: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, getCourses: coreApi.getCourses, getGraph: coreApi.getGraph }; +}); + +import { fallbackDefinition, useQuizHome } from "./useQuizHome"; +import { dismissAttempt, saveSession } from "./session"; +import { initialSession } from "./machine"; +import { DEFAULT_PREFS } from "./prefs"; +import type { EntryRequest } from "./source"; +import type { AttemptSummary } from "./types"; + +function node(over: Partial & { id: string }): GraphNode { + return { + concept_name: over.id, + mastery_score: 0.3, + mastery_tier: "struggling", + times_studied: 1, + last_studied_at: null, + subject: "CS", + course_id: "course-a", + ...over, + }; +} + +const COURSES = [ + { + enrollment_id: "e1", course_id: "course-a", course_code: "CS 330", course_name: "Algorithms", + school: "BU", department: "CS", color: "#123456", nickname: null, node_count: 3, + enrolled_at: "2026-01-01T00:00:00Z", term: "Fall 2026", terms: ["Fall 2026"], + }, +]; + +function attempt(over: Partial & { quiz_id: string }): AttemptSummary { + return { + status: "in_progress", + concept_node_id: "n1", + concept_name: "n1", + course_id: "course-a", + score: null, + total: null, + difficulty: "medium", + mastery_before: null, + mastery_after: null, + mastery_delta: null, + created_at: "2026-08-22T09:00:00Z", + completed_at: null, + ...over, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + coreApi.getCourses.mockResolvedValue({ courses: COURSES }); + coreApi.getGraph.mockResolvedValue({ + nodes: [ + node({ id: "n-new", mastery_score: 0, mastery_tier: "unexplored", times_studied: 0 }), + node({ id: "n1", mastery_score: 0.29, times_studied: 3 }), + node({ id: "n2", mastery_score: 0.44, mastery_tier: "learning" }), + node({ id: "n-done", mastery_score: 0.9, mastery_tier: "mastered" }), + ], + edges: [], + stats: {}, + }); + quizApi.listAttempts.mockResolvedValue({ total: 0, limit: 20, offset: 0, attempts: [] }); + quizApi.getAttempt.mockResolvedValue({ resumable: false }); + quizApi.describeConcept.mockResolvedValue("Recursion is a function calling itself."); +}); + +afterEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +describe("bootstrap", () => { + it("waits for the active semester to hydrate before fetching", async () => { + renderHook(() => useQuizHome("u1", null)); + await new Promise(r => setTimeout(r, 0)); + expect(coreApi.getGraph).not.toHaveBeenCalled(); + }); + + it("fetches unscoped for 'All semesters' and scoped for a term", async () => { + const { rerender } = renderHook(({ s }: { s: string }) => useQuizHome("u1", s), { + initialProps: { s: "" }, + }); + await waitFor(() => expect(coreApi.getGraph).toHaveBeenCalledWith("u1", undefined)); + + rerender({ s: "Fall 2026" }); + await waitFor(() => expect(coreApi.getGraph).toHaveBeenCalledWith("u1", "Fall 2026")); + }); + + it("ranks candidates, picks a studied primary, and counts the due set", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + + expect(result.current.candidates.map(c => c.node.id)).toEqual(["n-new", "n1", "n2"]); + // R-7: the primary prefers the weakest concept actually studied. + expect(result.current.primary?.node.id).toBe("n1"); + expect(result.current.alternatives.map(c => c.node.id)).toEqual(["n-new", "n2"]); + expect(result.current.due.count).toBe(3); + expect(result.current.due.courseCount).toBe(1); + expect(result.current.byCourse[0].course.course_code).toBe("CS 330"); + }); + + it("keeps the home screen when only the history read fails", async () => { + quizApi.listAttempts.mockRejectedValue(new ApiError("boom", 500)); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.attempts).toEqual([]); + }); + + it("reports an error when the graph read fails", async () => { + coreApi.getGraph.mockRejectedValue(new ApiError("boom", 500, { code: "QUIZ_INTERNAL_ERROR" })); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error?.message).toBe( + "Something went wrong on our side. Try again in a moment.", + ); + }); +}); + +describe("resume discovery (R-3)", () => { + it("verifies the stored attempt id against the wire before offering it", async () => { + const stored = { + ...initialSession({ source: { kind: "tree" }, concept: "n1" }, null, DEFAULT_PREFS), + attemptId: "attempt-stored", + phase: "paused" as const, + }; + saveSession(stored); + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-stored", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "n1", + questions: [], + responses: [{ question_index: 0, selected_index: 1, is_correct: true, answered_at: "x" }], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable).not.toBeNull()); + expect(quizApi.getAttempt).toHaveBeenCalledWith("attempt-stored"); + expect(result.current.resumable?.answered).toBe(1); + expect(result.current.resumable?.session?.attemptId).toBe("attempt-stored"); + }); + + it("finds an in_progress attempt started on another device", async () => { + quizApi.listAttempts.mockResolvedValue({ + total: 2, + limit: 20, + offset: 0, + attempts: [ + attempt({ quiz_id: "done", status: "completed", score: 3, total: 3 }), + attempt({ quiz_id: "open" }), + ], + }); + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "open", status: "in_progress", resumable: true, difficulty: "medium", + concept_node_id: "n1", questions: [], responses: [], score: null, total: null, + created_at: "2026-08-22T09:00:00Z", + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable?.attempt.quiz_id).toBe("open")); + // No local record for it, so there is no stored session to restore. + expect(result.current.resumable?.session).toBeNull(); + expect(quizApi.getAttempt).toHaveBeenCalledTimes(1); + expect(quizApi.getAttempt).toHaveBeenCalledWith("open"); + }); + + it("skips a discarded attempt (there is no abandon endpoint — G4)", async () => { + dismissAttempt("open"); + quizApi.listAttempts.mockResolvedValue({ + total: 1, limit: 20, offset: 0, attempts: [attempt({ quiz_id: "open" })], + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + expect(quizApi.getAttempt).not.toHaveBeenCalled(); + expect(result.current.resumable).toBeNull(); + }); + + it("moves on when the wire says an attempt is no longer resumable", async () => { + quizApi.listAttempts.mockResolvedValue({ + total: 2, limit: 20, offset: 0, + attempts: [attempt({ quiz_id: "stale" }), attempt({ quiz_id: "live" })], + }); + quizApi.getAttempt.mockImplementation((id: string) => + id === "stale" + ? Promise.reject(new ApiError("gone", 409, { code: "QUIZ_ATTEMPT_ABANDONED" })) + : Promise.resolve({ + quiz_id: "live", status: "in_progress", resumable: true, difficulty: "medium", + concept_node_id: "n1", questions: [], responses: [], score: null, total: null, + created_at: "2026-08-22T09:00:00Z", + }), + ); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable?.attempt.quiz_id).toBe("live")); + }); +}); + +describe("the card's concept (§5 B1.2 entry overrides)", () => { + function entry(over: Partial = {}): EntryRequest { + return { source: { kind: "link" }, ...over }; + } + + it("is the ranked primary when nothing was deep-linked", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.cardConceptId).toBe("n1"); + expect(result.current.primary?.node.id).toBe("n1"); + }); + + it("follows a ?concept= deep link, even to a concept the ranking would not pick", async () => { + const { result } = renderHook(() => useQuizHome("u1", "", entry({ concept: "n2" }))); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.cardConceptId).toBe("n2"); + // The ranking is untouched — only the card moved. + expect(result.current.primary?.node.id).toBe("n1"); + }); + + it("follows a ?topic= name", async () => { + const { result } = renderHook(() => useQuizHome("u1", "", entry({ topic: "n2" }))); + await waitFor(() => expect(result.current.cardConceptId).toBe("n2")); + }); + + it("opens a ?course= entry on that course's weakest due concept", async () => { + coreApi.getGraph.mockResolvedValue({ + nodes: [ + node({ id: "a-strongish", mastery_score: 0.4, course_id: "course-a" }), + node({ id: "b-weak", mastery_score: 0.05, course_id: "course-b" }), + node({ id: "b-mid", mastery_score: 0.3, course_id: "course-b" }), + ], + edges: [], + stats: {}, + }); + const { result } = renderHook(() => useQuizHome("u1", "", entry({ course: "course-b" }))); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.cardConceptId).toBe("b-weak"); + }); + + it("opens a ?scope=due entry on the weakest due concept overall", async () => { + const { result } = renderHook(() => useQuizHome("u1", "", entry({ scope: "due" }))); + await waitFor(() => expect(result.current.status).toBe("ready")); + // n-new scores 0.0 — the raw ranking order, not the studied-first tie-break. + expect(result.current.cardConceptId).toBe("n-new"); + }); + + it("falls back to the ranking when the deep link points outside the scope", async () => { + const { result } = renderHook(() => useQuizHome("u1", "", entry({ concept: "not-here" }))); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.cardConceptId).toBe("n1"); + }); + + it("is null before the graph has loaded", () => { + const { result } = renderHook(() => useQuizHome("u1", null, entry({ concept: "n2" }))); + expect(result.current.cardConceptId).toBeNull(); + }); +}); + +describe("the card definition (R-8)", () => { + it("asks for exactly one description — the CARD's concept, not the primary's", async () => { + const deepLink: EntryRequest = { source: { kind: "tree" }, concept: "n2" }; + const { result } = renderHook(() => useQuizHome("u1", "", deepLink)); + await waitFor(() => expect(result.current.cardDescription).not.toBeNull()); + expect(quizApi.describeConcept).toHaveBeenCalledTimes(1); + expect(quizApi.describeConcept).toHaveBeenCalledWith("u1", "n2", "CS 330"); + }); + + it("describes the primary when there is no deep link", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.cardDescription).not.toBeNull()); + expect(quizApi.describeConcept).toHaveBeenCalledTimes(1); + expect(quizApi.describeConcept).toHaveBeenCalledWith("u1", "n1", "CS 330"); + }); + + it("keeps primaryDescription as an alias of the same value", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.cardDescription).not.toBeNull()); + expect(result.current.primaryDescription).toBe(result.current.cardDescription); + }); + + it("leaves the description null when the call fails, so the card falls back", async () => { + quizApi.describeConcept.mockRejectedValue(new ApiError("agent down", 502)); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + expect(result.current.cardDescription).toBeNull(); + }); + + // The two queue-shaped cards are headed "Practice {CODE}" / "Review everything + // due" over a queue summary (§5 B1.2). There is no definition slot on either, + // so a description for the queue's first concept is an LLM call for text + // nothing renders. + it("does not describe a ?course= card — it has no definition slot", async () => { + const { result } = renderHook(() => + useQuizHome("u1", "", { source: { kind: "tree" }, course: "course-a" })); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + + // The concept id is still resolved — Start generates on it, and the accent + // comes from it. Only the paragraph is skipped. + expect(result.current.cardConceptId).toBe("n-new"); + expect(result.current.cardDescription).toBeNull(); + expect(quizApi.describeConcept).not.toHaveBeenCalled(); + }); + + it("does not describe a ?scope=due card either", async () => { + const { result } = renderHook(() => + useQuizHome("u1", "", { source: { kind: "dashboard" }, scope: "due" })); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + + expect(result.current.cardConceptId).toBe("n-new"); + expect(result.current.cardDescription).toBeNull(); + expect(quizApi.describeConcept).not.toHaveBeenCalled(); + }); + + it("still describes when an unusable ?course= falls back to the primary card", async () => { + const { result } = renderHook(() => + useQuizHome("u1", "", { source: { kind: "tree" }, course: "no-such-course" })); + await waitFor(() => expect(result.current.cardDescription).not.toBeNull()); + expect(result.current.cardConceptId).toBe("n1"); + expect(quizApi.describeConcept).toHaveBeenCalledTimes(1); + expect(quizApi.describeConcept).toHaveBeenCalledWith("u1", "n1", "CS 330"); + }); +}); + +describe("fallbackDefinition", () => { + it("builds the sentence the card shows while the description loads", () => { + const candidate = { + node: node({ id: "n1", mastery_tier: "struggling" }), + course: COURSES[0], + color: "#123456", + rationale: "", + }; + expect(fallbackDefinition(candidate, 4)).toBe("CS 330 · struggling · 4 connected concepts"); + expect(fallbackDefinition(candidate, 1)).toBe("CS 330 · struggling · 1 connected concept"); + expect(fallbackDefinition(null, 3)).toBe(""); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizHome.ts b/frontend/src/lib/quiz/useQuizHome.ts new file mode 100644 index 00000000..8f19bc7d --- /dev/null +++ b/frontend/src/lib/quiz/useQuizHome.ts @@ -0,0 +1,344 @@ +"use client"; + +/** + * Everything quiz home renders from: the graph, the courses, the attempt + * history, the ranked proposals derived from all three, and whichever unfinished + * quiz is waiting to be resumed. + * + * The bootstrap is `getCourses` + `getGraph(userId, activeSemester || undefined)` + * in parallel, held until the active semester has hydrated from localStorage. + * That wait is the point: fetching before it resolves means a returning user + * fetches unscoped and then immediately re-fetches scoped, and briefly sees + * concepts from a term they are not looking at. The empty string means + * "All semesters" and fetches unscoped (#360). + * + * Tree, Learn and Dashboard each run the same pair for themselves (R4 §4); this + * is deliberately the same shape rather than a new one, so the semester contract + * has one behaviour across every surface that reads the graph. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getCourses, getGraph, type EnrolledCourse } from "@/lib/api"; +import { courseInTerm } from "@/lib/useActiveSemester"; +import type { GraphEdge, GraphNode } from "@/lib/types"; +import { describeConcept, getAttempt, listAttempts } from "./api"; +import { describeQuizError, type QuizError } from "./errors"; +import { + alternativesOf, + dueSet, + entrySelection, + groupByCourse, + primaryOf, + queueFor, + rankCandidates, + type Candidate, +} from "./proposals"; +import { isDismissed, loadSession } from "./session"; +import type { EntryRequest } from "./source"; +import type { AttemptDetail, AttemptSummary, QuizSession } from "./types"; + +/** One page of history is enough for both the resume sweep and the "missed N + * last time" join; the route clamps `limit` to 100 anyway. */ +const HISTORY_LIMIT = 20; + +export interface ResumableQuiz { + attempt: AttemptDetail; + /** The locally stored session for this attempt, when there is one. It is the + * only place the scope, queue, feedback mode and origin survive. */ + session: QuizSession | null; + answered: number; +} + +export interface QuizHome { + status: "loading" | "ready" | "error"; + error: QuizError | null; + nodes: GraphNode[]; + edges: GraphEdge[]; + courses: EnrolledCourse[]; + attempts: AttemptSummary[]; + candidates: Candidate[]; + primary: Candidate | null; + alternatives: Candidate[]; + due: ReturnType; + byCourse: ReturnType; + resumable: ResumableQuiz | null; + /** + * The concept the "Ready for you" card will actually show, which is NOT always + * the ranked primary: a `?concept=`/`?topic=` deep link names its own, a + * `?course=` entry opens on that course's weakest, and `?scope=due` opens on + * the weakest due overall (§5 B1.2 "Entry overrides"). `null` before the graph + * has loaded, or when there is nothing to propose. + */ + cardConceptId: string | null; + /** + * The AI one-liner for the CARD's concept (R-8) — one call, for the one card + * that shows a paragraph. `null` while it is in flight or after a failure: the + * card falls back to a built sentence rather than blocking or showing an empty + * paragraph. + * + * Also `null`, and never fetched, for the two queue-shaped entries. A + * `?course=` or `?scope=due` card is headed "Practice {CODE}" / "Review + * everything due" over a queue summary (§5 B1.2) — it has no definition slot, + * so describing its first concept would be an LLM call for text nothing + * renders. + */ + cardDescription: string | null; + /** @deprecated Alias of `cardDescription`, kept so the current home render + * keeps compiling. Read `cardDescription` — it is right for a deep-linked + * card too, which this name implies it is not. */ + primaryDescription: string | null; + refresh(): void; +} + +/** The fallback definition when `concept-description` is slow or fails (R-8), + * the same shape Learn's focus card falls back to. */ +export function fallbackDefinition(candidate: Candidate | null, connected: number): string { + if (!candidate) return ""; + const course = candidate.course?.course_code ?? candidate.node.subject ?? "Your tree"; + const plural = connected === 1 ? "concept" : "concepts"; + return `${course} · ${candidate.node.mastery_tier} · ${connected} connected ${plural}`; +} + +/** + * Resume discovery (R-3), in two passes. + * + * The stored session names an attempt id without a round trip, so it goes first + * — but it is only a hint, and `GET /attempts/{id}` is what decides whether the + * attempt is really resumable (it may have been submitted elsewhere or swept + * past the 24h TTL). The history page then covers the other-device case, where + * this browser has no record at all. Discarded ids are skipped: there is no + * abandon endpoint, so a discard is client-side only (gap G4). + */ +async function discoverResumable( + attempts: AttemptSummary[], + stored: QuizSession | null, +): Promise { + const ids: string[] = []; + if (stored?.attemptId) ids.push(stored.attemptId); + for (const a of attempts) { + if (a.status === "in_progress" && !ids.includes(a.quiz_id)) ids.push(a.quiz_id); + } + + for (const id of ids) { + if (isDismissed(id)) continue; + try { + const attempt = await getAttempt(id); + if (!attempt.resumable) continue; + return { + attempt, + session: stored?.attemptId === id ? stored : null, + answered: attempt.responses?.length ?? 0, + }; + } catch { + // A 404 or a 409 means this one is not resumable after all; try the next. + } + } + return null; +} + +/** One resolved load, tagged with the request it answers. */ +interface Loaded { + key: string; + nodes: GraphNode[]; + edges: GraphEdge[]; + courses: EnrolledCourse[]; + attempts: AttemptSummary[]; + error: QuizError | null; +} + +const EMPTY_LOAD = { + nodes: [] as GraphNode[], + edges: [] as GraphEdge[], + courses: [] as EnrolledCourse[], + attempts: [] as AttemptSummary[], +}; + +/** + * @param userId the signed-in student. + * @param semester the active semester label, or `null` while it is still + * hydrating from localStorage — nothing is fetched until it resolves. The + * empty string means "All semesters" and fetches unscoped (#360). + */ +export function useQuizHome( + userId: string, + semester: string | null, + entry?: EntryRequest, +): QuizHome { + const [loaded, setLoaded] = useState(null); + const [resumable, setResumable] = useState<{ key: string; value: ResumableQuiz | null } | null>( + null, + ); + const [nonce, setNonce] = useState(0); + + const refresh = useCallback(() => setNonce(n => n + 1), []); + + // "Loading" is DERIVED from whether the resolved load answers the request + // currently on screen, rather than stored and reset at the top of the effect: + // a synchronous setState in an effect body is a cascading render, and the + // stale-response guard falls out of the same key for free. + // NUL-delimited so no id or term label can forge a collision. Written as an + // ESCAPE, never a literal byte: a raw NUL makes git treat the whole file as + // binary, and this one hid the resume-discovery hook from every diff. + const key = semester === null || !userId + ? "" + : `${nonce}\u0000${userId}\u0000${semester}`; + const current = loaded?.key === key ? loaded : null; + const status: QuizHome["status"] = !key || !current + ? "loading" + : current.error + ? "error" + : "ready"; + + useEffect(() => { + if (!key) return; + let cancelled = false; + + (async () => { + try { + // The three reads are independent; a failing history must not cost the + // student their whole home screen, so only the graph pair is fatal. + const [courseRes, graphRes, attemptRes] = await Promise.all([ + getCourses(userId), + getGraph(userId, semester || undefined), + listAttempts(userId, { limit: HISTORY_LIMIT }).catch( + () => ({ total: 0, limit: HISTORY_LIMIT, offset: 0, attempts: [] as AttemptSummary[] }), + ), + ]); + if (cancelled) return; + + const attempts = attemptRes.attempts ?? []; + setLoaded({ + key, + courses: courseRes.courses ?? [], + nodes: (graphRes.nodes ?? []) as GraphNode[], + edges: (graphRes.edges ?? []) as GraphEdge[], + attempts, + error: null, + }); + + const found = await discoverResumable(attempts, loadSession()); + if (cancelled) return; + setResumable({ key, value: found }); + } catch (err) { + if (cancelled) return; + setLoaded({ key, ...EMPTY_LOAD, error: describeQuizError(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [key, userId, semester]); + + const { nodes, edges, courses, attempts } = current ?? EMPTY_LOAD; + const error = current?.error ?? null; + + // The graph fetch is already scoped server-side; this mirrors the Tree/Learn + // pickers' defensive re-filter so a stale payload can't leak another term in. + const scopedCourses = useMemo( + () => (semester ? courses.filter(c => courseInTerm(c, semester)) : courses), + [courses, semester], + ); + const scopedNodes = useMemo(() => { + if (!semester) return nodes; + const allowed = new Set(scopedCourses.map(c => c.course_id)); + return nodes.filter(n => !n.course_id || allowed.has(n.course_id)); + }, [nodes, semester, scopedCourses]); + + const candidates = useMemo( + () => rankCandidates(scopedNodes, scopedCourses, attempts), + [scopedNodes, scopedCourses, attempts], + ); + const primary = useMemo(() => primaryOf(candidates), [candidates]); + const alternatives = useMemo(() => alternativesOf(candidates, primary), [candidates, primary]); + const due = useMemo(() => dueSet(scopedNodes), [scopedNodes]); + const byCourse = useMemo( + () => groupByCourse(scopedNodes, scopedCourses), + [scopedNodes, scopedCourses], + ); + + // Which concept the card will show. The entry overrides the ranking (§5 B1.2), + // so describing the ranked primary would have paid for an LLM call about a + // concept nobody is looking at — and left the deep-linked card with no + // paragraph at all. An entry that can't be resolved (a link into a term the + // student isn't viewing) falls through to the ranking rather than showing + // nothing. + // + // `describable` rides along because the two queue-shaped entries produce a + // card with no definition slot at all — the concept id is still needed (it is + // what Start generates on, and what the accent comes from), but a paragraph + // about the first concept in a queue is text nothing renders. + const card = useMemo<{ conceptId: string | null; describable: boolean }>(() => { + if (entry?.concept || entry?.topic) { + const resolved = entrySelection(entry, scopedNodes, scopedCourses).conceptId; + if (resolved) return { conceptId: resolved, describable: true }; + } else if (entry?.course) { + const first = queueFor("course", scopedNodes, entry.course)[0]; + if (first) return { conceptId: first, describable: false }; + } else if (entry?.scope === "due") { + const first = queueFor("due", scopedNodes)[0]; + if (first) return { conceptId: first, describable: false }; + } + // An entry that resolved to nothing lands here too, so an unusable + // `?course=` degrades to an ordinary primary card — description and all. + return { conceptId: primary?.node.id ?? null, describable: true }; + }, [entry, scopedNodes, scopedCourses, primary]); + const cardConceptId = card.conceptId; + const describable = card.describable; + + // One LLM call per home visit, for the one card that shows a paragraph (R-8). + // `graph_nodes` has no `description` column, so there is nothing stored to + // read; fetching this for every row on screen would multiply the cost by the + // length of the list, which is exactly why it is scoped to the card. + // Tagged with the concept it describes, so changing cards drops the old + // sentence without a synchronous reset in the effect body. + const [described, setDescribed] = useState<{ id: string; text: string } | null>(null); + const cardNode = useMemo( + () => scopedNodes.find(n => n.id === cardConceptId) ?? null, + [scopedNodes, cardConceptId], + ); + const cardName = cardNode?.concept_name ?? null; + const cardCourseLabel = cardNode?.course_id + ? scopedCourses.find(c => c.course_id === cardNode.course_id)?.course_code + : undefined; + + useEffect(() => { + if (!userId || !cardConceptId || !cardName || !describable) return; + let cancelled = false; + describeConcept(userId, cardName, cardCourseLabel).then( + description => { + const text = description.trim(); + if (!cancelled && text) setDescribed({ id: cardConceptId, text }); + }, + () => { + // The card renders the built fallback sentence instead. A missing + // definition must never hold up the Start button. + }, + ); + return () => { + cancelled = true; + }; + }, [userId, cardConceptId, cardName, cardCourseLabel, describable]); + + const cardDescription = + describable && described?.id === cardConceptId ? described.text : null; + + return { + status, + error, + nodes: scopedNodes, + edges, + courses: scopedCourses, + attempts, + candidates, + primary, + alternatives, + due, + byCourse, + resumable: resumable?.key === key ? resumable.value : null, + cardConceptId, + cardDescription, + primaryDescription: cardDescription, + refresh, + }; +} diff --git a/frontend/src/lib/quiz/useQuizSession.test.ts b/frontend/src/lib/quiz/useQuizSession.test.ts new file mode 100644 index 00000000..6b27c6ac --- /dev/null +++ b/frontend/src/lib/quiz/useQuizSession.test.ts @@ -0,0 +1,781 @@ +// @vitest-environment jsdom +/** + * `useQuizSession` is where the machine meets the network. These drive the + * whole loop through mocked clients: start → answer → answer → submit, the + * leave-and-resume round trip, the answered-then-unmounted resume, and the two + * failures whose copy the student actually sees. + */ + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import type { AnswerResult, AttemptDetail, GenerateResult, SubmitResult } from "./types"; + +const push = vi.fn(); +const replace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace, back: vi.fn(), prefetch: vi.fn() }), +})); + +/** Where the browser is, as `pathname + search`. `exit` reads + * `window.location.pathname` to tell a same-route URL edit from a real + * navigation, so the tests put jsdom on the quiz route the way a student is. */ +function locationNow(): string { + return window.location.pathname + window.location.search; +} + +const quizApi = vi.hoisted(() => ({ + fetchQuizConfig: vi.fn(), + generateQuiz: vi.fn(), + answerQuestion: vi.fn(), + submitQuiz: vi.fn(), + getAttempt: vi.fn(), + listAttempts: vi.fn(), + describeConcept: vi.fn(), +})); +vi.mock("./api", () => quizApi); + +const gamification = vi.hoisted(() => ({ fetchGamificationMe: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, fetchGamificationMe: gamification.fetchGamificationMe }; +}); + +import { useQuizSession } from "./useQuizSession"; +import { resetQuizConfigCache } from "./useQuizConfig"; +import { STORAGE_KEY, loadSession } from "./session"; +import type { EntryRequest } from "./source"; + +const CONFIG = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +const ENTRY: EntryRequest = { + concept: "c1", + source: { kind: "tree", returnTo: "/tree?node=c1", conceptId: "c1" }, +}; + +function question(id: number) { + return { + id, + question: `Q${id}?`, + options: [ + { label: "A", text: "a" }, + { label: "B", text: "b" }, + { label: "C", text: "c" }, + { label: "D", text: "d" }, + ], + difficulty: "medium", + }; +} + +function generated(n: number): GenerateResult { + return { + quiz_id: "attempt-1", + questions: Array.from({ length: n }, (_, i) => question(i + 1)), + requested_difficulty: "medium", + resolved_difficulty: "medium", + requested_count: n, + delivered_count: n, + }; +} + +function answerResult(index: number): AnswerResult { + return { + question_index: index, + question_id: index + 1, + is_correct: true, + correct_index: 1, + explanation: `because ${index}`, + next_question: null, + recorded: true, + }; +} + +const SUBMIT_RESULT: SubmitResult = { + score: 2, + total: 2, + mastery_before: 0.25, + mastery_after: 0.31, + results: [], +}; + +function me(total_xp: number, streak: number) { + return { + level: 3, next_level: 4, stage: "sprout", total_xp, xp_into_level: 10, xp_for_level: 100, + level_pct: 10, streak, longest_streak: streak, daily_goal_xp: 50, today_xp: 10, + earned_count: 1, total_count: 10, + }; +} + +const START = { + intent: "practice" as const, + scope: { kind: "concept" as const, conceptId: "c1" }, + conceptId: "c1", + courseId: "course-1", +}; + +beforeEach(() => { + resetQuizConfigCache(); + window.localStorage.clear(); + window.history.replaceState(null, "", "/quiz"); + push.mockClear(); + replace.mockClear(); + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + quizApi.generateQuiz.mockResolvedValue(generated(2)); + quizApi.answerQuestion.mockImplementation( + (_id: string, p: { questionIndex: number }) => Promise.resolve(answerResult(p.questionIndex)), + ); + quizApi.submitQuiz.mockResolvedValue(SUBMIT_RESULT); + quizApi.getAttempt.mockResolvedValue({} as AttemptDetail); + gamification.fetchGamificationMe + .mockResolvedValueOnce(me(100, 3)) + .mockResolvedValue(me(130, 4)); +}); + +afterEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +function mount(entry: EntryRequest = ENTRY) { + return renderHook(() => useQuizSession("u1", entry)); +} + +describe("start → answer → answer → submit", () => { + it("walks the whole loop and lands on results with the XP delta", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + expect(quizApi.generateQuiz).toHaveBeenCalledWith({ + userId: "u1", + conceptNodeId: "c1", + numQuestions: 5, + difficulty: "medium", + }); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledWith("attempt-1", { + questionIndex: 0, + selectedIndex: 1, + questionId: 1, + }); + + act(() => result.current.actions.select(2)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("results")); + expect(quizApi.submitQuiz).toHaveBeenCalledWith("attempt-1", [ + { question_id: 1, selected_label: "B" }, + { question_id: 2, selected_label: "C" }, + ]); + expect(result.current.session.result).toEqual(SUBMIT_RESULT); + expect(result.current.session.xp).toEqual({ before: 100, after: 130, streak: 4 }); + // The stored record is cleared once the attempt is scored. + expect(loadSession()).toBeNull(); + }); + + it("omits the XP line when a gamification read failed (R-9 — never invented)", async () => { + gamification.fetchGamificationMe.mockReset(); + gamification.fetchGamificationMe.mockRejectedValue(new Error("down")); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + quizApi.generateQuiz.mockResolvedValue(generated(1)); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("results")); + expect(result.current.session.xp).toBeNull(); + }); + + it("retries a dropped answer exactly once, and never a rejected one", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + quizApi.answerQuestion + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(answerResult(0)); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + + quizApi.answerQuestion.mockReset(); + quizApi.answerQuestion.mockRejectedValue( + new ApiError("bad", 400, { code: "QUIZ_QUESTION_INVALID" }), + ); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + expect(result.current.session.error?.message).toBe( + "That answer didn't line up with the question. Reload and try again.", + ); + }); +}); + +/** + * The two defects the Chapter-1 lane found, ridden in as `test.fixme` in + * `e2e/quiz-journeys.spec.ts`. Both are unit-reproducible; these are the guards + * that keep them fixed once those journeys are un-fixme'd. + */ +describe("#537 browser-lane regressions", () => { + const DASHBOARD: EntryRequest = { + source: { kind: "dashboard", returnTo: "/dashboard" }, + }; + + it("quiz home no longer deletes the paused attempt it is about to offer", async () => { + // Leave a quiz mid-flight, from the DASHBOARD — a tree origin is + // indistinguishable from the `returnToSource` fallback, which is what + // masked this in the green leave-and-return journey. + const first = mount(DASHBOARD); + await waitFor(() => expect(first.result.current.config).not.toBeNull()); + act(() => + first.result.current.actions.setConfig({ + count: 5, difficulty: "medium", feedback: "as-you-go", + }), + ); + act(() => first.result.current.actions.start(START)); + await waitFor(() => expect(first.result.current.session.phase).toBe("active")); + act(() => first.result.current.actions.select(1)); + await act(async () => { + first.result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(first.result.current.session.phase).toBe("answered")); + act(() => first.result.current.actions.requestLeave()); + act(() => first.result.current.actions.confirmLeave()); + expect(first.result.current.session.phase).toBe("paused"); + + const parked = loadSession(); + expect(parked?.items[0].verdict).not.toBeNull(); + expect(parked?.source.kind).toBe("dashboard"); + first.unmount(); + + // Now mount quiz home the way a student reaches it: no deep link. Its + // config effect fires `SET_CONFIG` as soon as `/api/quiz/config` resolves, + // and every accepted event is persisted. That used to wipe the record. + const second = mount({ source: { kind: "nav" } }); + await waitFor(() => expect(second.result.current.config).not.toBeNull()); + await waitFor(() => + expect(second.result.current.session.config.count).toBe(5)); + expect(loadSession()).toEqual(parked); + + // …so Resume gets the verdicts and the origin back. + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-1", status: "in_progress", resumable: true, difficulty: "medium", + concept_node_id: "c1", questions: [question(1), question(2)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, total: null, created_at: "2026-08-22T09:00:00Z", + } satisfies AttemptDetail); + + await act(async () => { + second.result.current.actions.resume("attempt-1"); + }); + await waitFor(() => expect(second.result.current.session.phase).toBe("active")); + expect(second.result.current.session.items[0].verdict).toEqual({ + isCorrect: true, correctIndex: 1, explanation: "because 0", + }); + expect(second.result.current.session.source.kind).toBe("dashboard"); + expect(second.result.current.session.cursor).toBe(1); + }); + + it("a resume that fails leaves the paused record it was trying to open intact", async () => { + // The same loss as above, reached through the other door. `error` is a + // persisted phase, so a resume that cannot be verified used to write its + // own attempt-less session over the paused record — and the verdicts, the + // scope and the origin only exist there. + const first = mount(DASHBOARD); + await waitFor(() => expect(first.result.current.config).not.toBeNull()); + act(() => first.result.current.actions.start(START)); + await waitFor(() => expect(first.result.current.session.phase).toBe("active")); + act(() => first.result.current.actions.select(1)); + await act(async () => { + first.result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(first.result.current.session.cursor).toBe(1)); + act(() => first.result.current.actions.requestLeave()); + act(() => first.result.current.actions.confirmLeave()); + const parked = loadSession(); + expect(parked?.attemptId).toBe("attempt-1"); + expect(parked?.items[0].verdict).not.toBeNull(); + first.unmount(); + + // The verification call never answers — a dropped connection, a 401 after a + // token refresh, a 5xx. + quizApi.getAttempt.mockRejectedValue(new TypeError("Failed to fetch")); + const second = mount({ attempt: "attempt-1", source: { kind: "quiz" } }); + await waitFor(() => expect(second.result.current.session.phase).toBe("error")); + expect(second.result.current.session.attemptId).toBeNull(); + expect(loadSession()).toEqual(parked); + + // Still true after the failed screen goes away — the unmount flush persists + // whatever the hook last held, which is the attempt-less error session. + second.unmount(); + expect(loadSession()).toEqual(parked); + }); + + it("a fresh quiz that fails to generate leaves the paused record intact", async () => { + const first = mount(DASHBOARD); + await waitFor(() => expect(first.result.current.config).not.toBeNull()); + act(() => first.result.current.actions.start(START)); + await waitFor(() => expect(first.result.current.session.phase).toBe("active")); + act(() => first.result.current.actions.select(1)); + await act(async () => { + first.result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(first.result.current.session.cursor).toBe(1)); + act(() => first.result.current.actions.requestLeave()); + act(() => first.result.current.actions.confirmLeave()); + const parked = loadSession(); + expect(parked?.attemptId).toBe("attempt-1"); + first.unmount(); + + // A different quiz, started from home while the old one is still parked. + // `generating` is persisted too, and this one never gets an attempt id. + quizApi.generateQuiz.mockRejectedValue( + new ApiError("timeout", 502, { code: "QUIZ_GENERATION_TIMEOUT" }), + ); + const second = mount({ source: { kind: "nav" } }); + await waitFor(() => expect(second.result.current.config).not.toBeNull()); + act(() => second.result.current.actions.start(START)); + await waitFor(() => expect(second.result.current.session.phase).toBe("error")); + expect(loadSession()).toEqual(parked); + }); + + it("Done drops the deep link instead of leaving home pinned to it", async () => { + window.history.replaceState(null, "", "/quiz?concept=c1&from=link"); + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount({ + concept: "c1", + source: { kind: "link", conceptId: "c1" }, + }); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + expect(locationNow()).toBe("/quiz?concept=c1&from=link"); + act(() => result.current.actions.exit("/quiz")); + + // Neither `push` nor `replace` moves the address bar for a query-only change + // on the route the app is already on — the lane measured both leaving the + // URL at `/quiz?concept=…`. The URL edit goes through the History API. + expect(locationNow()).toBe("/quiz"); + expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + + // And the session stops pointing at the concept that was just finished, so + // nothing that prefers the session over the proposal stays pinned to it. + expect(result.current.session.phase).toBe("home"); + expect(result.current.session.conceptId).toBe(""); + expect(result.current.session.scope).toEqual({ kind: "concept", conceptId: "" }); + expect(result.current.session.result).toBeNull(); + }); + + it("still pushes for an exit that really changes route", async () => { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + // No target: "Back to your tree" resolves through `returnToSource`, which + // needs the conceptId EXIT clears — so it has to be read before the reset. + act(() => result.current.actions.exit()); + expect(push).toHaveBeenCalledWith("/tree?node=c1"); + expect(replace).not.toHaveBeenCalled(); + // …and the History API is not used to fake a cross-route move. + expect(locationNow()).toBe("/quiz"); + }); +}); + +describe("a double-pressed Submit only answers once", () => { + it("fires exactly one /answer for the item while the first is in flight", async () => { + // The phase stays `active` for the whole round trip, so neither + // canSubmitAnswer nor a `phase !== "active"` disabled check rules the second + // press out — only the hook's in-flight guard does. + let release: (value: AnswerResult) => void = () => {}; + quizApi.answerQuestion.mockImplementationOnce( + () => new Promise(resolve => { + release = resolve; + }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + act(() => { + result.current.actions.submitAnswer(); + result.current.actions.submitAnswer(); + result.current.actions.submitAnswer(); + }); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + + await act(async () => { + release(answerResult(0)); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + + // The guard is per item, not a one-shot latch: the next question submits. + act(() => result.current.actions.select(2)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + }); + + it("releases the guard after a failure, so retrying is possible", async () => { + quizApi.answerQuestion.mockRejectedValueOnce( + new ApiError("bad", 400, { code: "QUIZ_QUESTION_INVALID" }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + + await act(async () => { + result.current.actions.retry(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + }); +}); + +describe('the "sapling:graph-changed" announcement', () => { + it("fires exactly once per submit, carrying the mastery move", async () => { + const seen: CustomEvent[] = []; + const listener = (e: Event) => seen.push(e as CustomEvent); + window.addEventListener("sapling:graph-changed", listener); + + try { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + expect(seen).toHaveLength(1); + expect(seen[0].detail).toEqual({ + conceptId: "c1", + masteryBefore: 0.25, + masteryAfter: 0.31, + }); + } finally { + window.removeEventListener("sapling:graph-changed", listener); + } + }); + + it("stays silent when the submit failed", async () => { + const seen: Event[] = []; + const listener = (e: Event) => seen.push(e); + window.addEventListener("sapling:graph-changed", listener); + + try { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + quizApi.submitQuiz.mockRejectedValue( + new ApiError("done", 409, { code: "QUIZ_ATTEMPT_ALREADY_COMPLETED" }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + + expect(seen).toHaveLength(0); + } finally { + window.removeEventListener("sapling:graph-changed", listener); + } + }); +}); + +describe("leave and resume", () => { + it("parks the session, navigates back to the source, and picks it up again", async () => { + const { result, unmount } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + + act(() => result.current.actions.requestLeave()); + expect(result.current.session.phase).toBe("confirm-leave"); + act(() => result.current.actions.confirmLeave()); + + expect(result.current.session.phase).toBe("paused"); + // A different route, so still a router navigation. + expect(push).toHaveBeenCalledWith("/tree?node=c1"); + expect(loadSession()?.attemptId).toBe("attempt-1"); + unmount(); + + // A fresh mount, arriving via the resume strip. + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + } satisfies AttemptDetail); + + const second = mount({ attempt: "attempt-1", source: { kind: "quiz" } }); + await waitFor(() => expect(second.result.current.session.phase).toBe("active")); + expect(second.result.current.session.cursor).toBe(1); + expect(second.result.current.session.attemptId).toBe("attempt-1"); + // The origin survives the round trip — it only exists in the stored record. + expect(second.result.current.session.source).toEqual(ENTRY.source); + }); + + it("resumes an answered-then-unmounted quiz that never reached the leave dialog", async () => { + const { result, unmount } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(3)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + + // No leave dialog, no CONFIRM_LEAVE — the tab simply goes away. + unmount(); + const stored = loadSession(); + expect(stored?.attemptId).toBe("attempt-1"); + expect(stored?.items[0].selectedIndex).toBe(3); + + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2)], + responses: [ + { question_index: 0, selected_index: 3, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + } satisfies AttemptDetail); + + const second = mount({ attempt: "attempt-1", source: { kind: "nav" } }); + await waitFor(() => expect(second.result.current.session.phase).toBe("active")); + expect(second.result.current.session.cursor).toBe(1); + expect(second.result.current.session.items[0].selectedIndex).toBe(3); + expect(second.result.current.session.source.kind).toBe("tree"); + }); + + it("explains an expired attempt instead of silently doing nothing", async () => { + quizApi.getAttempt.mockRejectedValue( + new ApiError("gone", 409, { code: "QUIZ_ATTEMPT_ABANDONED" }), + ); + const { result } = mount({ attempt: "attempt-9", source: { kind: "quiz" } }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "That quiz expired after a day. Start a fresh one.", + ); + act(() => result.current.actions.dismissError()); + expect(result.current.session.phase).toBe("home"); + }); +}); + +describe("failures the student reads", () => { + it("maps a generate timeout to its own copy, not a generic 502", async () => { + quizApi.generateQuiz.mockRejectedValue( + new ApiError("timeout", 502, { code: "QUIZ_GENERATION_TIMEOUT" }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error).toEqual({ + code: "QUIZ_GENERATION_TIMEOUT", + message: "Writing this quiz took too long. Try again — it usually works the second time.", + retryable: true, + }); + + act(() => result.current.actions.dismissError()); + expect(result.current.session.phase).toBe("home"); + }); + + it("interpolates Retry-After into the rate-limit copy", async () => { + quizApi.generateQuiz.mockRejectedValue( + new ApiError("slow", 429, { code: "QUIZ_RATE_LIMITED", retryAfterSec: 12 }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "You're quizzing fast — give it 12 seconds and try again.", + ); + }); + + it("shows the 409 copy when the attempt was already scored, and retries the submit", async () => { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + quizApi.submitQuiz.mockRejectedValueOnce( + new ApiError("done", 409, { code: "QUIZ_ATTEMPT_ALREADY_COMPLETED" }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "This quiz was already scored. Your results are on your tree.", + ); + + // Retry goes back through the submit path, not back to home. + quizApi.submitQuiz.mockResolvedValue(SUBMIT_RESULT); + await act(async () => { + result.current.actions.retry(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + }); +}); + +describe("exits and persistence", () => { + it("refuses to exit a live quiz", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.exit()); + expect(push).not.toHaveBeenCalled(); + expect(result.current.session.phase).toBe("active"); + }); + + it("clears the stored session on exit", async () => { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(result.current.session)); + window.history.replaceState(null, "", "/quiz?concept=c1"); + act(() => result.current.actions.exit("/quiz")); + // Same route: a URL edit, not a navigation — see the Done block below. + expect(locationNow()).toBe("/quiz"); + expect(loadSession()).toBeNull(); + }); + + it("persists an Adjust-dialog choice to prefs", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => + result.current.actions.setConfig({ count: 10, difficulty: "hard", feedback: "as-you-go" }), + ); + expect(result.current.session.config).toEqual({ + count: 10, + difficulty: "hard", + feedback: "as-you-go", + }); + const { loadPrefs } = await import("./prefs"); + expect(loadPrefs()).toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + }); + + it("holds on the verdict in as-you-go mode instead of advancing", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => + result.current.actions.setConfig({ count: 5, difficulty: "medium", feedback: "as-you-go" }), + ); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("answered")); + expect(result.current.session.cursor).toBe(0); + expect(result.current.session.items[0].verdict?.explanation).toBe("because 0"); + expect(quizApi.submitQuiz).not.toHaveBeenCalled(); + + act(() => result.current.actions.next()); + expect(result.current.session.phase).toBe("active"); + expect(result.current.session.cursor).toBe(1); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizSession.ts b/frontend/src/lib/quiz/useQuizSession.ts new file mode 100644 index 00000000..653b6c01 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizSession.ts @@ -0,0 +1,514 @@ +"use client"; + +/** + * The only place quiz effects happen: generate, answer, submit, navigate, + * persist. + * + * `machine.ts` decides what the session looks like after an event and nothing + * else. This hook drives it — it applies an event, reads the state that came + * back, and fires whatever that state calls for. Chaining off the RETURNED + * session rather than off a phase-watching `useEffect` is deliberate: an effect + * keyed on `phase === "submitting"` re-fires whenever that phase is re-entered + * (a dismissed submit error does exactly that), and double-submitting an attempt + * is a 409 the student would have to read. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { answerQuestion, generateQuiz, getAttempt, submitQuiz } from "./api"; +import { QUIZ_ERROR_COPY, describeQuizError, type QuizError } from "./errors"; +import { returnToSource } from "./exits"; +import { + canExit, + canSubmitAnswer, + defaultConfigFor, + errorReturnPhase, + initialSession, + reduce, + type QuizEvent, + type SessionConfig, + type StartRequest, +} from "./machine"; +import { loadPrefs, savePrefs } from "./prefs"; +import { clearSession, persistSession, loadSession } from "./session"; +import type { EntryRequest } from "./source"; +import { useGamificationDelta } from "./useGamificationDelta"; +import { useQuizConfig } from "./useQuizConfig"; +import type { QuizConfig, QuizSession, SubmitResult } from "./types"; + +export interface QuizActions { + configure(open: boolean): void; + setConfig(config: SessionConfig): void; + start(request: StartRequest, config?: SessionConfig): void; + select(index: number): void; + submitAnswer(): void; + next(): void; + finish(): void; + requestLeave(): void; + cancelLeave(): void; + confirmLeave(): void; + resume(attemptId: string): void; + practiseMissed(): void; + nextInQueue(): void; + exit(target?: string): void; + flag(): void; + dismissError(): void; + retry(): void; +} + +export interface QuizSessionHandle { + session: QuizSession; + /** A quiz call is in flight. The phase alone can't say so — `active` covers + * both "waiting for you" and "waiting for the server". */ + pending: boolean; + config: QuizConfig | null; + actions: QuizActions; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** The path part of a destination, without its query or hash. */ +function pathnameOf(href: string): string { + return href.split(/[?#]/, 1)[0]; +} + +/** + * Send the student to `destination`, by the mechanism that destination actually + * needs. + * + * A different route is a NAVIGATION: the router's job, and it works. + * + * The same route with a different query is NOT a navigation — the route tree is + * identical and only client-side search params differ. Asking the App Router for + * one is a no-op it never commits: `router.push` was tried, then + * `router.replace`, and the browser lane measured the address bar unchanged + * twenty seconds after Done both times, while every other effect of the exit + * (the phase reset, the cleared storage, the ranked card back on screen) landed + * exactly as written. A QuizScreen-level test pins the same thing from the other + * side: one router call, correct target, nothing re-issuing the deep link — the + * call simply does not move the URL. + * + * Changing search params in place is what the History API is for, and Next 15+ + * supports it explicitly for this case, threading the change back into + * `usePathname`/`useSearchParams`. Using the navigation API for navigations and + * the URL API for URL edits is the fix; reaching for `history` to force a + * navigation the router refused would have been the hack. + * + * `window.location.pathname` is the ground truth for "where am I", deliberately + * in place of `usePathname()` — a hook value that is one render stale, or null + * outside the App Router, would silently route us back to the broken branch. + */ +function goTo(destination: string, router: { push(href: string): void }): void { + const here = typeof window === "undefined" ? null : window.location.pathname; + if (here !== null && pathnameOf(destination) === here) { + // The state argument MUST be null, and that is the whole integration. + // + // Next patches `history.replaceState` (app-router.js, 16.2.9) and bails out + // of its own hook before doing anything: + // + // if (data?.__NA || data?._N) return originalReplaceState(data, _, url) + // + // — the guard that stops Next's internal navigations from looping. Passing + // `window.history.state` hands it the state Next itself wrote, `__NA: true` + // and all, so it takes that branch: the address bar changes and + // `applyUrlFromHistoryPushReplace(url)` never runs, which is exactly the + // half-fixed symptom the lane saw — URL clean, `useSearchParams()` still + // holding `?concept=`, card still pinned. + // + // Nothing is lost by passing null: `copyNextJsInternalHistoryState` starts + // from `{}` and copies `__NA` and `__PRIVATE_NEXTJS_INTERNALS_TREE` off the + // CURRENT entry, so the router state is preserved by Next rather than by us. + // Preserving it ourselves is what broke it. + window.history.replaceState(null, "", destination); + return; + } + router.push(destination); +} + +/** + * Tell any mounted graph that mastery just moved. + * + * Refreshing the tree after a quiz has always been purely navigational — leave + * the quiz, land on `/tree`, and its own mount-time `getGraph` picks up the new + * score (R5 §C). That still works, but it is nothing for a graph already on + * screen, so submit announces itself. Cheap and advisory: nothing listens today + * and nothing has to. + * + * Dispatched here rather than from the results screen because this is the one + * place that knows a submit actually LANDED — a component firing it on render + * would repeat it on every re-render of the same result. + */ +function announceGraphChanged(conceptId: string, result: SubmitResult): void { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("sapling:graph-changed", { + detail: { + conceptId, + masteryBefore: result.mastery_before, + masteryAfter: result.mastery_after, + }, + }), + ); +} + +/** + * Retries exactly once, and only for a transport failure. + * + * `/answer` is idempotent on `(attempt_id, question_index)`, so a retry after a + * dropped connection is safe: if the first call actually landed, the second + * returns the recorded response with `recorded: false`, which the machine + * advances on regardless (invariant 6). A 4xx is never retried — repeating a + * rejected request just spends the student's rate limit. + */ +async function withNetworkRetry(call: () => Promise): Promise { + try { + return await call(); + } catch (err) { + if (describeQuizError(err).code !== "NETWORK") throw err; + return call(); + } +} + +export function useQuizSession(userId: string, entry: EntryRequest): QuizSessionHandle { + const router = useRouter(); + const { config } = useQuizConfig(); + const gamification = useGamificationDelta(userId); + + const [session, setSession] = useState(() => + initialSession(entry, null, loadPrefs()), + ); + const [pending, setPendingState] = useState(false); + + // The async chains read the session through this ref: a closure captured at + // render time is one event behind by the time an await resolves. + const sessionRef = useRef(session); + // False once the screen is gone. The chains below are deliberately NOT + // cancelled on unmount — an `/answer` that was already sent must still reach + // `/submit`, or the student's last answer is scored as missing — but the + // state they set on the way is nobody's, so this makes "the screen left, the + // request didn't" explicit instead of relying on React 18 no longer warning. + const mountedRef = useRef(true); + const submittingRef = useRef(null); + // The item whose `/answer` is in flight, as `attemptId:index`. Without it a + // double-click fires the request twice: the phase stays `active` for the whole + // round trip, so neither `canSubmitAnswer` nor a `phase !== "active"` disabled + // check rules the second press out. + const answeringRef = useRef(null); + const generationRef = useRef(0); + const configAppliedRef = useRef(false); + const autoResumedRef = useRef(false); + + const setPending = useCallback((value: boolean) => { + if (mountedRef.current) setPendingState(value); + }, []); + + /** Apply one event, persist the outcome, and hand it back so the caller can + * decide what the new phase requires. */ + const apply = useCallback((event: QuizEvent): QuizSession => { + const next = reduce(sessionRef.current, event); + if (next !== sessionRef.current) { + sessionRef.current = next; + // The ref and the record are updated either way: a chain that outlives + // the screen still has to leave a resumable session behind. Only the + // render is skipped. + if (mountedRef.current) setSession(next); + persistSession(next); + } + return next; + }, []); + + // Once `/config` lands, adopt its defaults — but only while nothing is in + // flight and only if the student hasn't already chosen. The first paint uses + // the pre-config scalar so the "5 questions, medium" line isn't blank. + // + // `SET_CONFIG` is refused mid-quiz, so the flag is set only once the machine + // ACCEPTED it and the effect re-runs on the phase: a `/config` that resolves + // during a fast start or a `?attempt=` auto-resume would otherwise mark itself + // applied while being dropped, and the defaults would never land. + useEffect(() => { + if (!config || configAppliedRef.current) return; + const desired = defaultConfigFor(config, loadPrefs(config)); + if (apply({ type: "SET_CONFIG", config: desired }).config === desired) { + configAppliedRef.current = true; + } + }, [config, apply, session.phase]); + + // Persist on unmount and on a tab close, so "answered then navigated away" is + // resumable even though no transition fired on the way out. + useEffect(() => { + mountedRef.current = true; + const flush = () => persistSession(sessionRef.current); + window.addEventListener("beforeunload", flush); + return () => { + mountedRef.current = false; + window.removeEventListener("beforeunload", flush); + flush(); + }; + }, []); + + const runGenerate = useCallback( + async (from: QuizSession) => { + if (!from.conceptId) { + // Defensive: every start affordance names a concept. If one ever + // doesn't, say something true rather than "something went wrong on our + // side" — the student's move is to pick a different concept. + apply({ + type: "GENERATE_FAILED", + error: { + code: "QUIZ_CONCEPT_NOT_FOUND", + message: QUIZ_ERROR_COPY.QUIZ_CONCEPT_NOT_FOUND, + retryable: false, + }, + }); + return; + } + const token = generationRef.current + 1; + generationRef.current = token; + // Snapshot XP now so the results screen has a "before" to subtract from. + void gamification.snapshotBefore(); + setPending(true); + try { + const result = await generateQuiz({ + userId, + conceptNodeId: from.conceptId, + numQuestions: from.config.count, + difficulty: from.config.difficulty, + }); + if (generationRef.current !== token) return; + apply({ type: "GENERATED", result }); + } catch (err) { + if (generationRef.current !== token) return; + apply({ type: "GENERATE_FAILED", error: describeQuizError(err) }); + } finally { + if (generationRef.current === token) setPending(false); + } + }, + [apply, gamification, setPending, userId], + ); + + const runSubmit = useCallback( + async (from: QuizSession) => { + const attemptId = from.attemptId; + if (!attemptId || submittingRef.current === attemptId) return; + submittingRef.current = attemptId; + setPending(true); + try { + // Belt and braces: the server reconciles against `quiz_responses` and a + // recorded row always wins, so this payload only covers questions whose + // `/answer` call was lost. + const answers = from.items + .filter(i => i.selectedIndex !== null) + .map(i => ({ + question_id: i.question.id, + selected_label: i.question.options[i.selectedIndex as number]?.label ?? "", + })); + const result = await submitQuiz(attemptId, answers); + const xp = await gamification.deltaAfterSubmit(); + apply({ type: "SUBMITTED", result, xp }); + clearSession(); + announceGraphChanged(from.conceptId, result); + } catch (err) { + apply({ type: "SUBMIT_FAILED", error: describeQuizError(err) }); + } finally { + submittingRef.current = null; + setPending(false); + } + }, + [apply, gamification, setPending], + ); + + const runResume = useCallback( + async (attemptId: string) => { + setPending(true); + try { + const detail = await getAttempt(attemptId); + if (!detail.resumable) { + apply({ + type: "FAILED", + error: { + code: "QUIZ_ATTEMPT_NOT_RESUMABLE", + message: QUIZ_ERROR_COPY.QUIZ_ATTEMPT_NOT_RESUMABLE, + retryable: false, + } satisfies QuizError, + }); + return; + } + const stored = loadSession(); + apply({ type: "RESUME", detail, stored }); + void gamification.snapshotBefore(); + } catch (err) { + apply({ type: "FAILED", error: describeQuizError(err) }); + } finally { + setPending(false); + } + }, + [apply, gamification, setPending], + ); + + const submitAnswer = useCallback(async () => { + // SUBMIT_ANSWER is the machine's own guard against a submit with nothing + // selected; dispatching it keeps the transition table honest even though it + // is state-neutral, and `canSubmitAnswer` is the same predicate the footer + // button disables on. + const current = apply({ type: "SUBMIT_ANSWER" }); + if (!canSubmitAnswer(current) || !current.attemptId) return; + const item = current.items[current.cursor]; + const attemptId = current.attemptId; + const inFlight = `${attemptId}:${item.index}`; + if (answeringRef.current === inFlight) return; + answeringRef.current = inFlight; + setPending(true); + try { + const result = await withNetworkRetry(() => + answerQuestion(attemptId, { + questionIndex: item.index, + selectedIndex: item.selectedIndex as number, + questionId: item.question.id, + }), + ); + const next = apply({ type: "ANSWER_RECORDED", result }); + if (next.phase === "submitting") await runSubmit(next); + } catch (err) { + apply({ type: "ANSWER_FAILED", error: describeQuizError(err) }); + } finally { + answeringRef.current = null; + setPending(false); + } + }, [apply, runSubmit, setPending]); + + // A `?attempt=` entry (the resume strip, or a leave-and-return link) picks + // the quiz back up without a stop on home. + useEffect(() => { + if (autoResumedRef.current || !entry.attempt || !userId) return; + autoResumedRef.current = true; + void runResume(entry.attempt); + }, [entry.attempt, userId, runResume]); + + const actions = useMemo(() => { + const start = (request: StartRequest, override?: SessionConfig) => { + const next = apply({ + type: "START", + start: request, + config: override ?? sessionRef.current.config, + }); + if (next.phase === "generating") void runGenerate(next); + }; + + return { + configure: open => { + apply({ type: "CONFIGURE", open }); + }, + + setConfig: next => { + const applied = apply({ type: "SET_CONFIG", config: next }); + if (applied.config === next) { + savePrefs({ count: next.count, difficulty: next.difficulty, feedback: next.feedback }); + } + }, + + start, + + select: index => { + apply({ type: "SELECT", index }); + }, + + submitAnswer: () => { + void submitAnswer(); + }, + + next: () => { + const next = apply({ type: "NEXT" }); + if (next.phase === "submitting") void runSubmit(next); + }, + + finish: () => { + const next = apply({ type: "FINISH" }); + if (next.phase === "submitting") void runSubmit(next); + }, + + requestLeave: () => { + apply({ type: "REQUEST_LEAVE" }); + }, + + cancelLeave: () => { + apply({ type: "CANCEL_LEAVE" }); + }, + + confirmLeave: () => { + const next = apply({ type: "CONFIRM_LEAVE" }); + if (next.phase !== "paused") return; + // `apply` already persisted it; the navigation is what makes the answers + // recoverable from anywhere the student lands next. + goTo(returnToSource(next), router); + }, + + resume: attemptId => { + void runResume(attemptId); + }, + + practiseMissed: () => { + const current = sessionRef.current; + const result = current.result; + if (!result) return; + const missed = Math.max(result.total - result.score, 1); + const min = config?.num_questions.min ?? 1; + const max = config?.num_questions.max ?? missed; + const next = apply({ + type: "PRACTISE_MISSED", + missedCount: result.total - result.score, + numQuestions: clamp(missed, min, max), + }); + if (next.phase === "generating") void runGenerate(next); + }, + + nextInQueue: () => { + const next = apply({ type: "NEXT_IN_QUEUE" }); + if (next.phase === "generating") void runGenerate(next); + }, + + exit: target => { + const current = sessionRef.current; + if (!canExit(current)) return; + // Read the destination BEFORE the reset: `returnToSource` falls back to + // the tree focused on `conceptId`, which EXIT clears. + const destination = target ?? returnToSource(current); + apply({ type: "EXIT" }); + clearSession(); + goTo(destination, router); + }, + + flag: () => { + apply({ type: "FLAG" }); + }, + + dismissError: () => { + apply({ type: "DISMISS_ERROR" }); + }, + + retry: () => { + const failed = sessionRef.current; + if (failed.phase !== "error") return; + const back = errorReturnPhase(failed); + const next = apply({ type: "DISMISS_ERROR" }); + if (back === "submitting") void runSubmit(next); + else if (back === "home" && next.conceptId) { + const generating = apply({ + type: "START", + start: { + intent: next.intent, + scope: next.scope, + conceptId: next.conceptId, + courseId: next.courseId, + }, + config: next.config, + }); + if (generating.phase === "generating") void runGenerate(generating); + } else if (back === "active") void submitAnswer(); + }, + }; + }, [apply, config, router, runGenerate, runResume, runSubmit, submitAnswer]); + + return { session, pending, config, actions }; +} diff --git a/frontend/src/lib/quizSelection.ts b/frontend/src/lib/quizSelection.ts index de404b87..872daa01 100644 --- a/frontend/src/lib/quizSelection.ts +++ b/frontend/src/lib/quizSelection.ts @@ -1,12 +1,18 @@ -// Pure selection logic for the quiz picker's two-step flow: choose a course -// first, then a concept within that course. +// Pure course/concept selection logic, unit-testable without rendering React. // -// This lives apart from QuizPanel so the branching that decides which courses -// and concepts are offered can be unit-tested without rendering React. The old -// picker built a single flat dropdown straight from graph concept nodes, which -// (a) never surfaced enrolled courses that had no concept nodes yet and -// (b) listed concepts with the same name under different courses side by side, -// reading as duplicates. Splitting the choice into course → concept fixes both. +// Written for the old quiz picker's two-step flow (choose a course, then a +// concept within it), which it fixed two bugs in: a single flat dropdown built +// straight from graph nodes (a) never surfaced enrolled courses that had no +// concept nodes yet, and (b) listed same-named concepts from different courses +// side by side, reading as duplicates. +// +// That picker is gone. What survives it is `resolveInitialSelection`, now the +// id half of `lib/quiz/proposals.ts::entrySelection` — the resolver behind +// `/quiz?concept=`. Its "unknown id selects nothing" answer is the +// reason the redesign reuses this rather than re-deriving it: a deep link into +// a term the student is not viewing has to read as unresolved, not as a quiz on +// something off-screen. `courseOptions` / `conceptOptionsForCourse` remain +// available for any grouped picker that wants them. export interface QuizConcept { id: string; diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh index 7da551f1..9879436e 100755 --- a/scripts/e2e-up.sh +++ b/scripts/e2e-up.sh @@ -234,6 +234,20 @@ migrate_reload_seed # Same app entry as `python main.py` but without --reload: a file-watcher # restarting mid-test would make E2E runs nondeterministic. echo "▶ Starting backend (uvicorn on :$BACKEND_PORT, log: .e2e/backend.log)…" +# #537: raise the quiz-generation rate limit for the lane. The guard +# (services/quiz_config.py::QUIZ_GENERATE_RATE_LIMIT, 8 per 300s) keeps its +# sliding window in a module-level dict, so it is neither per-test nor +# per-spec: ONE Playwright run shares a single window across every worker, +# and the per-test TRUNCATE + re-seed does not reset it — only a backend +# restart does. The redesigned quiz lane makes ~20 real generations, so the +# production number 429s the specs that happen to run last (alphabetically: +# quiz.spec.ts after quiz-errors/quiz-integration). Under function mode a +# generation is a scripted constant that costs nothing, which is the only +# thing the limit exists to bound. Exported, so it beats backend/.env +# (python-dotenv never overrides existing env); overridable for anyone who +# wants to exercise the real guard. +export QUIZ_GENERATE_RATE_LIMIT="${QUIZ_GENERATE_RATE_LIMIT:-1000}" +echo " ℹ QUIZ_GENERATE_RATE_LIMIT=$QUIZ_GENERATE_RATE_LIMIT for this stack (production default is 8; #537)" # `setsid &` is load-bearing: bash fork+execs the simple # command directly, so $! is setsid's PID, which becomes the new session's # process-group leader — the PID e2e-down.sh kills as a group. (Backgrounding a diff --git a/supabase/snippets/.gitkeep b/supabase/snippets/.gitkeep new file mode 100644 index 00000000..e69de29b