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:
parent
7c41dd20ac
commit
dd71bed184
11 changed files with 372 additions and 72 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue