- predictedE2 : valeur brute au creux × facteur du ester (auto-calibration
ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu
coloré face à la cible dans labs.js + home.js.
- pooledRegimeDoses : les doses de tous les traitements partageant (ester
effectif, mg) forment UNE séquence — le re-parenting d'historique
(« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠
jamais poolé, dose ≠ exclue.
- Cible de creux E2 (opt-in) : store + carte settings + backup
(rétrocompatible — un backup ancien n'efface pas la cible locale) +
i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification.
- 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E :
bump WEB_VERSION sans section CHANGELOG = dialog vide.
199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0.
187 lines
8.3 KiB
JavaScript
187 lines
8.3 KiB
JavaScript
/**
|
|
* Tests des ajouts v1.12.0 à la page Doses (miroir de DosesExtrasTest.kt +
|
|
* ForecastDosesGuardTest.kt) :
|
|
* - FIX #70 : un traitement INACTIF ne projette PLUS de créneaux
|
|
* (generateForecastDoses) — l'historique reste simulé (§6.bis) ;
|
|
* - routeGlyph : un émoji PAR forme de prise (IM/SC partagent la seringue
|
|
* par design — différenciées par le texte de la ligne côté web) ;
|
|
* - labMarkersForDose : marqueurs « prise de sang le même jour » —
|
|
* avant/après la dose, jour calendaire LOCAL, frontière minuit.
|
|
*
|
|
* Déterministes (instants construits via Date locale, hors changement
|
|
* d'heure — même discipline que misc.test.js).
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { generateForecastDoses } from '../js/pk/pk-engine.js';
|
|
import { routeGlyph, treatmentsForDisplay } from '../js/data/models.js';
|
|
import { labMarkersForDose } from '../js/util/format.js';
|
|
import { makeTreatment, makeDose, DAY_MS, HOUR_MS } from './helpers.js';
|
|
|
|
const NOW = 1_790_000_000_000;
|
|
|
|
describe('forecast guard for inactive treatments (fix #70, v1.12.0)', () => {
|
|
test('un inactif à Posologie + historique ne projette plus rien', () => {
|
|
const archived = makeTreatment({ id: 1, isActive: false, forecastIntervalDays: 7 });
|
|
const doses = [
|
|
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - DAY_MS, 5, { treatmentId: 1 }),
|
|
];
|
|
assert.equal(generateForecastDoses(archived, doses, NOW + 30 * DAY_MS, NOW).length, 0);
|
|
});
|
|
|
|
test('un actif jumeau projette toujours (comportement inchangé)', () => {
|
|
const active = makeTreatment({ id: 2, isActive: true, forecastIntervalDays: 7 });
|
|
const doses = [
|
|
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 2 }),
|
|
makeDose(NOW - DAY_MS, 5, { treatmentId: 2 }),
|
|
];
|
|
const slots = generateForecastDoses(active, doses, NOW + 30 * DAY_MS, NOW);
|
|
assert.ok(slots.length > 0, 'un actif à Posologie projette');
|
|
assert.equal(slots[0].timestamp, NOW - DAY_MS + 7 * DAY_MS);
|
|
});
|
|
|
|
test('archiver un traitement actif arrête ses créneaux futurs', () => {
|
|
const treatment = makeTreatment({ id: 3, isActive: true, forecastIntervalDays: 7 });
|
|
const doses = [makeDose(NOW - DAY_MS, 5, { treatmentId: 3 })];
|
|
const before = generateForecastDoses(treatment, doses, NOW + 30 * DAY_MS, NOW);
|
|
const after = generateForecastDoses(
|
|
{ ...treatment, isActive: false }, doses, NOW + 30 * DAY_MS, NOW,
|
|
);
|
|
assert.ok(before.length > 0);
|
|
assert.equal(after.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('route glyphs (v1.12.0)', () => {
|
|
test('un glyphe par forme (IM/SC partagent la seringue par design)', () => {
|
|
const oral = routeGlyph('ORAL');
|
|
const im = routeGlyph('INJECTION_IM');
|
|
const sc = routeGlyph('INJECTION_SUBCUT');
|
|
const gel = routeGlyph('TRANSDERMAL_GEL');
|
|
const patch = routeGlyph('TRANSDERMAL_PATCH');
|
|
const other = routeGlyph('OTHER');
|
|
assert.notEqual(oral, im);
|
|
assert.notEqual(oral, gel);
|
|
assert.notEqual(im, gel);
|
|
assert.notEqual(gel, patch);
|
|
assert.notEqual(patch, other);
|
|
assert.equal(im, sc, 'IM/SC : même seringue (texte de ligne distinct côté web)');
|
|
});
|
|
|
|
test('route inconnue/null → glyphe OTHER', () => {
|
|
assert.equal(routeGlyph(null), routeGlyph('OTHER'));
|
|
assert.equal(routeGlyph(undefined), routeGlyph('OTHER'));
|
|
});
|
|
});
|
|
|
|
describe('lab markers per dose (v1.12.0)', () => {
|
|
// Jour calendaire LOCAL de node (hors changement d'heure)
|
|
const noon = new Date(2026, 8, 27, 12, 0).getTime(); // 27 sept 2026 12:00 local
|
|
|
|
const lab = (atMs) => ({ marker: 'E2', value: 100, unit: 'pg/mL', timestamp: atMs });
|
|
|
|
test('labs avant ET après la dose le même jour → les deux marqueurs', () => {
|
|
const m = labMarkersForDose([lab(noon - HOUR_MS), lab(noon + HOUR_MS)], noon);
|
|
assert.equal(m.before, true);
|
|
assert.equal(m.after, true);
|
|
});
|
|
|
|
test('lab d\'un autre jour ne marque jamais', () => {
|
|
const m = labMarkersForDose([lab(noon - 24 * HOUR_MS)], noon);
|
|
assert.equal(m.before, false);
|
|
assert.equal(m.after, false);
|
|
});
|
|
|
|
test('frontière minuit : 23:50 la veille ≠ jour suivant', () => {
|
|
const lateEvening = new Date(2026, 8, 26, 23, 50).getTime();
|
|
const earlyMorning = new Date(2026, 8, 27, 0, 10).getTime();
|
|
const m = labMarkersForDose([lab(lateEvening)], earlyMorning);
|
|
assert.equal(m.before, false);
|
|
assert.equal(m.after, false);
|
|
});
|
|
|
|
test('lab exactement à l\'heure de la dose compte comme APRÈS (convention)', () => {
|
|
const m = labMarkersForDose([lab(noon)], noon);
|
|
assert.equal(m.before, false);
|
|
assert.equal(m.after, true);
|
|
});
|
|
|
|
test('aucun lab → aucun marqueur', () => {
|
|
const m = labMarkersForDose([], noon);
|
|
assert.equal(m.before, false);
|
|
assert.equal(m.after, false);
|
|
});
|
|
});
|
|
|
|
// ── v1.13.0 : régime poolé + prédiction au creux + statut cible ────────────
|
|
import { pooledRegimeDoses, troughTargetStatus } from '../js/pk/lab-timing.js';
|
|
import { buildBackupJson, parseBackupJson } from '../js/data/backup.js';
|
|
|
|
describe('pooled regime doses (v1.13.0, miroir LabTimingV113Test)', () => {
|
|
test('le pool traverse la frontière de re-parenting (même ester, même mg)', () => {
|
|
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
|
|
const old = makeTreatment({ id: 10, isActive: false, forecastIntervalDays: 6 });
|
|
const all = [
|
|
makeDose(NOW - 50 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 43 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 36 * DAY_MS, 5, { treatmentId: 10 }),
|
|
makeDose(NOW - 29 * DAY_MS, 5, { treatmentId: 10 }),
|
|
makeDose(NOW - 22 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 15 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
|
];
|
|
const pooled = pooledRegimeDoses(active, all, [active, old]);
|
|
assert.equal(pooled.length, 7, 'le pool traverse la frontière t1/t10');
|
|
});
|
|
|
|
test('ester différent → jamais poolé (EV inactif hors du régime EEn)', () => {
|
|
const active = makeTreatment({ id: 1, isActive: true, esterType: 'EEN' });
|
|
const inactiveEv = makeTreatment({ id: 2, isActive: false, esterType: 'EV' });
|
|
const all = [
|
|
makeDose(NOW - 20 * DAY_MS, 5, { treatmentId: 2 }),
|
|
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
|
];
|
|
assert.equal(pooledRegimeDoses(active, all, [active, inactiveEv]).length, 1);
|
|
});
|
|
|
|
test('dose de mg différent → exclue (le 8 mg casse le régime)', () => {
|
|
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
|
|
const all = [
|
|
makeDose(NOW - 28 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 21 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 14 * DAY_MS, 5, { treatmentId: 1 }),
|
|
makeDose(NOW - 7 * DAY_MS, 8, { treatmentId: 1 }),
|
|
makeDose(NOW, 5, { treatmentId: 1 }),
|
|
];
|
|
const pooled = pooledRegimeDoses(active, all, [active]);
|
|
assert.equal(pooled.length, 4, 'le 8 mg est exclu du pool');
|
|
});
|
|
});
|
|
|
|
describe('trough target status (v1.13.0)', () => {
|
|
test('matrice in/below/above + bornes incluses + null', () => {
|
|
assert.equal(troughTargetStatus(175, 150, 200), 'IN_TARGET');
|
|
assert.equal(troughTargetStatus(140, 150, 200), 'BELOW');
|
|
assert.equal(troughTargetStatus(220, 150, 200), 'ABOVE');
|
|
assert.equal(troughTargetStatus(150, 150, 200), 'IN_TARGET');
|
|
assert.equal(troughTargetStatus(200, 150, 200), 'IN_TARGET');
|
|
assert.equal(troughTargetStatus(null, 150, 200), null);
|
|
assert.equal(troughTargetStatus(175, null, 200), null);
|
|
assert.equal(troughTargetStatus(175, 150, null), null);
|
|
});
|
|
});
|
|
|
|
describe('backup round-trip trough target (v1.13.0)', () => {
|
|
test('les champs voyagent et restent absents quand non définis', () => {
|
|
const userSettings = { language: 'fr', troughTargetLow: 150, troughTargetHigh: 200 };
|
|
const parsed = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, userSettings));
|
|
assert.equal(parsed.settings.troughTargetLow, 150);
|
|
assert.equal(parsed.settings.troughTargetHigh, 200);
|
|
const parsed2 = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, { language: 'en' }));
|
|
assert.equal(parsed2.settings.troughTargetLow, null);
|
|
assert.equal(parsed2.settings.troughTargetHigh, null);
|
|
});
|
|
});
|