From f5a084e49a5fda00c3d950d763c1c861905f22ea Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 8 Sep 2026 16:20:55 +0200 Subject: [PATCH] feat: Orthobullets-style lab panel with source deep links and card links Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass. --- .../versions/i2d3e4f5a607_lab_links.py | 30 ++++++ backend/app/models/__init__.py | 3 +- backend/app/models/lab_reference.py | 14 ++- backend/app/routers/study_tools.py | 93 ++++++++++++++++--- backend/tests/test_study_tools.py | 35 +++++++ frontend/src/components/QuizTools.css | 27 ++++++ frontend/src/components/QuizTools.jsx | 84 ++++++++++++++--- frontend/src/components/QuizTools.test.jsx | 70 ++++++++++++++ 8 files changed, 328 insertions(+), 28 deletions(-) create mode 100644 backend/alembic/versions/i2d3e4f5a607_lab_links.py create mode 100644 frontend/src/components/QuizTools.test.jsx 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}

+
    + {groupRows.map(row =>
  • +
    + {row.name}{!row.is_published && draft} + {row.specimen} +
    +
    {row.reference_range}{row.units && {row.units}}
    +
    {row.age_group}
    +
    + {row.article_id ? ( + + 📖 {row.article_title || 'Article'}{row.article_section_title ? ` › ${row.article_section_title}` : ''} + + ) : row.source_url ? {row.source} : {row.source}} +
    + {(row.cards?.length > 0 || manage) &&
    + {row.cards.map(card => ( + + {card.front.slice(0, 40)}{card.front.length > 40 ? '…' : ''} + {manage && } + + ))} + {manage && + setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} /> + + } +
    } + {manage &&
    + + {removeId === row.id ? <> + + : } +
    } +
  • )} +
+
+ })} +
} {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() + }) +})