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