diff --git a/README.md b/README.md index e9475e2..4d1b0ea 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.8.0 — build Android ✅, **lint vert** ✅, **220 tests unitaires** ✅ (190 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.8.1 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) @@ -66,8 +66,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L - **Prochaine prise de sang suggérée (v1.8.0)** : la page Analyses recommande le **creux estimé juste avant l'injection suivante** (moment le plus comparable), - **au premier creux où ton ester est stabilisé** (~5 demi-vies terminales après - le dernier changement) — déduit des courbes et de la Posologie ; requiert un + **au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier + changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un injectable E2 actif à Posologie (une invite invite à la renseigner sinon) ; estimations, jamais un avis médical - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), @@ -116,7 +116,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 220 tests (190 sans les données locales) +./gradlew testDebugUnitTest # 223 tests (193 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc5ccd5..6fcc863 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 35 - versionName = "1.8.0" + versionCode = 36 + versionName = "1.8.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt index f097267..4155d8b 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -130,9 +130,14 @@ object LabTiming { // ── 3) Début du régime courant + date de stabilisation ────────────── val myDoses = doseLogs.filter { it.treatmentId == carrier.id } - val regimeStartMs = myDoses.minOfOrNull { it.timestamp } ?: return null + if (myDoses.isEmpty()) return null + val regimeStartMs = regimeStartMs(carrier, myDoses) val stabilizedAtMs = regimeStartMs + (STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong() + // Ester EFFECTIF de la dernière dose (override compris) : c'est lui + // qui gouverne les creux futurs et le texte de la carte + val lastDose = myDoses.maxByOrNull { it.timestamp }!! + val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose) // ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ── val intervalDays = carrier.forecastIntervalDays!! @@ -172,7 +177,7 @@ object LabTiming { return LabRecommendation( troughMs = trough.timestamp, injectionMs = slot.timestamp, - ester = carrier.esterType, + ester = activeEster, terminalHalfLifeDays = tHalfDays, regimeStartMs = regimeStartMs, stabilizedAtMs = stabilizedAtMs, @@ -184,6 +189,65 @@ object LabTiming { return null // stabilisation hors horizon (ester ultra-long) → carte cachée } + /** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */ + private const val DOSE_EPS = 1e-6 + + /** + * DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**). + * + * **Critique remontée** (v1.8.0) : « je suis passée sous un autre ester, + * j'ai changé de dosage ET de posologie, et l'app me disait "stabilisée + * depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait + * PAS les changements récents de dose ni d'intervalle. + * + * **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de + * doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques + * à celles de la dose la plus récente, ET (b) **l'écart entre doses + * consécutives est constant** (= l'écart des deux doses les plus + * récentes). On remonte depuis la dose la plus récente tant que ces + * conditions tiennent ; le régime commence à la dose la plus ancienne + * de la séquence. + * + * Pourquoi chaque composante : + * - **ester effectif** (override par dose compris) : un switch EV → EEn + * réinitialise la stabilisation ; + * - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ; + * - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie + * modifiée/irrégulière) change le creux — conservateur : une série + * d'intervalles chaotiques maintient la carte « non stabilisée », ce + * qui est pharmacocinétiquement vrai (le trough n'est comparable que + * sur un intervalle régulier). La comparaison est EXACTE (arrondi au + * ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un + * vrai changement de créneau. + * + * @param carrier traitement porteur (injectable E2 actif à Posologie) + * @param myDoses doses du porteur (ordre indifférent) + * @return timestamp de la 1ʳᵉ dose du régime courant + */ + internal fun regimeStartMs(carrier: Treatment, myDoses: List): Long { + val sorted = myDoses.sortedBy { it.timestamp } + var idx = sorted.size - 1 + var regimeStart = sorted[idx].timestamp + var current = sorted[idx] + // Écart EXIGÉ pour remonter : celui des deux doses les plus récentes + // (inconnu si une seule dose — la séquence s'arrête là de toute façon) + var gapExpected = -1L + while (idx > 0) { + val prev = sorted[idx - 1] + val gap = current.timestamp - prev.timestamp + val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) && + Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS + // Remonter seulement si même (ester, dose) ET même écart + val sameGap = gapExpected < 0L || gap == gapExpected + if (!sameMarker || !sameGap) break + gapExpected = gap + regimeStart = prev.timestamp + current = prev + idx-- + } + return regimeStart + } + /** * Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable * ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index ff05a78..d0fa69c 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -48,6 +48,7 @@ import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.PharmacokineticEngine +import com.hormonetrack.pk.LabTiming import com.hormonetrack.pk.TConfig import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.ChartOptions @@ -222,6 +223,69 @@ fun HomeScreen( NextDoseCard(activeTreatments, doseLogs) + // ── Recommandation de prochaine prise de sang (v1.8.1) ─────────── + // Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE + // sur l'accueil : creux daté + créneau associé + disclaimer court. + // Cachée si non calculable (pas d'injectable actif à Posologie) ; + // l'invite « renseigne une Posologie » reste sur la page Analyses + // (l'accueil ne fait pas doublon d'explication). + val labRecommendation by produceState( + initialValue = null as LabTiming.LabRecommendation?, + allTreatments, doseLogs, labResults, tick + ) { + value = withContext(Dispatchers.Default) { + LabTiming.nextBloodDrawRecommendation( + allTreatments, doseLogs, labResults, System.currentTimeMillis() + ) + } + } + labRecommendation?.let { rec -> + val fmtDate = remember { + DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(Modifier.padding(12.dp)) { + Text( + stringResource(R.string.labrec_section), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))), + style = MaterialTheme.typography.bodyLarge + ) + Text( + stringResource( + R.string.labrec_before_injection, + rec.ester, + fmtDate.format(Instant.ofEpochMilli(rec.injectionMs)) + ), + style = MaterialTheme.typography.bodyMedium + ) + if (!rec.wasAlreadyStabilized) { + // Mention SEULEMENT si le régime n'était pas encore + // stabilisé (info utile — sinon le texte est identique + // à chaque ouverture) + Text( + stringResource( + R.string.labrec_not_stabilized, + rec.ester, + fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs)) + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) diff --git a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt index 8900d72..e3cc68e 100644 --- a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt @@ -151,6 +151,53 @@ class LabTimingTest { assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs()))) } + // ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ───────────── + // Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et + // l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose + // du traitement) ne détectait aucun de ces changements. NOUVELLE règle : + // le régime = séquence terminale à (ester, dose, écart inter-doses) + // constants ; tout changement réinitialise la stabilisation. + + @Test + fun `recent dose change resets stabilization`() { + // 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime + // courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du + // créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j) + val doses = weeklyDoses(9) + DoseLog( + id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0 + ) + val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! + assertEquals(NOW + 26 * DAY_MS, rec.injectionMs) + assertEquals(false, rec.wasAlreadyStabilized) + } + + @Test + fun `recent interval change resets stabilization`() { + // 9 semaines à 7 j, puis une dose 2 j après la première des deux plus + // récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient + // l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime + // (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime) + val doses = weeklyDoses(9) + DoseLog( + id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0 + ) + val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! + // régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j + assertEquals(NOW + 14 * DAY_MS, rec.injectionMs) + assertEquals(false, rec.wasAlreadyStabilized) + } + + @Test + fun `recent ester override resets stabilization`() { + // Même dose (5 mg), même écart (7 j), mais la dernière dose est un + // override d'ESTER (EEn → EV) : switch d'ester = nouveau régime + val doses = weeklyDoses(9) + DoseLog( + id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV" + ) + val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! + assertEquals("EV", rec.ester) + assertEquals(false, rec.wasAlreadyStabilized) + } + @Test fun `Estrannaise carrier reads its half-life from the ODS table`() { // EV / tables ODS : t½ estimée depuis la pente terminale de la table — diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt index fee6827..9457d09 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt @@ -40,6 +40,8 @@ class RegressionUserCase5Test { ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json") } + private val DAY_MS = 86_400_000L + private lateinit var data: BackupData @Before @@ -170,8 +172,9 @@ class RegressionUserCase5Test { ) assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt) assertTrue( - "le créneau d'injection suit le creux de près", - rec.injectionMs > rec.troughMs && rec.injectionMs - rec.troughMs <= 24L * 3_600_000L + "le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})", + rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L && + rec.troughMs < rec.injectionMs ) assertTrue( "le creux recommandé est postérieur à la dernière prise de sang", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2355492..804bc35 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,31 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). +## [1.8.1] — 2026-09-17 (versionCode 36) + +### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle) + +- **Bug remonté** : la recommandation disait « stabilisée depuis février » + alors que l'ester, le dosage ET la posologie venaient de changer — + l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle + aux changements récents). +- **Fix** : le régime courant = la **séquence terminale de doses** à + (ester effectif, dose mg, **écart inter-doses**) constants — tout + changement récent réinitialise la stabilisation : la recommandation saute + au premier creux **après** la stabilisation du nouveau régime (règle des + ~5 demi-vies conservée, intervalle comparé exactement). La carte porte + désormais l'**ester effectif** de la dernière dose (override compris). +- **Nouveau : suggestion aussi sur l'Accueil** (demande) — carte compacte + (creux daté + créneau associé), avec la mention de stabilisation + seulement si le régime n'était pas déjà stable. +- Validé émulateur sur l'export réel : la carte passe de « stabilisé depuis + le 06/02 » (v1.8.0) à « **EEN pas stabilisé avant le 29/09 — premier + creux fiable après ton dernier changement** », creux recommandé 27/09 + 22:43, 0 crash. +- 3 nouveaux tests (223 au total / 193 sans les données locales) : + changement de dose / d'intervalle / d'ester → réinitialisation épinglée. +- **Web** : même fix en miroir + encart Accueil (web v1.8.1, versions sync). + ## [1.8.0] — 2026-09-17 (versionCode 35) ### Prochaine prise de sang recommandée (page Analyses) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 07f5b6a..e18a469 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). | | 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. | | 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). | | 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). | @@ -617,9 +618,16 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : [PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente log-linéaire utilisée par l'extrapolation de `sample()` — dernier point ≥ 1 % du pic, 48 h précédentes — est partagée). -- **Début du régime courant** : 1ʳᵉ dose du traitement actif — PROXY assumé - (pas d'historique des éditions de Posologie/dose). Stabilisation = - régimeStart + 5 × t½. +- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** — + critique : « changé d'ester, de dosage ET de posologie, et l'app disait + stabilisée depuis février ») : le régime courant = la **séquence terminale + de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques + à la dose la plus récente, ET (b) **l'écart entre doses consécutives est + constant** (= l'écart des deux doses les plus récentes, comparé + exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart + + 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la + carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est + comparable que sur un intervalle régulier). - **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles + [PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux injections ; FORME brute (sans calibration — le facteur ne déplace pas le @@ -632,11 +640,11 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : minute ; carte avec creux exact (« At the estimated trough »), créneau associé (+ mention « ou simplement la veille »), statut de stabilisation, disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée. -- 8 tests `LabTimingTest` + 1 test régression n°5. +- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5. ## 8. Tests unitaires -**220 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **190 sans +**223 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans les données de test locales** (cf §8.bis : les 5 classes de régression, 6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = @@ -714,9 +722,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour). dans le code (le scanner refuserait, et ce serait une fuite). - les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI - exécute 190 tests au lieu de 220 (v1.8.0 ; chiffres historiques : 44/87 à v1.3.x, + exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x, 150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0, - 181/211 à v1.7.1) ; + 181/211 à v1.7.1, 190/220 à v1.8.0) ; - le workdir des tests Gradle est le dossier du module (`app/`) → les tests cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; - **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous