diff --git a/README.md b/README.md index 01f5c23..2c0f761 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** · +- **Statut** : web v1.5.0 — portage de l'Android v1.5.0 · **132 tests verts** · E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) @@ -113,6 +113,8 @@ machine qui sert les fichiers. (auto-calibration optionnelle, désactivée par défaut + bouton manuel). - **Seuils d'alerte** configurables (E2/T haut/bas) avec notification navigateur et anti-spam. +- **« Tracé labs » (v1.5.0)** : courbe hybride ancrée sur tes labs + (chip `Tracé labs`, off par défaut) — miroir strict de l'Android v1.5.0 - **Rappels** via les notifications du navigateur (actions « Loguer maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf [limites](#limites-vs-lapp-android). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d8c4e33..15f0466 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,24 @@ > section la plus récente EN PREMIER ; ce fichier est lu par le dialog > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). +## [1.5.0] — 2026-09-11 + +### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées) + +- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**) + : `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique + (Tmax, queues, accumulation), l'amplitude est recalée sur CHAQUE + prise de sang — **passage EXACT garanti** sur chaque lab, transition + log-linéaire d'un lab au suivant (jamais de saut vertical). +- Garde anti-ratés (#61) : un lab hors fenêtre d'action n'est pas un + ancrage ; E2 uniquement ; fenêtre bornée [1er ; dernier lab] — la + courbe s'arrête au dernier lab (pas d'extrapolation). +- Indépendante de la calibration (sa propre référence) ; hors + pics/creux ; n'entre JAMAIS dans niveau accueil/alertes. +- La rangée de chips du graphique défile horizontalement (4ᵉ chip — + miroir du fix Android v1.5.0). +- 11 tests miroirs de `LabTrajectoryModelTest.kt` (Android). + ## [1.4.10] — 2026-09-08 Version initiale de la version web — portage navigateur de l'app Android diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 237b653..597bce5 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -159,7 +159,11 @@ HormoneTrack-web/ (dépôt séparé) │ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params │ │ │ copiés à l'identique du Kotlin) │ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques) -│ │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état, +│ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0) : courbe hybride +│ │ M(t)×ρ(t) ancrée sur les labs (miroir strict +│ │ de pk/LabTrajectoryModel.kt — garde #61, +│ │ ρ log-linéaire, fenêtre labs) +│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état, │ │ │ shouldNotify (anti-spam) │ │ ├── presets.js PKPresets.kt (22 presets, clés i18n) │ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt : @@ -391,6 +395,9 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt. - **calibration** : par période d'ester, **par modèle** (#60, chaque courbe calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE. +- **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin + (passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du + scaleFactor, modelOverride, doublons, bornes). - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés, **imports des exports Android réels** (data-driven, hors dépôt). - **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), @@ -693,7 +700,7 @@ Structurels (plateforme), pas des bugs : §16.ter de la doc mère). --- -*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé +*Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E, backup compatible bidirectionnel, historique propre (aucune donnée de santée, aucune dette de confidentialité), versions alignées sur diff --git a/js/pk/index.js b/js/pk/index.js index 8b0134c..28efe11 100644 --- a/js/pk/index.js +++ b/js/pk/index.js @@ -8,6 +8,8 @@ export * from './pk-engine.js'; export * from './pk-calibration.js'; export * from './pk-reminders.js'; export * from './pk-extrema.js'; +export * as LabTrajectoryModel from './lab-trajectory-model.js'; +export { computeLabAnchoredCurve, ratioAt } from './lab-trajectory-model.js'; export * from './chart-helpers.js'; export * as Alerts from './alerts.js'; export * as PKProfileStore from './pk-profile-store.js'; diff --git a/js/pk/lab-trajectory-model.js b/js/pk/lab-trajectory-model.js new file mode 100644 index 0000000..bd177cb --- /dev/null +++ b/js/pk/lab-trajectory-model.js @@ -0,0 +1,112 @@ +/** + * ───────────────────────────────────────────────────────────────────────────── + * « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) — + * courbe hybride ancrée sur les résultats de sang. + * + * DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev + * web §7.10/§3) : courbe « biologiquement plausible MAIS passant par tous + * les points ». Formule identique à l'Android : + * + * courbe(t) = M(t) × ρ(t) + * + * - **M(t)** = niveau du modèle PK BRUT (scaleFactor forcé à 1, modèle + * stocké ou modelOverride) — porte toute la physiologie entre les labs ; + * - **ρ_k = lab_k / M(t_k)** à chaque lab ; ENTRE deux labs consécutifs ρ + * interpole LOG-LINÉAIREMENT (morphing d'amplitude continu) ; + * - courbe(t_k) = lab_k EXACTEMENT (garantie par construction, épinglé). + * + * GARDES (invariants Android portés tels quels) : + * - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ; + * - E2 uniquement (labs T trop rares + unités variées → bug #26) ; + * - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ; + * - < 2 labs significatifs → courbe vide ; + * - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé). + * + * ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders + * (invariant §9.bis Android transposé). + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { e2At } from './pk-engine.js'; +import { labIsSignificant } from './pk-calibration.js'; + +/** + * Construit la courbe ancrée sur les labs. + * + * @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis) + * @param {object[]} doseLogs Toutes les doses + * @param {object[]} labs Tous les LabResult (filtrés E2 ici) + * @param {number} startMs Fenêtre demandée (celle du graphique) + * @param {number} endMs + * @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange) + * @param {string|null} [modelOverride=null] + * @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}} + */ +export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null) { + if (startMs >= endMs) return { points: [], anchoredLabs: 0 }; + + const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL'); + if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 }; + + // Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur) + const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 })); + + // ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ──────────────── + const anchors = []; // (t_lab, ratio) + let maxPredictionSoFar = 0.0; + const e2Labs = labs + .filter((l) => String(l.marker).toUpperCase() === 'E2') + .slice() + .sort((a, b) => a.timestamp - b.timestamp); + for (const lab of e2Labs) { + const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null); + if (!labIsSignificant(predicted, maxPredictionSoFar)) continue; + anchors.push([lab.timestamp, lab.value / predicted]); + if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted; + } + if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length }; + + const firstLabMs = anchors[0][0]; + const lastLabMs = anchors[anchors.length - 1][0]; + + // ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ───────── + const t0 = Math.max(startMs, firstLabMs); + const t1 = Math.min(endMs, lastLabMs); + if (t1 <= t0) return { points: [], anchoredLabs: anchors.length }; + + // ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ─────── + const points = []; + for (let t = t0; t <= t1; t += stepMs) { + const m = e2At(unscaled, doseLogs, t, modelOverride, null); + const rho = ratioAt(anchors, t); + points.push({ timestamp: t, e2: m * rho, t: 0.0 }); + } + return { points, anchoredLabs: anchors.length }; +} + +/** + * ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs + * (`anchors` trié croissant). Garanties : ρ(t_k) exactement, continuité + * multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne + * (garde division par zéro) ; gardes défensives aux bornes. + * + * @param {[number, number][]} anchors (t_lab, ratio) trié croissant + * @param {number} tMs + * @returns {number} + */ +export function ratioAt(anchors, tMs) { + if (tMs <= anchors[0][0]) return anchors[0][1]; + if (tMs >= anchors[anchors.length - 1][0]) return anchors[anchors.length - 1][1]; + + for (let i = 0; i < anchors.length - 1; i++) { + const [t0, r0] = anchors[i]; + const [t1, r1] = anchors[i + 1]; + if (tMs >= t0 && tMs <= t1) { + const span = t1 - t0; + if (span <= 0) return r1; // doublon de timestamp : 2ᵉ ratio gagne + const f = (tMs - t0) / span; + return Math.exp(Math.log(r0) + f * (Math.log(r1) - Math.log(r0))); + } + } + return anchors[anchors.length - 1][1]; // inatteignable — garde +} diff --git a/js/ui/chart-canvas.js b/js/ui/chart-canvas.js index 6a4bc6a..61951a9 100644 --- a/js/ui/chart-canvas.js +++ b/js/ui/chart-canvas.js @@ -36,10 +36,18 @@ export const COLORS = { grid: '#cac4d0', label: '#6b7280', nowLine: '#2fa48d', + chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé) forecastMarker: 'rgba(47,164,141,0.85)', realDoseMarker: 'rgba(73,69,79,0.55)', }; + +/** + * Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema } + * v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs » + * épouse l'interp ρ, pas la physiologie du modèle). + */ + /** * Dessine le graphique dans un canvas. * @@ -195,7 +203,7 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses } // Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée - if (options.showExtrema) { + if (options.showExtrema && (s.showExtrema !== false)) { for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) { drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color); } diff --git a/js/ui/chart.js b/js/ui/chart.js index f8e0b67..6e3dc59 100644 --- a/js/ui/chart.js +++ b/js/ui/chart.js @@ -26,6 +26,7 @@ import { computeCurve, generateForecastDoses, autoCalibrated, TConfig, + computeLabAnchoredCurve, } from '../pk/index.js'; import { stepForRange, defaultModelToggles, panDeltaHours, clampPanHours, @@ -74,6 +75,7 @@ export function renderChart(container, ctx) { showWhs: false, showForecast: false, showExtrema: false, + showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0) panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) togglesInitialized: false, }; @@ -204,9 +206,21 @@ export function renderChart(container, ctx) { modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); })); modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); })); modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); })); + // v1.5.0 : courbe hybride ancrée sur les labs (off par défaut) + modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); })); // ── Courbes par modèle sélectionné ───────────────────────────────────── const curves = []; + // ── v1.5.0 : courbe « Tracé labs » ─────────────────────────────────────── + // Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et + // de la calibration (elle EST sa propre calibration continue — épinglé). + if (state.showLabTrack) { + const step = stepForRange(state.rangeHours); + const anchored = computeLabAnchoredCurve( + treatments, doseLogs, labResults, startMs, endMs, step, null, + ); + curves.push(['LAB', anchored.points]); + } if (state.showEse || state.showTfs || state.showWhs) { // Home utilise le modèle stocké ; le graphique calibre chaque modèle // avec SA prédiction (fix #60). Référence = auto du modèle TFS (les @@ -269,6 +283,11 @@ export function renderChart(container, ctx) { drawChart( canvas, curves.map(([model, pts]) => { + if (model === 'LAB') { + // v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T + // (série E2-only) et hors pics/creux (showExtrema=false) + return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false }; + } const st = seriesStyles(model); return { points: pts, e2Style: st.e2, tStyle: st.t }; }), @@ -291,6 +310,9 @@ export function renderChart(container, ctx) { // Légende dynamique (miroir du bloc Compose) for (const [model] of curves) { + // ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63 + // Android-miroir : sinon le `else` imprime la légende TFS en doublon) + if (model === 'LAB') continue; const st = seriesStyles(model); if (model === 'ESE') { legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color)); @@ -303,9 +325,16 @@ export function renderChart(container, ctx) { if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color)); } } + // Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne + // gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende + // dupliquée : miroir de la leçon #63 Android) if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot)); if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine)); if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema'))); + // v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide) + if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) { + legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory)); + } legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint'))); } diff --git a/js/ui/settings.js b/js/ui/settings.js index 0740080..e5ab7a1 100644 --- a/js/ui/settings.js +++ b/js/ui/settings.js @@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * v1.4.10 = portage initial de l'Android v1.4.10. */ -export const WEB_VERSION = '1.4.10'; +export const WEB_VERSION = '1.5.0'; /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ function parseNumOrNull(text) { diff --git a/js/util/i18n.js b/js/util/i18n.js index 5e5394a..9fadca8 100644 --- a/js/util/i18n.js +++ b/js/util/i18n.js @@ -181,6 +181,7 @@ const EN = { show_labs: 'Labs', show_forecast: 'Forecast', show_extrema: 'Peaks & troughs', + show_lab_track: 'Lab track', back_to_present: 'Back to present', legend_ese_e2: 'E2 — Estrannaise (blue)', legend_ese_t: 'T — Estrannaise (pink)', @@ -191,6 +192,7 @@ const EN = { legend_labs: '● labs (orange) — circle = E2, square = T', legend_forecast_dose: 'Dashed line + ▼ = projected dose', legend_extrema: '▲▼ = local peaks & troughs (with estimated values)', + legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)', drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).', // Rappels (bannière + notification) reminder_notif_title: 'HormoneTrack — dose reminder', @@ -357,6 +359,7 @@ const FR = { show_labs: 'Labs', show_forecast: 'Prévision', show_extrema: 'Pics & creux', + show_lab_track: 'Tracé labs', back_to_present: 'Revenir à maintenant', legend_ese_e2: 'E2 — Estrannaise (bleu)', legend_ese_t: 'T — Estrannaise (rose)', @@ -367,6 +370,7 @@ const FR = { legend_labs: '● labs (orange) — cercle = E2, carré = T', legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée', legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)', + legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)', drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).', reminder_notif_title: 'HormoneTrack — rappel de dose', reminder_notif_body: '{0} · {1}{2} — prévu à {3}', diff --git a/package.json b/package.json index b11ab92..0fe0504 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hormonetrack-web", - "version": "1.4.10", + "version": "1.5.0", "description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.", "private": true, "type": "module", diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 4bb15ea..c1cfcdc 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -131,7 +131,7 @@ async function main() { ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); if (dialog) { const dlgText = await dialog.textContent(); - ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante'); + ok(dlgText.includes('Version 1.5.0'), 'dialog : section de la version courante'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); await page.click('.dialog .actions button'); @@ -192,7 +192,7 @@ async function main() { await page.waitForTimeout(300); text = await page.textContent('main.content'); ok(text.includes('Langue'), 'Paramètres : carte langue'); - ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée'); + ok(text.includes('Version 1.5.0 (web)'), 'Paramètres : version affichée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée'); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); diff --git a/tests/lab-trajectory-model.test.js b/tests/lab-trajectory-model.test.js new file mode 100644 index 0000000..c7e25b7 --- /dev/null +++ b/tests/lab-trajectory-model.test.js @@ -0,0 +1,140 @@ +/** + * Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android) + * — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61, + * fenêtre bornée aux labs, indépendance du scaleFactor. + */ + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { + initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose, +} from './helpers.js'; +import { e2At } from '../js/pk/pk-engine.js'; +import { + computeLabAnchoredCurve, ratioAt, +} from '../js/pk/lab-trajectory-model.js'; +/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */ +function treatment(scaleFactor = 1.0) { + return makeTreatment({ scaleFactor }); +} + +function doses() { + return [makeDose(0, 5)]; +} + +const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts); +const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts); + +describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => { + before(() => initProfiles()); + + test('passe exactement sur chaque lab', () => { + const t2 = 2 * DAY_MS; + const t7 = 7 * DAY_MS; + const labs = [lab(300, t2), lab(260, t7)]; + const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); + assert.equal(curve.anchoredLabs, 2); + assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1'); + assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2'); + }); + + test('rho interpole en log entre deux labs (math exacte)', () => { + const t2 = 2 * DAY_MS; + const t7 = 7 * DAY_MS; + const tr = treatment(); + // Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence) + const m2 = e2At([tr], doses(), t2, null, null); + const m7 = e2At([tr], doses(), t7, null, null); + const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)]; + + // 1) La fonction PURE ratioAt interpole exactement + const pure = [[t2, 0.9], [t7, 1.2]]; + const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0); + assertClose(ratioAt(pure, t2), 0.9, 1e-12); + assertClose(ratioAt(pure, t7), 1.2, 1e-12); + assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9); + + // 2) La courbe restitue le même ρ + const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); + const mid = curve.points[Math.trunc(curve.points.length / 2)]; + const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null); + assertClose(mid.e2 / m, expectedMid, 1e-9); + }); + + test('un seul lab significatif donne une courbe vide', () => { + const curve = computeLabAnchoredCurve([treatment()], doses(), + [lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS); + assert.equal(curve.points.length, 0); + assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas'); + }); + + test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => { + const t2 = 2 * DAY_MS; + const t7 = 7 * DAY_MS; + const curve = computeLabAnchoredCurve([treatment()], doses(), + [lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS); + assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté'); + assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde'); + assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif'); + }); + + test('labs avant la première dose ne produisent aucun ancrage', () => { + const curve = computeLabAnchoredCurve([treatment()], doses(), + [lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS); + assert.equal(curve.points.length, 0); + assert.equal(curve.anchoredLabs, 0); + }); + + test('la fenêtre du tracé est bornée par les labs et par la demande', () => { + const t2 = 2 * DAY_MS; + const t7 = 7 * DAY_MS; + const labs = [lab(160, t2), lab(140, t7)]; + const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS); + assert.ok(wide.points.length > 0); + assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7)); + const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS); + assert.ok(narrow.points.length > 0); + assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS)); + }); + + test('indépendant du scaleFactor stocké (sinon double correction)', () => { + const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)]; + const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); + const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); + assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement'); + }); + + test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => { + // ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8) + const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' }; + const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)]; + const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE'); + const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS'); + assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6); + assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6); + const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2; + const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2; + const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs)); + assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`); + }); + + test('les labs T sont ignorés (E2 uniquement)', () => { + const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)]; + const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS); + assert.equal(curve.anchoredLabs, 2); + }); + + test('doublons de timestamp - le deuxième ratio gagne', () => { + const t = 5 * DAY_MS; + const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]]; + assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12); + const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2))); + assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9); + }); + + test('ratioAt - gardes defensives aux bornes', () => { + const anchors = [[0, 1.0], [10, 2.0]]; + assertClose(ratioAt(anchors, -5), 1.0, 1e-12); + assertClose(ratioAt(anchors, 100), 2.0, 1e-12); + }); +});