HormoneTrack-web/tests/pk-engine.test.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

273 lines
13 KiB
JavaScript
Raw Permalink 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 du moteur PK (miroir de PharmacokineticEngineTest.kt) :
* Bateman (bisection — attrape la bisection inversée #19), superposition,
* dispatch des 3 modèles, ester override par dose, modèle T monotone/borné,
* prévision (créneaux passés sautés #35), coupures.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import {
initProfiles, makeTreatment, makeDose, assertClose, HOUR_MS, DAY_MS,
} from './helpers.js';
import {
computeKa, batemanParams, concentrationOfDose, e2At, testosteroneAt,
convertTToNgMl, computeCurve, generateForecastDoses, activeEsterAt,
levelAt, currentLevel, doseEster, cutoffHours, Esters, PKModels, TConfig,
} from '../js/pk/index.js';
import * as TFS from '../js/pk/transfem-science-models.js';
describe('computeKa (bisection Bateman)', () => {
test('pic Bateman ≈ Tmax (attrape la bisection inversée, bug #19)', () => {
const treatment = makeTreatment({
route: 'TRANSDERMAL_GEL', esterType: 'NONE',
absorptionHours: 8, eliminationHalfLifeHours: 24, bioavailabilityFraction: 0.9,
});
const p = batemanParams(treatment);
// Le max de C(dt) doit tomber à ~8 h, pas à ~0 h
let peakH = 0;
let peakV = 0;
for (let h = 0; h <= 48; h += 0.1) {
const dose = makeDose(0, 1);
const c = concentrationOfDose(treatment, dose, h * HOUR_MS, p);
if (c > peakV) {
peakV = c;
peakH = h;
}
}
assert.ok(Math.abs(peakH - 8) < 0.6, `pic Bateman = ${peakH.toFixed(1)} h, attendu ≈ 8 h`);
});
test('ke = ln2/t½', () => {
const p = batemanParams(makeTreatment({ absorptionHours: 8, eliminationHalfLifeHours: 24 }));
assertClose(p.ke, Math.log(2) / 24, 1e-9);
});
test('cas dégénéré ka≈ke : pas de division par ~0, courbe en cloche', () => {
const t1 = makeTreatment({ route: 'ORAL', esterType: 'NONE', absorptionHours: 10, eliminationHalfLifeHours: 10.0005, bioavailabilityFraction: 0.5 });
const c1 = concentrationOfDose(t1, makeDose(0, 2), 5 * HOUR_MS);
const c2 = concentrationOfDose(t1, makeDose(0, 2), 10 * HOUR_MS);
assert.ok(c1 > 0 && c2 > 0, 'valeurs finies positives');
assert.ok(Number.isFinite(c1) && Number.isFinite(c2));
});
});
describe('dispatch concentrationOfDose (3 modèles superposables)', () => {
before(() => initProfiles());
test('TFS : EV 5 mg → pic ≈ 5×59 pg/mL (via le MOTEUR, pas directement TFS)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', doseAmount: 5 });
let peakV = 0;
for (let h = 0; h <= 14 * 24; h += 0.5) {
const c = concentrationOfDose(tr, makeDose(0, 5), h * HOUR_MS);
if (c > peakV) peakV = c;
}
assertClose(peakV, 295, 0.05, 'pic EV TFS via moteur');
});
test('ESE : EV 5 mg → pic ≈ 5×61,1 pg/mL (tables ODS)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'ESE', doseAmount: 5 });
let peakV = 0;
for (let h = 0; h <= 14 * 24; h += 0.5) {
const c = concentrationOfDose(tr, makeDose(0, 5), h * HOUR_MS);
if (c > peakV) peakV = c;
}
assertClose(peakV, 5 * 61.12, 0.5, 'pic EV ESE via moteur');
});
test('modelOverride : un traitement ESE force en TFS donne le pic TFS (et réciproquement)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'ESE', doseAmount: 5 });
const cTfs = concentrationOfDose(tr, makeDose(0, 5), 51 * HOUR_MS, null, 'TFS');
const cEse = concentrationOfDose(tr, makeDose(0, 5), 51 * HOUR_MS);
assert.ok(Math.abs(cTfs - cEse) > 1, `override doit changer la valeur (${cEse} → ${cTfs})`);
// TFS à 51 h ≈ autour du pic 2,1 j ≈ 295 ; contrôle de cohérence directe
assertClose(cTfs, TFS.sample('EV', 51) * 5, 1e-9);
});
test('WHS dispatch (v1.4.6) : coupure 10 × t½, PEP → 0', () => {
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'WHS', doseAmount: 5 });
const c = concentrationOfDose(tr, makeDose(0, 5), 5 * DAY_MS);
assertClose(c, 188, 2, 'pic EEn WHSAH via moteur ~5 j');
// coupure : t½ EEn WHS 7,34 j → 10 t½ = 73,4 j ; à 80 j → 0 (cutoff)
const cut = cutoffHours(tr);
assertClose(cut, 7.34 * 24 * 10, 0.5, 'cutoff WHSAH = 10 t½');
const trPep = makeTreatment({ esterType: 'PEP', pkModel: 'WHS', doseAmount: 5 });
assert.equal(concentrationOfDose(trPep, makeDose(0, 5), 10 * DAY_MS), 0);
});
test('anti-androgène → 0 en E2 (type != ESTRADIOL ignoré par e2At)', () => {
const tr = makeTreatment({ type: 'ANTI_ANDROGEN', route: 'ORAL', esterType: 'NONE' });
assert.equal(e2At([tr], [makeDose(0, 10)], 12 * HOUR_MS), 0);
});
test('superposition de deux doses + linéarité du scaleFactor', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', doseAmount: 5, scaleFactor: 1.0 });
const t = 100 * HOUR_MS;
const one = e2At([tr], [makeDose(0, 5)], t);
const two = e2At([tr], [makeDose(0, 5), makeDose(HOUR_MS, 5)], t + HOUR_MS);
assert.ok(two > one, 'deux doses > une dose');
const trScaled = { ...tr, scaleFactor: 0.72 };
assertClose(e2At([trScaled], [makeDose(0, 5)], t), one * 0.72, 1e-9, 'scaleFactor linéaire');
});
test('override d\'ESTER par dose (EV≫EU à 45 h)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
const doseEv = makeDose(0, 1, { esterType: 'EV' });
const doseEu = makeDose(0, 1, { esterType: 'EU' });
const cEv = concentrationOfDose(tr, doseEv, 45 * HOUR_MS);
const cEu = concentrationOfDose(tr, doseEu, 45 * HOUR_MS);
assert.ok(cEv > cEu * 4, `EV à 45 h (${cEv}) doit ≫ EU (${cEu})`);
assert.equal(doseEster(tr, { ...doseEu, esterType: null }), 'EV', 'null = ester du traitement');
});
test('dt ≤ 0 (dose future) et dose ≤ 0 → 0', () => {
const tr = makeTreatment();
assert.equal(concentrationOfDose(tr, makeDose(Date.now() + HOUR_MS, 5), Date.now()), 0);
assert.equal(concentrationOfDose(tr, makeDose(0, 0), 5 * HOUR_MS), 0);
});
});
describe('modèle T empirique', () => {
const cfg = new TConfig(6.0, 0.2, 0.19);
test('monotone décroissante en E2, bornée (floor, base)', () => {
let prev = testosteroneAt(0, cfg);
assert.equal(prev, 6.0, 'T(0) = base');
for (let e2 = 10; e2 <= 2000; e2 += 10) {
const t = testosteroneAt(e2, cfg);
assert.ok(t < prev, `décroissance attendue à E2=${e2}`);
assert.ok(t > cfg.floor, 'au-dessus du plancher');
prev = t;
}
// T ≈ 0,4 à E2 ≈ 150 (valeur de référence de la doc)
assertClose(testosteroneAt(150, cfg), 0.42, 0.08, 'T à E2=150');
});
test('convertTToNgMl : ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884, pg/mL défensif ÷1000 (bugs #23/#26)', () => {
assertClose(convertTToNgMl(44, 'ng/dL'), 0.44, 1e-9);
assertClose(convertTToNgMl(0.44, 'ng/mL'), 0.44, 1e-9);
assertClose(convertTToNgMl(440, 'ng/L'), 0.44, 1e-9);
assertClose(convertTToNgMl(1.5, 'nmol/L'), 0.4326, 1e-9);
assertClose(convertTToNgMl(38, 'pg/mL'), 0.038, 1e-9, 'lab aberrant neutralisé');
assertClose(convertTToNgMl(0.44, 'NG/ML'), 0.44, 1e-9, 'insensible à la casse');
});
test('activeEsterAt : la dernière dose ≤ t définit l\'ester actif', () => {
const trEv = makeTreatment({ id: 1, esterType: 'EV' });
const trEen = makeTreatment({ id: 2, esterType: 'EEN' });
const doses = [
makeDose(0, 2, { id: 10, treatmentId: 1 }),
makeDose(30 * DAY_MS, 5, { id: 11, treatmentId: 2 }),
];
assert.equal(activeEsterAt([trEv, trEen], doses, 10 * DAY_MS), 'EV');
assert.equal(activeEsterAt([trEv, trEen], doses, 31 * DAY_MS), 'EEN');
assert.equal(activeEsterAt([trEv, trEen], doses, 0), 'EV', 'la dose à t=0 compte (≤ t)');
assert.equal(activeEsterAt([trEv, trEen], doses, -1000), null, 'avant la 1ʳᵉ dose');
});
test('levelAt : k de l\'ester actif appliqué au modèle T', () => {
const tr = makeTreatment({ esterType: 'EEN' });
const doses = [makeDose(0, 5)];
const tMs = 5 * DAY_MS;
const base = levelAt([tr], doses, tMs, new TConfig(6, 0.2, 0.19), { EEN: 0.05 });
const withDefaultK = levelAt([tr], doses, tMs, new TConfig(6, 0.2, 0.19), null);
assert.ok(base.t > withDefaultK.t, 'k plus petit → T plus haute');
});
});
describe('computeCurve', () => {
before(() => initProfiles());
test('grille horaire clampée à la 1ʳᵉ dose (rien avant)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
const start = 10 * DAY_MS;
const firstDose = 20 * DAY_MS;
const curve = computeCurve([tr], [makeDose(firstDose, 5)], start, 30 * DAY_MS, HOUR_MS, new TConfig());
assert.equal(curve.length, (30 - 20) * 24 + 1, 'commence à la 1ʳᵉ dose');
assert.ok(curve.every((p) => p.timestamp >= firstDose));
});
test('vide sans doses / sans traitements / fenêtre inversée', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
assert.deepEqual(computeCurve([tr], [], 0, DAY_MS, HOUR_MS, new TConfig()), []);
assert.deepEqual(computeCurve([], [makeDose(0)], 0, DAY_MS, HOUR_MS, new TConfig()), []);
assert.deepEqual(computeCurve([tr], [makeDose(0)], DAY_MS, 0, HOUR_MS, new TConfig()), []);
});
test('inactivation d\'un traitement : la simulation RESTE (bug v1.2.4, régression #3)', () => {
const trInactive = makeTreatment({ isActive: false, esterType: 'EV', pkModel: 'TFS' });
const curve = computeCurve([trInactive], [makeDose(0, 5)], 0, 5 * DAY_MS, HOUR_MS, new TConfig());
assert.ok(curve.length > 0);
assert.ok(Math.max(...curve.map((p) => p.e2)) > 50, 'EV inactif doit être simulé');
});
test('les 3 modèles donnent 3 courbes distinctes (seuil 2 %)', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
const at = 2 * DAY_MS;
const cfg = new TConfig();
const ese = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'ESE' });
const tfs = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'TFS' });
const whs = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'WHS' });
const e2Of = (c) => c[Math.floor(c.length / 2)].e2;
assert.ok(Math.abs(e2Of(ese) - e2Of(tfs)) / e2Of(tfs) > 0.02, 'ESE ≠ TFS');
assert.ok(Math.abs(e2Of(whs) - e2Of(tfs)) / e2Of(tfs) > 0.02, 'WHS ≠ TFS');
});
});
describe('generateForecastDoses (prévision)', () => {
test('grille exacte depuis la dernière dose, strictement future', () => {
const now = 100 * DAY_MS;
const tr = makeTreatment({ forecastIntervalDays: 7 });
const doses = [makeDose(now - 10 * DAY_MS, 5)];
const forecast = generateForecastDoses(tr, doses, now + 30 * DAY_MS, now);
// Dernière dose J-10 → créneaux J-3 (passé, sauté), J+4, J+11, J+18, J+25
assert.deepEqual(
forecast.map((d) => Math.round((d.timestamp - now) / DAY_MS)),
[4, 11, 18, 25],
'créneaux passés sautés (bug #35), rythme exact 7 j',
);
});
test('vide sans intervalle / intervalle ≤ 0 / sans doses', () => {
const now = 100 * DAY_MS;
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: null }), [makeDose(now - 1)], now + 7 * DAY_MS, now), []);
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: 0 }), [makeDose(now - 1)], now + 7 * DAY_MS, now), []);
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: 7 }), [], now + 7 * DAY_MS, now), []);
});
test('l\'override d\'ester de la DERNIÈRE injection est projeté', () => {
const now = 100 * DAY_MS;
const tr = makeTreatment({ forecastIntervalDays: 7, esterType: 'EEN' });
const doses = [makeDose(now - 1 * DAY_MS, 5, { esterType: 'EV' })];
const forecast = generateForecastDoses(tr, doses, now + 14 * DAY_MS, now);
assert.equal(forecast.length, 2, 'créneaux J+6 et J+13');
assert.equal(forecast[0].esterType, 'EV', 'la projection suit la dernière injection réelle');
assert.equal(forecast[0].doseAmount, 5, 'dose standard du traitement');
});
test('un RETARD décale toute la prévision (comportement voulu)', () => {
const now = 100 * DAY_MS;
const tr = makeTreatment({ forecastIntervalDays: 7 });
// dernière prise il y a 9 jours (2 jours de retard)
// créneaux théoriques : J-2 (passé, SAUTÉ) puis J+5, J+12…
const forecast = generateForecastDoses(tr, [makeDose(now - 9 * DAY_MS, 5)], now + 10 * DAY_MS, now);
assert.deepEqual(forecast.map((d) => Math.round((d.timestamp - now) / DAY_MS)), [5]);
});
});
describe('currentLevel', () => {
before(() => initProfiles());
test('niveau combiné cohérent avec computeCurve (même calibration)', () => {
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'TFS' });
const now = 6 * DAY_MS;
// Grille 1 h sur [now−1 h ; now] → le dernier point est exactement « now »
const curve = computeCurve([tr], [makeDose(0, 5)], now - HOUR_MS, now, HOUR_MS, new TConfig());
const lvl = currentLevel([tr], [makeDose(0, 5)], new TConfig(), null, now);
assert.ok(curve.length >= 1);
assertClose(lvl.e2, curve[curve.length - 1].e2, 1e-9);
assertClose(lvl.t, curve[curve.length - 1].t, 1e-9);
});
});