feat: performance over time, locked until it means something
`GET /study-tools/performance-over-time` returns a point per completed session with two figures: that session's percentage, and the running score across everything answered up to that day. The chart draws the running line and marks the sessions along it — a single session of twelve questions swings too far to say anything about whether a learner is improving. It stays shut below 40 answers or 3 sessions and says which of the two it is waiting for, rather than drawing a line through two points and letting the shape suggest a trend that is not there. LineChart was in the tree unused, with a hardcoded slate palette that vanishes on a dark page. Rewritten against the theme tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
767bb60099
commit
68d65ac782
7 changed files with 286 additions and 57 deletions
|
|
@ -5,7 +5,7 @@ from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
from sqlalchemy import func, inspect, or_
|
from sqlalchemy import case, func, inspect, or_
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -356,6 +356,72 @@ def answer_split(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Two points make a line but not a trend; the chart stays shut until there is
|
||||||
|
#: something to see in it.
|
||||||
|
TREND_MIN_SESSIONS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/performance-over-time")
|
||||||
|
def performance_over_time(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""The headline score by date.
|
||||||
|
|
||||||
|
Two lines are meant here, and only one of them is the score. A session's
|
||||||
|
own percentage swings with whatever twelve questions it happened to hold;
|
||||||
|
the running figure — everything answered up to that day — is the one that
|
||||||
|
says whether the learner is getting better. The chart draws the running
|
||||||
|
line and marks the sessions along it.
|
||||||
|
|
||||||
|
It stays locked until there are enough answers to mean anything, for the
|
||||||
|
same reason readiness does: a line through two points is a decoration.
|
||||||
|
"""
|
||||||
|
rows = db.query(
|
||||||
|
QuizAttempt.id, QuizAttempt.completed_at, Quiz.title,
|
||||||
|
func.count(AttemptAnswer.id).label("answered"),
|
||||||
|
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)).label("correct"),
|
||||||
|
).join(AttemptAnswer, AttemptAnswer.attempt_id == QuizAttempt.id
|
||||||
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
||||||
|
).filter(
|
||||||
|
QuizAttempt.user_id == user.id,
|
||||||
|
QuizAttempt.completed_at.isnot(None),
|
||||||
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
||||||
|
Quiz.course_id.is_(None),
|
||||||
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
||||||
|
AttemptAnswer.user_answer != "",
|
||||||
|
).group_by(QuizAttempt.id, QuizAttempt.completed_at, Quiz.title
|
||||||
|
).order_by(QuizAttempt.completed_at.asc()).all()
|
||||||
|
|
||||||
|
points = []
|
||||||
|
seen = correct_so_far = 0
|
||||||
|
for row in rows:
|
||||||
|
answered = int(row.answered or 0)
|
||||||
|
if not answered:
|
||||||
|
continue
|
||||||
|
correct = int(row.correct or 0)
|
||||||
|
seen += answered
|
||||||
|
correct_so_far += correct
|
||||||
|
points.append({
|
||||||
|
"attempt_id": row.id,
|
||||||
|
"date": row.completed_at.date().isoformat() if row.completed_at else None,
|
||||||
|
"title": row.title,
|
||||||
|
"answered": answered,
|
||||||
|
"percent": round(100 * correct / answered, 1),
|
||||||
|
# Everything answered up to and including this session.
|
||||||
|
"running": round(100 * correct_so_far / seen, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
total = seen
|
||||||
|
return {
|
||||||
|
"points": points,
|
||||||
|
"total_answered": total,
|
||||||
|
"unlocked": total >= READINESS_UNLOCK_ANSWERS and len(points) >= TREND_MIN_SESSIONS,
|
||||||
|
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total),
|
||||||
|
"sessions_needed": max(0, TREND_MIN_SESSIONS - len(points)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommendations")
|
@router.get("/recommendations")
|
||||||
def study_recommendations(
|
def study_recommendations(
|
||||||
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
||||||
|
|
|
||||||
|
|
@ -404,3 +404,54 @@ class AnswerSplitTests(CompletionTests):
|
||||||
# It was right, so it counts as right: the percentage is not docked.
|
# It was right, so it counts as right: the percentage is not docked.
|
||||||
self.assertEqual(data['percent_correct'], 100.0)
|
self.assertEqual(data['percent_correct'], 100.0)
|
||||||
self.assertEqual(data['answered'], 2)
|
self.assertEqual(data['answered'], 2)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceOverTimeTests(CompletionTests):
|
||||||
|
"""The headline score by date, and the honesty of an empty chart."""
|
||||||
|
|
||||||
|
def trend(self):
|
||||||
|
response = self.client.get('/study-tools/performance-over-time')
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def test_the_running_figure_is_every_answer_so_far_not_this_session(self):
|
||||||
|
# Two right, then one wrong: the session drops to 0%, the running
|
||||||
|
# figure to 67% — which is the honest account of what is known.
|
||||||
|
self.sat(1, True, ago_days=3, seconds=30)
|
||||||
|
self.sat(2, True, ago_days=2, seconds=30)
|
||||||
|
self.sat(3, False, ago_days=1, seconds=30)
|
||||||
|
points = self.trend()['points']
|
||||||
|
self.assertEqual([p['percent'] for p in points], [100.0, 100.0, 0.0])
|
||||||
|
self.assertEqual([p['running'] for p in points], [100.0, 100.0, 66.7])
|
||||||
|
|
||||||
|
def test_a_chart_is_locked_until_there_is_something_in_it(self):
|
||||||
|
self.sat(1, True, ago_days=1, seconds=30)
|
||||||
|
data = self.trend()
|
||||||
|
self.assertFalse(data['unlocked'])
|
||||||
|
self.assertEqual(data['sessions_needed'], 2)
|
||||||
|
self.assertEqual(data['answers_needed'], 39)
|
||||||
|
# The points are still returned: the page says how far off it is.
|
||||||
|
self.assertEqual(len(data['points']), 1)
|
||||||
|
|
||||||
|
def test_blank_answers_and_repetitions_are_not_points_on_the_line(self):
|
||||||
|
from app.models.course import Course
|
||||||
|
from app.models.quiz import Quiz
|
||||||
|
course = Course(title='Neonatology', user_id=1)
|
||||||
|
self.bank.db.add(course)
|
||||||
|
self.bank.db.flush()
|
||||||
|
again = Quiz(title='Repeat of test', user_id=1, is_repetition=1)
|
||||||
|
self.bank.db.add(again)
|
||||||
|
self.bank.db.commit()
|
||||||
|
self.sat(1, True, ago_days=2, seconds=30)
|
||||||
|
self.sat(2, True, ago_days=2, seconds=30, quiz_id=again.id)
|
||||||
|
# A session where nothing was answered is not a session at 0%.
|
||||||
|
self.sat(3, False, ago_days=1, seconds=0, answered=False)
|
||||||
|
points = self.trend()['points']
|
||||||
|
self.assertEqual(len(points), 1)
|
||||||
|
self.assertEqual(points[0]['answered'], 1)
|
||||||
|
|
||||||
|
def test_nothing_sat_is_an_empty_chart_not_a_flat_line(self):
|
||||||
|
data = self.trend()
|
||||||
|
self.assertEqual(data['points'], [])
|
||||||
|
self.assertFalse(data['unlocked'])
|
||||||
|
self.assertEqual(data['total_answered'], 0)
|
||||||
|
|
|
||||||
10
frontend/src/components/LineChart.css
Normal file
10
frontend/src/components/LineChart.css
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* Themed rather than painted: the chart is read on a dark page as often as a
|
||||||
|
light one, and a hardcoded slate grid disappears on one of them. */
|
||||||
|
.lc { width: 100%; height: auto; overflow: visible; }
|
||||||
|
.lc-grid { stroke: var(--border); stroke-width: 1; }
|
||||||
|
.lc-grid.is-target { stroke: var(--primary); stroke-width: 1.5; stroke-dasharray: 4 3; opacity: 0.55; }
|
||||||
|
.lc-axis { font-size: 9px; fill: var(--text-subtle); }
|
||||||
|
.lc-area { fill: var(--primary); opacity: 0.1; }
|
||||||
|
.lc-line { fill: none; stroke: var(--primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.lc-dot { fill: var(--card-bg); stroke: var(--primary); stroke-width: 2; }
|
||||||
|
.lc-dot.is-good { stroke: var(--correct-fg); }
|
||||||
|
|
@ -1,71 +1,71 @@
|
||||||
export default function LineChart({ data, width = 500, height = 180 }) {
|
import './LineChart.css'
|
||||||
if (!data || data.length < 2) {
|
|
||||||
return (
|
|
||||||
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
|
|
||||||
Need at least 2 attempts to show a graph
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pad = { top: 16, right: 16, bottom: 32, left: 40 }
|
/**
|
||||||
const W = width - pad.left - pad.right
|
* A score by date.
|
||||||
const H = height - pad.top - pad.bottom
|
*
|
||||||
|
* Two figures are plotted and only one of them is a line: the running score —
|
||||||
|
* everything answered up to that day — because a single session's percentage
|
||||||
|
* swings with whatever twelve questions it happened to hold, and a line that
|
||||||
|
* jumps between 40 and 90 says nothing about whether anyone is improving. The
|
||||||
|
* sessions themselves are marked along it, each one hoverable for its own
|
||||||
|
* figure.
|
||||||
|
*
|
||||||
|
* Nothing is drawn through fewer than two points. A chart with one dot in it
|
||||||
|
* is a decoration, and this page is supposed to be evidence.
|
||||||
|
*/
|
||||||
|
|
||||||
const minY = 0, maxY = 100
|
const PAD = { top: 16, right: 18, bottom: 30, left: 36 }
|
||||||
const xStep = W / (data.length - 1)
|
const W = 520
|
||||||
|
const H = 190
|
||||||
|
const PLOT_W = W - PAD.left - PAD.right
|
||||||
|
const PLOT_H = H - PAD.top - PAD.bottom
|
||||||
|
|
||||||
const toX = (i) => pad.left + i * xStep
|
const GRID = [0, 25, 50, 75, 100]
|
||||||
const toY = (v) => pad.top + H - ((v - minY) / (maxY - minY)) * H
|
|
||||||
|
|
||||||
// Line path
|
const day = (iso) => (iso
|
||||||
const points = data.map((d, i) => `${toX(i)},${toY(d.percentage)}`)
|
? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
const linePath = `M ${points.join(' L ')}`
|
: '')
|
||||||
|
|
||||||
// Fill path
|
export default function LineChart({ points = [], target = 75, label = 'Score over time' }) {
|
||||||
const fillPath = `M ${toX(0)},${toY(0)} L ${points.join(' L ')} L ${toX(data.length - 1)},${toY(0)} Z`
|
if (points.length < 2) return null
|
||||||
|
|
||||||
// Y gridlines
|
const toX = (i) => PAD.left + (points.length === 1 ? PLOT_W / 2 : (i / (points.length - 1)) * PLOT_W)
|
||||||
const gridLines = [0, 25, 50, 75, 100]
|
const toY = (value) => PAD.top + PLOT_H - (Math.max(0, Math.min(100, value)) / 100) * PLOT_H
|
||||||
|
|
||||||
|
const line = points.map((point, i) => `${toX(i)},${toY(point.running)}`).join(' L ')
|
||||||
|
const area = `M ${toX(0)},${toY(0)} L ${line} L ${toX(points.length - 1)},${toY(0)} Z`
|
||||||
|
|
||||||
|
// Only the ends and the middle are labelled: a dozen dates along the foot of
|
||||||
|
// a chart this wide overlap into a smear.
|
||||||
|
const ticks = new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>
|
<svg className="lc" viewBox={`0 0 ${W} ${H}`} role="img"
|
||||||
{/* Grid lines */}
|
aria-label={`${label}: ${points[0].running}% at the start, ${points[points.length - 1].running}% now, over ${points.length} sessions`}>
|
||||||
{gridLines.map(v => (
|
{GRID.map(value => (
|
||||||
<g key={v}>
|
<g key={value}>
|
||||||
<line
|
<line className={value === target ? 'lc-grid is-target' : 'lc-grid'}
|
||||||
x1={pad.left} y1={toY(v)} x2={pad.left + W} y2={toY(v)}
|
x1={PAD.left} y1={toY(value)} x2={PAD.left + PLOT_W} y2={toY(value)} />
|
||||||
stroke={v === 75 ? '#fde68a' : '#f1f5f9'} strokeWidth={v === 75 ? 1.5 : 1}
|
<text className="lc-axis" x={PAD.left - 6} y={toY(value)}
|
||||||
strokeDasharray={v === 75 ? '4 3' : 'none'}
|
textAnchor="end" dominantBaseline="middle">{value}%</text>
|
||||||
/>
|
|
||||||
<text x={pad.left - 6} y={toY(v)} textAnchor="end" dominantBaseline="middle"
|
|
||||||
fontSize="10" fill="#94a3b8">{v}%</text>
|
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 75% label */}
|
<path className="lc-area" d={area} />
|
||||||
<text x={pad.left + W + 4} y={toY(75)} dominantBaseline="middle" fontSize="9" fill="#d97706">75%</text>
|
<path className="lc-line" d={`M ${line}`} />
|
||||||
|
|
||||||
{/* Fill */}
|
{points.map((point, i) => (
|
||||||
<path d={fillPath} fill="#e0e7ff" opacity="0.4" />
|
<g key={point.attempt_id ?? i}>
|
||||||
|
<circle className={point.percent >= target ? 'lc-dot is-good' : 'lc-dot'}
|
||||||
{/* Line */}
|
cx={toX(i)} cy={toY(point.running)} r="3.5" />
|
||||||
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
{ticks.has(i) && (
|
||||||
|
<text className="lc-axis" x={toX(i)} y={PAD.top + PLOT_H + 15} textAnchor="middle">
|
||||||
{/* Dots + tooltips */}
|
{day(point.date)}
|
||||||
{data.map((d, i) => {
|
</text>
|
||||||
const x = toX(i), y = toY(d.percentage)
|
)}
|
||||||
const color = d.percentage >= 75 ? '#22c55e' : '#ef4444'
|
<title>{day(point.date)} — this session {point.percent}% of {point.answered}; overall {point.running}%</title>
|
||||||
const label = new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
</g>
|
||||||
return (
|
))}
|
||||||
<g key={i}>
|
|
||||||
<circle cx={x} cy={y} r="4" fill={color} stroke="white" strokeWidth="2" />
|
|
||||||
{/* X axis label */}
|
|
||||||
<text x={x} y={pad.top + H + 16} textAnchor="middle" fontSize="9" fill="#94a3b8">{label}</text>
|
|
||||||
{/* Hover tooltip via title */}
|
|
||||||
<title>{label}: {d.percentage}% ({d.score}/{d.total})</title>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,3 +165,13 @@
|
||||||
.an-perf-top { grid-template-columns: minmax(0, 1fr); }
|
.an-perf-top { grid-template-columns: minmax(0, 1fr); }
|
||||||
.an-split-card { margin-top: 0; }
|
.an-split-card { margin-top: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Performance over time ────────────────────────────────────────────
|
||||||
|
Locked until it would mean something, and saying which of the two things
|
||||||
|
it is waiting for. */
|
||||||
|
.an-trend {
|
||||||
|
margin-top: 24px; padding: 16px 18px 18px;
|
||||||
|
border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.an-trend-now { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.an-trend .an-basis { margin-top: 10px; }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import CategoryPerformance from '../components/CategoryPerformance'
|
import CategoryPerformance from '../components/CategoryPerformance'
|
||||||
import Donut from '../components/Donut'
|
import Donut from '../components/Donut'
|
||||||
|
import LineChart from '../components/LineChart'
|
||||||
import AnalysisShell from '../components/AnalysisShell'
|
import AnalysisShell from '../components/AnalysisShell'
|
||||||
import './AnalysisPage.css'
|
import './AnalysisPage.css'
|
||||||
|
|
||||||
|
|
@ -93,6 +94,56 @@ function Completion() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the score is going anywhere.
|
||||||
|
*
|
||||||
|
* Locked until there are enough answers and enough sessions behind it, and it
|
||||||
|
* says which is missing rather than drawing a line through two points and
|
||||||
|
* letting the shape of it suggest a trend that is not there.
|
||||||
|
*/
|
||||||
|
function OverTime() {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
api.get('/study-tools/performance-over-time')
|
||||||
|
.then(res => { if (live) setData(res.data) })
|
||||||
|
.catch(() => { if (live) setError('Could not load your score over time') })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const short = data && !data.unlocked && (
|
||||||
|
data.answers_needed > 0
|
||||||
|
? `Answer ${data.answers_needed} more question${data.answers_needed === 1 ? '' : 's'} to unlock this chart.`
|
||||||
|
: `Complete ${data.sessions_needed} more session${data.sessions_needed === 1 ? '' : 's'} to unlock this chart.`)
|
||||||
|
|
||||||
|
const last = data?.points?.[data.points.length - 1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="an-card an-trend">
|
||||||
|
<div className="an-completion-head">
|
||||||
|
<h2>Performance over time</h2>
|
||||||
|
{data?.unlocked && last && <span className="an-trend-now">{last.running}% overall</span>}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="an-empty" role="alert">{error}</p>
|
||||||
|
: !data ? <p className="an-empty">Loading…</p>
|
||||||
|
: !data.unlocked ? <p className="an-empty">{short}</p>
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<LineChart points={data.points} />
|
||||||
|
<p className="an-basis">
|
||||||
|
The line is your score across everything answered up to that day,
|
||||||
|
not the session on its own — one session of twelve questions
|
||||||
|
swings too far to say anything. Each mark is a session; hover it
|
||||||
|
for its own figure. The dashed rule is 75%.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const BASES = [
|
const BASES = [
|
||||||
{ key: 'all', label: 'All attempts' },
|
{ key: 'all', label: 'All attempts' },
|
||||||
{ key: 'latest', label: 'Latest attempt' },
|
{ key: 'latest', label: 'Latest attempt' },
|
||||||
|
|
@ -384,6 +435,7 @@ export default function AnalysisPage() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<OverTime />
|
||||||
<div className="an-perf-top">
|
<div className="an-perf-top">
|
||||||
<Completion />
|
<Completion />
|
||||||
<AnswerSplit />
|
<AnswerSplit />
|
||||||
|
|
|
||||||
|
|
@ -152,3 +152,43 @@ it('says plainly when nothing has been sat', async () => {
|
||||||
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||||
expect(await screen.findByText(/Nothing sat yet/)).toBeInTheDocument()
|
expect(await screen.findByText(/Nothing sat yet/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const trend = (over = {}) => ({
|
||||||
|
points: [
|
||||||
|
{ attempt_id: 1, date: '2026-08-01', title: 'One', answered: 20, percent: 50.0, running: 50.0 },
|
||||||
|
{ attempt_id: 2, date: '2026-08-15', title: 'Two', answered: 20, percent: 70.0, running: 60.0 },
|
||||||
|
{ attempt_id: 3, date: '2026-09-01', title: 'Three', answered: 20, percent: 90.0, running: 70.0 },
|
||||||
|
],
|
||||||
|
total_answered: 60, unlocked: true, answers_needed: 0, sessions_needed: 0, ...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const withTrend = (data) => {
|
||||||
|
api.get.mockImplementation(url =>
|
||||||
|
Promise.resolve({ data: url === '/study-tools/performance-over-time' ? data : payload() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('draws the running score and says what the line is', async () => {
|
||||||
|
withTrend(trend())
|
||||||
|
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||||
|
|
||||||
|
const panel = (await screen.findByText('Performance over time')).closest('.an-trend')
|
||||||
|
expect(within(panel).getByText('70% overall')).toBeInTheDocument()
|
||||||
|
// The running figure, not the last session's 90%.
|
||||||
|
expect(within(panel).getByRole('img', { name: /50% at the start, 70% now, over 3 sessions/ }))
|
||||||
|
.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('will not draw a trend it does not have, and says which half is missing', async () => {
|
||||||
|
withTrend(trend({ unlocked: false, answers_needed: 25, sessions_needed: 0, total_answered: 15 }))
|
||||||
|
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||||
|
expect(await screen.findByText('Answer 25 more questions to unlock this chart.')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('img', { name: /at the start/ })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
withTrend(trend({ unlocked: false, answers_needed: 0, sessions_needed: 1 }))
|
||||||
|
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||||
|
const tabs = await screen.findAllByRole('tab', { name: 'Performance' })
|
||||||
|
await userEvent.click(tabs[tabs.length - 1])
|
||||||
|
expect(await screen.findByText('Complete 1 more session to unlock this chart.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue