pediatric-ai-scribe-v3/test/calc-math.test.js
Daniel 54285865d5 feat: B — extract drug data to public/data/drugs.json (schema v1.0)
Moved 43 weight-based drug entries out of calculators.js string literals
into a structured JSON reference. Renderers now iterate JSON → S.drugRow.

Sections extracted (43 drugs):
- anaphylaxis (7), sedation (11), agitation (11), antiemetics (7), seizure (7)

Out of scope: seizure refractory drips (compound strings), NRP, antimicrobial
empiric regimens (already structured), airway/cardiac/tox/trauma/respiratory
/sepsis/burns sections (fine as-is or no drugs).

New:
- public/data/drugs.json — { version, last_reviewed, sections.<key>.drugs[] }
- public/js/drugs-loader.js — fetches JSON on boot, exposes window._DRUGS +
  window._DRUGS_READY Promise. Non-fatal: each calc function has a matching
  *_FALLBACK constant so a 404 on drugs.json doesn't break anything.

Schema:
- dose_mg_per_kg | dose_mg_per_kg_low/high (for ranges) | max_mg | unit |
  route | notes | source | optional special-cases (weight bands, age-dep text)

Added one unit test asserting drugs.json loads + has all 5 sections with
non-empty drugs arrays. 37/37 unit tests + 26/26 Playwright e2e tests pass.
2026-04-20 04:23:24 +02:00

184 lines
8.7 KiB
JavaScript

