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 ( +
+ {ANALYSIS_TABS.map(([key, label]) => ( + + ))} +
+ ) +} + /** * 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 : '—'} {label}
))} @@ -159,9 +175,9 @@ export default function AnalysisSessionPage() {

{data.title}

+ answered={answeredOnly} skipped={skipped} graded={marked} />
    - {graded ? ( + {marked ? ( <>
  • {correct} correct
  • {incorrect} incorrect
  • @@ -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 = () =>

    overall analysis: {useSearchParams()[0].get('view')}

    + + 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