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:
parent
8f612c60a9
commit
aafe7981dc
8 changed files with 1652 additions and 146 deletions
90
client/src/data/pe-data.test.ts
Normal file
90
client/src/data/pe-data.test.ts
Normal 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
1082
client/src/data/pe-data.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
2
public/app/assets/index-Cun0WMrQ.css
Normal file
2
public/app/assets/index-Cun0WMrQ.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue