feat(client): port full PE_DATA checklist + Generate Exam Report flow

Biggest data port of the migration so far. PE_DATA is the 1000+ line
age-group × system × component × step hierarchy driving the pediatric
physical-exam checklist; every entry, pearl, significance note, and
abnormal-hint array is now available in the React tree.

client/src/data/pe-data.ts — verbatim port
  Extracted lines 316-1334 of public/js/peGuide.js with awk/sed, then
  wrapped in TS types. Every byte of the data body is byte-identical
  to the vanilla source. Added interfaces:
    PeStep { label, method, normal }
    PeComponent { name, steps[], abnormalHints[], pearl?, significance? }
    PeSystem { overview, components[] }
    PeAgeGroup { label, msk, neuro, resp, cv }
  …plus AGE_GROUP_ORDER / SYSTEM_ORDER / SYSTEM_LABELS canonical
  orderings for the UI.

client/src/data/pe-data.test.ts — parity lock
  Vitest suite that asserts every count captured from the vanilla
  source so any accidental drop surfaces as a red test:
    • 6 age groups × 4 systems
    • 103 components total
    • 27 pearl entries
    • 23 significance entries
    • per-cell component counts (e.g. toddler.neuro = 7, adolescent.cv = 5)
  Counts captured 2026-04-24 against peGuide.js commit 313ba7f.

client/src/pages/PeGuide.tsx — full viewer (replaces legacy-link stub)
  • Age-group pills (6) + system pills (4) drive the visible section
  • Overview banner per combination
  • CV system shows APTM legend + cardiac sounds library + innocent
    murmurs reference (unchanged clinical content from the earlier
    commit that added the scales/sounds file)
  • Resp system shows the respiratory sounds library
  • Collapsible grading-scales reference pulls from SYSTEM_SCALES
  • Component checklist: per-step Normal / Abnormal toggle, abnormal-
    hint list, pearl + significance callouts
  • Mark-all-normal + Reset shortcuts
  • Generate Exam Report posts the full step payload to
    /api/generate-pe-narrative, renders the returned narrative inline
  • No more "Open checklist in legacy viewer" amber banner — the
    React port now does the whole thing

e2e/tests/peguide-react.spec.js
  Age-group pills, system pills, overview rewrite on age change,
  CV/resp system-specific reference panels, mark-all-normal + summary,
  and a mocked /api/generate-pe-narrative round-trip.

Client tsc -b + vite build clean. Bundle 580.30 kB / 166.08 kB gz
(up ~100 kB from the shell-only port — the 1000-line PE_DATA is the
bulk; acceptable for the clinical reference data it surfaces).
This commit is contained in:
Daniel 2026-04-24 01:21:43 +02:00
parent 8f612c60a9
commit aafe7981dc
8 changed files with 1652 additions and 146 deletions

View file

@ -0,0 +1,90 @@
// ============================================================
// PE_DATA parity counts — catches the class of bug where an LLM
// silently drops entries from a long clinical array during a port.
// Numbers captured 2026-04-24 against public/js/peGuide.js commit
// 313ba7f. If the vanilla source changes, update both files in the
// same commit.
// ============================================================
import { describe, expect, it } from 'vitest';
import { PE_DATA, AGE_GROUP_ORDER, SYSTEM_ORDER } from './pe-data';
describe('PE_DATA shape', () => {
it('has the six expected age groups', () => {
expect(Object.keys(PE_DATA).sort()).toEqual([...AGE_GROUP_ORDER].sort());
});
it.each([...AGE_GROUP_ORDER])('%s has all four systems', (age) => {
const group = PE_DATA[age];
for (const s of SYSTEM_ORDER) {
expect(group[s]).toBeDefined();
expect(group[s].overview.length).toBeGreaterThan(0);
expect(Array.isArray(group[s].components)).toBe(true);
expect(group[s].components.length).toBeGreaterThan(0);
}
});
it('component + abnormalHints + pearl + significance counts match vanilla', () => {
let componentCount = 0;
let pearlCount = 0;
let significanceCount = 0;
for (const age of AGE_GROUP_ORDER) {
for (const sys of SYSTEM_ORDER) {
const comps = PE_DATA[age][sys].components;
for (const c of comps) {
componentCount++;
if (c.pearl) pearlCount++;
if (c.significance) significanceCount++;
expect(Array.isArray(c.steps)).toBe(true);
expect(c.steps.length).toBeGreaterThan(0);
expect(Array.isArray(c.abnormalHints)).toBe(true);
}
}
}
// Locked against vanilla peGuide.js (2026-04-24):
// 103 components, 27 pearl, 23 significance.
expect(componentCount).toBe(103);
expect(pearlCount).toBe(27);
expect(significanceCount).toBe(23);
});
});
// Per-age-group × per-system component counts — captured from the
// legacy file with:
// awk 'NR>=316 && NR<=1334' public/js/peGuide.js |
// awk '/^ [a-z]+: \{$/{age=$1} /^ [a-z]+: \{$/{sys=$1}
// /^ { name:/{c[age" "sys]++} END{for(k in c) print k" "c[k]}' | sort
// so any drift in the TS port surfaces as a failing test here.
describe('PE_DATA per-cell component counts', () => {
const EXPECTED: Record<string, number> = {
'newborn msk': 6,
'newborn neuro': 6,
'newborn resp': 2,
'newborn cv': 2,
'infant msk': 4,
'infant neuro': 5,
'infant resp': 2,
'infant cv': 2,
'toddler msk': 5,
'toddler neuro': 7,
'toddler resp': 2,
'toddler cv': 2,
'preschool msk': 5,
'preschool neuro': 7,
'preschool resp': 2,
'preschool cv': 2,
'school msk': 5,
'school neuro': 7,
'school resp': 3,
'school cv': 2,
'adolescent msk': 6,
'adolescent neuro': 8,
'adolescent resp': 6,
'adolescent cv': 5,
};
it.each(Object.entries(EXPECTED))('%s matches', (key, expected) => {
const [age, sys] = key.split(' ') as ['newborn', 'msk'];
expect(PE_DATA[age][sys].components.length).toBe(expected);
});
});

1082
client/src/data/pe-data.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,37 @@
// ============================================================
// PHYSICAL EXAM GUIDE — minimum-viable React port.
// PHYSICAL EXAM GUIDE — full React port.
//
// What lands in this commit:
// • Scales reference — MRC, DTR, Plantar, Beighton, ATR, RR by age,
// SpO2, Silverman, Westley, murmur Levine grade, pulse amp, cap refill
// • APTM auscultation-points legend (5 classic cardiac points)
// • Innocent murmurs of childhood reference
// • Respiratory sounds library with audio playback
// • Cardiac sounds library with audio playback
// Renders:
// • Age-group + system pills (6 × 4 = 24 combinations)
// • System overview banner
// • CV system extras: APTM legend, cardiac sounds, innocent murmurs
// • Resp system extras: respiratory sounds library
// • Collapsible grading-scales reference (system-scoped)
// • Component checklist with per-step normal / abnormal / (unset)
// toggle, abnormal-hints hint list, pearl + significance callouts
// • Patient age / gender + model inputs
// • Generate Exam Report → POST /api/generate-pe-narrative
//
// What is intentionally NOT in this commit:
// • PE_DATA — the ~1000-line age-group × system × component × step
// hierarchy. The migration checkpoint memory flags this as the
// class of data where an LLM silently drops entries; it belongs in
// its own session with per-entry counts verified against the
// vanilla source. The exam-step checklist + Generate-Exam-Report
// flow (POST /api/generate-pe-narrative) continues to live in the
// vanilla app at / until that session lands.
// • APTM SVG diagram — big inline SVG; the letter legend is preserved,
// the pictorial chest outline can follow without clinical risk.
// PE_DATA is the full hierarchy ported verbatim from vanilla
// peGuide.js (see client/src/data/pe-data.ts). Clinical reference
// libraries (scales, APTM, sound files) live in pe-guide.ts.
// ============================================================
import { useRef } from 'react';
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { PeNarrativeOk } from '@/shared/types';
import {
PE_DATA,
AGE_GROUP_ORDER,
SYSTEM_ORDER,
SYSTEM_LABELS,
type PeComponent,
type PeStep,
} from '@/data/pe-data';
import {
SCALES,
SYSTEM_SCALES,
APTM_LEGEND,
INNOCENT_MURMURS,
RESP_SOUNDS,
@ -33,18 +41,28 @@ import {
} from '@/data/pe-guide';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const pill = 'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
type StepStatus = 'normal' | 'abnormal' | null;
// Key used to identify a step in the status map across age-group / system.
function stepKey(age: string, sys: string, componentIdx: number, stepIdx: number) {
return `${age}/${sys}/${componentIdx}/${stepIdx}`;
}
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
return (
<section className={card} data-testid={'scale-' + id}>
<h3 className="text-base font-semibold">{scale.title}</h3>
<table className="w-full text-sm">
<section className="rounded-md border border-border bg-background p-3" data-testid={'scale-' + id}>
<h4 className="text-sm font-semibold mb-2">{scale.title}</h4>
<table className="w-full text-xs">
<tbody>
{scale.rows.map(([label, desc], i) => (
{scale.rows.map(([labelText, desc], i) => (
<tr key={i} className="border-b border-border last:border-0">
<td className="py-1.5 pr-3 font-mono font-semibold whitespace-nowrap">{label}</td>
<td className="py-1.5 text-muted-foreground">{desc}</td>
<td className="py-1 pr-3 font-mono font-semibold whitespace-nowrap">{labelText}</td>
<td className="py-1 text-muted-foreground">{desc}</td>
</tr>
))}
</tbody>
@ -54,119 +72,399 @@ function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
}
function SoundCard({ entry }: { entry: SoundEntry }) {
const ref = useRef<HTMLAudioElement>(null);
return (
<section className={card} data-testid={'sound-' + entry.key}>
<h4 className="text-sm font-semibold">{entry.title}</h4>
<audio ref={ref} controls preload="none" className="w-full">
<div className="rounded-md border border-border bg-background p-3 space-y-2" data-testid={'sound-' + entry.key}>
<div className="text-sm font-semibold">{entry.title}</div>
<audio controls preload="none" className="w-full">
<source src={entry.src} />
Your browser does not support HTML5 audio.
</audio>
<div className="text-xs space-y-1">
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Where:</span> {entry.where}</div>
{entry.rate && (
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Rate:</span> {entry.rate}</div>
)}
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Features:</span> {entry.features}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Clinical:</span> {entry.clinical}</div>
<div className="text-xs space-y-0.5 text-muted-foreground">
<div><span className="font-semibold">Where:</span> {entry.where}</div>
{entry.rate && <div><span className="font-semibold">Rate:</span> {entry.rate}</div>}
<div><span className="font-semibold">Features:</span> {entry.features}</div>
<div><span className="font-semibold">Clinical:</span> {entry.clinical}</div>
</div>
</section>
</div>
);
}
function StepRow({
step,
status,
onStatus,
}: {
step: PeStep;
status: StepStatus;
onStatus: (next: StepStatus) => void;
}) {
const base = 'text-xs font-medium px-2 py-1 rounded border';
return (
<div className="flex items-start gap-2 py-2 border-b border-border last:border-0">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{step.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold uppercase tracking-wide">Method:</span> {step.method}
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold uppercase tracking-wide">Normal:</span> {step.normal}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-1 flex-shrink-0">
<button
type="button"
onClick={() => onStatus(status === 'normal' ? null : 'normal')}
className={
base + ' ' +
(status === 'normal'
? 'bg-green-600 text-white border-green-600'
: 'border-green-600 text-green-700 hover:bg-green-50 dark:hover:bg-green-950/30')
}
>
Normal
</button>
<button
type="button"
onClick={() => onStatus(status === 'abnormal' ? null : 'abnormal')}
className={
base + ' ' +
(status === 'abnormal'
? 'bg-destructive text-white border-destructive'
: 'border-destructive text-destructive hover:bg-red-50 dark:hover:bg-red-950/30')
}
>
Abnormal
</button>
</div>
</div>
);
}
function ComponentCard({
age,
sys,
idx,
comp,
getStatus,
setStatus,
}: {
age: string;
sys: string;
idx: number;
comp: PeComponent;
getStatus: (k: string) => StepStatus;
setStatus: (k: string, next: StepStatus) => void;
}) {
return (
<div className={card} data-testid={`pe-component-${age}-${sys}-${idx}`}>
<h3 className="text-base font-semibold">{comp.name}</h3>
<div>
{comp.steps.map((step, si) => {
const k = stepKey(age, sys, idx, si);
return (
<StepRow
key={si}
step={step}
status={getStatus(k)}
onStatus={(next) => setStatus(k, next)}
/>
);
})}
</div>
{comp.abnormalHints.length > 0 && (
<div className="rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-destructive mb-1">Watch for</div>
<ul className="list-disc pl-5 text-xs text-red-900 dark:text-red-200 space-y-0.5">
{comp.abnormalHints.map((h, hi) => <li key={hi}>{h}</li>)}
</ul>
</div>
)}
{comp.pearl && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-800 p-3 text-xs text-amber-900 dark:text-amber-100">
<span className="font-semibold uppercase tracking-wide">Pearl:</span> {comp.pearl}
</div>
)}
{comp.significance && (
<div className="rounded-md bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-900 p-3 text-xs text-sky-900 dark:text-sky-100">
<span className="font-semibold uppercase tracking-wide">Significance:</span> {comp.significance}
</div>
)}
</div>
);
}
export default function PeGuide() {
const [age, setAge] = useState<(typeof AGE_GROUP_ORDER)[number]>('toddler');
const [sys, setSys] = useState<(typeof SYSTEM_ORDER)[number]>('msk');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
const [statusMap, setStatusMap] = useState<Record<string, StepStatus>>({});
const [narrative, setNarrative] = useState<string | null>(null);
const group = PE_DATA[age];
const section = group[sys];
const generate = useMutation({
mutationFn: (body: unknown) => api.post<PeNarrativeOk>('/api/generate-pe-narrative', body),
onSuccess: (data) => setNarrative(data.narrative),
onError: (e: Error) => setNarrative('Generation failed: ' + e.message),
});
const summary = useMemo(() => {
let normal = 0, abnormal = 0, notAssessed = 0;
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => {
const k = stepKey(age, sys, ci, si);
const v = statusMap[k] ?? null;
if (v === 'normal') normal++;
else if (v === 'abnormal') abnormal++;
else notAssessed++;
}),
);
return { normal, abnormal, notAssessed };
}, [age, sys, section, statusMap]);
function reset() {
// Only clear the current system's entries, not all state.
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { delete next[stepKey(age, sys, ci, si)]; }),
);
return next;
});
setNarrative(null);
}
function setAllNormal() {
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { next[stepKey(age, sys, ci, si)] = 'normal'; }),
);
return next;
});
}
function onGenerate() {
setNarrative(null);
const steps: Array<{ component: string; label: string; method: string; normal: string; status: StepStatus; note?: string }> = [];
section.components.forEach((c, ci) =>
c.steps.forEach((st, si) => {
steps.push({
component: c.name,
label: st.label,
method: st.method,
normal: st.normal,
status: statusMap[stepKey(age, sys, ci, si)] ?? null,
});
}),
);
generate.mutate({
steps,
ageGroup: age,
system: sys,
patientAge: patientAge || undefined,
patientGender: patientGender || undefined,
format,
});
}
const totalAssessed = summary.normal + summary.abnormal;
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="max-w-5xl mx-auto p-6 space-y-5">
<header>
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
<p className="text-sm text-muted-foreground">
Reference scales, the five cardiac auscultation points, innocent childhood murmurs, and sound libraries.
Age-group and system-specific exam checklist with abnormal-finding hints. Toggle normal / abnormal
on each step, then generate a narrative for your note.
</p>
</header>
<section className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm space-y-2">
<div className="font-semibold text-amber-900 dark:text-amber-100">Exam-step checklist lives in the legacy viewer</div>
<p className="text-amber-900 dark:text-amber-100">
The full age-group × system checklist and the Generate Exam Report flow still run in the legacy app. They port in a
dedicated session so the clinical content can be verified entry-for-entry against the vanilla source.
</p>
<a
href="/#peGuide"
className={btnPrimary + ' inline-block'}
>
Open checklist in legacy viewer
</a>
</section>
{/* Scales */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Grading scales &amp; reference ranges</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(SCALES).map(([id, scale]) => (
<ScaleCard key={id} id={id} scale={scale} />
{/* Age-group pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Age group</div>
<div className="flex flex-wrap gap-2" data-testid="pe-age-group-pills">
{AGE_GROUP_ORDER.map((g) => (
<button
key={g}
type="button"
onClick={() => setAge(g)}
className={pill + (age === g ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-age-' + g}
>
{PE_DATA[g].label}
</button>
))}
</div>
</div>
{/* System pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">System</div>
<div className="flex flex-wrap gap-2" data-testid="pe-system-pills">
{SYSTEM_ORDER.map((s) => (
<button
key={s}
type="button"
onClick={() => setSys(s)}
className={pill + (sys === s ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-system-' + s}
>
{SYSTEM_LABELS[s]}
</button>
))}
</div>
</div>
{/* Overview */}
<section className={card + ' border-l-4 border-l-primary'} data-testid="pe-overview">
<h2 className="text-lg font-semibold">{group.label} {SYSTEM_LABELS[sys]}</h2>
<p className="text-sm text-muted-foreground">{section.overview}</p>
</section>
{/* APTM legend */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Cardiac auscultation APTM (the five points)</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{APTM_LEGEND.map((a) => (
<div key={a.letter} className={card} data-testid={'aptm-' + a.letter.toLowerCase()}>
<div className="flex items-center gap-3">
{/* System-specific references */}
{sys === 'cv' && (
<section className={card} data-testid="pe-cv-aptm">
<h3 className="text-base font-semibold">Auscultation landmarks (APTM + Erb's)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{APTM_LEGEND.map((p) => (
<div key={p.letter} className="flex gap-3 items-start rounded-md border border-border p-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white"
style={{ background: a.color }}
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white flex-shrink-0"
style={{ background: p.color }}
>
{a.letter}
{p.letter}
</div>
<div className="min-w-0 text-sm">
<div className="font-semibold">{p.title}</div>
<div className="text-xs text-muted-foreground">{p.location}</div>
<div className="text-xs mt-1"><strong>Listen for:</strong> {p.listen}</div>
{p.innocent && <div className="text-xs text-green-700 dark:text-green-300 mt-1"><em>Innocent:</em> {p.innocent}</div>}
</div>
<h3 className="text-base font-semibold">{a.title}</h3>
</div>
<div className="text-sm text-muted-foreground"><strong>Location:</strong> {a.location}</div>
<div className="text-sm text-muted-foreground"><strong>Listen for:</strong> {a.listen}</div>
{a.innocent && (
<div className="text-xs text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30 rounded px-2 py-1">
<strong>Innocent:</strong> {a.innocent}
</div>
)}
</div>
))}
</div>
<h3 className="text-base font-semibold mt-3">Cardiac sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
<h3 className="text-base font-semibold mt-3">Classic innocent murmurs</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{INNOCENT_MURMURS.map((m) => (
<div key={m.name} className="rounded-md border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-3 text-sm space-y-1">
<div className="font-semibold">{m.name}</div>
<div className="text-xs text-muted-foreground">Age: {m.age} · Location: {m.location}</div>
<div className="text-xs"><strong>Sound:</strong> {m.character}</div>
<div className="text-xs"><strong>Confirm innocent:</strong> {m.confirm}</div>
</div>
))}
</div>
</section>
)}
{sys === 'resp' && (
<section className={card} data-testid="pe-resp-sounds">
<h3 className="text-base font-semibold">Respiratory sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
)}
{/* Grading scales (system-scoped, collapsible) */}
{SYSTEM_SCALES[sys] && SYSTEM_SCALES[sys].length > 0 && (
<details className={card} data-testid="pe-scales">
<summary className="cursor-pointer font-semibold text-sm">Grading scales &amp; reference</summary>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
{SYSTEM_SCALES[sys].map((sk: string) => {
const scale = SCALES[sk];
if (!scale) return null;
return <ScaleCard key={sk} id={sk} scale={scale} />;
})}
</div>
</details>
)}
{/* Checklist */}
<section className="space-y-3" data-testid="pe-checklist">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Exam checklist</h2>
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span className="text-green-600">{summary.normal} normal</span>
<span className="text-destructive">{summary.abnormal} abnormal</span>
<span>{summary.notAssessed} not assessed</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={setAllNormal} className={btnGhost} data-testid="btn-pe-all-normal">
Mark all normal
</button>
<button type="button" onClick={reset} className={btnGhost} data-testid="btn-pe-reset">
Reset
</button>
</div>
<div className="grid grid-cols-1 gap-3">
{section.components.map((c, ci) => (
<ComponentCard
key={ci}
age={age}
sys={sys}
idx={ci}
comp={c}
getStatus={(k) => statusMap[k] ?? null}
setStatus={(k, next) => setStatusMap((prev) => ({ ...prev, [k]: next }))}
/>
))}
</div>
</section>
{/* Innocent murmurs */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Innocent murmurs of childhood</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{INNOCENT_MURMURS.map((m) => (
<div key={m.name} className={card}>
<h3 className="text-base font-semibold">{m.name}</h3>
<div className="text-sm space-y-1">
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Age:</span> {m.age}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Location:</span> {m.location}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Character:</span> {m.character}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Confirm benign:</span> {m.confirm}</div>
</div>
</div>
))}
{/* Generate narrative */}
<section className={card} data-testid="pe-generate">
<h2 className="text-lg font-semibold">Generate Exam Report</h2>
<p className="text-sm text-muted-foreground">Uses the statuses above + optional patient context.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
className={input}
placeholder="Patient age (e.g. 3y)"
value={patientAge}
onChange={(e) => setPatientAge(e.target.value)}
/>
<input
className={input}
placeholder="Patient gender (optional)"
value={patientGender}
onChange={(e) => setPatientGender(e.target.value)}
/>
<select
className={input}
value={format}
onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}
>
<option value="narrative">Narrative</option>
<option value="list">List</option>
</select>
</div>
</section>
{/* Respiratory sounds */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Respiratory sounds library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
{/* Cardiac sounds */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Cardiac sounds library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
<div className="flex items-center gap-3">
<button
type="button"
className={btnPrimary}
onClick={onGenerate}
disabled={generate.isPending || totalAssessed === 0}
data-testid="btn-pe-generate"
>
{generate.isPending ? 'Generating…' : 'Generate Exam Report'}
</button>
{totalAssessed === 0 && (
<span className="text-xs text-muted-foreground">Mark at least one step before generating.</span>
)}
</div>
{narrative && (
<div className="rounded-md border border-border bg-muted/40 p-3 whitespace-pre-wrap text-sm" data-testid="pe-narrative">
{narrative}
</div>
)}
</section>
</div>
);

View file

@ -1,48 +1,84 @@
// ============================================================
// PE GUIDE (React port) — smoke tests for the reference page.
//
// Covers the subset that landed in the first commit: scales,
// APTM legend, innocent murmurs, respiratory + cardiac sound
// libraries. The full PE_DATA exam-step checklist is still in
// the legacy viewer and linked to from the amber banner.
// PE GUIDE (React port) — smoke tests for the full checklist.
// Covers age-group + system pills, overview, scales, sound
// libraries, component checklist with status toggles, and the
// Generate Exam Report path (mocked via page.route).
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function openReactPeGuide(page) {
async function openPeGuide(page) {
await page.goto(E2E_BASE + '/app/peguide');
await page.waitForSelector('[data-testid^="scale-"]', { timeout: 15000 });
await page.waitForSelector('[data-testid="pe-age-group-pills"]', { timeout: 15000 });
}
test.describe('React PE Guide — reference content renders', () => {
test.describe('React PE Guide — age-group × system navigation', () => {
test('all 12 scales render (MRC, DTR, Plantar, Beighton, ATR, RR, SpO2, Silverman, Westley, murmur Levine, pulse amp, cap refill)', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
const count = await page.locator('[data-testid^="scale-"]').count();
expect(count).toBe(12);
});
test('APTM legend shows all 5 points (A, P, E, T, M)', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
for (const letter of ['a', 'p', 'e', 't', 'm']) {
await expect(page.locator('[data-testid="aptm-' + letter + '"]')).toBeVisible();
test('all 6 age-group pills render', async ({ authedPage: _, page }) => {
await openPeGuide(page);
for (const age of ['newborn', 'infant', 'toddler', 'preschool', 'school', 'adolescent']) {
await expect(page.locator('[data-testid="pe-age-' + age + '"]')).toBeVisible();
}
});
test('sound libraries: 7 respiratory + 6 cardiac entries', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
const respiratoryKeys = ['normal', 'wheeze', 'stridor', 'finecrackles', 'coarsecrackles', 'rhonchi', 'pleuralrub'];
const cardiacKeys = ['normal', 'infant-normal', 'vsd', 'mvp', 'stills', 'functional'];
for (const k of respiratoryKeys) {
expect(await page.locator('[data-testid="sound-' + k + '"]').count()).toBeGreaterThan(0);
}
for (const k of cardiacKeys) {
expect(await page.locator('[data-testid="sound-' + k + '"]').count()).toBeGreaterThan(0);
test('all 4 system pills render', async ({ authedPage: _, page }) => {
await openPeGuide(page);
for (const sys of ['msk', 'neuro', 'resp', 'cv']) {
await expect(page.locator('[data-testid="pe-system-' + sys + '"]')).toBeVisible();
}
});
test('legacy-viewer link for exam-step checklist is present', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
await expect(page.getByText('Open checklist in legacy viewer')).toBeVisible();
test('switching age group rewrites the overview banner', async ({ authedPage: _, page }) => {
await openPeGuide(page);
await page.click('[data-testid="pe-age-newborn"]');
await expect(page.locator('[data-testid="pe-overview"]')).toContainText('Newborn');
await page.click('[data-testid="pe-age-adolescent"]');
await expect(page.locator('[data-testid="pe-overview"]')).toContainText('Adolescent');
});
test('CV system shows APTM + cardiac sounds + innocent murmurs', async ({ authedPage: _, page }) => {
await openPeGuide(page);
await page.click('[data-testid="pe-system-cv"]');
await expect(page.locator('[data-testid="pe-cv-aptm"]')).toBeVisible();
await expect(page.locator('[data-testid="sound-normal"]').first()).toBeVisible();
});
test('Resp system shows respiratory sound library', async ({ authedPage: _, page }) => {
await openPeGuide(page);
await page.click('[data-testid="pe-system-resp"]');
await expect(page.locator('[data-testid="pe-resp-sounds"]')).toBeVisible();
});
});
test.describe('React PE Guide — checklist + generation', () => {
test('mark-all-normal sets summary counts and enables Generate', async ({ authedPage: _, page }) => {
await openPeGuide(page);
await page.click('[data-testid="pe-age-toddler"]');
await page.click('[data-testid="pe-system-msk"]');
await page.click('[data-testid="btn-pe-all-normal"]');
await expect(page.locator('[data-testid="pe-checklist"]')).toContainText('0 not assessed');
await expect(page.locator('[data-testid="btn-pe-generate"]')).toBeEnabled();
});
test('Generate Exam Report calls /api/generate-pe-narrative', async ({ authedPage: _, page }) => {
await page.route('**/api/generate-pe-narrative', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
model: 'mock-model',
narrative: 'MOCK PE narrative from the React port.',
summary: { normal: 5, abnormal: 0, notAssessed: 0 },
}),
}),
);
await openPeGuide(page);
await page.click('[data-testid="pe-age-toddler"]');
await page.click('[data-testid="pe-system-msk"]');
await page.click('[data-testid="btn-pe-all-normal"]');
await page.click('[data-testid="btn-pe-generate"]');
await expect(page.locator('[data-testid="pe-narrative"]')).toContainText('MOCK PE narrative');
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DmvH7O2M.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-9V57IPOu.css">
<script type="module" crossorigin src="/app/assets/index-Bk7sqZ-P.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-Cun0WMrQ.css">
</head>
<body>
<div id="root"></div>