feat: an opened explanation figure shows what the library knows
Two voices, and they belong in different places. The thumbnail in an explanation carries what the *question* says about the figure — words an educator chose for this question. Opening it now adds the catalogue entry underneath: what the image was filed as, the description written when it was filed, where it came from, and any marks an educator drew on it, behind a switch. It rides on explanation rows only. Those are already withheld by the server until answers are revealed, so the record reaches somebody who has finished the question and gets nowhere near somebody who has not — which is the whole reason the caption came off the figure in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
954b13e7b3
commit
094bab20cd
5 changed files with 142 additions and 2 deletions
|
|
@ -42,6 +42,28 @@ def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict:
|
|||
"caption": link.caption or None,
|
||||
"path": getattr(asset, "path", None),
|
||||
"position": link.position,
|
||||
# What the library knows, for an explanation figure only.
|
||||
#
|
||||
# Once the answer is in, the catalogue entry is worth having: the
|
||||
# title, the description written when the image was filed, where it
|
||||
# came from, and any marks an educator drew on it. Before the answer
|
||||
# it is a giveaway, which is why the caption fell out of the figure
|
||||
# itself — so it rides only on the rows that are already withheld
|
||||
# until answers are revealed. Nothing here is sent with a stem.
|
||||
"library": _library_json(asset) if link.role == "explanation" else None,
|
||||
}
|
||||
|
||||
|
||||
def _library_json(asset: MediaAsset) -> dict | None:
|
||||
if asset is None:
|
||||
return None
|
||||
return {
|
||||
"title": asset.title or None,
|
||||
"caption": asset.caption or None,
|
||||
"alt_text": asset.alt_text or None,
|
||||
"source": asset.source or None,
|
||||
"source_url": asset.source_url or None,
|
||||
"overlay": asset.overlay or None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,24 @@ class FigureCaptionTests(unittest.TestCase):
|
|||
self.assertNotIn("title", figure)
|
||||
self.assertEqual(figure["path"], "uploads/hip.png")
|
||||
|
||||
def test_the_library_record_rides_only_on_an_explanation_figure(self):
|
||||
"""Once the answer is in, the catalogue entry is worth having.
|
||||
|
||||
The server already withholds explanation figures until answers are
|
||||
revealed, so hanging the library record on those rows — and only those
|
||||
— puts it in front of a reader who has finished the question and
|
||||
nowhere near one who has not.
|
||||
"""
|
||||
self.asset.source = "Teaching file"
|
||||
self.asset.source_url = "https://example.org/x"
|
||||
stem = QuestionMedia(id=3, question_id=9, media_id=5, role="stem", position=0)
|
||||
back = QuestionMedia(id=4, question_id=9, media_id=5, role="explanation", position=0)
|
||||
self.assertIsNone(figure_json(stem, self.asset)["library"])
|
||||
shelf = figure_json(back, self.asset)["library"]
|
||||
self.assertEqual(shelf["title"], "Right SCFE, AP pelvis")
|
||||
self.assertIn("right hip joint", shelf["caption"])
|
||||
self.assertEqual(shelf["source"], "Teaching file")
|
||||
|
||||
def test_what_the_question_itself_says_is_shown(self):
|
||||
link = QuestionMedia(id=2, question_id=9, media_id=5, role="stem",
|
||||
label="Figure 1", caption="AP pelvis at presentation.",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,29 @@
|
|||
}
|
||||
.fs-overlay img { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; }
|
||||
.fs-overlay-cap { margin: 10px 0 0; font-size: 0.85rem; line-height: 1.6; color: var(--text-muted); }
|
||||
|
||||
/* The image and anything drawn on it, in one box: the overlay is positioned
|
||||
against this, so it has to be the size of the picture rather than the
|
||||
panel around it. */
|
||||
.fs-overlay-frame { position: relative; display: block; width: fit-content; margin: 0 auto; }
|
||||
.fs-overlay-mark {
|
||||
margin-left: auto; padding: 4px 10px; font-size: .78rem; font-weight: 600;
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
background: none; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.fs-overlay-mark.is-on { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
/* What the library holds, under what the question said. Set apart, because
|
||||
they are two different voices: one was written for this question, the other
|
||||
catalogues the image wherever it turns up. */
|
||||
.fs-overlay-shelf {
|
||||
margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border);
|
||||
font-size: .82rem; line-height: 1.6; color: var(--text-muted);
|
||||
}
|
||||
.fs-overlay-shelf strong { display: block; color: var(--text); font-size: .88rem; }
|
||||
.fs-overlay-shelf p { margin: 4px 0 0; }
|
||||
.fs-overlay-source { font-size: .78rem; }
|
||||
.fs-overlay-source a { color: var(--primary); }
|
||||
.fs-overlay-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 12px; font-size: 0.82rem; color: var(--text-muted); }
|
||||
.fs-overlay-nav button {
|
||||
min-height: 38px; padding: 8px 14px; font: inherit; font-size: 0.82rem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import ImageOverlay from './ImageOverlay'
|
||||
import './FigureStrip.css'
|
||||
|
||||
/**
|
||||
|
|
@ -16,6 +17,12 @@ import './FigureStrip.css'
|
|||
*/
|
||||
export default function FigureStrip({ figures, attemptId, size = 'full', label }) {
|
||||
const [open, setOpen] = useState(null)
|
||||
//: Whether the educator's marks are drawn. Off until asked for, like every
|
||||
//: other overlay: marks shown before somebody has looked answer the question
|
||||
//: for them.
|
||||
const [marked, setMarked] = useState(false)
|
||||
|
||||
useEffect(() => { setMarked(false) }, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open === null) return undefined
|
||||
|
|
@ -26,6 +33,13 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label }
|
|||
|
||||
if (!figures?.length) return null
|
||||
const shown = figures[open]
|
||||
// What the library knows about this image. Sent on explanation figures only,
|
||||
// so it appears after the answer and never beside a stem. The thumbnail is
|
||||
// unaffected either way: in the explanation it shows what the *question*
|
||||
// says about the figure, which is what an educator wrote for this question.
|
||||
const shelf = shown?.library || null
|
||||
const shapes = shelf?.overlay?.shapes
|
||||
const hasShapes = Array.isArray(shapes) && shapes.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -59,11 +73,35 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label }
|
|||
onClick={e => e.target === e.currentTarget && setOpen(null)}>
|
||||
<div className="fs-overlay-inner">
|
||||
<div className="fs-overlay-head">
|
||||
<strong>{shown.label}</strong>
|
||||
<strong>{shown.label || shelf?.title}</strong>
|
||||
{hasShapes && (
|
||||
<button type="button" className={`fs-overlay-mark${marked ? ' is-on' : ''}`}
|
||||
aria-pressed={marked} onClick={() => setMarked(v => !v)}>◫ Overlay</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setOpen(null)} aria-label="Close figure">✕</button>
|
||||
</div>
|
||||
<img src={uploadUrl(shown.path, attemptId)} alt={shown.caption || shown.label} />
|
||||
<span className="fs-overlay-frame">
|
||||
<img src={uploadUrl(shown.path, attemptId)}
|
||||
alt={shown.caption || shelf?.alt_text || shown.label} />
|
||||
{marked && hasShapes && <ImageOverlay overlay={shelf.overlay} />}
|
||||
</span>
|
||||
{shown.caption && <p className="fs-overlay-cap">{shown.caption}</p>}
|
||||
{/* Everything the library holds, once the answer is in: what it was
|
||||
filed as, the description written for it, and where it came
|
||||
from. The thumbnail keeps the question's own words. */}
|
||||
{shelf && (shelf.title || shelf.caption || shelf.source) && (
|
||||
<div className="fs-overlay-shelf">
|
||||
{shelf.title && shelf.title !== shown.label && <strong>{shelf.title}</strong>}
|
||||
{shelf.caption && shelf.caption !== shown.caption && <p>{shelf.caption}</p>}
|
||||
{shelf.source && (
|
||||
<p className="fs-overlay-source">
|
||||
Source: {shelf.source_url
|
||||
? <a href={shelf.source_url} target="_blank" rel="noopener noreferrer">{shelf.source}</a>
|
||||
: shelf.source}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{figures.length > 1 && (
|
||||
<div className="fs-overlay-nav">
|
||||
<button type="button" disabled={open === 0}
|
||||
|
|
|
|||
|
|
@ -56,3 +56,42 @@ describe('figures on a question', () => {
|
|||
expect(screen.queryByRole('button', { name: 'Next ›' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the library record when an explanation figure is opened, never on the thumbnail', async () => {
|
||||
// Two voices. The thumbnail carries what the *question* says about the
|
||||
// figure — words an educator chose for this question — and the opened
|
||||
// viewer adds what the library holds: what it was filed as, the description
|
||||
// written when it was catalogued, and where it came from. The library half
|
||||
// rides only on explanation rows, which the server withholds until answers
|
||||
// are revealed, so none of it can sit beside a stem.
|
||||
render(<FigureStrip attemptId={7} figures={[{
|
||||
id: 1, role: 'explanation', path: 'x.png',
|
||||
label: 'Figure 1', caption: 'AP pelvis after fixation.',
|
||||
library: {
|
||||
title: 'Right SCFE, AP pelvis',
|
||||
caption: 'An X-ray of a child\u2019s pelvis showing the right hip.',
|
||||
source: 'Teaching file', source_url: 'https://example.org/x',
|
||||
},
|
||||
}]} />)
|
||||
|
||||
// Nothing of the library on the thumbnail.
|
||||
expect(screen.queryByText(/Right SCFE/)).toBeNull()
|
||||
expect(screen.queryByText(/Teaching file/)).toBeNull()
|
||||
expect(screen.getByText('AP pelvis after fixation.')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Open Figure 1/ }))
|
||||
const viewer = screen.getByRole('dialog')
|
||||
expect(within(viewer).getByText('Right SCFE, AP pelvis')).toBeInTheDocument()
|
||||
expect(within(viewer).getByText(/pelvis showing the right hip/)).toBeInTheDocument()
|
||||
expect(within(viewer).getByRole('link', { name: 'Teaching file' }))
|
||||
.toHaveAttribute('href', 'https://example.org/x')
|
||||
})
|
||||
|
||||
it('carries nothing from the library on a stem figure', () => {
|
||||
// The server sends `library` only for explanation rows; if that ever
|
||||
// changed, the stem must still show only the question's own words.
|
||||
render(<FigureStrip attemptId={7} figures={[{
|
||||
id: 2, role: 'stem', path: 'y.png', label: null, caption: null, library: null,
|
||||
}]} />)
|
||||
expect(screen.queryByRole('button', { name: /Overlay/ })).toBeNull()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue