fix: the other registration form, our own transcriber first, and a rail

that stops answering the wrong question

Invite-only was set and the sign-up form had nowhere to type a code.
There are two registration forms — /register and the modal on the landing
page — and only the first had been taught about invite codes. The modal
is the one most people meet, so turning the gate on failed everybody with
"an invite code is required" and no field to satisfy it. It now asks the
same signup-policy question and shows the same field.

Dictation records to our own transcriber first and falls back to the
browser's recogniser only where recording is unavailable. It was the
other way round for speed, but 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 Speech Dispatcher library, which is
alarming and is not about us.

A draft question could be promoted into the bank with no category. That
question would reach nothing: no discipline, no organ system, no
relevance, no row on any tab of the analysis — in the bank and invisible
to every page that counts. Promotion now refuses, before an id is spent.

And "Your overall analysis" is out of the session rail. It put lifetime
figures one click away while you were standing in front of a single
session, which is the thing that was supposed to have moved to the
Performance tab.

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 04:41:27 +02:00
parent 13d5f50c58
commit 8614792bdf
9 changed files with 171 additions and 82 deletions

View file

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

View file

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

View file

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

View file

@ -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. */}
<NavLink to="/sessions" end className="ax-rail-all">
<strong>Your overall analysis</strong>
<span>Everything you have practised</span>
</NavLink>
{sessions.length > 6 && (
<input className="ax-rail-search" type="search" value={query}
onChange={e => setQuery(e.target.value)}

View file

@ -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', () => {

View file

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

View file

@ -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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness onError={onError} />)
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', () => {

View file

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

View file

@ -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 }) {
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
{inviteRequired && (
<div className="form-group">
<label htmlFor="modal-invite-code">Invite code</label>
{/* 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. */}
<input id="modal-invite-code" value={inviteCode} required autoComplete="off"
placeholder="From whoever invited you"
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
<small style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
This site is invite only.
</small>
</div>
)}
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)
|| (inviteRequired && !inviteCode.trim())}>
{loading ? 'Creating account…' : 'Sign Up'}
</button>
</form>