HormoneTrack-web/tests/lab-trajectory-model.test.js
Siphonight dfef8e90ef v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) :
- js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les
  labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors
  fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab],
  < 2 labs → vide, indépendante de la calibration (sinon double
  correction). JAMAIS dans levelAt/alertes.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles +
  scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ;
  couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE
  de la boucle légende (sinon légende TFS dupliquée).
- 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total).
- WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README).

Détail d'exécution épingle: les deux problèmes du portage ont été
attrapés par node --check/E2E avant release (imports ESM faux —
labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
2026-09-11 16:17:36 +02:00

141 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});