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