// ============================================================
// Unit tests for public/js/calc-math.js (pediatric dose math).
// Run with: npm test (alias for: node --test test/)
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const M = require('../public/js/calc-math.js');
// ── estimateWeightFromAgeMonths ────────────────────────────────
test('APLS 0-12 mo: 6 mo -> 7 kg', () => {
const r = M.estimateWeightFromAgeMonths(6);
assert.equal(r.weight, 7); // 0.5*6 + 4 = 7
assert.match(r.formulaLabel, /0-12 mo/);
});
test('APLS 1-5 yr: 3 yr -> 14 kg', () => {
const r = M.estimateWeightFromAgeMonths(36);
assert.equal(r.weight, 14); // 2*3 + 8 = 14
assert.match(r.formulaLabel, /1-5 yr/);
});
test('APLS 6-12 yr: 8 yr -> 31 kg', () => {
const r = M.estimateWeightFromAgeMonths(96);
assert.equal(r.weight, 31); // 3*8 + 7 = 31
assert.match(r.formulaLabel, /6-12 yr/);
});
test('Best Guess 14 yr -> 56 kg (switches away from APLS at >=13 yr)', () => {
const r = M.estimateWeightFromAgeMonths(168);
assert.equal(r.weight, 56); // 4 * 14 = 56
assert.match(r.formulaLabel, /Best Guess/);
});
test('APLS and Best Guess both reported', () => {
const r = M.estimateWeightFromAgeMonths(48);
assert.equal(r.all.apls, 16); // 2*4 + 8
assert.equal(r.all.bestGuess, 18); // 2*(4+5)
});
test('Null/negative age returns null', () => {
assert.equal(M.estimateWeightFromAgeMonths(null), null);
assert.equal(M.estimateWeightFromAgeMonths(-5), null);
});
// ── Parkland ────────────────────────────────────────────────────
test('Parkland 20kg, 25% TBSA -> 2000 mL total, 1000 first 8h', () => {
const p = M.parkland(20, 25);
assert.equal(p.totalMl, 2000); // 4 * 20 * 25
assert.equal(p.first8Ml, 1000);
assert.equal(p.next16Ml, 1000);
assert.equal(p.rateFirst8, 125); // 1000 / 8
assert.equal(p.rateNext16, 63); // 1000 / 16 = 62.5 -> 63
});
test('Parkland invalid inputs return null', () => {
assert.equal(M.parkland(0, 25), null);
assert.equal(M.parkland(20, 0), null);
assert.equal(M.parkland(null, 25), null);
});
// ── Maintenance 4-2-1 ──────────────────────────────────────────
test('Maintenance: 8 kg -> 32 mL/hr', () => { assert.equal(M.maintenance421(8), 32); });
test('Maintenance: 15 kg -> 50 mL/hr', () => { assert.equal(M.maintenance421(15), 50); });
test('Maintenance: 25 kg -> 65 mL/hr', () => { assert.equal(M.maintenance421(25), 65); });
test('Maintenance: 70 kg -> 110 mL/hr', () => { assert.equal(M.maintenance421(70), 110); });
// ── PRAM ────────────────────────────────────────────────────────
test('PRAM all zero -> Mild (0)', () => {
const r = M.pramScore(0, 0, 0, 0, 0);
assert.equal(r.total, 0); assert.equal(r.severity, 'Mild');
});
test('PRAM total 5 -> Moderate', () => {
const r = M.pramScore(1, 2, 0, 1, 1);
assert.equal(r.total, 5); assert.equal(r.severity, 'Moderate');
});
test('PRAM total 10 -> Severe', () => {
const r = M.pramScore(2, 2, 2, 2, 2);
assert.equal(r.total, 10); assert.equal(r.severity, 'Severe');
});
// ── Westley ─────────────────────────────────────────────────────
test('Westley 2 -> Mild', () => { assert.equal(M.westleyScore(0, 0, 1, 0, 1).severity, 'Mild'); });
test('Westley 4 -> Moderate', () => { assert.equal(M.westleyScore(0, 0, 2, 1, 1).severity, 'Moderate'); });
test('Westley 8 -> Severe', () => { assert.equal(M.westleyScore(0, 4, 2, 1, 1).severity, 'Severe'); });
test('Westley 12 -> Impending respiratory failure', () => { assert.equal(M.westleyScore(5, 4, 0, 1, 2).severity, 'Impending respiratory failure'); });
// ── Epinephrine (anaphylaxis vs arrest) ─────────────────────────
test('Epi anaphylaxis IM: 20 kg -> 0.2 mg (1:1000)', () => {
const r = M.epiAnaphylaxisIM(20);
assert.equal(r.doseMg, 0.2);
assert.equal(r.volumeMl_1in1000, 0.2); // 1 mg/mL concentration
});
test('Epi anaphylaxis IM: max 0.5 mg for >=50 kg', () => {
assert.equal(M.epiAnaphylaxisIM(80).doseMg, 0.5);
});
test('Epi arrest IV: 10 kg -> 0.1 mg, 1 mL of 1:10,000', () => {
const r = M.epiArrestIV(10);
assert.equal(r.doseMg, 0.1);
assert.equal(r.volumeMl_1in10000, 1); // 0.1 mg/mL concentration
});
test('Epi arrest IV: max 1 mg for >=100 kg', () => {
assert.equal(M.epiArrestIV(120).doseMg, 1);
});
// ── NRP epi ──────────────────────────────────────────────────────
test('NRP epi: 3 kg newborn -> 0.03-0.09 mg IV (0.3-0.9 mL of 1:10,000)', () => {
const r = M.nrpEpiIV(3);
assert.equal(r.lowMg, 0.03);
assert.equal(r.highMg, 0.09);
assert.equal(r.lowMl, 0.3);
assert.equal(r.highMl, 0.9);
});
// ── RSI drugs ────────────────────────────────────────────────────
test('Ketamine RSI 20 kg -> 30-40 mg', () => {
const r = M.rsiKetamine(20);
assert.equal(r.lowMg, 30);
assert.equal(r.highMg, 40);
});
test('Rocuronium 25 kg -> 30 mg (1.2 mg/kg)', () => {
assert.equal(M.rsiRocuronium(25).mg, 30);
});
test('Succinylcholine dose/kg: <10 kg = 2 mg/kg; >=10 kg = 1.5 mg/kg', () => {
assert.equal(M.rsiSuccinylcholine(8).perKg, 2);
assert.equal(M.rsiSuccinylcholine(20).perKg, 1.5);
assert.equal(M.rsiSuccinylcholine(20).mg, 30); // 20*1.5
});
// ── Min systolic BP ─────────────────────────────────────────────
test('Min SBP: 5 yr -> 80', () => { assert.equal(M.minSBP(5), 80); });
test('Min SBP: infant (<1 yr) -> 70', () => { assert.equal(M.minSBP(0.5), 70); });
test('Min SBP: 12 yr -> 90', () => { assert.equal(M.minSBP(12), 90); });
// ── ETT sizing ──────────────────────────────────────────────────
test('ETT 4 yr: uncuffed 5.0, cuffed 4.5, depth 15', () => {
const r = M.ettSize(4);
assert.equal(r.uncuffedMm, 5);
assert.equal(r.cuffedMm, 4.5);
assert.equal(r.depthCm, 15);
});
// ── Lund-Browder TBSA ───────────────────────────────────────────
test('Lund-Browder: full head infant = 18%', () => {
assert.equal(M.lundBrowderTBSA('infant', { head: 100 }), 18);
});
test('Lund-Browder: full head adult = 7%', () => {
assert.equal(M.lundBrowderTBSA('adult', { head: 100 }), 7);
});
test('Lund-Browder: full body adds to ~100% (infant) — tolerance for published rounding', () => {
const all = {};
Object.keys(M.LB).forEach((k) => { all[k] = 100; });
const t = M.lundBrowderTBSA('infant', all);
// Published charts typically sum to 99-100% depending on head % (18 vs 19 at 0 yr vs 1 yr).
assert.ok(t >= 98 && t <= 100.5, 'expected 98-100.5, got ' + t);
});
test('Lund-Browder: 50% of both thighs young child = 8%', () => {
// young (1-5) thigh = 8% each, so 50% of each = 4% + 4% = 8%
assert.equal(M.lundBrowderTBSA('young', { r_thigh: 50, l_thigh: 50 }), 8);
});
test('Lund-Browder: invalid bracket returns null', () => {
assert.equal(M.lundBrowderTBSA('bogus', { head: 50 }), null);
});
// ── drugs.json schema ───────────────────────────────────────────
// Asserts the JSON is loadable and that each of the 5 extracted
// bedside sections exists with a non-empty drugs array.
test('drugs.json: schema loads with all 5 extracted sections + non-empty drugs', () => {
const fs = require('node:fs');
const path = require('node:path');
const raw = fs.readFileSync(path.join(__dirname, '..', 'public', 'data', 'drugs.json'), 'utf8');
const j = JSON.parse(raw);
assert.equal(j.version, '1.0');
assert.ok(j.sections, 'top-level sections object present');
['anaphylaxis', 'sedation', 'agitation', 'antiemetics', 'seizure'].forEach((key) => {
const sec = j.sections[key];
assert.ok(sec, `section ${key} present`);
assert.ok(Array.isArray(sec.drugs) && sec.drugs.length > 0, `${key}.drugs non-empty`);
});
});