From dd71bed184a1067e1e202a0a0d0c216926bb9e34 Mon Sep 17 00:00:00 2001
From: Daniel
Date: Sat, 12 Sep 2026 05:54:09 +0200
Subject: [PATCH] feat: adaptive weighs the paper as well as the learner, and
Session analysis is a real tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adaptive selection knew what you were weak at and nothing about what the
exam is made of, so being weak at something worth 5% of the paper ranked
the same as being weak at something worth 1%. Every score is now
multiplied by the weight the board publishes for that topic's domain —
the same `exam_blueprints.weight` behind the Relevance column.
A topic the blueprint does not cover takes the median published weight. A
zero would make unmapped material unreachable and the highest would make
it the priority; neither is a claim the blueprint supports. With no study
objective the multiplier is absent and selection is about weakness alone,
exactly as before.
Weight scales weakness, it does not replace it: a topic you are certain of
does not surface because it is worth 5% of the paper, because (1 −
accuracy) is near zero and no multiplier rescues that. docs/adaptive-
sessions.md says all of this, including what is still open.
Session analysis is the third tab rather than a link out of the page —
two of the three used to change what you were reading and the third took
you somewhere else. The tab bar is one component both routes wear,
AnalysisSessionPage's body is a component the tab renders in place, and
the tab lives in the address so a link opens where it says.
Two things that were wrong turned up in that work: a session nobody had
sat showed 0% in the figures and "0% correct" in the donut — two separate
statements of a score on a session that had none — and the old third tab
disappeared entirely for anyone with no attempts, so the strip silently
changed shape.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
---
backend/app/services/quiz_builder.py | 66 +++++++++++++++--
backend/tests/test_exam_blueprint.py | 60 +++++++++++++++-
docs/adaptive-sessions.md | 40 ++++++-----
frontend/src/components/AnalysisShell.css | 11 +++
frontend/src/components/AnalysisShell.jsx | 26 +++++++
frontend/src/pages/AnalysisPage.css | 9 ---
frontend/src/pages/AnalysisPage.jsx | 42 ++++++-----
frontend/src/pages/AnalysisPage.test.jsx | 64 +++++++++++++++++
frontend/src/pages/AnalysisSessionPage.jsx | 70 +++++++++++++++----
.../src/pages/AnalysisSessionPage.test.jsx | 56 +++++++++++++--
...rnstile.test.jsx => LoginCaptcha.test.jsx} | 0
11 files changed, 372 insertions(+), 72 deletions(-)
rename frontend/src/pages/{LoginTurnstile.test.jsx => LoginCaptcha.test.jsx} (100%)
diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py
index 8654f24..68566d5 100644
--- a/backend/app/services/quiz_builder.py
+++ b/backend/app/services/quiz_builder.py
@@ -1,5 +1,6 @@
"""Permission-safe, saved general-bank tests and category selection."""
import random
+import statistics
from typing import Literal
from fastapi import HTTPException
@@ -247,10 +248,46 @@ def create_saved_test(db, user, data, question_ids):
return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count}
+def blueprint_weights(db, user) -> dict[int, float]:
+ """Each category mapped to the share of the paper its domain accounts for.
+
+ Empty when the learner has no study objective, or when the objective has no
+ published weights — in which case selection falls back to being about
+ weakness alone, which is what it always was.
+
+ `categories_for` already returns the descendants of a mapped category, so a
+ leaf topic under a weighted domain is in here too.
+ """
+ exam_id = getattr(user, "active_exam_id", None)
+ if not exam_id:
+ return {}
+ from app.services import exam_blueprint
+
+ weights: dict[int, float] = {}
+ for line in exam_blueprint.domains(db, exam_id):
+ if line.weight is None:
+ continue
+ for category_id in exam_blueprint.categories_for(db, line.id):
+ weights[category_id] = float(line.weight)
+ return weights
+
+
def adaptive_select(db, user, count, category_ids, state, difficulty):
- """AMBOSS-style adaptive selection: prefer unanswered questions, then recycle
- (older incorrect first), pick the weakest category each step and damp that
- category so the session moves between weak areas."""
+ """Adaptive selection: the questions most likely to raise the learner's score.
+
+ Three rules, in order. Unanswered before answered, because a question never
+ seen teaches more than one already met. Among those, weakest topic first.
+ When the unanswered run out, recycle — wrong ones before right ones, oldest
+ first — damping each category as it is drawn from so a session of twenty
+ does not become twenty questions from the single worst subject.
+
+ Weakness is then multiplied by the topic's share of the real paper, which
+ the examining board publishes and `exam_blueprints.weight` holds. Being weak
+ at something worth 5% of the exam is worth more study than being equally
+ weak at something worth 1%, and until this was added the two ranked the
+ same. A topic the blueprint does not cover takes the median weight, so it
+ is neither promoted nor made unreachable.
+ """
from collections import defaultdict
query = filtered_bank_query(db, user, category_ids, state)
if difficulty:
@@ -285,17 +322,34 @@ def adaptive_select(db, user, count, category_ids, state, difficulty):
def accuracy(category):
total, correct = cat_correct[category]
return correct / total if total else 0.5
+
+ weights = blueprint_weights(db, user)
+ # The middle of what the board publishes, for a topic it does not mention.
+ # A zero would make unmapped material unreachable; the highest would make
+ # it the priority. Neither is a claim the blueprint supports.
+ neutral = statistics.median(weights.values()) if weights else 1.0
+
+ def weight(category):
+ return weights.get(category, neutral) if weights else 1.0
+
+ def impact(category):
+ """How much a question here could move the score."""
+ return (1 - accuracy(category)) * weight(category)
+
unanswered = [r for r in rows if r[0] not in latest]
recycled = [r for r in rows if r[0] in latest]
# Older incorrect questions resurface first among recycled.
recycled.sort(key=lambda r: (latest[r[0]][0], latest[r[0]][1]))
- # Step 2: prefer unanswered, ordered by weakest category first (step 3 estimate).
- unanswered.sort(key=lambda r: accuracy(r[1]))
+ # Step 2: prefer unanswered, most impact first.
+ unanswered.sort(key=lambda r: -impact(r[1]))
selected = [r[0] for r in unanswered[:count]]
if len(selected) < count:
damping = defaultdict(lambda: 1.0)
while recycled and len(selected) < count:
- best = max(recycled, key=lambda r: (1 - (0.85 if latest[r[0]][0] else 0.25)) * damping[r[1]])
+ # Wrong last time is worth about five times right last time, and
+ # the topic's share of the paper scales both.
+ best = max(recycled, key=lambda r: (
+ (1 - (0.85 if latest[r[0]][0] else 0.25)) * damping[r[1]] * weight(r[1])))
recycled.remove(best)
selected.append(best[0])
damping[best[1]] *= 0.5
diff --git a/backend/tests/test_exam_blueprint.py b/backend/tests/test_exam_blueprint.py
index df98e20..55aa4c3 100644
--- a/backend/tests/test_exam_blueprint.py
+++ b/backend/tests/test_exam_blueprint.py
@@ -7,17 +7,19 @@ paper.
"""
import random
import unittest
+from unittest.mock import patch
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
-import test_quiz_builder # noqa: F401 — imports every model, so the metadata resolves
+import test_quiz_builder as fixtures # noqa: F401 — imports every model, so the metadata resolves
from app.database import Base
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink
from app.models.question import Question
from app.models.question_category import QuestionCategory
+from app.services import quiz_builder
from app.models.user import User
from app.services import exam_blueprint
@@ -133,3 +135,59 @@ class BlueprintTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+class AdaptiveWeightedByTheBlueprintTests(unittest.TestCase):
+ """Weakness is scaled by how much of the real paper a topic accounts for.
+
+ Being weak at something worth 5% of the exam is worth more study than being
+ equally weak at something worth 1%. Until this was added the two ranked the
+ same, because selection knew about the learner and nothing about the paper.
+ """
+
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.db = self.bank.db
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def weigh(self, mapping):
+ """Patch the blueprint so the test states the weights it is about."""
+ return patch.object(quiz_builder, "blueprint_weights", lambda db, user: mapping)
+
+ def test_the_heavier_topic_is_drawn_first_when_both_are_untouched(self):
+ # Categories 1 and 2 both unanswered, so weakness is equal at 0.5.
+ # Question 1 sits under category 1, question 2 under category 2.
+ with self.weigh({1: 10.0, 2: 1.0}):
+ ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
+ self.assertEqual(ids, [1])
+
+ with self.weigh({1: 1.0, 2: 10.0}):
+ ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
+ self.assertEqual(ids, [2])
+
+ def test_weakness_still_beats_weight_when_it_is_lopsided(self):
+ # Right in the heavy topic, wrong in the light one: the light topic is
+ # where the learner actually is weak, and 10× the weight does not undo
+ # being certain about the other.
+ self.bank.answer(1, True, day=0)
+ self.bank.answer(2, False, day=1)
+ with self.weigh({1: 10.0, 2: 1.0}):
+ ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 1, [], "all", None)
+ # 3 and 6 are unanswered, so they come before anything recycled.
+ self.assertTrue(set(ids) <= {3, 6}, ids)
+
+ def test_a_topic_the_blueprint_does_not_cover_is_neither_promoted_nor_lost(self):
+ # Only category 1 is mapped. Category 2 takes the median, so a question
+ # under it is still reachable — a zero would make it invisible.
+ with self.weigh({1: 4.0}):
+ ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 4, [], "all", None)
+ self.assertIn(2, ids)
+
+ def test_with_no_study_objective_it_is_about_weakness_alone(self):
+ # No exam, no weights: selection is what it always was.
+ self.assertEqual(quiz_builder.blueprint_weights(self.db, self.bank.owner), {})
+ ids = quiz_builder.adaptive_select(self.db, self.bank.owner, 2, [], "all", None)
+ self.assertEqual(len(ids), 2)
diff --git a/docs/adaptive-sessions.md b/docs/adaptive-sessions.md
index d0dd242..e36d03b 100644
--- a/docs/adaptive-sessions.md
+++ b/docs/adaptive-sessions.md
@@ -10,7 +10,7 @@ session* card on Analysis (`frontend/src/pages/AnalysisPage.jsx`).
## What it does today
-Three rules, in order.
+Three rules, then one multiplier.
**1. Unanswered first.** A question you have never seen teaches more than one
you have. Everything you have not answered is taken before anything you have.
@@ -27,28 +27,36 @@ is worth roughly five times one you got right. After each pick, that category's
damping halves, which stops a session of twenty becoming twenty cardiology
questions because cardiology happens to be your worst subject.
+**4. All of it is scaled by the topic's share of the real paper.** Weakness
+alone said that being weak at something worth 5% of the exam and something
+worth 1% were the same problem. They are not. Every score above is multiplied
+by the weight the examining board publishes for that topic's domain, which
+`exam_blueprints.weight` holds and `blueprint_weights()` loads.
+
+A topic the blueprint does not cover takes the **median** of the published
+weights. A zero would make unmapped material unreachable; the highest would
+make it the priority. Neither is a claim the blueprint supports.
+
+With no study objective set, or an objective carrying no weights, the
+multiplier is absent and selection is about weakness alone — which is what it
+was before this existed.
+
Only completed, non-expired, non-course attempts count, and only your most
recent answer to each question.
-## What it is *not*
+## What it is still *not*
-It is **not** weighted by the exam blueprint. Impact, here, means *what you are
-weakest at*, not *what the paper is mostly made of*. Those are different
-questions and we answer only the first one.
+Weight scales weakness; it does not replace it. A topic you are certain of does
+not surface because it is worth 5% of the paper — `(1 − accuracy)` is near zero
+and no multiplier rescues it. That is deliberate: this picks what to *study*,
+not what the paper is made of.
-The blueprint is a separate algorithm — `algorithm: "blueprint"`,
-`_blueprint_test` → `services/exam_blueprint.py` — which deals a paper shaped
-like the real exam using the published domain weights. Nothing currently
-combines the two.
+Dealing a paper shaped like the real exam is a different algorithm —
+`algorithm: "blueprint"`, `_blueprint_test` → `services/exam_blueprint.py`.
-## The obvious next refinement
+## Refinements still open
-Multiply the adaptive score by the domain's published weight, so being weak at
-something worth 5% of the paper outranks being weak at something worth 1%. The
-weights are already in `exam_blueprints.weight` and already drive the Relevance
-column on Analysis. This is not built.
-
-Two smaller ones, in the order they are worth doing:
+In the order they are worth doing:
- **Use every category a question is filed under, not only its primary.**
Step 2 reads `question_category_id` alone, so a question's extra links do not
diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css
index f132d8d..1eff383 100644
--- a/frontend/src/components/AnalysisShell.css
+++ b/frontend/src/components/AnalysisShell.css
@@ -23,6 +23,17 @@
.ax-main { min-width: 0; padding: 8px 0 48px; }
+/* Section tabs. They live here because both routes wear them, and a page that
+ only sometimes has them is how the third one ended up looking like a link. */
+.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
+.an-tabs::-webkit-scrollbar { display: none; }
+.an-tab {
+ background: none; border: none; border-bottom: 2px solid transparent;
+ padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
+ color: var(--text-muted); cursor: pointer; white-space: nowrap;
+}
+.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
+
/* ── Rail ─────────────────────────────────────────────────────────── */
.ax-rail {
/* Stuck below the header, exactly as tall as what is left of the window.
diff --git a/frontend/src/components/AnalysisShell.jsx b/frontend/src/components/AnalysisShell.jsx
index 0e3db46..0b6b7b4 100644
--- a/frontend/src/components/AnalysisShell.jsx
+++ b/frontend/src/components/AnalysisShell.jsx
@@ -3,6 +3,32 @@ import SessionRail from './SessionRail'
import api from '../api/client'
import './AnalysisShell.css'
+export const ANALYSIS_TABS = [
+ ['performance', 'Performance'],
+ ['recommendations', 'Recommendations'],
+ ['session', 'Session analysis'],
+]
+
+/**
+ * The three readings of the same answers, in one bar.
+ *
+ * Session analysis used to be a link out of the page, which is why it never
+ * felt like a tab: two of the three changed what you were reading and the
+ * third took you somewhere else. What selecting does is left to the caller,
+ * since the session's own route has to hand you back to the page holding the
+ * other two — but from the outside all three are the same control.
+ */
+export function AnalysisTabs({ active, onSelect }) {
+ return (
+
+ )
+}
+
/**
* The frame both analysis views sit in: the session rail, then the content.
*
diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css
index 129f009..004cd59 100644
--- a/frontend/src/pages/AnalysisPage.css
+++ b/frontend/src/pages/AnalysisPage.css
@@ -6,15 +6,6 @@
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
.an-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
-.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
-.an-tabs::-webkit-scrollbar { display: none; }
-.an-tab {
- background: none; border: none; border-bottom: 2px solid transparent;
- padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
- color: var(--text-muted); cursor: pointer; white-space: nowrap;
-}
-.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
-
/* ── Summary tiles ────────────────────────────────────────────────── */
.an-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
.an-stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; }
diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx
index ee87bba..0511863 100644
--- a/frontend/src/pages/AnalysisPage.jsx
+++ b/frontend/src/pages/AnalysisPage.jsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
+import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import api from '../api/client'
import CategoryPerformance from '../components/CategoryPerformance'
import Donut from '../components/Donut'
import LineChart from '../components/LineChart'
-import AnalysisShell from '../components/AnalysisShell'
+import AnalysisShell, { AnalysisTabs, ANALYSIS_TABS } from '../components/AnalysisShell'
+import { SessionAnalysis } from './AnalysisSessionPage'
import './AnalysisPage.css'
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
@@ -371,7 +372,17 @@ function FocusRow({ row, onPractise }) {
}
export default function AnalysisPage() {
- const [tab, setTab] = useState('recommendations')
+ // Which tab is on lives in the URL, so a reader can be sent to one and so
+ // the session's own route can hand them back to the tab they came from.
+ const [params, setParams] = useSearchParams()
+ const view = params.get('view')
+ const tab = ANALYSIS_TABS.some(([key]) => key === view) ? view : 'recommendations'
+ const setTab = (key) => {
+ const next = new URLSearchParams(params)
+ if (key === 'recommendations') next.delete('view')
+ else next.set('view', key)
+ setParams(next, { replace: true })
+ }
const [group, setGroup] = useState('articles')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
@@ -413,27 +424,20 @@ export default function AnalysisPage() {
-
-
-
- {/* A session's own analysis is a session, and the rail beside this
- page is the list of them — so this points at the last one rather
- than being a third empty tab. */}
- {data?.last_attempt_id && (
- Session analysis
- )}
-
+
{error &&
{error}
}
{loading && !data ? (
Loading…
- ) : !data ? null : (
+ ) : !data ? null : tab === 'session' ? (
+ /* One sitting is its own subject. The lifetime tiles and the adaptive
+ button below them are about the whole bank, and repeating them over
+ a single session's figures would invite the two to be read as one
+ set. Which session: the one most recently worked on, and the rail
+ beside it is how you get to any of the others. */
+
+ ) : (
<>
diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx
index feaadd3..a7e0bb3 100644
--- a/frontend/src/pages/AnalysisPage.test.jsx
+++ b/frontend/src/pages/AnalysisPage.test.jsx
@@ -86,6 +86,70 @@ it('switches to the performance tab', async () => {
expect(screen.queryByText('Cardiology')).not.toBeInTheDocument()
})
+it('opens on the tab the address names', async () => {
+ // Which tab is on is in the URL, so one can be linked to — which is how the
+ // session's own route hands a reader back to the other two.
+ render()
+ expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: 'Performance' })).toHaveAttribute('aria-selected', 'true')
+})
+
+const SESSION = {
+ attempt_id: 91, quiz_id: 3, title: 'My Custom Test', mode: 'learning',
+ completed_at: '2026-09-11T10:00:00', graded: true,
+ total: 2, answered: 2, score: 1, percent: 50,
+ seconds_total: 180, seconds_per_question: 90,
+ questions: [{ position: 1, question_id: 1, excerpt: 'A 4-month-old…', status: 'correct',
+ difficulty: 'medium', category: 'Neonatology', seconds_spent: 90, peer_percent: 60, peer_sample: 5 }],
+ recommendations: [], plan: null, not_started: false,
+}
+
+const withSession = (session) => api.get.mockImplementation(url => {
+ if (url === '/quizzes/sessions') return Promise.resolve({ data: [] })
+ if (url.endsWith('/analysis')) return Promise.resolve({ data: session })
+ if (url.startsWith('/attempts/')) return Promise.resolve({ data: { rows: [] } })
+ return Promise.resolve({ data: payload({ last_attempt_id: 91 }) })
+})
+
+const sessionTab = async () => {
+ render()
+ await userEvent.click(await screen.findByRole('tab', { name: 'Session analysis' }))
+}
+
+it('reads a session on the third tab, in place, with the rail still beside it', async () => {
+ withSession(SESSION)
+ await sessionTab()
+ // The third tab used to be a link to a page of its own, so choosing it left
+ // the analysis rather than changing which part of it you were reading.
+ expect(await screen.findByRole('heading', { name: /Your performance for My Custom Test/ }))
+ .toBeInTheDocument()
+ expect(api.get).toHaveBeenCalledWith('/attempts/91/analysis')
+ expect(screen.getByRole('tab', { name: 'Performance' })).toBeInTheDocument()
+ expect(screen.getByRole('complementary', { name: 'Sessions' })).toBeInTheDocument()
+ const figures = document.querySelector('.an-figures')
+ expect(within(figures).getByText('50%')).toBeInTheDocument()
+})
+
+it('offers the way back into a session still being sat', async () => {
+ withSession({ ...SESSION, completed_at: null, answered: 1 })
+ await sessionTab()
+ expect(await screen.findByRole('link', { name: 'Resume session' }))
+ .toHaveAttribute('href', '/study/3?start=1')
+ expect(screen.queryByRole('link', { name: 'Review session' })).toBeNull()
+ // And no figures, because a session half sat has no result — only a 50% that
+ // would be read as one.
+ expect(within(document.querySelector('.an-figures')).getAllByText('—')).toHaveLength(4)
+})
+
+it('says there is nothing to analyse rather than showing an empty session', async () => {
+ await sessionTab()
+ // Not the rail's "No sessions yet" repeated: this says what the tab would
+ // have shown, which is the question the reader just asked.
+ expect(await screen.findByText(/sit one and this is where it is picked apart/))
+ .toBeInTheDocument()
+ expect(api.get).not.toHaveBeenCalledWith('/attempts/undefined/analysis')
+})
+
it('reports completion over a window, and changes the window', async () => {
const completion = (over = {}) => ({
days: 30, answered: 120, bank_total: 330, correct: 78,
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx
index 2524c47..6606fe6 100644
--- a/frontend/src/pages/AnalysisSessionPage.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.jsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
-import AnalysisShell from '../components/AnalysisShell'
+import AnalysisShell, { AnalysisTabs } from '../components/AnalysisShell'
import Donut from '../components/Donut'
import RepeatSession from '../components/RepeatSession'
import api from '../api/client'
@@ -34,11 +34,12 @@ const SORTS = {
* right, how much was reached at all, how long each question took, and how long
* the sitting was. Time per question is the one that says whether you are
* reading carefully or stalling.
+ *
+ * It takes the session as props and brings no frame of its own, because the
+ * Analysis page shows it on a tab and the route below shows it on its own —
+ * the same reading either way, from one piece of markup.
*/
-export default function AnalysisSessionPage() {
- // Two ways in: an attempt to analyse, or — when nothing has been sat yet —
- // the quiz itself, so the page can say what is missing instead of 404ing.
- const { attemptId, quizId } = useParams()
+export function SessionAnalysis({ attemptId, quizId }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -51,9 +52,12 @@ export default function AnalysisSessionPage() {
// table and the peer statistics beside it.
const [group, setGroup] = useState('disciplines')
const [recs, setRecs] = useState(null)
- const navigate = useNavigate()
const load = useCallback(() => {
+ // Reached with neither on the Analysis page before a first session exists.
+ // Asking for /attempts/undefined/analysis is how that came back as a
+ // failure rather than as a beginning.
+ if (!attemptId && !quizId) { setLoading(false); return }
setLoading(true)
const url = attemptId ? `/attempts/${attemptId}/analysis` : `/attempts/quiz/${quizId}/analysis`
api.get(url)
@@ -82,11 +86,18 @@ export default function AnalysisSessionPage() {
const shown = rows.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE)
useEffect(() => { setPage(0) }, [sort])
+ if (!attemptId && !quizId) {
+ return (
+
+ No session to read yet — sit one and this is where it is picked apart.
+
+ )
+ }
if (loading) {
- return
+ return
}
if (!data) {
- return
{error || 'Session not found.'}
+ return
{error || 'Session not found.'}
}
// An exam still running is not marked, so there is nothing to split the
@@ -95,13 +106,18 @@ export default function AnalysisSessionPage() {
const graded = data.graded !== false
// Nothing left to answer is finished, whether or not it was handed in.
const finished = !!data.completed_at || (data.total > 0 && data.answered >= data.total)
- const correct = graded ? data.score : 0
- const incorrect = graded ? data.answered - data.score : 0
- const answeredOnly = graded ? 0 : data.answered
+ // A sitting in progress has no result, and zeros are read as one: 0% is a
+ // score of nought, not "not answered yet". So an unfinished session reports
+ // how far through it is and nothing else — which is also why the button
+ // beneath it is the way back in rather than the way through the answers.
+ const marked = graded && finished
+ const correct = marked ? data.score : 0
+ const incorrect = marked ? data.answered - data.score : 0
+ const answeredOnly = marked ? 0 : data.answered
const skipped = data.total - data.answered
return (
-
+ <>
Your performance for {data.title}
@@ -148,7 +164,7 @@ export default function AnalysisSessionPage() {
['⏲', hours(data.seconds_total), 'total time spent'],
].map(([icon, value, label]) => (
- {value}
+ {finished ? value : '—'}{icon} {label}
))}
@@ -159,9 +175,9 @@ export default function AnalysisSessionPage() {
@@ -331,6 +347,30 @@ export default function AnalysisSessionPage() {
setRepeating(false)} />
)}
+ >
+ )
+}
+
+/**
+ * One session at its own address.
+ *
+ * The rail hands out a link per row and people have kept the ones they were
+ * sent, so the route stays; it lands on the Session analysis tab with that
+ * session showing, which is the same view the tab gives on its own. The other
+ * two tabs are about the whole bank rather than this sitting, so choosing one
+ * goes back to the page that holds them.
+ */
+export default function AnalysisSessionPage() {
+ // Two ways in: an attempt to analyse, or — when nothing has been sat yet —
+ // the quiz itself, so the page can say what is missing instead of 404ing.
+ const { attemptId, quizId } = useParams()
+ const navigate = useNavigate()
+
+ return (
+
+ { if (key !== 'session') navigate(`/sessions?view=${key}`) }} />
+
)
}
diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx
index 3ba6b86..b3178ba 100644
--- a/frontend/src/pages/AnalysisSessionPage.test.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.test.jsx
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router-dom'
import AnalysisSessionPage from './AnalysisSessionPage'
import api from '../api/client'
@@ -71,11 +71,13 @@ describe('a session nobody has sat', () => {
it('is the same page at zero, not a different kind of page', async () => {
mountQuiz()
- // The real figures, all zero. Scoped to the figure strip: the title and
- // the percentage also appear in the rail and the donut.
- const figures = (await screen.findByText('0/20')).closest('.an-figures')
- expect(within(figures).getByText('0%')).toBeInTheDocument()
- expect(within(figures).getByText('0/20')).toBeInTheDocument()
+ // Nothing sat is not a result of nought. The figures used to read
+ // "0%, 0/20", which is a mark on an empty page; a session with nothing in
+ // it has no figures, and the donut says how far through it is instead.
+ await screen.findByText(/Nothing answered yet/)
+ const figures = document.querySelector('.an-figures')
+ expect(within(figures).getAllByText('—')).toHaveLength(4)
+ expect(within(figures).queryByText('0%')).toBeNull()
// And the real table, every row skipped.
// Ten rows a page, every one of them skipped.
expect(screen.getAllByText('skipped')).toHaveLength(10)
@@ -149,6 +151,37 @@ describe('a session whose only attempt is still in progress', () => {
})
})
+describe('a session reached by its own URL', () => {
+ const Overall = () =>
+
+ const mountRouted = () => render(
+
+
+ } />
+ } />
+
+ )
+
+ beforeEach(() => { vi.clearAllMocks(); mock(SAT) })
+
+ it('lands on the session tab rather than on a page of its own', async () => {
+ mountRouted()
+ // People were sent these links before there were tabs, and the rail hands
+ // out one per row. The address still works, and it arrives somewhere that
+ // looks like the rest of the analysis.
+ expect(await screen.findByRole('tab', { name: 'Session analysis' }))
+ .toHaveAttribute('aria-selected', 'true')
+ expect(await screen.findByRole('heading', { name: /Your performance for My Custom Test/ }))
+ .toBeInTheDocument()
+ })
+
+ it('hands the reader back to the two tabs it does not hold', async () => {
+ mountRouted()
+ await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
+ expect(screen.getByText('overall analysis: performance')).toBeInTheDocument()
+ })
+})
+
describe('the session rail', () => {
it('gives the content the whole width when the rail is put away', async () => {
mountAttempt()
@@ -237,6 +270,17 @@ describe('a session left part way', () => {
expect(screen.getByRole('link', { name: 'Resume session' })).toHaveAttribute('href', '/study/3?start=1')
expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument()
})
+
+ it('withholds the figures until there is a sitting behind them', async () => {
+ mountAttempt()
+ await screen.findByText(/1 of 2 questions still unanswered/)
+ // Half a session answered would score 50%, which is the sort of figure a
+ // learner remembers and the sort a page should not offer: it is the score
+ // of a session that does not exist yet.
+ const figures = document.querySelector('.an-figures')
+ expect(within(figures).getAllByText('—')).toHaveLength(4)
+ expect(within(figures).queryByText('50%')).toBeNull()
+ })
})
it('ranks this session\'s topics, and asks the same answers three ways', async () => {
diff --git a/frontend/src/pages/LoginTurnstile.test.jsx b/frontend/src/pages/LoginCaptcha.test.jsx
similarity index 100%
rename from frontend/src/pages/LoginTurnstile.test.jsx
rename to frontend/src/pages/LoginCaptcha.test.jsx