/** * 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); }); });