diff --git a/backend/app/services/draft_questions.py b/backend/app/services/draft_questions.py index d236d14..e35ea4d 100644 --- a/backend/app/services/draft_questions.py +++ b/backend/app/services/draft_questions.py @@ -53,7 +53,14 @@ def accept(db: Session, draft: DraftQuestion, batch: DraftBatch, user: User) -> raise HTTPException(400, f"Not ready: {', '.join(faults)}") category_id = category_for(draft, batch) - if category_id is not None and not db.get(QuestionCategory, category_id): + # A question with no category reaches nothing: no discipline, no organ + # system, no relevance, and no row on any tab of the analysis. It would sit + # in the bank and be invisible to every page that counts. The batch carries + # one so that saying it once covers the whole extraction. + if category_id is None: + raise HTTPException(400, "Give this batch a category before accepting from it — " + "a question filed nowhere is invisible to the analysis") + if not db.get(QuestionCategory, category_id): raise HTTPException(400, "The category this batch files into no longer exists") question = Question( diff --git a/backend/tests/test_draft_questions.py b/backend/tests/test_draft_questions.py index 2d92855..4de962e 100644 --- a/backend/tests/test_draft_questions.py +++ b/backend/tests/test_draft_questions.py @@ -7,19 +7,20 @@ corrected would be sitting in the bank while it was wrong. """ import unittest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool -import test_quiz_builder # noqa: F401 — imports every model +import test_quiz_builder as fixtures # noqa: F401 — imports every model from app.database import Base, get_db from app.models.draft_question import DraftBatch, DraftQuestion from app.models.question import Question from app.models.question_category import QuestionCategory from app.models.user import User from app.routers import drafts +from app.services import draft_questions from app.utils.auth import get_current_user @@ -161,3 +162,50 @@ class DraftTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class UnfiledDraftTests(unittest.TestCase): + """A question filed nowhere is invisible to every page that counts it. + + No category means no discipline, no organ system, no relevance and no row + on any tab of the analysis — it would sit in the bank and be seen by + nobody. The batch carries the category so saying it once covers the whole + extraction. + """ + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.db = self.bank.db + + def tearDown(self): + self.bank.tearDown() + + def draft_in(self, category_id): + batch = DraftBatch(title="Unfiled batch", category_id=category_id, + created_by=1, status="open") + self.db.add(batch) + self.db.flush() + draft = DraftQuestion( + batch_id=batch.id, question_text="A 2-year-old with a barking cough.", + question_type="mcq", options=["Croup", "Epiglottitis"], + correct_answer="Croup", status="pending") + self.db.add(draft) + self.db.commit() + return draft, batch + + def test_an_unfiled_draft_is_refused_and_takes_no_id(self): + draft, batch = self.draft_in(None) + before = self.db.query(Question).count() + with self.assertRaises(HTTPException) as caught: + draft_questions.accept(self.db, draft, batch, self.bank.owner) + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("category", caught.exception.detail) + # Nothing was created, so no permanent id was spent on a refusal. + self.assertEqual(self.db.query(Question).count(), before) + self.assertEqual(draft.status, "pending") + + def test_a_filed_draft_carries_the_batch_s_category_through(self): + draft, batch = self.draft_in(1) + question = draft_questions.accept(self.db, draft, batch, self.bank.owner) + self.assertEqual(question.question_category_id, 1) diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css index 5db557b..f132d8d 100644 --- a/frontend/src/components/AnalysisShell.css +++ b/frontend/src/components/AnalysisShell.css @@ -64,15 +64,6 @@ } .ax-rail-show:hover { color: var(--primary); border-color: var(--primary); } -.ax-rail-all { - display: flex; flex-direction: column; gap: 3px; padding: 13px 16px; - text-decoration: none; color: var(--text); border-bottom: 1px solid var(--border); - flex-shrink: 0; -} -.ax-rail-all:hover { background: var(--bg); } -.ax-rail-all strong { font-size: 0.88rem; font-weight: 650; } -.ax-rail-all span { font-size: 0.76rem; color: var(--text-muted); } - .ax-rail-search { flex-shrink: 0; margin: 10px 16px; padding: 8px 11px; /* 16px on touch: iOS zooms the page in on any smaller font when a field @@ -103,10 +94,10 @@ .ax-rail-empty { margin: 0; padding: 14px 16px; font-size: 0.83rem; color: var(--text-muted); } /* Which one you are reading. */ -.ax-rail-all.active, .ax-rail-list a.active { +.ax-rail-list a.active { background: var(--option-sel-bg); box-shadow: inset 3px 0 0 var(--primary); } -.ax-rail-all.active strong, .ax-rail-list a.active .ax-rail-title { color: var(--primary); } +.ax-rail-list a.active .ax-rail-title { color: var(--primary); } /* ── Narrow ───────────────────────────────────────────────────────── */ /* Below 1000px the rail stops being a column. It becomes a band above the @@ -125,7 +116,6 @@ } .ax-layout.is-collapsed .ax-rail { display: flex; max-height: none; } .ax-layout.is-collapsed .ax-rail-list, - .ax-layout.is-collapsed .ax-rail-all, .ax-layout.is-collapsed .ax-rail-search { display: none; } .ax-rail-head { border-bottom: 0; } .ax-rail-show { display: none; } diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx index 406cfe3..0608c4f 100644 --- a/frontend/src/components/SessionRail.jsx +++ b/frontend/src/components/SessionRail.jsx @@ -6,9 +6,15 @@ import SessionProgress from './SessionProgress' * The list of sessions, alongside whatever session you are reading. * * Sessions and analysis are the same subject, so this is the navigation for - * both: the overall picture sits at the top, every session follows, and the - * one you are looking at is marked. It replaced a separate sessions page, - * which listed the same rows a second time under a different heading. + * both: every session, with the one you are looking at marked. It replaced a + * separate sessions page, which listed the same rows a second time under a + * different heading. + * + * The overall picture is not in here. It used to sit at the top of the rail, + * which put lifetime figures one click away while you were standing in front + * of a single session — a question nobody asked there. It lives on the + * Analysis page's Performance tab, which is the page for it, and the section + * bar is how you get back to it. * * Nothing is truncated. A learner asking "what have I done" wants the whole * answer, and the search box is what narrows it. Styling lives in @@ -33,13 +39,6 @@ export default function SessionRail({ sessions, open, onToggle, loading }) { {open && ( <> - {/* `end` so this only lights up on /sessions itself, not on every - session underneath it. */} - - Your overall analysis - Everything you have practised - - {sessions.length > 6 && ( setQuery(e.target.value)} diff --git a/frontend/src/components/SessionRail.test.jsx b/frontend/src/components/SessionRail.test.jsx index d77fb6f..5e5a111 100644 --- a/frontend/src/components/SessionRail.test.jsx +++ b/frontend/src/components/SessionRail.test.jsx @@ -20,10 +20,13 @@ const mount = (props = {}) => render( ) describe('the session rail', () => { - it('puts the overall analysis above the sessions themselves', () => { + it('holds sessions and nothing else', () => { mount() - expect(screen.getByRole('link', { name: /Your overall analysis/ })) - .toHaveAttribute('href', '/sessions') + // The overall picture used to sit at the top of this rail, which put + // lifetime figures one click away while you were standing in front of a + // single session. It lives on the Analysis page's Performance tab. + expect(screen.queryByRole('link', { name: /overall analysis/ })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /Board Review I\b/ })).toBeInTheDocument() }) it('never launches a test — an unsat session opens its overview instead', () => { diff --git a/frontend/src/hooks/useDictation.js b/frontend/src/hooks/useDictation.js index 32ed506..1fab84e 100644 --- a/frontend/src/hooks/useDictation.js +++ b/frontend/src/hooks/useDictation.js @@ -4,14 +4,15 @@ import api from '../api/client' /** * Speaking instead of typing. * - * Two ways to do it, and the browser's own is tried first: `SpeechRecognition` - * transcribes as you talk, costs nothing, and needs no round trip. Where it is - * missing — Firefox, and every browser on iOS that is not Safari — the audio - * is recorded and sent to our own transcriber instead, which is slower but - * works everywhere a microphone does. + * Two ways to do it, and ours is tried first: record the audio and send it to + * our own transcriber. It is a round trip, but it behaves the same in every + * browser, and the browser's own speech stack announces itself to the user in + * ways we do not control — Firefox interrupts the page with a warning about a + * missing system library, which is alarming and is not about us. * - * The fallback is also taken when the browser's recogniser fails mid-sentence, - * because a half-finished dictation is worse than a slow one. + * `SpeechRecognition` is the fallback, for a browser that cannot record. It + * transcribes as you talk and costs nothing, so it is a fine second choice + * where recording is unavailable. */ /** Read when asked, not when this module loads: a constant captured at import @@ -50,11 +51,6 @@ export default function useDictation({ onText, onError } = {}) { /** Record, then send it to our own transcriber. */ const record = useCallback(async () => { - if (!canRecord()) { - onError?.('This browser cannot record audio. Try typing instead.') - cleanup() - return - } try { stream.current = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch { @@ -86,11 +82,13 @@ export default function useDictation({ onText, onError } = {}) { setListening(true) }, [cleanup, onError, onText]) - const start = useCallback(async () => { - if (listening || transcribing) return + /** The browser's own recogniser, where recording is not available. */ + const listen = useCallback(async () => { const Engine = recognition() - if (!Engine) { await record(); return } - + if (!Engine) { + onError?.('This browser cannot record audio. Try typing instead.') + return + } heard.current = '' const engine = new Engine() engine.lang = navigator.language || 'en-US' @@ -108,8 +106,7 @@ export default function useDictation({ onText, onError } = {}) { onError?.('No microphone. Check the permission for this site.') return } - if (canRecord()) record() - else onError?.('Could not hear anything. Try typing instead.') + onError?.('Could not hear anything. Try typing instead.') } engine.onend = () => { setListening(false) @@ -119,7 +116,14 @@ export default function useDictation({ onText, onError } = {}) { recogniser.current = engine engine.start() setListening(true) - }, [cleanup, listening, onError, onText, record, transcribing]) + }, [cleanup, onError, onText]) + + const start = useCallback(async () => { + if (listening || transcribing) return + // Ours first. + if (canRecord()) await record() + else await listen() + }, [listen, listening, record, transcribing]) const stop = useCallback(() => { if (recogniser.current) { recogniser.current.stop(); return } diff --git a/frontend/src/hooks/useDictation.test.jsx b/frontend/src/hooks/useDictation.test.jsx index e29c987..5e0d234 100644 --- a/frontend/src/hooks/useDictation.test.jsx +++ b/frontend/src/hooks/useDictation.test.jsx @@ -31,6 +31,7 @@ const withBrowserRecogniser = () => { } const withMediaRecorderOnly = () => { + engines = [] delete window.SpeechRecognition delete window.webkitSpeechRecognition const track = { stop: vi.fn() } @@ -56,7 +57,27 @@ afterEach(() => { delete navigator.mediaDevices }) -it('uses the browser recogniser when there is one, and never calls the server', async () => { +it('records and sends to our own transcriber, even where the browser has its own', async () => { + // Ours first. The browser's speech stack announces itself to the user in + // ways we do not control — Firefox interrupts the page with a warning about + // a missing system library, which is alarming and is not about us. + withMediaRecorderOnly() + withBrowserRecogniser() + api.post.mockResolvedValue({ data: { text: 'A neonate with bilious vomiting' } }) + render() + await userEvent.click(screen.getByRole('button')) + await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('listening')) + await userEvent.click(screen.getByRole('button')) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tts/transcribe', expect.any(FormData))) + await waitFor(() => expect(document.title).toBe('A neonate with bilious vomiting')) + // The browser recogniser was never constructed, so it never spoke up. + expect(engines).toHaveLength(0) +}) + +it('uses the browser recogniser only where there is no recording', async () => { + engines = [] + delete navigator.mediaDevices + delete window.MediaRecorder withBrowserRecogniser() render() await userEvent.click(screen.getByRole('button')) @@ -66,42 +87,23 @@ it('uses the browser recogniser when there is one, and never calls the server', engine.onresult({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: 'What causes stridor' } }] }) await userEvent.click(screen.getByRole('button')) await waitFor(() => expect(document.title).toBe('What causes stridor')) - // Free and local: nothing was uploaded. expect(api.post).not.toHaveBeenCalled() }) -it('records and sends to our own transcriber where the browser has none', async () => { - withMediaRecorderOnly() - api.post.mockResolvedValue({ data: { text: 'A neonate with bilious vomiting' } }) - render() - await userEvent.click(screen.getByRole('button')) - await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('listening')) - await userEvent.click(screen.getByRole('button')) - await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tts/transcribe', expect.any(FormData))) - await waitFor(() => expect(document.title).toBe('A neonate with bilious vomiting')) -}) - -it('falls back to recording when the browser recogniser fails mid-sentence', async () => { - withBrowserRecogniser() - const track = { stop: vi.fn() } - navigator.mediaDevices = { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) } - window.MediaRecorder = function Recorder() { this.start = vi.fn(); this.state = 'recording'; return this } - render() - await userEvent.click(screen.getByRole('button')) - engines[engines.length - 1].onerror({ error: 'network' }) - // A half-finished dictation is worse than a slow one. - await waitFor(() => expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled()) -}) - it('a refused microphone is an answer, not a fault to route around', async () => { - withBrowserRecogniser() - navigator.mediaDevices = { getUserMedia: vi.fn() } const onError = vi.fn() + // Recording is available and the learner says no. Both halves must be + // present or the hook would take the recogniser instead, which is a + // different test. + window.MediaRecorder = function Recorder() { this.start = vi.fn(); return this } + navigator.mediaDevices = { getUserMedia: vi.fn().mockRejectedValue(new Error('denied')) } + withBrowserRecogniser() render() await userEvent.click(screen.getByRole('button')) - engines[engines.length - 1].onerror({ error: 'not-allowed' }) - expect(onError).toHaveBeenCalledWith('No microphone. Check the permission for this site.') - expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled() + // No silent hop to the browser recogniser to ask a second time. + await waitFor(() => expect(onError).toHaveBeenCalledWith( + 'No microphone. Check the permission for this site.')) + expect(engines).toHaveLength(0) }) it('offers nothing at all where neither exists', () => { diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index 38a9c2b..3ba6b86 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -121,11 +121,12 @@ describe('a session that has been sat', () => { expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument() }) - it('keeps the session rail beside it', async () => { + it('keeps the session rail beside it, holding only sessions', async () => { mountAttempt() const rail = await screen.findByRole('complementary', { name: 'Sessions' }) - expect(within(rail).getByRole('link', { name: /Your overall analysis/ })) - .toHaveAttribute('href', '/sessions') + expect(within(rail).getByText('My Custom Test')).toBeInTheDocument() + // Lifetime figures are not the question in front of one session. + expect(within(rail).queryByRole('link', { name: /overall analysis/ })).not.toBeInTheDocument() }) }) diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 59e7e5b..723df08 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -183,6 +183,20 @@ function AuthModal({ mode, onClose, onSwitch }) { const [resendSent, setResendSent] = useState(false) const [resending, setResending] = useState(false) const [registered, setRegistered] = useState(false) + // Whether this site is invite only. The register page asks the same + // question; this form is the other door and used not to ask at all, so + // turning the gate on left everyone here failing with "an invite code is + // required" and nowhere to put one. + const [inviteRequired, setInviteRequired] = useState(false) + const [inviteCode, setInviteCode] = useState('') + + useEffect(() => { + let live = true + api.get('/auth/signup-policy') + .then(res => { if (live) setInviteRequired(!!res.data?.invite_required) }) + .catch(() => {}) + return () => { live = false } + }, []) const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) } const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) } @@ -206,7 +220,11 @@ function AuthModal({ mode, onClose, onSwitch }) { setError('') setLoading(true) try { - const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null }) + const res = await api.post('/auth/register', { + email, password, name, + turnstile_token: turnstileToken || null, + invite_code: inviteCode.trim() || null, + }) if (res.data.requires_verification) { setRegistered(true) } else { @@ -324,8 +342,25 @@ function AuthModal({ mode, onClose, onSwitch }) { setPassword(e.target.value)} required minLength={8} /> + {inviteRequired && ( +
+ + {/* Asked for only where it is needed. The form never says + whether a code is valid before the account is made — + that would be a place to guess them. */} + setInviteCode(e.target.value.toUpperCase())} /> + + This site is invite only. + +
+ )} -