diff --git a/backend/alembic/versions/i2d3e4f5a607_lab_links.py b/backend/alembic/versions/i2d3e4f5a607_lab_links.py new file mode 100644 index 0000000..d3387ad --- /dev/null +++ b/backend/alembic/versions/i2d3e4f5a607_lab_links.py @@ -0,0 +1,30 @@ +"""Lab reference article deep links and card associations. + +Revision ID: i2d3e4f5a607 +Revises: h1c2d3e4f506 +""" +from alembic import op + +revision = "i2d3e4f5a607" +down_revision = "h1c2d3e4f506" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE lab_reference_values ADD COLUMN IF NOT EXISTS article_id INTEGER REFERENCES articles(id) ON DELETE SET NULL") + op.execute("ALTER TABLE lab_reference_values ADD COLUMN IF NOT EXISTS article_section_id VARCHAR(64)") + op.execute(""" + CREATE TABLE IF NOT EXISTS lab_reference_card_links ( + id SERIAL PRIMARY KEY, + lab_reference_id INTEGER NOT NULL REFERENCES lab_reference_values(id) ON DELETE CASCADE, + flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE, + CONSTRAINT uq_lab_card_link UNIQUE (lab_reference_id, flashcard_id) + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_lab_reference_card_links_lab ON lab_reference_card_links (lab_reference_id)") + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS lab_reference_card_links") + op.execute("ALTER TABLE lab_reference_values DROP COLUMN IF EXISTS article_section_id") + op.execute("ALTER TABLE lab_reference_values DROP COLUMN IF EXISTS article_id") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b4b14e0..cba7335 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,7 +8,7 @@ from app.models.reminder import ReminderSchedule from app.models.ai_model_config import AIModelConfig from app.models.favorite import Favorite from app.models.user_note import UserNote -from app.models.lab_reference import LabReference +from app.models.lab_reference import LabReference, LabReferenceCardLink from app.models.article import Article, QuestionArticleLink from app.models.comment import Comment from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink @@ -27,6 +27,7 @@ __all__ = [ "Favorite", "UserNote", "LabReference", + "LabReferenceCardLink", "Article", "QuestionArticleLink", "Comment", diff --git a/backend/app/models/lab_reference.py b/backend/app/models/lab_reference.py index 11cbd30..b6bf844 100644 --- a/backend/app/models/lab_reference.py +++ b/backend/app/models/lab_reference.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint from app.database import Base @@ -15,6 +15,18 @@ class LabReference(Base): specimen = Column(String(120), nullable=False) source = Column(String(500), nullable=False) source_url = Column(String(2000), nullable=True) + # Orthobullets-style deep link: jump straight to an article subsection. + article_id = Column(Integer, ForeignKey("articles.id", ondelete="SET NULL"), nullable=True) + article_section_id = Column(String(64), nullable=True) is_published = Column(Boolean, nullable=False, default=False, server_default="false") updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class LabReferenceCardLink(Base): + __tablename__ = "lab_reference_card_links" + __table_args__ = (UniqueConstraint("lab_reference_id", "flashcard_id", name="uq_lab_card_link"),) + + id = Column(Integer, primary_key=True) + lab_reference_id = Column(Integer, ForeignKey("lab_reference_values.id", ondelete="CASCADE"), nullable=False) + flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False) diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 15aa733..8afc849 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -3,13 +3,15 @@ from collections import Counter from datetime import datetime from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator +from pydantic import BaseModel, Field, HttpUrl, field_validator from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.database import get_db +from app.models.article import Article from app.models.attempt import AttemptAnswer, QuizAttempt -from app.models.lab_reference import LabReference +from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.lab_reference import LabReference, LabReferenceCardLink from app.models.question import Question from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.quiz import Quiz @@ -30,6 +32,8 @@ class LabInput(BaseModel): specimen: str = Field(min_length=1, max_length=120) source: str = Field(min_length=1, max_length=500) source_url: HttpUrl | None = None + article_id: int | None = None + article_section_id: str | None = Field(default=None, max_length=64) is_published: bool = False @field_validator("source_url") @@ -47,13 +51,7 @@ class LabInput(BaseModel): return value.strip() -class LabOutput(LabInput): - model_config = ConfigDict(from_attributes=True) - id: int - updated_at: datetime - - -@router.get("/lab-values", response_model=list[LabOutput]) +@router.get("/lab-values") def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): if include_drafts and not user.is_moderator: raise HTTPException(403, "Educator access required") @@ -61,36 +59,103 @@ def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user if not include_drafts: query = query.filter(LabReference.is_published.is_(True)) # ponytail: bounded personal-project table; add pagination before exceeding 500 entries. - return query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all() + entries = query.order_by(LabReference.group, LabReference.name, LabReference.age_group).limit(500).all() + card_rows = db.query(LabReferenceCardLink.lab_reference_id, Flashcard.id, Flashcard.front, Flashcard.deck_id, + FlashcardDeck.title).join( + Flashcard, Flashcard.id == LabReferenceCardLink.flashcard_id, + ).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter( + LabReferenceCardLink.lab_reference_id.in_([entry.id for entry in entries]) if entries else False, + ).all() + rows_by_lab: dict[int, list] = {} + for lab_id, cid, front, deck_id, deck_title in card_rows: + rows_by_lab.setdefault(lab_id, []).append((cid, front, deck_id, deck_title)) + return [lab_json(db, entry, rows_by_lab.get(entry.id, [])) for entry in entries] def lab_fields(data): fields = data.model_dump() fields["source_url"] = str(data.source_url) if data.source_url else None + fields["article_section_id"] = data.article_section_id or None return fields -@router.post("/lab-values", response_model=LabOutput, status_code=201) +def validate_lab_target(db, article_id, article_section_id): + if article_id is None: + if article_section_id: + raise HTTPException(400, "article_section_id requires article_id") + return + article = db.get(Article, article_id) + if not article: + raise HTTPException(400, "Article not found") + if article_section_id and article_section_id not in {s["id"] for s in (article.sections or [])}: + raise HTTPException(400, "Section not found in this article") + + +def lab_json(db, entry, card_rows=None): + article = db.get(Article, entry.article_id) if entry.article_id else None + section_title = None + if article and entry.article_section_id: + section_title = next((s["title"] for s in (article.sections or []) if s["id"] == entry.article_section_id), None) + cards = [] + if card_rows: + cards = [{"card_id": cid, "front": front, "deck_id": deck_id, "deck_title": deck_title} + for cid, front, deck_id, deck_title in card_rows] + return { + "id": entry.id, "name": entry.name, "group": entry.group, + "reference_range": entry.reference_range, "units": entry.units, + "age_group": entry.age_group, "specimen": entry.specimen, + "source": entry.source, "source_url": entry.source_url, + "article_id": entry.article_id, "article_section_id": entry.article_section_id, + "article_title": article.title if article else None, + "article_section_title": section_title, + "is_published": entry.is_published, "updated_at": entry.updated_at, + "cards": cards, + } + + +@router.post("/lab-values", status_code=201) def create_lab_value(data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + validate_lab_target(db, data.article_id, data.article_section_id) entry = LabReference(**lab_fields(data), updated_by=user.id) db.add(entry) db.commit() db.refresh(entry) - return entry + return lab_json(db, entry) -@router.put("/lab-values/{entry_id}", response_model=LabOutput) +@router.put("/lab-values/{entry_id}") def update_lab_value(entry_id: int, data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)): entry = db.get(LabReference, entry_id) if not entry: raise HTTPException(404, "Reference not found") + validate_lab_target(db, data.article_id, data.article_section_id) for key, value in lab_fields(data).items(): setattr(entry, key, value) entry.updated_by = user.id entry.updated_at = datetime.utcnow() db.commit() db.refresh(entry) - return entry + return lab_json(db, entry) + + +@router.put("/lab-values/{entry_id}/cards/{card_id}") +def link_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + entry = db.get(LabReference, entry_id) + if not entry: + raise HTTPException(404, "Reference not found") + if not db.get(Flashcard, card_id): + raise HTTPException(404, "Card not found") + if db.query(LabReferenceCardLink.id).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).first(): + return {"linked": False} + db.add(LabReferenceCardLink(lab_reference_id=entry_id, flashcard_id=card_id)) + db.commit() + return {"linked": True} + + +@router.delete("/lab-values/{entry_id}/cards/{card_id}", status_code=204) +def unlink_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)): + db.query(LabReferenceCardLink).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).delete(synchronize_session=False) + db.commit() @router.delete("/lab-values/{entry_id}", status_code=204) diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index f52cb64..d5367c6 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -169,6 +169,41 @@ class StudyToolTests(unittest.TestCase): self.assertEqual(data['total_answered'], 4) self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first. + def test_lab_article_deep_links_and_card_links(self): + from app.models.article import Article + from app.models.flashcard import Flashcard, FlashcardDeck + article = Article(slug='lab-source', title='Lab source article', content='Intro', + sections=[{'id': 'd' * 32, 'slug': 'ranges', 'title': 'Ranges', 'content': 'Body'}], + user_id=3, status='published') + deck = FlashcardDeck(user_id=3, title='Sodium cards', is_shared=0) + self.bank.db.add_all([article, deck]) + self.bank.db.flush() + card = Flashcard(deck_id=deck.id, front='Sodium card front', back='back') + self.bank.db.add(card) + self.bank.db.commit() + self.bank.user = self.bank.mod + payload = dict(name='Deep linked', group='Blood', reference_range='1-2', units='u', + age_group='a', specimen='s', source='src', article_id=article.id, + article_section_id='d' * 32, is_published=True) + self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': 999}).status_code, 400) + self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_section_id': 'bad'}).status_code, 400) + self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': None, 'article_section_id': 'd' * 32}).status_code, 400) + created = self.client.post('/study-tools/lab-values', json=payload) + self.assertEqual(created.status_code, 201, created.text) + entry_id = created.json()['id'] + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/999').status_code, 404) + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], True) + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], False) + row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id) + self.assertEqual(row['article_title'], 'Lab source article') + self.assertEqual(row['article_section_title'], 'Ranges') + self.assertEqual([c['front'] for c in row['cards']], ['Sodium card front']) + self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 204) + row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id) + self.assertEqual(row['cards'], []) + self.bank.user = self.bank.owner + self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 403) + def test_lab_reference_permissions_validation_and_publication(self): payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units', age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference') diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css index abba534..e273c79 100644 --- a/frontend/src/components/QuizTools.css +++ b/frontend/src/components/QuizTools.css @@ -34,3 +34,30 @@ .quiz-check input { width: auto; } .quiz-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } @media (max-width: 520px) { .quiz-tool-body { padding: 14px; } .quiz-reference-form { grid-template-columns: minmax(0, 1fr); } } +.quiz-labs { display: flex; flex-direction: column; gap: 14px; } +.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:last-child { border-bottom: none; } +.quiz-lab-name strong { display: block; font-size: .88rem; } +.quiz-lab-name small { color: var(--text-muted); font-size: .72rem; } +.quiz-lab-range strong { font-size: .9rem; } +.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; } +.quiz-lab-card button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: .72rem; } +.quiz-lab-card-link { display: inline-flex; align-items: center; gap: 5px; } +.quiz-lab-card-link input { width: 70px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: .74rem; background: var(--input-bg); color: var(--text); } +.quiz-lab-card-link button { font-size: .72rem; } +.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; } +} diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index 830bfd0..dbe37f0 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -1,4 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react' +import { Link } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' import { calculate } from '../utils/calculator' @@ -43,11 +44,12 @@ function Calculator() { } -const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', is_published: false } +const emptyReference = { name: '', group: 'Blood', reference_range: '', units: '', age_group: '', specimen: '', source: '', source_url: '', article_id: '', article_section_id: '', is_published: false } const labFields = [ ['name', 'Test name', 120], ['group', 'Group', 60], ['reference_range', 'Reference range', 250], ['units', 'Units', 80], ['age_group', 'Age / population', 120], ['specimen', 'Specimen', 120], ['source', 'Source citation', 500], ['source_url', 'Source URL (optional)', 2000], + ['article_id', 'Article ID (optional deep link)', 12], ['article_section_id', 'Article section ID (optional)', 64], ] function LabValues() { @@ -63,6 +65,8 @@ function LabValues() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [refresh, setRefresh] = useState(0) + const [cardLink, setCardLink] = useState({}) + const [linkError, setLinkError] = useState('') useEffect(() => { let active = true setLoading(true) @@ -77,6 +81,8 @@ function LabValues() { setSaving(true); setError('') const payload = Object.fromEntries(Object.keys(emptyReference).map(key => [key, form[key]])) payload.source_url ||= null + payload.article_id = payload.article_id ? parseInt(payload.article_id, 10) : null + payload.article_section_id ||= null try { if (form.id) await api.put(`/study-tools/lab-values/${form.id}`, payload) else await api.post('/study-tools/lab-values', payload) @@ -90,29 +96,83 @@ function LabValues() { catch { setError('Could not delete this reference.') } finally { setSaving(false) } } + const linkCard = async (labId) => { + setLinkError('') + const cardId = parseInt(cardLink[labId], 10) + if (!cardId) { setLinkError('Enter a card ID'); return } + try { + await api.put(`/study-tools/lab-values/${labId}/cards/${cardId}`) + setCardLink(prev => ({ ...prev, [labId]: '' })) + setRefresh(v => v + 1) + } catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link card') } + } + const unlinkCard = async (labId, cardId) => { + setLinkError('') + try { await api.delete(`/study-tools/lab-values/${labId}/cards/${cardId}`); setRefresh(v => v + 1) } + catch { setLinkError('Could not remove card link') } + } const filtered = rows.filter(row => (group === 'All' || row.group === group) && [row.name, row.age_group, row.specimen, row.units].some(value => value.toLowerCase().includes(query.toLowerCase()))) + 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. Use the source and population shown; follow local laboratory intervals.

+

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.

{isEducator && } {error &&

{error}

} + {linkError &&

{linkError}

}
{manage && }
-
{['All', ...new Set(rows.map(row => row.group))].map(name => )}
+
{groups.map(name => )}
{loading ?

Loading references…

: filtered.length === 0 ?

No published reference values match. Educators can add sourced, age-specific entries; no ranges are assumed.

: -
{manage && }{filtered.map(row => - - - - {manage && } - )}
Test / specimenReference rangePopulation / sourceActions
{row.name}{row.specimen}{!row.is_published && ' · Draft'}{row.reference_range} {row.units}{row.age_group}{row.source_url ? {row.source} : row.source}{removeId === row.id ? <> - - : }
} +
+ {(group === 'All' ? groups.slice(1) : [group]).map(groupName => { + const groupRows = ordered.filter(row => row.group === groupName) + if (!groupRows.length) return null + return
+

{groupName}

+ +
+ })} +
} {form && manage &&

{form.id ? 'Edit reference' : 'New reference'}

- {labFields.map(([key, label, max]) => )} + {labFields.map(([key, label, max]) => )}
} diff --git a/frontend/src/components/QuizTools.test.jsx b/frontend/src/components/QuizTools.test.jsx new file mode 100644 index 0000000..0ca5101 --- /dev/null +++ b/frontend/src/components/QuizTools.test.jsx @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import QuizTools from './QuizTools' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) +vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 3, name: 'Educator', role: 'moderator' } }) })) + +let nativeShowModal +let nativeClose +beforeAll(() => { + nativeShowModal = HTMLDialogElement.prototype.showModal + nativeClose = HTMLDialogElement.prototype.close + HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', '') } + HTMLDialogElement.prototype.close = function () { this.removeAttribute('open') } +}) +afterAll(() => { + HTMLDialogElement.prototype.showModal = nativeShowModal + HTMLDialogElement.prototype.close = nativeClose +}) + +const rows = [ + { id: 1, name: 'Sodium', group: 'Chemistries', reference_range: '135–145', units: 'mmol/L', age_group: '>10 days', specimen: 'Plasma', source: 'PediRounds', source_url: 'https://www.pedirounds.com/lab-values/', article_id: null, article_section_id: null, article_title: null, article_section_title: null, is_published: true, cards: [] }, + { id: 2, name: 'CSF white cell count', group: 'CSF', reference_range: '<5', units: '/mm³', age_group: 'Child vs neonate', specimen: 'CSF', source: 'AAP 2018', source_url: null, article_id: 7, article_section_id: 'a'.repeat(32), article_title: 'Cerebrospinal fluid', article_section_title: 'Cell counts', is_published: true, cards: [{ card_id: 9, front: 'CSF card front', deck_id: 4, deck_title: 'CSF deck' }] }, +] + +beforeEach(() => { + vi.resetAllMocks() + api.get.mockResolvedValue({ data: rows }) + api.put.mockResolvedValue({ data: { linked: true } }) + api.delete.mockResolvedValue({ data: null }) +}) + +function renderLabs() { + return render( {}} />) +} + +describe('lab values panel', () => { + it('renders Orthobullets-style groups with deep source links and card links', async () => { + renderLabs() + expect(await screen.findByRole('heading', { name: 'Chemistries' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'CSF' })).toBeInTheDocument() + expect(screen.getByText('135–145')).toBeInTheDocument() + const deep = screen.getByRole('link', { name: /Cerebrospinal fluid › Cell counts/ }) + expect(deep).toHaveAttribute('href', `/articles/7?section=${'a'.repeat(32)}`) + expect(screen.getByRole('link', { name: 'PediRounds' })).toHaveAttribute('href', 'https://www.pedirounds.com/lab-values/') + expect(screen.getByRole('link', { name: 'CSF card front' })).toHaveAttribute('href', '/flashcards/4/study') + }) + + it('lets an educator link and unlink a card per reference', async () => { + renderLabs() + await screen.findByRole('heading', { name: 'CSF' }) + await userEvent.click(screen.getByRole('checkbox', { name: /Manage references and drafts/ })) + const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section') + const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count') + await userEvent.type(input, '12') + await userEvent.click(within(csfSection).getByRole('button', { name: 'Link card' })) + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/12')) + await userEvent.click(screen.getByRole('button', { name: /Unlink card 9 from CSF white cell count/ })) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/9')) + }) + + it('shows an honest empty state', async () => { + api.get.mockResolvedValue({ data: [] }) + renderLabs() + expect(await screen.findByText(/No published reference values match/)).toBeInTheDocument() + }) +})