/** * Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0 — miroir de * `LabTimingTest.kt` Android, `js/pk/lab-timing.js`) : * - creux recommandé JUSTE AVANT le créneau d'injection associé ; * - régime non stabilisé → saut au premier creux stabilisé (5 × t½) ; * - régime stabilisé → premier creux (`wasAlreadyStabilized = true`) ; * - sans Posologie / sans injectable E2 → null (+ invite Posologie) ; * - prise récente → creux SUIVANT (jamais un creux déjà mesuré). */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../js/pk/lab-timing.js'; import { makeTreatment, makeDose, makeLab } from './helpers.js'; const DAY_MS = 86_400_000; const HOUR_MS = 3_600_000; /** Maintenant FIXE : les tests sont déterministes. */ const NOW = 1_790_000_000_000; /** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */ function eenTfs(posologyDays = 7.0) { return makeTreatment({ name: 'EEn', type: 'ESTRADIOL', route: 'INJECTION_IM', doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'TFS', forecastIntervalDays: posologyDays, isActive: true, }); } /** Doses hebdomadaires sur [weeks] semaines (la plus récente à NOW − 7 j). */ function weeklyDoses(weeks) { const out = []; for (let k = weeks; k >= 1; k--) { out.push(makeDose(NOW - k * 7 * DAY_MS, 5.0)); } return out; } const lab = (ts) => makeLab('E2', 200, 'pg/mL', ts); describe('LabTiming (miroir Android v1.8.0)', () => { test('le creux est trouvé juste avant le créneau d injection associé', () => { const rec = nextBloodDrawRecommendation( [eenTfs()], weeklyDoses(9), [], NOW, ); assert.ok(rec, 'une recommandation est calculable'); assert.equal(rec.injectionMs, NOW + 7 * DAY_MS); const gap = rec.injectionMs - rec.troughMs; assert.ok(gap > 0 && gap <= 2 * HOUR_MS, `creux juste avant l'injection (écart ${gap} ms)`); assert.equal(rec.wasAlreadyStabilized, true); }); test('non stabilisé - saute au premier creux STABILISÉ', () => { // Une seule dose il y a 7 j : EEn TFS t½ = ln2/0.15291485 ≈ 4,53 j → // stabilisation ≈ NOW + 15,7 j → créneaux +7/+14 trop tôt, +21 premier stable const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(1), [], NOW); assert.equal(rec.injectionMs, NOW + 21 * DAY_MS); assert.equal(rec.wasAlreadyStabilized, false); }); test('déjà stabilisé - premier creux, wasAlreadyStabilized à true', () => { const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(9), [], NOW); assert.equal(rec.injectionMs, NOW + 7 * DAY_MS); assert.equal(rec.wasAlreadyStabilized, true); assert.ok(rec.stabilizedAtMs < NOW); }); test('prise de sang récente → recommande le creux SUIVANT', () => { const recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000); // 30 min avant le créneau const rec = nextBloodDrawRecommendation( [eenTfs()], weeklyDoses(9), [recentLab], NOW, ); assert.equal(rec.injectionMs, NOW + 14 * DAY_MS); }); test('sans Posologie → null', () => { assert.equal( nextBloodDrawRecommendation([eenTfs(null)], weeklyDoses(9), [], NOW), null, ); }); test('oral seul → null (et pas d\'invite Posologie)', () => { const oral = makeTreatment({ name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL', doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE', forecastIntervalDays: 2.0, isActive: true, }); assert.equal(nextBloodDrawRecommendation([oral], weeklyDoses(9), [], NOW), null); assert.equal(shouldSuggestPosology([oral]), false); }); test('invite Posologie seulement pour un injectable E2 sans Posologie', () => { assert.equal(shouldSuggestPosology([eenTfs(null)]), true); assert.equal(shouldSuggestPosology([eenTfs(7.0)]), false); }); });