feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have. **Spaced repetition.** There was none. "Known" and "to review" were React state that vanished on reload, so a deck of two hundred was two hundred cards every time and the only spacing was whichever cards a learner remembered to skip. Verdicts are now kept, and the deck comes back in the order the learner's own history calls for: due first, most decayed first, then never seen, then the rest — because somebody who has met the whole deck recently should still get a deck rather than a screen saying come back on Thursday. It borrows the question player's arithmetic rather than choosing its own. `recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers with two ideas of "due", in one product that shows a learner one readiness number, is how the number stops meaning anything. Two outcomes and no four-point scale — a scale asks a learner to rate their own recall in units they have never calibrated, and the extra resolution is noise. **Cards are prose.** Both faces go through the same renderer as everything else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a teaching tip or a figure. That is most of what "link cards to things" turns out to mean. **A deck is reachable from the question.** Beside the topic-reading chip under the correct answer, one chip per linked deck. Read from the question's end only, deliberately: a card that listed the questions it belongs to would hand a learner revising the deck the shape of the exam, and the answer with it. Migration m3d4e5f6a7b8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
ddd8c4c4e3
commit
04abd78380
9 changed files with 367 additions and 9 deletions
46
backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py
Normal file
46
backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Cards remember when they were last answered
|
||||
|
||||
"Known" and "to review" were React state: they vanished on reload, so a deck of
|
||||
two hundred was two hundred cards every time and the only spacing was whichever
|
||||
cards a learner remembered to skip. This is the log the scheduler reads.
|
||||
|
||||
A log rather than a row per card, because what the scheduler needs is the
|
||||
latest verdict and its age — and keeping the history means a card missed three
|
||||
times running can later be treated differently from one missed once, without a
|
||||
migration to add the column that would have recorded it.
|
||||
|
||||
Revision ID: m3d4e5f6a7b8
|
||||
Revises: l2c3d4e5f6a7
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "m3d4e5f6a7b8"
|
||||
down_revision = "l2c3d4e5f6a7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if "flashcard_reviews" in sa.inspect(op.get_bind()).get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"flashcard_reviews",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("flashcard_id", sa.Integer(),
|
||||
sa.ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("outcome", sa.String(length=10), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_flashcard_reviews_user_id", "flashcard_reviews", ["user_id"])
|
||||
op.create_index("ix_flashcard_reviews_flashcard_id", "flashcard_reviews", ["flashcard_id"])
|
||||
# The scheduler asks "this learner's latest verdict per card", which is this
|
||||
# index read backwards.
|
||||
op.create_index("ix_flashcard_reviews_recent", "flashcard_reviews",
|
||||
["user_id", "flashcard_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("flashcard_reviews")
|
||||
|
|
@ -9,7 +9,9 @@ from app.models.favorite import Favorite
|
|||
from app.models.user_note import UserNote
|
||||
from app.models.lab_reference import LabReference, LabReferenceCardLink
|
||||
from app.models.article import Article, ArticleTopicClaim, QuestionArticleLink
|
||||
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
|
||||
from app.models.flashcard import (FlashcardDeck, Flashcard, FlashcardDeckRating,
|
||||
FlashcardQuestionLink, FlashcardArticleLink,
|
||||
FlashcardReview)
|
||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||
from app.models.collection import UserCollection, UserCollectionQuestion
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ __all__ = [
|
|||
"Flashcard",
|
||||
"FlashcardDeckRating",
|
||||
"FlashcardQuestionLink",
|
||||
"FlashcardReview",
|
||||
"FlashcardArticleLink",
|
||||
"QuestionCategory",
|
||||
"QuestionCategoryLink",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,34 @@ class Flashcard(Base, Embeddable):
|
|||
deck = relationship("FlashcardDeck", back_populates="cards")
|
||||
|
||||
|
||||
class FlashcardReview(Base):
|
||||
"""One verdict on one card, kept so the deck can come back at the right time.
|
||||
|
||||
Cards had no memory at all: "known" and "to review" were React state that
|
||||
vanished on reload, so a deck of two hundred was two hundred cards every
|
||||
time and the only spacing was whichever ones you happened to remember to
|
||||
skip.
|
||||
|
||||
A log rather than a row per card. What the scheduler needs is the *latest*
|
||||
verdict and how long ago it was, which a log gives; and keeping the history
|
||||
means a card answered wrongly three times in a row can eventually be
|
||||
treated differently from one missed once, without a migration to add the
|
||||
column that would have recorded it.
|
||||
"""
|
||||
|
||||
__tablename__ = "flashcard_reviews"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True)
|
||||
#: "known" or "again". Two outcomes, because a self-graded scale of four
|
||||
#: asks a learner to rate their own recall on a scale they have not
|
||||
#: calibrated, and the extra resolution is noise.
|
||||
outcome = Column(String(10), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class FlashcardQuestionLink(Base):
|
||||
__tablename__ = "flashcard_question_links"
|
||||
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
||||
"""Flashcard decks and cards — generate, browse, edit, delete, and schedule."""
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -16,6 +17,7 @@ from app.models.question import Question
|
|||
from app.models.section import Section
|
||||
from app.models.question_category import QuestionCategory
|
||||
from app.models.user import User
|
||||
from app.services import card_review
|
||||
from app.services.quiz_builder import category_descendants
|
||||
from app.services.search_service import hybrid_ids
|
||||
from app.services.quiz_builder import bank_question_predicate
|
||||
|
|
@ -244,6 +246,71 @@ def get_flashcard_deck(
|
|||
return deck
|
||||
|
||||
|
||||
class CardVerdict(BaseModel):
|
||||
"""What a learner said about a card. Two answers, deliberately."""
|
||||
|
||||
outcome: Literal["known", "again"]
|
||||
|
||||
|
||||
@router.get("/{deck_id}/study")
|
||||
def study_deck(deck_id: int, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""The deck in the order it should be sat, with what is due said out loud.
|
||||
|
||||
Ordering happens here rather than in the page because it needs this
|
||||
learner's history, and a page that fetched the whole review log to sort
|
||||
twenty cards would be downloading a year of answers to draw one screen.
|
||||
"""
|
||||
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
if deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
|
||||
raise HTTPException(status_code=403, detail="Not your deck")
|
||||
|
||||
cards = db.query(Flashcard).filter(Flashcard.deck_id == deck_id).order_by(Flashcard.id).all()
|
||||
ordered, counts = card_review.study_order(db, current_user.id, cards)
|
||||
return {
|
||||
"deck": {"id": deck.id, "title": deck.title, "card_count": len(cards)},
|
||||
**counts,
|
||||
"cards": [{"id": card.id, "front": card.front, "back": card.back,
|
||||
"image_path": card.image_path} for card in ordered],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/review")
|
||||
def review_card(card_id: int, data: CardVerdict, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""Record how a card went, so the deck can come back at the right time."""
|
||||
card = db.get(Flashcard, card_id)
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
deck = db.get(FlashcardDeck, card.deck_id)
|
||||
if deck and deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
|
||||
raise HTTPException(status_code=403, detail="Not your deck")
|
||||
card_review.record(db, current_user.id, card_id, data.outcome)
|
||||
return {"card_id": card_id, "outcome": data.outcome}
|
||||
|
||||
|
||||
@router.get("/questions/{question_id}/cards")
|
||||
def cards_for_question(question_id: int, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""The cards an educator tied to this question.
|
||||
|
||||
The link is read from the question's end only. A card that listed the
|
||||
questions it belongs to would hand a learner revising the deck the shape of
|
||||
the exam — and the answer, since a card's back is an answer.
|
||||
"""
|
||||
rows = db.query(Flashcard, FlashcardDeck).join(
|
||||
FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).join(
|
||||
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
|
||||
FlashcardQuestionLink.question_id == question_id,
|
||||
FlashcardDeck.deleted_at.is_(None)).all()
|
||||
return [{"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
|
||||
"front": card.front}
|
||||
for card, deck in rows
|
||||
if deck.user_id == current_user.id or deck.is_shared or current_user.is_admin]
|
||||
|
||||
|
||||
@router.delete("/{deck_id}", status_code=204)
|
||||
def delete_flashcard_deck(
|
||||
deck_id: int,
|
||||
|
|
|
|||
93
backend/app/services/card_review.py
Normal file
93
backend/app/services/card_review.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""When a card should come round again.
|
||||
|
||||
The same arithmetic the question player uses, pointed at cards, and for the
|
||||
same reason: two schedulers with two ideas of what "due" means, in one product
|
||||
that shows a learner one readiness number, is how the number stops meaning
|
||||
anything.
|
||||
|
||||
So this imports the constants rather than choosing its own. A card answered
|
||||
correctly decays past `DUE_RECALL` at about three and a half weeks; a card
|
||||
answered wrongly is below it immediately. There is no separate ease factor and
|
||||
no four-point self-grading scale: a scale asks a learner to rate their own
|
||||
recall on units they have never calibrated, and the extra resolution is noise
|
||||
dressed as precision.
|
||||
|
||||
What a study session is, then: everything due, oldest evidence first, then
|
||||
cards never seen, then — only if the deck is short of both — the rest, so that
|
||||
opening a deck always gives you a deck.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.flashcard import Flashcard, FlashcardReview
|
||||
from app.services.quiz_builder import DUE_RECALL, recall_probability
|
||||
|
||||
KNOWN = "known"
|
||||
AGAIN = "again"
|
||||
OUTCOMES = (KNOWN, AGAIN)
|
||||
|
||||
|
||||
def latest(db: Session, user_id: int, card_ids) -> dict[int, tuple[bool, float]]:
|
||||
"""Each card's most recent verdict and how many days ago, for this learner.
|
||||
|
||||
One query for the whole deck, not one per card: a deck of two hundred is
|
||||
two hundred round trips otherwise, which is how a study page ends up
|
||||
waiting a second before it can draw anything.
|
||||
"""
|
||||
if not card_ids:
|
||||
return {}
|
||||
now = datetime.utcnow()
|
||||
newest = db.query(
|
||||
FlashcardReview.flashcard_id.label("card"),
|
||||
func.max(FlashcardReview.id).label("row"),
|
||||
).filter(
|
||||
FlashcardReview.user_id == user_id,
|
||||
FlashcardReview.flashcard_id.in_(card_ids),
|
||||
).group_by(FlashcardReview.flashcard_id).subquery()
|
||||
|
||||
rows = db.query(FlashcardReview).join(newest, FlashcardReview.id == newest.c.row).all()
|
||||
out = {}
|
||||
for row in rows:
|
||||
when = row.created_at or now
|
||||
age = max(0.0, (now - when).total_seconds() / 86400.0)
|
||||
out[row.flashcard_id] = (row.outcome == KNOWN, age)
|
||||
return out
|
||||
|
||||
|
||||
def study_order(db: Session, user_id: int, cards: list[Flashcard]) -> tuple[list[Flashcard], dict]:
|
||||
"""The deck in the order it should be sat, and what to say about it.
|
||||
|
||||
Due first — the most decayed first inside that — then never seen, then
|
||||
everything else. A learner who has met the whole deck recently still gets a
|
||||
deck rather than an empty screen saying come back on Thursday, because a
|
||||
card they choose to look at again is not a mistake to prevent.
|
||||
"""
|
||||
history = latest(db, user_id, [card.id for card in cards])
|
||||
due, unseen, rest = [], [], []
|
||||
for card in cards:
|
||||
seen = history.get(card.id)
|
||||
if seen is None:
|
||||
unseen.append(card)
|
||||
continue
|
||||
recall = recall_probability(seen[0], seen[1])
|
||||
(due if recall < DUE_RECALL else rest).append((recall, card))
|
||||
|
||||
due.sort(key=lambda pair: pair[0])
|
||||
rest.sort(key=lambda pair: -pair[0])
|
||||
ordered = [card for _, card in due] + unseen + [card for _, card in rest]
|
||||
return ordered, {
|
||||
"due": len(due),
|
||||
"unseen": len(unseen),
|
||||
"settled": len(rest),
|
||||
"reviewed": len(history),
|
||||
}
|
||||
|
||||
|
||||
def record(db: Session, user_id: int, card_id: int, outcome: str) -> FlashcardReview:
|
||||
row = FlashcardReview(user_id=user_id, flashcard_id=card_id, outcome=outcome)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
|
@ -11,20 +11,32 @@ import api from '../api/client'
|
|||
*/
|
||||
export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
|
||||
const [links, setLinks] = useState(null)
|
||||
//: Cards tied to this question, read from the question's end only. A card
|
||||
//: that listed the questions it belongs to would hand a learner revising the
|
||||
//: deck the shape of the exam — and the answer, since a card's back is one.
|
||||
const [cards, setCards] = useState([])
|
||||
useEffect(() => {
|
||||
if (!questionId) { setLinks([]); return }
|
||||
if (!questionId) { setLinks([]); setCards([]); return }
|
||||
api.get(`/questions/${questionId}/articles`)
|
||||
.then(res => setLinks(res.data)).catch(() => setLinks([]))
|
||||
api.get(`/flashcards/questions/${questionId}/cards`)
|
||||
.then(res => setCards(res.data || [])).catch(() => setCards([]))
|
||||
}, [questionId])
|
||||
if (!links || links.length === 0) return null
|
||||
if ((!links || links.length === 0) && cards.length === 0) return null
|
||||
|
||||
// Under the right answer in the player, the same links are a row of chips —
|
||||
// one control, plainly a door to an article — rather than a headed list,
|
||||
// which at that point in the page reads as another section of explanation.
|
||||
if (variant === 'chips') {
|
||||
// Decks are listed once each, however many of their cards are tied here:
|
||||
// three chips saying "Board Review" is three doors to the same room.
|
||||
const decks = []
|
||||
for (const card of cards) {
|
||||
if (!decks.some(deck => deck.deck_id === card.deck_id)) decks.push(card)
|
||||
}
|
||||
return (
|
||||
<div className="question-reading-chips" data-testid="question-reading">
|
||||
{links.map(link => (
|
||||
{(links || []).map(link => (
|
||||
<Link key={`${link.article_id}-${link.section_id || 'all'}`}
|
||||
className="question-reading-chip"
|
||||
to={link.section_id ? `/articles/${link.article_id}?section=${link.section_id}` : `/articles/${link.article_id}`}>
|
||||
|
|
@ -32,6 +44,13 @@ export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
|
|||
{link.section_title || link.title}
|
||||
</Link>
|
||||
))}
|
||||
{decks.map(card => (
|
||||
<Link key={`deck-${card.deck_id}`} className="question-reading-chip is-cards"
|
||||
to={`/flashcards/${card.deck_id}/study`}>
|
||||
<span aria-hidden="true">▦</span>
|
||||
{card.deck_title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import RichText from '../components/RichText'
|
||||
|
||||
export default function FlashcardStudyPage() {
|
||||
const { deckId } = useParams()
|
||||
|
|
@ -14,10 +15,18 @@ export default function FlashcardStudyPage() {
|
|||
const [mode, setMode] = useState('all') // 'all' | 'review'
|
||||
const [shuffled, setShuffled] = useState(false)
|
||||
const [cardOrder, setCardOrder] = useState([])
|
||||
//: What the deck looked like when it was opened: due, never seen, settled.
|
||||
//: Not recomputed as the session goes — a counter that falls as you work is
|
||||
//: encouraging, one that rearranges itself under you is not.
|
||||
const [counts, setCounts] = useState({ due: 0, unseen: 0, settled: 0 })
|
||||
|
||||
// The order comes from the server, because it depends on this learner's own
|
||||
// history: what is due first, then what has never been seen, then the rest.
|
||||
// Sorting here would mean downloading a year of answers to draw one screen.
|
||||
useEffect(() => {
|
||||
api.get(`/flashcards/${deckId}`).then(res => {
|
||||
setDeck(res.data)
|
||||
api.get(`/flashcards/${deckId}/study`).then(res => {
|
||||
setDeck({ ...res.data.deck, cards: res.data.cards })
|
||||
setCounts({ due: res.data.due, unseen: res.data.unseen, settled: res.data.settled })
|
||||
setCardOrder(res.data.cards.map((_, i) => i))
|
||||
setLoading(false)
|
||||
}).catch(() => navigate('/flashcards'))
|
||||
|
|
@ -39,10 +48,19 @@ export default function FlashcardStudyPage() {
|
|||
if (currentIdx > 0) setCurrentIdx(i => i - 1)
|
||||
}, [currentIdx])
|
||||
|
||||
// Both verdicts are sent as well as held: the set in this component is what
|
||||
// draws the progress bar, and the row on the server is what decides when the
|
||||
// card comes round again. It was only ever the first, so a deck of two
|
||||
// hundred was two hundred cards every time.
|
||||
const record = (cardId, outcome) => {
|
||||
api.post(`/flashcards/cards/${cardId}/review`, { outcome }).catch(() => {})
|
||||
}
|
||||
|
||||
const markKnown = () => {
|
||||
if (currentCard) {
|
||||
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
||||
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
||||
record(currentCard.id, 'known')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
|
@ -51,6 +69,7 @@ export default function FlashcardStudyPage() {
|
|||
if (currentCard) {
|
||||
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
||||
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
||||
record(currentCard.id, 'again')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
|
@ -103,7 +122,8 @@ export default function FlashcardStudyPage() {
|
|||
<div>
|
||||
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{cards.length} cards · {known.size} known · {review.size} to review
|
||||
{cards.length} cards · {counts.due} due · {counts.unseen} new
|
||||
{known.size > 0 && ` · ${known.size} answered`}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
|
|
@ -156,7 +176,11 @@ export default function FlashcardStudyPage() {
|
|||
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
|
||||
</div>
|
||||
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
|
||||
{flipped ? currentCard.back : currentCard.front}
|
||||
{/* Rendered, not printed. A card's two faces are prose like
|
||||
everything else here, so `[[264|respiratory failure]]`,
|
||||
`==key points==` and a figure all work on a card — which is
|
||||
most of what "link cards to things" turns out to mean. */}
|
||||
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
|
||||
</p>
|
||||
{!flipped && (
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
|
||||
|
|
|
|||
71
frontend/src/pages/FlashcardStudyPage.test.jsx
Normal file
71
frontend/src/pages/FlashcardStudyPage.test.jsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import FlashcardStudyPage from './FlashcardStudyPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
vi.mock('../utils/uploads', () => ({
|
||||
markdownImageUrl: (src) => `/uploads/${src}`,
|
||||
uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`,
|
||||
THUMB_WIDTHS: [256, 640],
|
||||
}))
|
||||
|
||||
const DECK = {
|
||||
deck: { id: 3, title: 'Board Review', card_count: 2 },
|
||||
due: 1, unseen: 1, settled: 0,
|
||||
cards: [
|
||||
{ id: 11, front: 'Hereditary angioedema', back: 'Low **C1 inhibitor**; see [[264|respiratory failure]].' },
|
||||
{ id: 12, front: 'Second card', back: 'Second back' },
|
||||
],
|
||||
}
|
||||
|
||||
const mount = () => render(
|
||||
<MemoryRouter initialEntries={['/flashcards/3/study']}>
|
||||
<Routes>
|
||||
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
||||
<Route path="/flashcards" element={<h1>Decks</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: DECK })
|
||||
api.post.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('asks the server for the order and says what is due', async () => {
|
||||
mount()
|
||||
await screen.findByText('Board Review')
|
||||
// Ordering depends on this learner's own history, so the server does it —
|
||||
// sorting here would mean downloading a year of answers to draw one screen.
|
||||
expect(api.get).toHaveBeenCalledWith('/flashcards/3/study')
|
||||
expect(screen.getByText(/1 due/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 new/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('remembers a verdict on the server, not just in the page', async () => {
|
||||
mount()
|
||||
// The verdicts appear once the card is turned over: there is nothing to say
|
||||
// about a card whose answer you have not seen.
|
||||
await userEvent.click(await screen.findByText('Hereditary angioedema'))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Got it/ }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||
'/flashcards/cards/11/review', { outcome: 'known' }))
|
||||
|
||||
await userEvent.click(screen.getByText('Second card'))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Review again/ }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||
'/flashcards/cards/12/review', { outcome: 'again' }))
|
||||
})
|
||||
|
||||
it('renders a card face as prose, so a card can carry a link', async () => {
|
||||
mount()
|
||||
await screen.findByText('Hereditary angioedema')
|
||||
await userEvent.click(screen.getByText('Hereditary angioedema'))
|
||||
const back = await screen.findByText(/Low/)
|
||||
expect(within(back.closest('.rich-text')).getByText('C1 inhibitor').tagName).toBe('STRONG')
|
||||
expect(screen.getByText('respiratory failure')).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -956,3 +956,10 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
|||
@media (max-width: 640px) {
|
||||
.question-reading-chips { margin-left: 0; }
|
||||
}
|
||||
/* A deck chip sits beside the reading chips and is told apart by its icon
|
||||
rather than by a heading over a second group: they are the same offer — the
|
||||
thing to go and read next — and two labelled sections would make a decision
|
||||
out of it. */
|
||||
.question-reading-chip.is-cards span { color: #8a6417; }
|
||||
.question-reading-chip.is-cards:hover { border-color: #b8860b; color: #8a6417; }
|
||||
.question-reading-chip.is-cards:hover span { color: #8a6417; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue