Files
HealthLog/src/lib/analytics/__tests__/classifications.test.ts
T
Marc-André BombeckandMarc-André Bombeck 8adeb59396 v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations

Three correctness issues that the v1.3.3 ecosystem audit surfaced and
that we deferred until the v1.4 cycle:

1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the
   default age-based targets (DBP 70-79), the lower orange wing was
   computed as `diaLow - 10 = 60`, putting a sustained reading of
   60 mmHg into "mildly low" territory — visually a yellow tile, not a
   red one. 60 mmHg is widely treated as a hypotension / perfusion-risk
   threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in
   ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit
   caution against treating below 70 in the elderly). The orange floor
   is now clamped at 65 mmHg, so a reading of 60 lands in red. The
   user-override path stays untouched and is already audit-logged.

2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew
   from the 2023 authoring; the 2023 hypertension document is ESH-only.
   The codebase had a mix of "ESC/ESH 2018" (analytics) and
   "ESC/ESH 2023" (AI prompts). Both are wrong in different directions.
   Every site (`bp-targets.ts`, `targets/route.ts`,
   `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now
   cites "ESH 2023" with the published source URL. Target bands are
   unchanged 2018 → 2023 — only the citation moves.

3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's
   2020 Physical Activity guidelines publish minutes per week, not a
   step quota. The cohort study HealthLog actually relies on is
   Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000
   steps). The v1.3.3 fix landed in `effective-range.ts` but four AI
   prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN)
   carried the old wording forward. Same fix shape: cite Saint-Maurice
   for the step number and add an explicit "WHO does not publish a
   step quota" note so the model doesn't regress. Sleep target moves
   from "ESC" to AASM 2015 in the same surfaces — ESC publishes no
   adult sleep duration guideline.

Adds `src/lib/medical-citations.ts` as the new single source of truth
for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will
import these constants instead of duplicating strings, and the new
`medical-citations.test.ts` drift-test asserts that every entry has a
non-empty url + caveat, that ids are unique, and that the WHO-as-step-
source hallucination cannot reappear as a constant.

Tests: 358 → 363 (5 new for the master citations list). Typecheck and
existing analytics tests pass. No clinical numbers changed beyond the
DBP orange-floor clamp.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source

Two related medical-truthfulness issues from the v1.3.3 ecosystem audit.

**Body-fat classification: ACE bands corrected.** v1.3.3's classifier
used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential"
category, which is actually ACE's *Athletes* lower bound. Anything
below was lumped under "Essential" — including readings (e.g. 1% male)
that are clinically concerning, not just "athletically extreme". The
classifier now mirrors the ACE table precisely:

- Below essential (M <2 / F <10): danger.
- Essential (M 2–5 / F 10–13): warning.
- Athletic (M 6–13 / F 14–20): normal.
- Fitness (M 14–17 / F 21–24): normal.
- Acceptable (M 18–24 / F 25–31): warning.
- Obese (M ≥25 / F ≥32): danger.

**Three-way drift in body-fat target ranges consolidated.** v1.3.3 had
three sites with three different green-band numbers:

- `value-bands.ts:118-120` returned M{10,20} / F{18,28}.
- `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} /
  F{21,31} with `source: "ACE"`.
- `classifications.ts:155-205` used a third overlapping set of
  thresholds for the band labels.

ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy
band for a non-athlete adult and is now the single source in
`getBodyFatTargetRange`. Targets route and chart value-bands import the
helper instead of duplicating the numbers.

**Bedtime glucose citation softened.** ADA Standards of Care 2024 §6
publishes pre-prandial 80–130 and post-prandial <180; the document does
NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog
ships is reasonable advice (within the daytime pre-prandial range,
slightly raised floor to avoid nocturnal hypoglycaemia) but it's not
ADA-published. The inline comment in `effective-range.ts` now states
the absence of an adult guideline explicitly and cites ISPAD 2022
(pediatric T1D, 80–140) as the closest comparator. Numbers unchanged.

Tests: 363 → 369 (6 new for the ACE bands). Existing assertions
unchanged.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(medical): align step-range target with the effective-range source

