HormoneTrack-web/tests/lab-timing.test.js
Siphonight ff4a1374db v1.9.7 (web) : tolérance ± 24 h de l'écart inter-doses — miroir Android
La règle de stabilisation comparait l'écart inter-doses EXACTEMENT à
l'écart précédent : un log 30 min plus tard cassait le régime et
repoussait la stabilisation de 5 × t½ à chaque injection (creux fuyant).

Fix : chaque écart doit rester dans l'intervalle de Posologie ± 24 h
(GAP_TOLERANCE_MS, fenêtre vs l'intervalle THÉORIQUE) — des logs à 6,8 j
puis 7,2 j ne se déstabilisent plus en cascade ; un vrai changement de
créneau reste hors fenêtre.

Tests : « interval change » ré-épinglé (+21 j) + 2 tests de tolérance
miroirs (12 verts lab-timing). WEB_VERSION 1.9.7 + CHANGELOG/README.
2026-09-21 22:20:21 +02:00

160 lines
7.3 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 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);
});
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ──────────────
// Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
// me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose du
// traitement) ne détectait rien. NOUVELLE règle : le régime = séquence
// terminale à (ester, dose, écart inter-doses) constants ; tout changement
// récent réinitialise la stabilisation.
test('changement de dose récent réinitialise la stabilisation', () => {
// 9 semaines à 5 mg, puis 3 mg il y a 2 j : le régime (3 mg) n'a que
// 2 j → non stabilisé → créneau ≥ NOW−2 j + ~22,7 j ≈ NOW+26 j (le 1ᵉʳ
// créneau part de la DERNIÈRE dose réelle, pas de NOW)
const doses = weeklyDoses(9).concat([makeDose(NOW - 2 * DAY_MS, 3.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.injectionMs, NOW + 26 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('changement d\'intervalle récent réinitialise la stabilisation', () => {
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart de
// 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7, [6 j ;
// 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un vrai
// changement de créneau reste un changement — miroir Android)
const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assert.equal(rec.injectionMs, NOW + 21 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('le flou d\'heure de log ne réinitialise PAS la stabilisation (v1.9.7)', () => {
// REMONTÉE v1.9.7 (miroir Android) : « la suggestion change tout le
// temps à chaque injection si l'injection n'est pas faite pile à la
// même heure » — chaque écart reste dans Posologie ± 24 h → le régime
// remonte la séquence entière (63 j) → déjà stabilisée.
const jitterHours = [2, -3, 5, -1, 23, -23, 1, -0.5, 0.75];
const doses = jitterHours.map((jh, i) =>
makeDose(NOW - (63 - 7 * i) * DAY_MS + jh * HOUR_MS, 5.0));
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, true);
});
test('un écart au-delà de 24 h de tolérance réinitialise (v1.9.7)', () => {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j : hors fenêtre
// → le régime ne remonte pas avant la dose en retard → non stabilisée
const doses = weeklyDoses(9).map((d, i) =>
i === 7 ? makeDose(NOW - 14 * DAY_MS + 25 * HOUR_MS, 5.0) : d);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('override d\'ester récent réinitialise la stabilisation (ester effectif)', () => {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override EV : switch d'ester = nouveau régime + l'ester EFFECTIF
// (EV, pas EEN) alimente la carte
const doses = weeklyDoses(9).concat([
Object.assign(makeDose(NOW, 5.0), { esterType: 'EV' }),
]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.ester, 'EV');
assert.equal(rec.wasAlreadyStabilized, false);
});
});