feat: adaptive weighs the paper as well as the learner, and Session

analysis is a real tab

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 05:54:09 +02:00
parent 7c41dd20ac
commit dd71bed184
11 changed files with 372 additions and 72 deletions

View file

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

View file

@ -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)

View file

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

View file

@ -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.

View file

@ -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 (
<div className="an-tabs" role="tablist">
{ANALYSIS_TABS.map(([key, label]) => (
<button key={key} type="button" role="tab" className="an-tab"
aria-selected={active === key} onClick={() => onSelect(key)}>{label}</button>
))}
</div>
)
}
/**
* The frame both analysis views sit in: the session rail, then the content.
*

View file

@ -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; }

View file

@ -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() {
</p>
</div>
<div className="an-tabs" role="tablist">
<button className="an-tab" role="tab" aria-selected={tab === 'performance'} onClick={() => setTab('performance')}>
Performance
</button>
<button className="an-tab" role="tab" aria-selected={tab === 'recommendations'} onClick={() => setTab('recommendations')}>
Recommendations
</button>
{/* 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 && (
<Link className="an-tab" role="tab" aria-selected="false"
to={`/sessions/${data.last_attempt_id}`}>Session analysis</Link>
)}
</div>
<AnalysisTabs active={tab} onSelect={setTab} />
{error && <p className="an-empty" role="alert">{error}</p>}
{loading && !data ? (
<div className="loading"><div className="spinner" /> Loading</div>
) : !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. */
<SessionAnalysis attemptId={data.last_attempt_id} />
) : (
<>
<div className="an-summary">
<div className="an-stat">

View file

@ -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(<MemoryRouter initialEntries={['/sessions?view=performance']}><AnalysisPage /></MemoryRouter>)
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(<MemoryRouter><AnalysisPage /></MemoryRouter>)
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,

View file

@ -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 (
<div className="an-empty">
No session to read yet sit one and this is where it is picked apart.
</div>
)
}
if (loading) {
return <AnalysisShell><div className="loading"><div className="spinner" /></div></AnalysisShell>
return <div className="loading"><div className="spinner" /></div>
}
if (!data) {
return <AnalysisShell><div className="an-empty">{error || 'Session not found.'}</div></AnalysisShell>
return <div className="an-empty">{error || 'Session not found.'}</div>
}
// 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 (
<AnalysisShell>
<>
<div className="an-main">
<div className="an-head">
<h1>Your performance for <span>{data.title}</span></h1>
@ -148,7 +164,7 @@ export default function AnalysisSessionPage() {
['⏲', hours(data.seconds_total), 'total time spent'],
].map(([icon, value, label]) => (
<div key={label} className="an-figure">
<strong>{value}</strong>
<strong>{finished ? value : '—'}</strong>
<span><span aria-hidden="true">{icon}</span> {label}</span>
</div>
))}
@ -159,9 +175,9 @@ export default function AnalysisSessionPage() {
<h2>{data.title}</h2>
<div className="an-donut-wrap">
<Donut correct={correct} incorrect={answeredOnly ? 0 : incorrect}
answered={answeredOnly} skipped={skipped} graded={graded} />
answered={answeredOnly} skipped={skipped} graded={marked} />
<ul className="an-legend">
{graded ? (
{marked ? (
<>
<li><i className="is-right" />{correct} correct</li>
<li><i className="is-wrong" />{incorrect} incorrect</li>
@ -331,6 +347,30 @@ export default function AnalysisSessionPage() {
<RepeatSession title={data.title} rows={data.questions}
onClose={() => 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 (
<AnalysisShell>
<AnalysisTabs active="session"
onSelect={key => { if (key !== 'session') navigate(`/sessions?view=${key}`) }} />
<SessionAnalysis attemptId={attemptId} quizId={quizId} />
</AnalysisShell>
)
}

View file

@ -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 = () => <p>overall analysis: {useSearchParams()[0].get('view')}</p>
const mountRouted = () => render(
<MemoryRouter initialEntries={['/sessions/91']}>
<Routes>
<Route path="/sessions" element={<Overall />} />
<Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} />
</Routes>
</MemoryRouter>)
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 () => {