diff --git a/frontend/src/components/PractiseTopic.jsx b/frontend/src/components/PractiseTopic.jsx
new file mode 100644
index 0000000..5b1770e
--- /dev/null
+++ b/frontend/src/components/PractiseTopic.jsx
@@ -0,0 +1,113 @@
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '../api/client'
+
+const COUNTS = [5, 10, 20, 30]
+
+/**
+ * Turns an article into a test instead of listing its questions.
+ *
+ * Reading pages must not show the questions themselves: the linked list used to
+ * print every stem with its correct answer and explanation, which spoiled the
+ * questions before the learner ever attempted them. Educators still get the
+ * membership list so they can unlink, but without the answers.
+ */
+export default function PractiseTopic({ article, canEdit, questions, onUnlink }) {
+ const [available, setAvailable] = useState(null)
+ const [count, setCount] = useState(10)
+ const [mode, setMode] = useState('learning')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [listOpen, setListOpen] = useState(false)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ let active = true
+ api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all', is_shared: 'false' } })
+ .then(res => { if (active) setAvailable(res.data.count) })
+ .catch(() => { if (active) setAvailable(null) })
+ return () => { active = false }
+ }, [article.id])
+
+ const create = async () => {
+ setBusy(true)
+ setError('')
+ try {
+ const res = await api.post('/questions/builder', {
+ title: `${article.title} — practice`,
+ category_ids: [], state: 'all', count: Math.min(count, available),
+ expected_count: available, mode, time_limit_minutes: null, is_shared: false,
+ difficulty: null, algorithm: 'random',
+ article_ids: [article.id], tag_ids: [], explicit_ids: [],
+ })
+ navigate(`/quizzes/${res.data.id}`)
+ } catch (err) {
+ const detail = err.response?.data?.detail
+ setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')
+ } finally { setBusy(false) }
+ }
+
+ if (!available && !canEdit) return null
+
+ return (
+
+
+
Practise this topic
+
+ {available === null ? 'Counting…' : `${available} question${available === 1 ? '' : 's'} linked`}
+
+
+
+ {available === 0 ? (
+
+ No questions are linked to this article yet{canEdit ? ' — link some below.' : '.'}
+
+ ) : (
+ <>
+
+
+ Questions
+ setCount(Number(e.target.value))} aria-label="Questions">
+ {COUNTS.filter(n => available === null || n <= available).map(n => {n} )}
+ {available !== null && !COUNTS.includes(available) && All {available} }
+
+
+
+ Mode
+ setMode(e.target.value)} aria-label="Mode">
+ Study
+ Exam
+
+
+
+ {busy ? 'Creating…' : 'Create test'}
+
+
+ {error && {error}
}
+ >
+ )}
+
+ {canEdit && questions.length > 0 && (
+
+
setListOpen(v => !v)}>
+ Linked questions ({questions.length}) {listOpen ? '▲' : '▼'}
+
+ {listOpen && (
+
+ {questions.map(q => (
+
+ {q.question_text.slice(0, 110)}{q.question_text.length > 110 ? '…' : ''}
+ {q.section_id && (
+ {(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed section'}
+ )}
+ onUnlink(q.question_id, q.section_id)}>Unlink
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/PractiseTopic.test.jsx b/frontend/src/components/PractiseTopic.test.jsx
new file mode 100644
index 0000000..b918497
--- /dev/null
+++ b/frontend/src/components/PractiseTopic.test.jsx
@@ -0,0 +1,84 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { expect, it, vi, beforeEach } from 'vitest'
+import PractiseTopic from './PractiseTopic'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
+
+const ARTICLE = { id: 7, title: 'Kawasaki disease', sections: [{ id: 'sec1', title: 'Clinical features' }] }
+const QUESTIONS = [
+ { question_id: 1, section_id: null, question_text: 'A 3-year-old with 6 days of fever…', correct_answer: 'IVIG', explanation: 'Because…' },
+ { question_id: 2, section_id: 'sec1', question_text: 'Which feature is required?', correct_answer: 'Fever', explanation: 'Because…' },
+]
+
+const mount = (props = {}) => render(
+
+
+ } />
+ Test ready} />
+
+
+)
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ api.get.mockResolvedValue({ data: { count: 12 } })
+})
+
+it('offers a test instead of listing the questions and their answers', async () => {
+ mount()
+ expect(await screen.findByText('12 questions linked')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Create test' })).toBeInTheDocument()
+
+ // Reading the article must never reveal a stem, its answer or its explanation.
+ expect(screen.queryByText(/A 3-year-old with 6 days of fever/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/IVIG/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Because/)).not.toBeInTheDocument()
+})
+
+it('builds a test scoped to this article', async () => {
+ api.post.mockResolvedValue({ data: { id: 55 } })
+ mount()
+ await screen.findByText('12 questions linked')
+ await userEvent.selectOptions(screen.getByLabelText('Questions'), '12') // the "All 12" option
+ await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
+ await userEvent.click(screen.getByRole('button', { name: 'Create test' }))
+
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/builder', expect.objectContaining({
+ title: 'Kawasaki disease — practice', article_ids: [7], count: 12, expected_count: 12, mode: 'timed',
+ })))
+ expect(await screen.findByRole('heading', { name: 'Test ready' })).toBeInTheDocument()
+})
+
+it('surfaces a builder failure without navigating', async () => {
+ api.post.mockRejectedValue({ response: { data: { detail: 'Available count changed. Refresh and try again' } } })
+ mount()
+ await screen.findByText('12 questions linked')
+ await userEvent.click(screen.getByRole('button', { name: 'Create test' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Available count changed')
+ expect(screen.queryByRole('heading', { name: 'Test ready' })).not.toBeInTheDocument()
+})
+
+it('stays hidden for a learner when nothing is linked', async () => {
+ api.get.mockResolvedValue({ data: { count: 0 } })
+ const { container } = mount()
+ await waitFor(() => expect(container).toBeEmptyDOMElement())
+})
+
+it('lets an educator review and unlink without showing answers', async () => {
+ api.get.mockResolvedValue({ data: { count: 0 } })
+ const onUnlink = vi.fn()
+ mount({ canEdit: true, onUnlink })
+ expect(await screen.findByText(/No questions are linked/)).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /Linked questions \(2\)/ }))
+ expect(screen.getByText(/A 3-year-old with 6 days of fever/)).toBeInTheDocument()
+ expect(screen.queryByText('IVIG')).not.toBeInTheDocument()
+ expect(screen.getByText('Clinical features')).toBeInTheDocument()
+
+ await userEvent.click(screen.getAllByRole('button', { name: 'Unlink' })[0])
+ expect(onUnlink).toHaveBeenCalledWith(1, null)
+})
diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css
index 859ed1e..7c9c407 100644
--- a/frontend/src/components/QuizTools.css
+++ b/frontend/src/components/QuizTools.css
@@ -38,15 +38,14 @@
.quiz-lab-group { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.quiz-lab-group-title { margin: 0; padding: 8px 14px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--primary); background: var(--primary-soft, #eef4fb); border-bottom: 1px solid var(--border); }
.quiz-lab-rows { list-style: none; margin: 0; padding: 0; }
-.quiz-lab-row { display: grid; grid-template-columns: minmax(140px, 1.2fr) minmax(130px, 1fr) minmax(110px, .9fr) minmax(140px, 1.1fr); gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--border); font-size: .84rem; align-items: center; }
+.quiz-lab-row { display: grid; gap: 6px 16px; padding: 11px 14px; border-bottom: 1px solid var(--border); font-size: .84rem; align-items: start; }
.quiz-lab-row:last-child { border-bottom: none; }
-.quiz-lab-name strong { display: block; font-size: .88rem; }
+.quiz-lab-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.quiz-lab-test { font-size: .88rem; font-weight: 600; overflow-wrap: anywhere; }
.quiz-lab-name small { color: var(--text-muted); font-size: .72rem; }
-.quiz-lab-range strong { font-size: .9rem; }
+.quiz-lab-name .article-status-draft { align-self: flex-start; margin: 2px 0 0; }
+.quiz-lab-range { font-size: .88rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.quiz-lab-units { color: var(--text-muted); font-size: .74rem; margin-left: 5px; }
-.quiz-lab-pop { color: var(--text-muted); }
-.quiz-lab-source a { color: var(--primary); text-decoration: none; font-size: .78rem; }
-.quiz-lab-source a:hover { text-decoration: underline; }
.quiz-lab-cards { display: flex; gap: 5px; flex-wrap: wrap; grid-column: 1 / -1; padding-top: 4px; }
.quiz-lab-card { display: inline-flex; align-items: center; gap: 5px; font-size: .72rem; background: var(--bg, #f1f5f9); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
.quiz-lab-card a { color: var(--primary); text-decoration: none; }
@@ -57,15 +56,11 @@
.quiz-lab-actions { display: flex; gap: 6px; flex-wrap: wrap; grid-column: 1 / -1; }
.quiz-lab-actions button { font-size: .72rem; }
@media (max-width: 640px) {
- .quiz-lab-row { grid-template-columns: 1fr 1fr; }
- .quiz-lab-source, .quiz-lab-actions, .quiz-lab-cards { grid-column: 1 / -1; }
- .quiz-lab-pop { grid-column: 1 / -1; }
+ .quiz-lab-row { grid-template-columns: 1fr; }
+ .quiz-lab-actions, .quiz-lab-cards { grid-column: 1 / -1; }
}
-.quiz-labs-sources { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px 14px; flex-wrap: wrap; align-items: baseline; font-size: .72rem; color: var(--text-muted); }
-.quiz-labs-sources a { color: var(--primary); text-decoration: none; }
-.quiz-labs-sources a:hover { text-decoration: underline; }
.quiz-lab-values { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
-.quiz-lab-age-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 3px 0; border-bottom: 1px dashed var(--border); }
+.quiz-lab-age-line { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 4px 12px; padding: 4px 0; border-bottom: 1px solid var(--border); }
.quiz-lab-age-line:last-child { border-bottom: none; }
-.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 130px; }
-.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(200px, 1.6fr); }
+.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 0; overflow-wrap: anywhere; }
+.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.5fr); }
diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx
index 7d7b109..b66eb4a 100644
--- a/frontend/src/components/QuizTools.jsx
+++ b/frontend/src/components/QuizTools.jsx
@@ -116,7 +116,7 @@ function LabValues() {
const groups = ['All', ...new Set(rows.map(row => row.group))]
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
return <>
- Ranges vary with age, laboratory and method. Sources link straight to the cited document — open the section or the full source before relying on a value.
+ Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.
{isEducator && { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts }
{error && {error}
}
{linkError && {linkError}
}
@@ -142,14 +142,15 @@ function LabValues() {