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

203 lines
9.9 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 de calibration (miroir de CalibrationPerModelTest.kt +
* ScaleFactorWhsahReproTest.kt + V120FeaturesTest.kt) :
*
* - facteur PAR PÉRIODE D'ESTER (labs EV → EV, labs EEn → EEn) ;
* - calibration PAR MODÈLE (fix #60) : échelles distinctes ESE/TFS/WHS,
* chaque courbe calibrée passe par le lab ;
* - GARDE DE SIGNIFICATIVITÉ (fix #61) : reproduction du scénario ×2,21
* (dose de test ancienne + labs tardifs) — le facteur retombe physiologique ;
* - k T par ester, calibré contre l'E2 CALIBRÉE ;
* - autoCalibrated ne modifie RIEN (copies).
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import {
initProfiles, makeTreatment, makeDose, makeLab, assertClose, HOUR_MS, DAY_MS,
} from './helpers.js';
import {
autoCalibrated, computeEsterScaleFactors, computeTKPerEster, computeScaleFactor,
labIsSignificant, computeTConfigCalibration, LAB_MIN_PREDICTION_FRACTION,
} from '../js/pk/pk-calibration.js';
import { e2At, TConfig } from '../js/pk/index.js';
describe('labIsSignificant (garde #61)', () => {
test('prédiction > 0,5 et ≥ 15 % du max observé', () => {
assert.equal(labIsSignificant(100, 0), true, 'premier lab (max 0)');
assert.equal(labIsSignificant(100, 500), true, '20 % du max');
assert.equal(labIsSignificant(50, 500), false, '10 % du max → ignoré');
assert.equal(labIsSignificant(0.3, 0), false, 'bruit < 0,5 pg/mL');
assert.equal(LAB_MIN_PREDICTION_FRACTION, 0.15);
});
});
describe('calibration par période d\'ester (v1.2.1)', () => {
before(() => initProfiles());
test('labs EV calibrent EV, labs EEn calibrent EEn (scénario transition)', () => {
// Scénario : période EV (2 mg / 5 j, jours 30–60) puis période EEn
// (5 mg / 7 j, jours 67–95). Les lab values sont posées DATA-DRIVEN :
// lab = prédiction complète × facteur voulu (l'accumulation est prise
// en compte par e2At, comme dans l'app).
const trEv = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS', doseAmount: 2 });
const trEen = makeTreatment({ id: 2, esterType: 'EEN', pkModel: 'TFS' });
const treatments = [trEv, trEen];
const doses = [];
let id = 1;
for (let day = 30; day <= 60; day += 5) {
doses.push(makeDose(day * DAY_MS, 2, { id: id++, treatmentId: 1, esterType: 'EV' }));
}
for (let day = 67; day <= 95; day += 7) {
doses.push(makeDose(day * DAY_MS, 5, { id: id++, treatmentId: 2, esterType: 'EEN' }));
}
// Lab EV : jour 62 (dernière dose EV jour 60, avant la 1ʳᵉ EEn jour 67)
const labEvTs = 62 * DAY_MS;
const predEv = e2At(treatments, doses, labEvTs, null, null);
// Lab EEn : jour 97 (dernière dose EEn jour 95)
const labEenTs = 97 * DAY_MS;
const predEen = e2At(treatments, doses, labEenTs, null, null);
const labs = [
makeLab('E2', predEv * 1.2, 'pg/mL', labEvTs),
makeLab('E2', predEen * 0.8, 'pg/mL', labEenTs),
];
const scales = computeEsterScaleFactors(treatments, doses, labs, null);
// Chaque ester calibré par SON lab (pas de mélange des périodes)
assertClose(scales.EV, 1.2, 0.02, `facteur EV = ${scales.EV}`);
assertClose(scales.EEN, 0.8, 0.02, `facteur EEN = ${scales.EEN}`);
});
test('labs antérieurs à la 1ʳᵉ dose → échelles vides (rien à calibrer)', () => {
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'TFS' });
const doses = [makeDose(50 * DAY_MS, 5)];
const labs = [makeLab('E2', 300, 'pg/mL', 10 * DAY_MS)];
assert.deepEqual(computeEsterScaleFactors([tr], doses, labs, null), {});
});
});
describe('calibration PAR MODÈLE (fix #60)', () => {
before(() => initProfiles());
test('chaque courbe calibrée avec SON modèle passe par le lab ; échelles distinctes', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', scaleFactor: 1.0 });
const doses = [makeDose(0, 5)];
const labTs = 2.1 * DAY_MS; // ~pic TFS EV
const labValue = 300;
const labs = [makeLab('E2', labValue, 'pg/mL', labTs)];
const scalesByModel = {};
for (const model of ['ESE', 'TFS', 'WHS']) {
const scales = computeEsterScaleFactors([tr], doses, labs, model);
scalesByModel[model] = scales.EV;
const calibrated = e2At([tr], doses, labTs, model, scales);
assertClose(calibrated, labValue, 0.02, `courbe ${model} calibrée passe par le lab`);
}
// Les 3 facteurs sont DISTINCTS (prédictions différentes par modèle) :
assert.ok(Math.abs(scalesByModel.ESE - scalesByModel.TFS) > 0.02, 'ESE ≠ TFS');
assert.ok(Math.abs(scalesByModel.WHS - scalesByModel.TFS) > 0.02, 'WHS ≠ TFS');
assertClose(scalesByModel.TFS, 1.02, 0.05, 'TFS : ratio vs son propre pic ≈ 1');
});
});
describe('garde de significativité — reproduction du bug ×2,21 (fix #61)', () => {
before(() => initProfiles());
test('garde de significativité : les labs hors fenêtre d\'action sont EXCLUS des 2 pipelines', () => {
// Reproduction du scénario ×2,21 (v1.4.8) : UNE dose + un lab dans la
// fenêtre d'action (ratio correct) + un lab TARDIF avec un ratio absurde
// (prédiction résiduelle ≈ 0). Sans garde, la médiane explosait.
const tr = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS' });
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const goodTs = 2 * DAY_MS; // ~pic : prédiction ≈ 295
const goodValue = e2At([tr], doses, goodTs, null, null) * 0.9;
const lateTs = 60 * DAY_MS; // prédiction résiduelle ≈ 0
const labs = [
makeLab('E2', goodValue, 'pg/mL', goodTs),
makeLab('E2', 3000, 'pg/mL', lateTs), // ratio ~×1000 — doit être ignoré
];
// Pipeline MANUEL
const sf = computeScaleFactor(tr, doses, labs);
assertClose(sf, 0.9, 0.05, `facteur manuel = ${sf} (jamais la médiane contaminée)`);
// Pipeline AUTO
const scales = computeEsterScaleFactors([tr], doses, labs, 'TFS');
assertClose(scales.EV, 0.9, 0.05, `facteur auto = ${scales.EV}`);
});
test('le facteur reste exact quand les labs sont dans la fenêtre d\'action', () => {
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
const doses = [makeDose(0, 5)];
const labTs = 2 * DAY_MS;
const labValue = e2At([tr], doses, labTs, null, null) * 0.9; // prédiction ×0.9
const labs = [makeLab('E2', labValue, 'pg/mL', labTs)];
const sf = computeScaleFactor(tr, doses, labs);
assertClose(sf, 0.9, 0.05, 'facteur manuel dans la fenêtre d\'action');
});
});
describe('k T par ester (v1.2.3)', () => {
before(() => initProfiles());
test('k calibré par période contre l\'E2 CALIBRÉE ; originaux non modifiés', () => {
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const tConfig = new TConfig(6, 0.2, 0.19);
const scales = { EEN: 1.0 };
// Lab T = 0.35 ng/mL à l'équilibre (E2 ≈ 160 calibrée)
const labs = [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)];
const ks = computeTKPerEster([tr], doses, labs, tConfig, scales, null);
assert.ok(ks.EEN > 1e-4 && ks.EEN < 10, `k = ${ks.EEN} dans la garde`);
// le tConfig d'origine n'est pas modifié (pur)
assert.equal(tConfig.k, 0.19);
});
test('lab T en ng/dL converti avant calibration (bug #23)', () => {
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const tConfig = new TConfig(6, 0.2, 0.19);
const ksNgMl = computeTKPerEster([tr], doses, [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)], tConfig, { EEN: 1.0 }, null);
const ksNgDl = computeTKPerEster([tr], doses, [makeLab('T', 35, 'ng/dL', 7 * DAY_MS)], tConfig, { EEN: 1.0 }, null);
assertClose(ksNgMl.EEN, ksNgDl.EEN, 1e-9, 'même k quelle que soit l\'unité saisie');
});
});
describe('autoCalibrated (option Paramètres)', () => {
before(() => initProfiles());
test('renvoie des COPIES : traitements et tConfig inchangés', () => {
const tr = makeTreatment({ id: 1, scaleFactor: 0.72 });
const tConfig = new TConfig(6, 0.2, 0.19);
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const labs = [makeLab('E2', 300, 'pg/mL', 2 * DAY_MS)];
const result = autoCalibrated([tr], doses, labs, tConfig);
assert.equal(result.treatments[0].scaleFactor, 0.72, 'scaleFactor stocké intact');
assert.equal(result.tConfig.k, 0.19, 'tConfig intact');
assert.equal(result.calibratedEsters, 1);
assert.equal(result.tRecalibrated, false, 'pas de lab T → pas de recalibrage T');
});
test('modelOverride délégue : implémentation unique (Home et graphique ne peuvent pas diverger)', () => {
const tr = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS' });
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const labs = [makeLab('E2', 300, 'pg/mL', 2.1 * DAY_MS)];
const cfg = new TConfig();
const viaAuto = autoCalibrated([tr], doses, labs, cfg, 'TFS').esterScales;
const viaDirect = computeEsterScaleFactors([tr], doses, labs, 'TFS');
assert.deepEqual(viaAuto, viaDirect, 'autoCalibrated = computeEsterScaleFactors (délégation)');
});
});
describe('computeTConfigCalibration (k global, bouton Paramètres)', () => {
test('récupère un k planté ; null sans labs exploitables', () => {
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
const doses = [makeDose(0, 5, { treatmentId: 1 })];
const cfg = new TConfig(6, 0.2, 0.19);
const labs = [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)];
const calibrated = computeTConfigCalibration(labs, [tr], doses, cfg);
assert.ok(calibrated && calibrated.k > 1e-4 && calibrated.k < 10, `k = ${calibrated && calibrated.k}`);
assert.equal(calibrated.base, 6);
assert.equal(calibrated.floor, 0.2);
// lab sous le plancher + 0.02 → inutilisable
const low = computeTConfigCalibration([makeLab('T', 0.2, 'ng/mL', 7 * DAY_MS)], [tr], doses, cfg);
assert.equal(low, null);
});
});