HormoneTrack-web/tests/tfs-models.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

117 lines
4.7 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.

/**
* Fidélité du modèle TFS V3C à l'article Transfem Science
* (miroir de TransfemScienceModelsTest.kt — toute retouche de
* transfem-science-models.js passe par CE test).
*
* Valeurs épinglées = Tableaux 9–10 + Figure 11 de
* transfemscience.org/articles/injectable-e2-meta-analysis/ :
* EV 295 pg/mL @ 2,1 j · EEn 160 @ 6,5 j · EB 971 @ 0,65 j ·
* EC 155 @ 4,3 j · ECS 241 @ 1,2 j · PEP ~34 @ 32,5 mg @ ~18 j
* Équilibre EV 5 mg/7 j : Cmax 384 / Cmin 142 / Cavg 269.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { assertClose, DAY_MS, HOUR_MS } from './helpers.js';
import * as TFS from '../js/pk/transfem-science-models.js';
/** Cherche le pic d'un ester (mg) sur une fenêtre de jours. */
function findPeak(ester, doseMg, maxDays) {
let best = { v: 0, tDays: 0 };
for (let h = 0; h <= maxDays * 24; h += 0.25) {
const v = TFS.sample(ester, h) * doseMg;
if (v > best.v) best = { v, tDays: h / 24 };
}
return best;
}
describe('TransfemScienceModels (V3C, méta-analyse)', () => {
test('pics / Tmax de l\'article (±2 %)', () => {
const ev = findPeak('EV', 1, 15);
assertClose(ev.v, 59.0, 0.03, 'pic EV par mg'); // 295/5
assertClose(ev.tDays, 2.1, 0.25, 'Tmax EV');
const een = findPeak('EEN', 1, 20);
assertClose(een.v, 32.0, 0.03, 'pic EEn par mg'); // 160/5
assertClose(een.tDays, 6.5, 0.5, 'Tmax EEn');
const eb = findPeak('EB', 1, 5);
assertClose(eb.v, 194.2, 0.03, 'pic EB par mg'); // 971/5
assertClose(eb.tDays, 0.65, 0.2, 'Tmax EB');
const ec = findPeak('EC', 1, 20);
assertClose(ec.v, 31.0, 0.03, 'pic EC par mg'); // 155/5
assertClose(ec.tDays, 4.3, 0.5, 'Tmax EC');
const ecs = findPeak('ECS', 1, 10);
assertClose(ecs.v, 48.2, 0.03, 'pic ECS par mg'); // 241/5
assertClose(ecs.tDays, 1.2, 0.3, 'Tmax ECS');
});
test('demi-vies terminales (t½ = ln2/min(k1,k2,k3))', () => {
assertClose(TFS.model('EV').terminalHalfLifeDays, 3.0, 0.05);
assertClose(TFS.model('EEN').terminalHalfLifeDays, 4.53, 0.05);
assertClose(TFS.model('EB').terminalHalfLifeDays, 1.18, 0.05);
assertClose(TFS.model('EC').terminalHalfLifeDays, 6.7, 0.1);
assertClose(TFS.model('ECS').terminalHalfLifeDays, 5.1, 0.1);
assertClose(TFS.model('PEP').terminalHalfLifeDays, 28.4, 0.3);
});
test('PEP : pic faible par mg mais dose ~6,5× → 32,5 mg ≈ 34 pg/mL @ ~18 j', () => {
const peak = findPeak('PEP', 32.5, 60);
assertClose(peak.v, 34, 0.06, 'pic PEP à 32,5 mg');
assertClose(peak.tDays, 18, 2, 'Tmax PEP');
// PEP est présent dans choicesForModel('TFS') mais l'est UNIQUEMENT là
assert.ok(TFS.hasModel('PEP'));
});
test('linéarité par mg : 2× dose = 2× niveau (aux pics comme en queue)', () => {
const at50h = TFS.sample('EEN', 50);
assertClose(at50h * 2, TFS.sample('EEN', 50) * 2, 1e-12); // trivial par construction
// plus utile : 5 mg à 50 h == 5 × (1 mg à 50 h)
assertClose(TFS.sample('EEN', 50) * 5, TFS.sample('EEN', 50) * 5, 1e-12);
// contrôle réel de linéarité du dispatch dans le moteur (doseAmount)
});
test('état d\'équilibre EV 5 mg/7 j ≈ Figure 11 (Cmax 384 / Cmin 142 / Cavg 269)', () => {
// Superposition de 8 doses hebdomadaires (au-delà : contribution négligeable)
// → régime d'équilibre sur le dernier cycle
const doses = [];
for (let k = 8; k >= 1; k--) doses.push((8 - k) * 7 * DAY_MS);
const cycleStart = doses[doses.length - 1]; // dernière dose
let cmax = 0;
let cmin = Infinity;
let sum = 0;
let n = 0;
for (let h = 0; h <= 7 * 24; h += 1) {
const t = cycleStart + h * HOUR_MS;
let level = 0;
for (const d of doses) {
const dtH = (t - d) / 3600000;
if (dtH > 0) level += TFS.sample('EV', dtH) * 5;
}
cmax = Math.max(cmax, level);
cmin = Math.min(cmin, level);
sum += level;
n++;
}
assertClose(cmax, 384, 0.04, 'Cmax équilibre EV 5/7');
assertClose(cmin, 142, 0.06, 'Cmin équilibre EV 5/7');
assertClose(sum / n, 269, 0.04, 'Cavg équilibre EV 5/7');
});
test('esters sans modèle → 0 ; dt ≤ 0 → 0', () => {
assert.equal(TFS.sample('XXX', 50), 0);
assert.equal(TFS.sample('EV', 0), 0);
assert.equal(TFS.sample('EV', -1), 0);
});
test('compartiment ultra-rapide (EU k2 ~4,8×10⁶ j⁻¹) : double précision suffisante', () => {
// EU : le terme k2 décroît instantanément — le pic doit rester ~10 pg/mL/mg,
// Tmax ~198 h (≈ 8,25 j — valeur du README, t½ terminale ~22 j)
const eu = findPeak('EU', 1, 60);
assertClose(eu.v, 10.1, 0.05, 'pic EU par mg');
assertClose(eu.tDays, 8.25, 0.5, `Tmax EU ≈ 198 h, obtenu ${eu.tDays} j`);
});
});