fix: moderator flag in /auth/me, and AI Mode answers that go wrong
The moderator-only UI was hidden from moderators. `/auth/me` never returned `is_moderator`, and the whole frontend decides what to show by reading it — which is why "Draft with AI" appeared to do nothing: the panel was never rendered for the person who owns the button. AI Mode, from the reports alongside it: - An empty answer was stored and drawn as a blank card. Both the empty reply and the reply that is empty once an invented citation is deleted are now refused before anything is written, so the question comes back in the box with a reason. Old blank cards render an "Ask again" instead of nothing. - The model narrated its own retrieval — how many sources it had seen, what it could go and fetch — when asked for more questions than it could cite. It is now told to name what there is in one sentence and stop. - A thread took its name from the learner's raw typing, so the rail read "hi, how do i treat a febrile seizure?". Openers are trimmed, "i" is a capital, and the cut falls on a word. - A cited article opened somewhere else. It now gets the same hover card as a cross-reference in prose — excerpt, new tab, and the pane beside the answer. - A built session said "questions ready" with no number: the endpoint returns `count`, not `questions_count`. And a race the last of those exposed: the guard that stops the transcript being fetched over an answer that has just arrived was cleared as soon as the request finished, so whether the exchange survived depended on when React ran the effect. It now tracks which thread is on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
e1b8ba012d
commit
2e8f8dda94
8 changed files with 212 additions and 18 deletions
|
|
@ -203,9 +203,19 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
|
|||
log.error("AI Mode failed for user %s", current_user.id, exc_info=True)
|
||||
raise HTTPException(502, "AI Mode is temporarily unavailable. Try again in a moment.")
|
||||
|
||||
# An empty answer is a failure, not an answer. It used to be stored and
|
||||
# drawn as a blank card the learner could neither read nor retry — a silent
|
||||
# failure is the worst kind, because it looks like the product working.
|
||||
if not raw.strip():
|
||||
raise HTTPException(502, "The model returned nothing. Ask again.")
|
||||
|
||||
# The safety step: anything the model cited that retrieval did not find is
|
||||
# removed here, before it is stored or shown.
|
||||
reply, citations = ai_mode_service.enforce_citations(raw, sources)
|
||||
# Citations can be the whole of a short reply — "See [[article:7]]." with an
|
||||
# invented marker leaves an empty string once the marker is deleted.
|
||||
if not reply.strip():
|
||||
raise HTTPException(502, "The model's answer did not survive checking. Ask again.")
|
||||
|
||||
db.add(ConversationMessage(conversation_id=conversation.id, role="user",
|
||||
content=question, citations=[]))
|
||||
|
|
@ -217,7 +227,7 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
|
|||
# the conversation turned out to be about, so the name waits for the turn
|
||||
# that is — which is usually the very next one.
|
||||
if conversation.title == "New chat" and mode != "chat":
|
||||
conversation.title = question[:80] + ("…" if len(question) > 80 else "")
|
||||
conversation.title = ai_mode_service.thread_title(question)
|
||||
conversation.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(answer)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ class UserResponse(BaseModel):
|
|||
# Whether there is one at all, never anything about it. Settings has to say
|
||||
# "Set a password" or "Change password", and it cannot tell from the outside.
|
||||
has_password: bool = False
|
||||
# What the role *means*, computed once here rather than in every page that
|
||||
# asks. The interface had been checking `user.is_moderator` for months on a
|
||||
# payload that has never carried it, so every moderator-only control was
|
||||
# hidden from moderators — including the AI draft panel, which is why
|
||||
# "Draft with AI" appeared to do nothing.
|
||||
is_moderator: bool = False
|
||||
is_admin: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -33,6 +40,7 @@ class UserResponse(BaseModel):
|
|||
"id": user.id, "email": user.email, "name": user.name, "role": user.role,
|
||||
"is_unthrottled": user.is_unthrottled or 0, "created_at": user.created_at,
|
||||
"has_password": bool(user.hashed_password),
|
||||
"is_moderator": bool(user.is_moderator), "is_admin": bool(user.is_admin),
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -287,6 +287,14 @@ CITE = (
|
|||
"write a URL and never cite a marker that is not listed here.\n\n"
|
||||
"Never reveal the answer to a practice question. You may say what a question "
|
||||
"is about so the learner can go and attempt it.\n\n"
|
||||
# Asked for five questions and able to see one, it explained at length that
|
||||
# it had "only actually looked at one cervicitis item so far" and offered to
|
||||
# go and gather the rest. None of that is the learner's problem: what they
|
||||
# can practise is under the answer, as a control that builds the session.
|
||||
"Never describe your own retrieval — how many sources you were given, that "
|
||||
"you have not looked at more, or what you could go and fetch. If the learner "
|
||||
"asks for more questions than you can cite, name what there is in one "
|
||||
"sentence and stop. Do not write practice questions of your own.\n\n"
|
||||
"Be brief: a few sentences or a short list.\n\n"
|
||||
)
|
||||
|
||||
|
|
@ -515,3 +523,40 @@ def practice_ids(db: Session, user: User, citations, question: str) -> list[int]
|
|||
allowed = allowed.filter(scope)
|
||||
visible = {row.id for row in allowed.all()}
|
||||
return [qid for qid in ordered if qid in visible][:PRACTICE_MAX]
|
||||
|
||||
|
||||
#: Openers a learner types before the real question. Dropped from the thread
|
||||
#: name, never from the question itself.
|
||||
_FILLER = re.compile(
|
||||
r"^(?:hi|hey|hello|ok|okay|so|please|pls|can you|could you|tell me|"
|
||||
r"i want to know|i'd like to know|explain to me)\b[\s,:-]*", re.I)
|
||||
_TITLE_MAX = 60
|
||||
|
||||
|
||||
def thread_title(question: str) -> str:
|
||||
"""A name for a conversation, taken from its first question.
|
||||
|
||||
It used to be the raw question truncated at eighty characters, which is how
|
||||
a sidebar ends up reading "how do i treat cervicitis in a teenager and wh…"
|
||||
— the learner's typing, warts and all. This trims the throat-clearing,
|
||||
starts with a capital, and cuts at a word rather than mid-syllable.
|
||||
"""
|
||||
original = " ".join((question or "").split())
|
||||
text = original
|
||||
# "ok so bronchiolitis" is two openers, not one.
|
||||
for _ in range(3):
|
||||
stripped = _FILLER.sub("", text).strip()
|
||||
if stripped == text:
|
||||
break
|
||||
text = stripped
|
||||
text = text or original
|
||||
if not text:
|
||||
return "New chat"
|
||||
text = re.sub(r"\bi\b", "I", text)
|
||||
if len(text) > _TITLE_MAX:
|
||||
cut = text[:_TITLE_MAX].rsplit(" ", 1)[0] or text[:_TITLE_MAX]
|
||||
text = cut.rstrip(" ,;:-") + "…"
|
||||
else:
|
||||
# A full stop adds nothing to a label; a question mark says what it is.
|
||||
text = text.rstrip(" .,;:")
|
||||
return text[0].upper() + text[1:]
|
||||
|
|
|
|||
|
|
@ -236,6 +236,32 @@ class AiModeRouteTests(_AiModeBase):
|
|||
# A half-written exchange is worse than none: the question is not kept.
|
||||
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
|
||||
|
||||
def test_an_empty_answer_is_refused_rather_than_drawn_as_a_blank_card(self):
|
||||
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||
with self.reply_with(" "):
|
||||
response = self.client.post(f'/ai/conversations/{conversation_id}/messages',
|
||||
json={'message': 'febrile seizure'})
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
|
||||
|
||||
def test_an_answer_that_was_only_an_invented_citation_is_refused(self):
|
||||
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||
# Every word of it goes when the marker nobody can vouch for goes.
|
||||
with self.reply_with("[[article:9999]]"):
|
||||
response = self.client.post(f'/ai/conversations/{conversation_id}/messages',
|
||||
json={'message': 'febrile seizure'})
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
|
||||
|
||||
def test_a_thread_is_named_tidily_from_its_first_question(self):
|
||||
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||
with self.reply_with("An answer."):
|
||||
response = self.client.post(
|
||||
f'/ai/conversations/{conversation_id}/messages',
|
||||
json={'message': 'hi, how do i treat a febrile seizure?'})
|
||||
# Not the learner's typing verbatim: no opener, a capital, and "I".
|
||||
self.assertEqual(response.json()['title'], 'How do I treat a febrile seizure?')
|
||||
|
||||
def test_deleting_a_thread_takes_its_messages(self):
|
||||
with self.reply_with("An answer."):
|
||||
conversation_id, _ = self.ask('febrile seizure')
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function fetchPreview(slug) {
|
|||
/** The numeric id behind a slug, reusing whatever the hover card already fetched. */
|
||||
export const resolveArticleId = (slug) => fetchPreview(slug).then(data => data?.id ?? null)
|
||||
|
||||
export default function ArticleLink({ slug, children, className = '' }) {
|
||||
export default function ArticleLink({ slug, sectionId = null, children, className = '' }) {
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [above, setAbove] = useState(false)
|
||||
|
|
@ -48,7 +48,10 @@ export default function ArticleLink({ slug, children, className = '' }) {
|
|||
// A cross-reference written by id addresses the article directly; one
|
||||
// written by slug goes through the slug route, which also resolves the
|
||||
// names an article used to have.
|
||||
const href = /^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`
|
||||
// A citation can point at one section rather than the whole article; the
|
||||
// reader opens on it. Nothing in prose writes one, so it is normally absent.
|
||||
const where = sectionId ? `?section=${encodeURIComponent(sectionId)}` : ''
|
||||
const href = (/^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`) + where
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@
|
|||
}
|
||||
|
||||
.ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; }
|
||||
.ai-empty { color: var(--text-muted); font-size: 0.9rem; margin: 0; }
|
||||
.ai-again {
|
||||
background: none; border: 0; padding: 0; cursor: pointer;
|
||||
color: var(--primary); font: inherit; text-decoration: underline;
|
||||
}
|
||||
.ai-again:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* The field and its controls are one object with one outline, so the microphone
|
||||
reads as part of the question rather than as a button parked next to it. */
|
||||
|
|
@ -136,8 +142,23 @@
|
|||
}
|
||||
.ai-starters-more:hover { color: var(--primary); }
|
||||
|
||||
/* A source read beside the answer. The page is capped narrow for reading a
|
||||
conversation; the pane needs the rest of the monitor, so the cap lifts only
|
||||
while one is open and drops again when it closes. */
|
||||
.ai-page.has-split { max-width: 1560px; grid-template-columns: 250px minmax(0, 1fr) minmax(360px, 42%); }
|
||||
.ai-page.has-split.is-folded { grid-template-columns: 52px minmax(0, 1fr) minmax(360px, 42%); }
|
||||
.ai-page.has-split .article-split-pane { position: sticky; top: 12px; max-height: calc(100dvh - 24px); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
/* Not enough width for three columns: the pane takes the conversation's
|
||||
place rather than squeezing both into ribbons. */
|
||||
.ai-page.has-split, .ai-page.has-split.is-folded { grid-template-columns: 52px minmax(0, 1fr); }
|
||||
.ai-page.has-split .ai-main { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.ai-page, .ai-page.is-folded { grid-template-columns: 1fr; }
|
||||
.ai-page, .ai-page.is-folded,
|
||||
.ai-page.has-split, .ai-page.has-split.is-folded { grid-template-columns: 1fr; }
|
||||
/* A drawer from the left, like the session's questions and an article's
|
||||
contents — not a panel that pushes the conversation down the page. */
|
||||
.ai-rail {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import api from '../api/client'
|
||||
import ArticleLink from '../components/ArticleLink'
|
||||
// The whole article reader, loaded only if a source is actually opened beside
|
||||
// the conversation — most chats never open one.
|
||||
const ArticleSplitPane = lazy(() => import('../components/ArticleSplitPane'))
|
||||
import { SplitViewProvider } from '../context/SplitViewContext'
|
||||
import useMediaQuery from '../hooks/useMediaQuery'
|
||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||
import useDictation, { canDictate } from '../hooks/useDictation'
|
||||
|
|
@ -167,6 +172,14 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on
|
|||
<summary>{citation.title}</summary>
|
||||
{citation.text && <p>{citation.text}</p>}
|
||||
</details>
|
||||
) : citation.kind === 'article' || citation.kind === 'section' ? (
|
||||
/* The same hover card a cross-reference in prose gets: what
|
||||
the source says, and the choice of a tab or the pane beside
|
||||
the answer. A source you have to leave the conversation to
|
||||
read is a source most people will not read. */
|
||||
<ArticleLink slug={String(citation.id)} sectionId={citation.section_id}>
|
||||
{citation.title}
|
||||
</ArticleLink>
|
||||
) : (
|
||||
<Link to={citationHref(citation)}>{citation.title}</Link>
|
||||
)}
|
||||
|
|
@ -190,7 +203,7 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on
|
|||
{built && (
|
||||
<div className="ai-built" role="status">
|
||||
<strong>
|
||||
{built.count} question{built.count === 1 ? '' : 's'} ready
|
||||
{built.count} question{built.count === 1 ? '' : 's'} ready to sit
|
||||
</strong>
|
||||
<span>Sit it now, or leave it in your sessions and carry on here.</span>
|
||||
<div className="ai-built-actions">
|
||||
|
|
@ -222,6 +235,7 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on
|
|||
*/
|
||||
export default function AiModePage() {
|
||||
const [threads, setThreads] = useState([])
|
||||
const [splitTrail, setSplitTrail] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [draft, setDraft] = useState('')
|
||||
|
|
@ -260,9 +274,12 @@ export default function AiModePage() {
|
|||
// thread of its own for the answer, and the thread list must not quietly open
|
||||
// the most recent chat underneath it.
|
||||
const fromUrl = useRef(Boolean(incoming))
|
||||
// The thread whose first answer is still in flight. Its transcript lives only
|
||||
// in this tab so far, so fetching it would wipe the question sitting in it.
|
||||
const inFlight = useRef(null)
|
||||
// Which thread the messages on screen belong to. It used to be "the thread
|
||||
// whose first answer is in flight", cleared the moment that request finished
|
||||
// — which lost the race whenever React ran the effect below after that: it
|
||||
// fetched an empty transcript over the exchange that had just happened, and
|
||||
// left the learner looking at the starting prompts again.
|
||||
const shown = useRef(null)
|
||||
// Speaking instead of typing. The browser's own recogniser where there is
|
||||
// one, our transcriber where there is not.
|
||||
const dictation = useDictation({
|
||||
|
|
@ -282,8 +299,10 @@ export default function AiModePage() {
|
|||
}, [loadThreads])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId == null) { setMessages([]); return }
|
||||
if (inFlight.current === activeId) return
|
||||
if (activeId == null) { shown.current = null; setMessages([]); return }
|
||||
// Already on screen — either just fetched, or written here as it happened.
|
||||
if (shown.current === activeId) return
|
||||
shown.current = activeId
|
||||
api.get(`/ai/conversations/${activeId}`)
|
||||
.then(res => setMessages(res.data.messages || []))
|
||||
.catch(() => setError('Could not open that conversation'))
|
||||
|
|
@ -328,7 +347,9 @@ export default function AiModePage() {
|
|||
// sit twenty questions is a decision, and it was being made for the
|
||||
// learner by a button that read "Practise these 3 topics" — the session
|
||||
// is now made, kept, and waiting either way.
|
||||
setBuilt({ messageId, quizId: res.data.quiz_id, count: res.data.questions_count })
|
||||
// `count`, which is what the endpoint returns. Reading a field that does
|
||||
// not exist rendered the offer as a headless "questions ready".
|
||||
setBuilt({ messageId, quizId: res.data.quiz_id, count: res.data.count })
|
||||
} catch (err) {
|
||||
setError(apiError(err, 'Could not build a session from this answer'))
|
||||
} finally {
|
||||
|
|
@ -358,7 +379,7 @@ export default function AiModePage() {
|
|||
if (threadId == null) {
|
||||
const created = await api.post('/ai/conversations')
|
||||
threadId = created.data.id
|
||||
inFlight.current = threadId
|
||||
shown.current = threadId
|
||||
setActiveId(threadId)
|
||||
setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0, updated_at: null }, ...prev])
|
||||
}
|
||||
|
|
@ -370,7 +391,6 @@ export default function AiModePage() {
|
|||
setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-')))
|
||||
setDraft(question) // Handing the question back rather than losing it.
|
||||
} finally {
|
||||
inFlight.current = null
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -410,8 +430,18 @@ export default function AiModePage() {
|
|||
const blank = messages.length === 0 && !sending
|
||||
const starters = moreStarters ? STARTERS : STARTERS.slice(0, STARTERS_SHOWN)
|
||||
|
||||
// A source opened beside the answer rather than instead of it — the same
|
||||
// trail the reading page keeps, so following a reference out of a source
|
||||
// replaces the pane and offers the way back.
|
||||
const pushSplit = useCallback((slug) => setSplitTrail(
|
||||
trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
|
||||
const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit])
|
||||
const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit])
|
||||
const splitSlug = splitTrail.at(-1) || null
|
||||
|
||||
return (
|
||||
<div className={`ai-page${railFolded ? ' is-folded' : ''}`}>
|
||||
<SplitViewProvider value={splitView}>
|
||||
<div className={`ai-page${railFolded ? ' is-folded' : ''}${splitSlug ? ' has-split' : ''}`}>
|
||||
{/* No in-page Chats button. On a phone the menu in the header opens this
|
||||
rail, the way it opens a session's questions and an article's
|
||||
contents — one control, in the same place, on every page. */}
|
||||
|
|
@ -457,10 +487,19 @@ export default function AiModePage() {
|
|||
<h1 className="ai-hero-title">How can PedsHub help you today?</h1>
|
||||
) : (
|
||||
<div className="ai-thread-view">
|
||||
{messages.map(message => (
|
||||
{messages.map((message, i) => (
|
||||
<div key={message.id} className={`ai-msg is-${message.role}`}>
|
||||
{message.role === 'user'
|
||||
? <p>{message.content}</p>
|
||||
: !String(message.content || '').trim()
|
||||
// An answer that came back empty used to be drawn as an empty
|
||||
// card — nothing to read and nothing to do. New ones are
|
||||
// refused before they are stored; these are the old ones.
|
||||
? <p className="ai-empty" role="status">
|
||||
No answer came back.{' '}
|
||||
<button type="button" className="ai-again" disabled={sending}
|
||||
onClick={() => ask(messages[i - 1]?.content || '')}>Ask again</button>
|
||||
</p>
|
||||
: <Answer content={message.content} citations={message.citations || []}
|
||||
practising={practising === message.id}
|
||||
built={built?.messageId === message.id ? built : null}
|
||||
|
|
@ -524,6 +563,17 @@ export default function AiModePage() {
|
|||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{splitSlug && (
|
||||
<SplitViewProvider value={paneView}>
|
||||
<Suspense fallback={<aside className="article-split-pane" aria-busy="true" />}>
|
||||
<ArticleSplitPane slug={splitSlug} depth={splitTrail.length - 1}
|
||||
onBack={() => setSplitTrail(trail => trail.slice(0, -1))}
|
||||
onClose={() => setSplitTrail([])} />
|
||||
</Suspense>
|
||||
</SplitViewProvider>
|
||||
)}
|
||||
</div>
|
||||
</SplitViewProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,37 @@ describe('AI Mode', () => {
|
|||
expect(within(curated).getByText('linked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('gives a source the same hover card as a cross-reference, pane and all', async () => {
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/ai/conversations') return Promise.resolve({ data: threads })
|
||||
if (url === '/articles/preview/7') return Promise.resolve({ data: {
|
||||
id: 7, slug: 'febrile-seizures', title: 'Febrile seizures',
|
||||
excerpt: 'A seizure with a fever and nothing else to blame.',
|
||||
section_count: 4, status: 'published' } })
|
||||
if (url === '/articles/7') return Promise.resolve({ data: {
|
||||
id: 7, title: 'Febrile seizures', sections: [], status: 'published' } })
|
||||
if (url.startsWith('/ai/conversations/')) return Promise.resolve({
|
||||
data: { id: 1, title: 'Febrile seizures', messages: [answer] } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
mount()
|
||||
await screen.findByText(/Fever first/)
|
||||
|
||||
// Clicking the source shows what it says rather than leaving the
|
||||
// conversation to find out — the same card the prose links have.
|
||||
const sources = document.querySelector('.ai-sources')
|
||||
await userEvent.click(within(sources).getByText('Febrile seizures'))
|
||||
const card = await screen.findByRole('tooltip')
|
||||
expect(card).toHaveTextContent('A seizure with a fever')
|
||||
expect(within(card).getByRole('link', { name: /new tab/i }))
|
||||
.toHaveAttribute('href', '/articles/7')
|
||||
|
||||
// And the pane opens beside the answer, not instead of it.
|
||||
await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
|
||||
await screen.findByRole('region', { name: /Split view/ })
|
||||
expect(document.querySelector('.ai-page')).toHaveClass('has-split')
|
||||
})
|
||||
|
||||
it('shows the question straight away rather than after a round trip', async () => {
|
||||
mount()
|
||||
await screen.findByRole('button', { name: 'Febrile seizures' })
|
||||
|
|
@ -143,7 +174,7 @@ describe('AI Mode', () => {
|
|||
|
||||
it('turns an answer into a session, and says so when there is nothing to sit', async () => {
|
||||
mockApi(threads, [answer])
|
||||
api.post.mockResolvedValue({ data: { quiz_id: 91, questions_count: 6 } })
|
||||
api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } })
|
||||
mount()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Febrile seizures' }))
|
||||
// Named after what it will build, and named before it is pressed.
|
||||
|
|
@ -155,7 +186,7 @@ describe('AI Mode', () => {
|
|||
// Built, and offered rather than entered: leaving a conversation to sit
|
||||
// twenty questions is a decision, and "Later" leaves the session in the
|
||||
// list rather than throwing it away.
|
||||
expect(await screen.findByText(/6 questions ready/)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/6 questions ready to sit/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Start now' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Later' }))
|
||||
expect(screen.queryByText(/questions ready/)).not.toBeInTheDocument()
|
||||
|
|
|
|||
Loading…
Reference in a new issue