/** * 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); }); });