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.
273 lines
13 KiB
JavaScript
273 lines
13 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
});
|