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