/** * Tests du NUAGE D'INCERTITUDE (v1.9.0 — miroir de `EstrannaiseCloudTest.kt`, * `js/pk/estrannaise-cloud.js`) : 32 courbes par défaut, dispersion réelle * entre courbes, fenêtre respectée, exclusivité ESE (TFS/oral → vide). */ import { test, describe, before } from 'node:test'; import { join } from 'node:path'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { initProfiles, makeTreatment, makeDose, DAY_MS, WEB_ROOT } from './helpers.js'; import { compute } from '../js/pk/estrannaise-cloud.js'; import { initWithJson as initMcmc } from '../js/pk/estrannaise-models.js'; const NOW = 1_790_000_000_000; function ese() { return makeTreatment({ name: 'EEn ESE', type: 'ESTRADIOL', route: 'INJECTION_IM', doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'ESE', forecastIntervalDays: 7.0, isActive: true, }); } function doses() { return [4, 3, 2, 1].map((k) => makeDose(NOW - k * DAY_MS, 5.0)); } describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => { before(() => { initProfiles(); // Le nuage exige le posterior MCMC chargé (313 échantillons/ester) initMcmc(readFileSync(join(WEB_ROOT, 'assets/mcmc_samples.json'), 'utf8')); }); test('32 courbes avec dispersion réelle entre elles', () => { const cloud = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW); assert.equal(cloud.length, 32); assert.ok(cloud.every((c) => c.length >= 2)); const peaks = [...new Set(cloud.map((c) => Math.max(...c.map((p) => p.e2))))]; assert.ok(peaks.length >= 8, `les courbes divergent (${peaks.length} maxima distincts)`); }); test('le nuage reste dans la fenêtre demandée', () => { const start = NOW - 10 * DAY_MS; const end = NOW; const cloud = compute([ese()], doses(), start, end, 3_600_000, NOW); assert.ok(cloud.every((c) => c.every((p) => p.timestamp >= start && p.timestamp <= end))); }); test('exclusivité ESE : TFS et oral produisent un nuage vide', () => { const tfs = { ...ese(), pkModel: 'TFS' }; assert.equal(compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0); const oral = makeTreatment({ name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL', doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE', forecastIntervalDays: 2.0, isActive: true, }); assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0); }); test('aucune dose sur le porteur → nuage vide', () => { assert.equal(compute([ese()], [], NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0); }); test('nbCurves < 2 → nuage vide', () => { assert.equal(compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW, 1).length, 0); }); test('le nuage suit la calibration (fix v1.9.1, miroir Android)', () => { // scalePerEster ×2 → nuage calibré = nuage brut ×2 (le nuage ENTOURE // la courbe ESE calibrée au lieu de flotter à une autre échelle) const scaled = { EEN: 2.0 }; const calibrated = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW, 32, scaled); const raw = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW); assert.equal(calibrated.length, raw.length); assert.ok(raw[raw.length - 1].length >= 2); const rawLast = raw[raw.length - 1][raw[raw.length - 1].length - 1].e2; const calLast = calibrated[calibrated.length - 1][calibrated[calibrated.length - 1].length - 1].e2; assert.ok( Math.abs(calLast - rawLast * 2) <= rawLast * 0.01, `calibré (${calLast}) ≈ 2× brut (${rawLast})`, ); }); });