/** * Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android) * — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61, * fenêtre bornée aux labs, indépendance du scaleFactor. */ import { test, describe, before } from 'node:test'; import assert from 'node:assert/strict'; import { initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose, } from './helpers.js'; import { e2At } from '../js/pk/pk-engine.js'; import { computeLabAnchoredCurve, ratioAt, } from '../js/pk/lab-trajectory-model.js'; /** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */ function treatment(scaleFactor = 1.0) { return makeTreatment({ scaleFactor }); } function doses() { return [makeDose(0, 5)]; } const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts); const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts); describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => { before(() => initProfiles()); test('passe exactement sur chaque lab', () => { const t2 = 2 * DAY_MS; const t7 = 7 * DAY_MS; const labs = [lab(300, t2), lab(260, t7)]; const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); assert.equal(curve.anchoredLabs, 2); assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1'); assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2'); }); test('rho interpole en log entre deux labs (math exacte)', () => { const t2 = 2 * DAY_MS; const t7 = 7 * DAY_MS; const tr = treatment(); // Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence) const m2 = e2At([tr], doses(), t2, null, null); const m7 = e2At([tr], doses(), t7, null, null); const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)]; // 1) La fonction PURE ratioAt interpole exactement const pure = [[t2, 0.9], [t7, 1.2]]; const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0); assertClose(ratioAt(pure, t2), 0.9, 1e-12); assertClose(ratioAt(pure, t7), 1.2, 1e-12); assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9); // 2) La courbe restitue le même ρ const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); const mid = curve.points[Math.trunc(curve.points.length / 2)]; const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null); assertClose(mid.e2 / m, expectedMid, 1e-9); }); test('un seul lab significatif donne une courbe vide', () => { const curve = computeLabAnchoredCurve([treatment()], doses(), [lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS); assert.equal(curve.points.length, 0); assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas'); }); test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => { const t2 = 2 * DAY_MS; const t7 = 7 * DAY_MS; const curve = computeLabAnchoredCurve([treatment()], doses(), [lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS); assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté'); assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde'); assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif'); }); test('labs avant la première dose ne produisent aucun ancrage', () => { const curve = computeLabAnchoredCurve([treatment()], doses(), [lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS); assert.equal(curve.points.length, 0); assert.equal(curve.anchoredLabs, 0); }); test('la fenêtre du tracé est bornée par les labs et par la demande', () => { const t2 = 2 * DAY_MS; const t7 = 7 * DAY_MS; const labs = [lab(160, t2), lab(140, t7)]; const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS); assert.ok(wide.points.length > 0); assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7)); const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS); assert.ok(narrow.points.length > 0); assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS)); }); test('indépendant du scaleFactor stocké (sinon double correction)', () => { const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)]; const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement'); }); test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => { // ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8) const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' }; const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)]; const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE'); const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS'); assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6); assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6); const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2; const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2; const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs)); assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`); }); test('les labs T sont ignorés (E2 uniquement)', () => { const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)]; const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); assert.equal(curve.anchoredLabs, 2); }); test('doublons de timestamp - le deuxième ratio gagne', () => { const t = 5 * DAY_MS; const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]]; assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12); const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2))); assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9); }); test('ratioAt - gardes defensives aux bornes', () => { const anchors = [[0, 1.0], [10, 2.0]]; assertClose(ratioAt(anchors, -5), 1.0, 1e-12); assertClose(ratioAt(anchors, 100), 2.0, 1e-12); }); });