`getStepsRange()` in classifications.ts returned `{7 000, 10 000}`
while `effective-range.ts` (the canonical resolver behind
`getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces
showed different "green" bands to the same user — `/api/insights/
targets` used the lower pair, the dashboard chart-bands used the
upper pair.

Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA
2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation
header to the steps section so the next audit pass can verify in
place. WHO is explicitly called out as NOT a source for a step quota
(only an activity-time guideline).

The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5
/ 10 k bands match Tudor-Locke 2004 pedometer indices and are kept
distinct from the two-number "target range" helper.

Tests stay at 369/369.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:07:46 +02:00

222 lines
7.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
classifyBMI,
classifyBP,
classifyBodyFat,
generateAlerts,
} from "../classifications";
describe("classifyBMI", () => {
it("classifies underweight", () => {
const result = classifyBMI(17.5);
expect(result.category).toBe("Underweight");
expect(result.severity).toBe("warning");
});
it("classifies normal weight", () => {
const result = classifyBMI(22);
expect(result.category).toBe("Normal");
expect(result.severity).toBe("normal");
});
it("classifies overweight", () => {
const result = classifyBMI(27);
expect(result.category).toBe("Overweight");
expect(result.severity).toBe("warning");
});
it("classifies obesity grade I", () => {
const result = classifyBMI(32);
expect(result.category).toBe("Obesity Grade I");
expect(result.severity).toBe("danger");
});
it("classifies obesity grade II", () => {
const result = classifyBMI(37);
expect(result.category).toBe("Obesity Grade II");
expect(result.severity).toBe("danger");
});
it("classifies obesity grade III", () => {
const result = classifyBMI(42);
expect(result.category).toBe("Obesity Grade III");
expect(result.severity).toBe("danger");
});
it("handles boundary values", () => {
expect(classifyBMI(18.5).category).toBe("Normal");
expect(classifyBMI(25).category).toBe("Overweight");
expect(classifyBMI(30).category).toBe("Obesity Grade I");
expect(classifyBMI(35).category).toBe("Obesity Grade II");
expect(classifyBMI(40).category).toBe("Obesity Grade III");
});
});
describe("classifyBP", () => {
it("classifies optimal", () => {
const result = classifyBP(115, 75);
expect(result.category).toBe("Optimal");
expect(result.severity).toBe("normal");
});
it("classifies normal", () => {
const result = classifyBP(125, 82);
expect(result.category).toBe("Normal");
expect(result.severity).toBe("normal");
});
it("classifies high-normal", () => {
const result = classifyBP(135, 87);
expect(result.category).toBe("High-normal");
expect(result.severity).toBe("elevated");
});
it("classifies hypertension grade 1", () => {
const result = classifyBP(150, 95);
expect(result.category).toBe("Hypertension Grade 1");
expect(result.severity).toBe("warning");
});
it("classifies hypertension grade 2", () => {
const result = classifyBP(170, 105);
expect(result.category).toBe("Hypertension Grade 2");
expect(result.severity).toBe("danger");
});
it("classifies hypertension grade 3", () => {
const result = classifyBP(185, 115);
expect(result.category).toBe("Hypertension Grade 3");
expect(result.severity).toBe("danger");
});
it("uses the higher category when sys and dia differ", () => {
// Sys is optimal but dia is high
const result = classifyBP(115, 95);
expect(result.category).toBe("Hypertension Grade 1");
});
});
describe("generateAlerts", () => {
it("returns empty for empty input", () => {
expect(generateAlerts({})).toEqual([]);
});
it("generates BMI success alert for normal BMI", () => {
const alerts = generateAlerts({ bmi: 22 });
expect(alerts).toHaveLength(1);
expect(alerts[0].level).toBe("success");
expect(alerts[0].title).toContain("normal range");
});
it("generates warning for underweight BMI", () => {
const alerts = generateAlerts({ bmi: 16 });
expect(alerts[0].level).toBe("warning");
expect(alerts[0].title).toContain("below normal");
});
it("generates warning for obese BMI", () => {
const alerts = generateAlerts({ bmi: 32 });
expect(alerts[0].level).toBe("warning");
expect(alerts[0].title).toContain("obesity");
});
it("generates danger for very high BP", () => {
const alerts = generateAlerts({ bpAvgSys: 175, bpAvgDia: 105 });
const bpAlert = alerts.find((a) => a.title.includes("Blood pressure"));
expect(bpAlert?.level).toBe("danger");
});
it("generates success for good BP target adherence", () => {
const alerts = generateAlerts({ bpPctInTarget: 85 });
const targetAlert = alerts.find((a) =>
a.title.includes("target adherence"),
);
expect(targetAlert?.level).toBe("success");
});
it("generates warning for low BP target adherence", () => {
const alerts = generateAlerts({ bpPctInTarget: 40 });
const targetAlert = alerts.find((a) => a.title.includes("outside"));
expect(targetAlert?.level).toBe("warning");
});
it("generates info for significant weight increase", () => {
const alerts = generateAlerts({ weightSlope30: 0.1 }); // 0.7 kg/week
const weightAlert = alerts.find((a) => a.title.includes("Weight"));
expect(weightAlert?.level).toBe("info");
expect(weightAlert?.title).toContain("increasing");
});
it("generates warning for pulse anomalies", () => {
const alerts = generateAlerts({ pulseAnomalyCount: 5 });
const pulseAlert = alerts.find((a) => a.title.includes("Pulse"));
expect(pulseAlert?.level).toBe("warning");
});
it("generates medication compliance alerts", () => {
const alerts = generateAlerts({
medications: [
{ name: "Metoprolol", compliance7: 60, compliance30: 70 },
{ name: "Ramipril", compliance7: 98, compliance30: 95 },
],
});
const lowAlert = alerts.find(
(a) => a.title.includes("Metoprolol") && a.level === "warning",
);
const highAlert = alerts.find(
(a) => a.title.includes("Ramipril") && a.level === "success",
);
expect(lowAlert).toBeDefined();
expect(highAlert).toBeDefined();
});
});
// v1.4 marathon — closes the body-fat ACE drift identified in the
// medical-truthfulness audit. Below-essential is now a danger band,
// "Essential" is the warning band proper, and the labels match ACE.
describe("classifyBodyFat (ACE bands)", () => {
it("flags below-essential as danger for males", () => {
expect(classifyBodyFat(1, "MALE").category).toBe("Below essential");
expect(classifyBodyFat(1, "MALE").severity).toBe("danger");
});
it("flags below-essential as danger for females", () => {
expect(classifyBodyFat(8, "FEMALE").category).toBe("Below essential");
expect(classifyBodyFat(8, "FEMALE").severity).toBe("danger");
});
it("classifies the ACE essential band as a warning, not normal", () => {
expect(classifyBodyFat(4, "MALE").category).toBe("Essential");
expect(classifyBodyFat(4, "MALE").severity).toBe("warning");
expect(classifyBodyFat(11, "FEMALE").category).toBe("Essential");
expect(classifyBodyFat(11, "FEMALE").severity).toBe("warning");
});
it("recognises ACE Athletic and Fitness as normal", () => {
expect(classifyBodyFat(10, "MALE").category).toBe("Athletic");
expect(classifyBodyFat(15, "MALE").category).toBe("Fitness");
expect(classifyBodyFat(17, "FEMALE").category).toBe("Athletic");
expect(classifyBodyFat(22, "FEMALE").category).toBe("Fitness");
for (const result of [
classifyBodyFat(10, "MALE"),
classifyBodyFat(15, "MALE"),
classifyBodyFat(17, "FEMALE"),
classifyBodyFat(22, "FEMALE"),
]) {
expect(result.severity).toBe("normal");
}
});
it("acceptable band uses the new label and warning severity", () => {
expect(classifyBodyFat(20, "MALE").category).toBe("Acceptable");
expect(classifyBodyFat(20, "MALE").severity).toBe("warning");
expect(classifyBodyFat(28, "FEMALE").category).toBe("Acceptable");
expect(classifyBodyFat(28, "FEMALE").severity).toBe("warning");
});
it("classifies obesity by ACE upper bound", () => {
expect(classifyBodyFat(30, "MALE").category).toBe("Obese");
expect(classifyBodyFat(35, "FEMALE").category).toBe("Obese");
});
});