- js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel (minimum de la courbe E2 entre 2 injections), saut au premier creux STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor de la pente d'extrapolation de sample(), zéro duplication), filtres honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand un injectable actif est sans Posologie — demande v1.8.0). - labs.js : encart « Prochaine prise de sang (suggestion) » ou invite, mutuellement exclusifs (miroir carte Android). - i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E. - tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E (encart rendu : titre, creux daté, créneau, stabilisation). - Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README.
98 lines
3.9 KiB
JavaScript
98 lines
3.9 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);
|
||
});
|
||
});
|