From 23c62e85f23285d013226a2f40593729423b9811 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Thu, 17 Sep 2026 12:29:44 +0200 Subject: [PATCH] =?UTF-8?q?v1.7.1=20:=20fix=20=C2=AB=20une=20seule=20note?= =?UTF-8?q?=20sur=20deux=20=C2=BB=20dans=20les=20Analyses=20(Android)=20+?= =?UTF-8?q?=20r=C3=A9gression=20n=C2=B05?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LabsScreen : labNotesForDisplay (helper PUR, testé) — notes DISTINCTES d'une prise E2+T → DEUX lignes préfixées du marqueur (« E2 : … » / « T : … ») ; note identique (dialog commun) → une seule ligne (dédupliquée) ; vides ignorées ; note seule → brute (v1.0 préservé). L'ancien code ne prenait que la première note non vide (hypothèse « toutes identiques » cassée par l'édition unitaire) : l'autre note était conservée mais perdue à l'affichage. - RegressionUserCase5Test (7 tests) : NOUVEL export réel v1.7.0 (local-test-data/backup-v1.7.0.json, gitigné) — 3 traitements dont CPA oral, 64 doses, 28 labs ; la dernière prise porte EXACTEMENT le cas du bug (deux notes distinctes E2/T) : les deux affichées, aucune perdue ; plausibilité moteur (CPA oral + EEn actif + EV inactif simulé), prévision 7 j, notes de doses préservées. Aucune valeur de santé en dur (data-driven, garde §8.bis). - LabsGroupingTest : +5 tests sur labNotesForDisplay. 211 verts (181 sans données locales) + lint vert. - Validé émulateur §16.ter : APK release seedé avec l'export v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … », 0 crash. - versionCode 34 / versionName 1.7.1. Web sync en miroir (web v1.7.1). --- README.md | 4 +- app/build.gradle.kts | 4 +- .../com/hormonetrack/ui/screens/LabsScreen.kt | 47 +++++- .../com/hormonetrack/pk/LabsGroupingTest.kt | 69 ++++++++ .../pk/RegressionUserCase5Test.kt | 155 ++++++++++++++++++ docs/CHANGELOG.md | 21 +++ docs/DEVELOPPEMENT.md | 43 +++-- 7 files changed, 325 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt diff --git a/README.md b/README.md index b647c61..377535c 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.7.0 — build Android ✅, **lint vert** ✅, **200 tests unitaires** ✅ (177 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.7.1 — build Android ✅, **lint vert** ✅, **211 tests unitaires** ✅ (181 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) @@ -110,7 +110,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 # 200 tests (177 sans les données locales) +./gradlew testDebugUnitTest # 211 tests (181 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 f6dd031..c5c06bc 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 = 33 - versionName = "1.7.0" + versionCode = 34 + versionName = "1.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt index 80be255..79989f7 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt @@ -62,6 +62,44 @@ fun groupLabsForDisplay(labs: List): List> } .sortedByDescending { it.first } +/** + * Notes à AFFICHER pour un groupe de labs (v1.7.1 — fix du bug « une seule + * note sur deux s'affiche »). + * + * HISTORIQUE DU BUG : à l'origine, le dialog de création mettait LA MÊME note + * aux deux entrées d'une prise E2+T — l'affichage ne prenait donc que la + * première non vide (`firstNotNullOfOrNull`), hypothèse documentée « toutes + * identiques en pratique ». Depuis l'édition UNITAIRE (tap → sélecteur E2/T), + * chaque entrée porte SA note (cas réel : « Analyse faite suite à des maux de + * tête. » sur E2 et « DHT : 12ng/dL » sur T) → l'autre note disparaissait de + * l'affichage tout en restant bien sauvegardée. Même bug côté web (mirror + * `.find()`), corrigé en miroir. + * + * RÈGLES : + * - notes vides/blanches ignorées ; + * - ordre du groupe préservé (E2 avant T — ordre de [groupLabsForDisplay]) ; + * - note IDENTIQUE sur plusieurs entrées (création via le dialog commun) → + * affichée UNE seule fois (pas de doublon) ; + * - notes DISTINCTES → une ligne par note, préfixée du marqueur + * (« E2 : … » / « T : … ») pour savoir à quelle entrée elle se rapporte ; + * - une seule note distincte → affichée brute (comportement v1.0 préservé). + * + * PUR (aucune dépendance Android) → testé en JVM (LabsGroupingTest). + */ +fun labNotesForDisplay(group: List): List { + val notes = group.mapNotNull { lab -> + lab.notes?.takeIf { it.isNotBlank() } + ?.let { lab.marker.uppercase() to it.trim() } + } + if (notes.isEmpty()) return emptyList() + val distinct = notes.distinctBy { it.second } // même texte = une seule ligne + return if (distinct.size == 1) { + listOf(distinct.first().second) + } else { + distinct.map { (marker, note) -> "$marker : $note" } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun LabsScreen() { @@ -114,9 +152,12 @@ fun LabsScreen() { supportingContent = { Column { Text(fmt.format(Instant.ofEpochMilli(ts))) - // Notes du groupe (toutes identiques en pratique) - group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let { - Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) + // Notes du groupe (v1.7.1) : TOUTES les notes + // distinctes, préfixées du marqueur quand elles + // diffèrent — l'ancien code n'affichait que la + // première non vide (bug « une note sur deux ») + labNotesForDisplay(group).forEach { note -> + Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant) } } }, diff --git a/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt index 34240be..a3ed0ab 100644 --- a/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt @@ -2,18 +2,27 @@ package com.hormonetrack.pk import com.hormonetrack.data.model.LabResult import com.hormonetrack.ui.screens.groupLabsForDisplay +import com.hormonetrack.ui.screens.labNotesForDisplay import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test /** * Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même * timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant. + * v1.7.1 : affichage des NOTES du groupe ([labNotesForDisplay]) — fix du bug + * « une seule note sur deux s'affiche » quand E2 et T portent des notes + * DISTINCTES (chacune conservée en base, une seule affichée avant le fix). */ class LabsGroupingTest { private fun lab(marker: String, ts: Long, value: Double) = LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts) + /** Lab avec note (les autres paramètres neutres). */ + private fun labWithNotes(marker: String, ts: Long, notes: String?) = + LabResult(marker = marker, value = 1.0, unit = "pg/mL", timestamp = ts, notes = notes) + @Test fun `E2 and T at the same timestamp are grouped into one blood draw`() { val ts = 1_788_621_894_346L @@ -53,4 +62,64 @@ class LabsGroupingTest { val groups = groupLabsForDisplay(labs) assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() }) } + + // ── Notes d'un groupe (v1.7.1 — fix « une seule note sur deux ») ──────── + + @Test + fun `distinct notes on E2 and T are BOTH shown, prefixed by marker`() { + // Cas réel remonté (paire v1.7.0 de l'utilisatrice) : note E2 ≠ note T + val ts = 1_788_934_260_000L + val group = listOf( + labWithNotes("E2", ts, "Analyse faite suite à des maux de tête."), + labWithNotes("T", ts, "DHT : 12 ng/dL"), + ) + assertEquals( + listOf( + "E2 : Analyse faite suite à des maux de tête.", + "T : DHT : 12 ng/dL", + ), + labNotesForDisplay(group) + ) + } + + @Test + fun `identical notes on both entries are shown once (no duplicate)`() { + // Création via le dialog commun : LA MÊME note part sur E2 et T + val ts = 1_000L + val group = listOf( + labWithNotes("E2", ts, "Prise à jeun"), + labWithNotes("T", ts, "Prise à jeun"), + ) + assertEquals(listOf("Prise à jeun"), labNotesForDisplay(group)) + } + + @Test + fun `single note stays unprefixed (v1_0 behaviour preserved)`() { + val ts = 2_000L + val group = listOf( + labWithNotes("E2", ts, "Seule note"), + labWithNotes("T", ts, null), + ) + assertEquals(listOf("Seule note"), labNotesForDisplay(group)) + } + + @Test + fun `blank and null notes are ignored, order follows the group (E2 before T)`() { + val ts = 3_000L + val group = listOf( + labWithNotes("E2", ts, " "), // blanche → ignorée + labWithNotes("T", ts, "note T"), + labWithNotes("PRL", ts, "note PRL"), + ) + // Ordre du groupe (déjà trié E2→T→PRL par le caller) : T avant PRL, + // la note blanche d'E2 n'apparaît pas + assertEquals(listOf("T : note T", "PRL : note PRL"), labNotesForDisplay(group)) + } + + @Test + fun `no notes at all yields no display lines`() { + val ts = 4_000L + val group = listOf(labWithNotes("E2", ts, null), labWithNotes("T", ts, "")) + assertTrue(labNotesForDisplay(group).isEmpty()) + } } diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt new file mode 100644 index 0000000..6b607e8 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt @@ -0,0 +1,155 @@ +package com.hormonetrack.pk + +import com.google.gson.Gson +import com.hormonetrack.data.backup.BackupData +import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.TreatmentType +import com.hormonetrack.ui.screens.labNotesForDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * **5ᵉ régression épinglée sur données réelles** (export v1.7.0, HORS dépôt + * `local-test-data/backup-v1.7.0.json`, gitigné — données de santé). + * + * Contexte (remontée 2026-09-16) : dans une prise de sang E2+T, quand les + * DEUX entrées portent des notes DISTINCTES (ex. une note clinique sur l'E2 + * et une note « DHT » sur la T), une seule s'affichait dans l'écran Analyses + * — l'autre était bien conservée mais perdue à l'affichage. Fix : + * `labNotesForDisplay` (Android + web en miroir), épinglé ici sur la VRAIE + * paire de données. Profil mis à jour au passage : 3 traitements dont un CPA + * oral (anti-androgène, Bateman) + EEn actif + EV historique INACTIF — + * rappelle que l'inactif reste simulé (§6.bis). + * + * ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) : + * aucune valeur de santé en dur dans ce fichier (garde de confidentialité + * §8.bis — le scanner refuserait, et ce serait une fuite). + */ +class RegressionUserCase5Test { + + companion object { + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.7.0.json"), + File("local-test-data/backup-v1.7.0.json"), + File("app/local-test-data/backup-v1.7.0.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json") + } + + private lateinit var data: BackupData + + @Before + fun setup() { + assumeTrue( + "local-test-data/backup-v1.7.0.json absent — tests ignorés (données locales)", + DATA_FILE.exists() + ) + if (!PKProfileStore.hasProfile("EV", "TFS")) { + val file = listOf( + File("src/main/assets/pk_profiles.json"), + File("app/src/main/assets/pk_profiles.json") + ).first { it.exists() } + PKProfileStore.initWithJson(file.readText()) + } + data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java) + } + + /** Structure attendue de l'export (les nombres viennent du FICHIER). */ + @Test + fun `backup parses with the expected shape`() { + assertEquals(3, data.treatments.size) + assertTrue("doses présentes", data.doseLogs.isNotEmpty()) + assertTrue("labs présents", data.labResults.isNotEmpty()) + // Profil : un anti-androgène ORAL actif, un E2 INACTIF resté dans les + // données (§6.bis : jamais filtré de la simulation), un E2 actif + assertEquals(1, data.treatments.count { it.type == TreatmentType.ANTI_ANDROGEN }) + assertEquals(1, data.treatments.count { !it.isActive }) + } + + /** + * LE fix de cette régression : la prise la plus récente porte DEUX notes + * DISTINCTES (une par entrée) → [labNotesForDisplay] en rend DEUX, + * préfixées du marqueur. Avant le fix, l'affichage n'en montrait qu'une + * (première non vide) bien que les deux fussent conservées. + */ + @Test + fun `the latest blood draw has two distinct notes and BOTH are displayed`() { + val latestTs = data.labResults.maxOf { it.timestamp } + val pair = data.labResults.filter { it.timestamp == latestTs } + assertTrue("la dernière prise est une paire E2+T", pair.size >= 2) + + val distinctNotes = pair.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }.distinct() + assertTrue( + "la dernière paire porte au moins deux notes distinctes (données du bug)", + distinctNotes.size >= 2 + ) + val displayed = labNotesForDisplay(pair) + assertEquals("DEUX lignes affichées", 2, displayed.size) + assertTrue("chaque ligne est préfixée du marqueur", displayed.all { it.contains(" : ") }) + // Les DEUX textes d'origine se retrouvent (aucune perdue) + for (note in distinctNotes) { + assertTrue(displayed.any { it.endsWith(note) }) + } + } + + /** Même prise → note identique dupliquée = une seule ligne (pas de doublon). */ + @Test + fun `a blood draw with the SAME note on both entries shows it once`() { + val pair = listOf( + LabResult(marker = "E2", value = 1.0, unit = "pg/mL", timestamp = 0, notes = "Prise à jeun"), + LabResult(marker = "T", value = 1.0, unit = "ng/mL", timestamp = 0, notes = "Prise à jeun"), + ) + assertEquals(1, labNotesForDisplay(pair).size) + } + + /** Notes de DOSES (côté injection) : parsées et préservées. */ + @Test + fun `dose notes are preserved by the round-trip`() { + assertTrue( + "au moins une dose porte une note (données réelles)", + data.doseLogs.any { !it.notes.isNullOrBlank() } + ) + } + + /** + * Plausibilité du moteur sur CE profil (CPA oral Bateman + EEn actif + + * EV historique inactif) : niveau actuel physiologique, courbe 30 j non + * vide et bornée, auto-calibration (activée dans les settings) couvre + * l'ester actif sans planter. + */ + @Test + fun `engine stays plausible on the new profile`() { + val now = data.exportedAt + 300_000L // +5 min après l'export + val level = PharmacokineticEngine.levelAt( + data.treatments, data.doseLogs, now, + tConfig = data.tConfig, scalePerEster = null, tKPerEster = null + ) + assertTrue("E2 estimée physiologique (${level.e2})", level.e2 in 20.0..1000.0) + assertTrue("T estimée physiologique (${level.t})", level.t in 0.01..3.0) + + val curve = PharmacokineticEngine.computeCurve( + data.treatments, data.doseLogs, now - 30 * 3_600_000L * 24, now, + stepMs = 3_600_000L, tConfig = data.tConfig + ) + assertTrue("courbe 30 j non vide", curve.isNotEmpty()) + + // Auto-calibration (option activée dans les settings de cet export) + val auto = PharmacokineticEngine.autoCalibrated( + data.treatments, data.doseLogs, data.labResults, data.tConfig + ) + assertTrue("l'auto-calibration couvre au moins un ester", auto.calibratedEsters >= 1) + } + + /** Prévision : le traitement actif a une Posologie → créneaux projetés. */ + @Test + fun `forecast slots exist for the active treatment`() { + val active = data.treatments.first { it.isActive && it.type == TreatmentType.ESTRADIOL } + val slots = PharmacokineticEngine.generateForecastDoses( + active, data.doseLogs, data.exportedAt + 10L * 24 * 3_600_000L, data.exportedAt + ) + assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty()) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cabcac8..6b98563 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,27 @@ 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.7.1] — 2026-09-16 (versionCode 34) + +### Corrigé — une seule note sur deux s'affichait dans les Analyses + +- **Bug remonté** (identique côté web) : dans une prise de sang E2+T, quand + les deux entrées portent des notes **distinctes**, une seule s'affichait + dans l'écran Analyses — l'autre était bien **conservée** (export/import + l'avaient) mais perdue à l'affichage. +- **Cause** : l'affichage ne prenait que la première note non vide du groupe + (hypothèse historique « toutes identiques en pratique » — vraie à l'époque + du dialog de création, fausse depuis l'édition unitaire E2/T). +- **Fix** : `labNotesForDisplay` (helper pur, testé) — notes distinctes → + une ligne par note **préfixée du marqueur** (« E2 : … » / « T : … ») ; + note identique sur les deux entrées → une seule ligne (pas de doublon) ; + notes vides ignorées ; une seule note → brute (comportement v1.0 gardé). +- **Épinglé par** : 5 nouveaux tests LabsGroupingTest + **régression n°5 + data-driven sur le nouvel export réel** (v1.7.0 : la dernière prise + porte exactement ce cas — deux notes distinctes E2/T, les deux + affichées). Validé émulateur sur l'APK release seedé avec cet export. +- **Web** : même fix en miroir (web v1.7.1, versions sync). + ## [1.7.0] — 2026-09-13 (versionCode 33) ### Sauvegarde automatique quotidienne (opt-in) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 4370c5f..8354988 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 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). | | 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. **+ Rattrapage validation émulateur §16.ter (oubliée, demandée par l'utilisatrice) → bug #64 TROUVÉ** : « Tracé labs » vide EN RELEASE SEULE (R8 full mode avait REMOVÉ la classe — mapping `R8$$REMOVED$$CLASS` — et inliné le calcul dans le producer ; debug OK, 191 tests JVM aveugles). FIX : `-keep class ...LabTrajectoryModel { *; }` ; validé émulateur APK release re-buildé avec les données réelles seedées : ancrée (7090 px rose + légende), prolongée (légende + avertissement), 0 crash. Tag v1.6.0 reposé sur le commit de fix AVANT publication d'APK (checklist §16 étape 4). **Checklist §16 : étape 3.bis « validation émulateur release OBLIGATOIRE avant tag » ajoutée.** | | 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). | @@ -596,9 +597,9 @@ constant à droite comme l'interpolation aboutit à ρ_last à gauche). ## 8. Tests unitaires -**200 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **177 sans -les données de test locales** (cf §8.bis : les 4 classes de régression, -6/6/6/5 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. +**211 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **181 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 = `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). @@ -653,11 +654,14 @@ dépôt (ni maintenant, ni si le repo devient public un jour). - emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`, `backup-v1.2.3.json` (ancien export, plus consommé par un test), - `backup-v1.3.1.json` et `backup-v1.4.2.json` (copiés tels quels depuis - l'export JSON de l'app) ; `RegressionUserCaseTest` lit v1.0.0, + `backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json` + (copiés tels quels depuis l'export JSON de l'app) ; + `RegressionUserCaseTest` lit v1.0.0, `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit **v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec - les settings v2) ; + les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3 + traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes — + épine le fix « une note sur deux » v1.7.1) ; - `.gitignore` contient `local-test-data/` → jamais commités. **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : 1. `git check-ignore -v local-test-data/…` → la règle matche ; @@ -671,8 +675,8 @@ 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 177 tests au lieu de 200 (v1.7.0 ; chiffres historiques : 44/87 à v1.3.x, - 150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0) ; + exécute 181 tests au lieu de 211 (v1.7.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) ; - 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 @@ -696,9 +700,13 @@ dépôt (ni maintenant, ni si le repo devient public un jour). répliquées sur les deux Gitea, aucune fuite nouvelle par push) ; la dette (réécriture d'historique + re-tags + force-push des 2 remotes + re-publish des releases) reste ouverte et doit être re-décisionnée si le dépôt passe PUBLIC. -- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T +- **`LabsGroupingTest`** (9) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; - ordre chronologique décroissant). + ordre chronologique décroissant). v1.7.1 — **notes d'un groupe** + (`labNotesForDisplay`) : notes distinctes E2/T → DEUX lignes préfixées du + marqueur (fix « une seule note sur deux »), note identique → une seule + ligne (dédupliquée), note seule → brute (v1.0 préservé), vides/blanches + ignorées, aucune note → aucune ligne. - **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique** (1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction de sections depuis la dernière vue, première installation (section courante @@ -770,6 +778,14 @@ dépôt (ni maintenant, ni si le repo devient public un jour). J+1 < 12 % du total, plateau 48 h ≤ 25 % — c'est la réponse épinglée à « pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse alerte à l'observation. **Tout nouvel export = une régression data-driven.** +- **`RegressionUserCase5Test`** (7) : v1.7.1 — 5ᵉ régression épinglée sur + les données réelles (export v1.7.0, HORS dépôt) : profil à 3 traitements + (CPA oral Bateman + EEn actif + EV inactif simulé, §6.bis), **le fix + « une note sur deux » épinglé sur la VRAIE paire** (dernière prise E2+T + avec deux notes distinctes → `labNotesForDisplay` en rend deux, préfixées ; + note identique → une seule), notes de DOSES préservées, plausibilité + moteur (niveau physiologique, courbe 30 j, auto-calibration couvre un + ester), prévision 7 j. **Tout nouvel export = une régression data-driven.** - **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) : HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas, @@ -2044,6 +2060,11 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : gauche (E2) et « ng/mL » au sommet de l'axe droit (T — disparaît avec le toggle T), au-dessus des labels numériques ; portées web (versions sync) +- [ ] **v1.7.1** : **notes des analyses** : une prise E2+T avec des notes + DISTINCTES affiche LES DEUX lignes (« E2 : … » / « T : … ») ; + note identique sur les deux → une seule ligne ; la note seule reste + brute ; ré-éditer une entrée → sa note se met à jour sans effacer + l'autre - [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à 24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent — avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et @@ -2064,7 +2085,7 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : tap ; et TOUTES les courbes affichées reflètent la calibration (test) --- -*Doc mise à jour le 13 sept. 2026 (v1.7.0) — build OK, lint vert, 200/200 tests verts (177 sans +*Doc mise à jour le 16 sept. 2026 (v1.7.1) — build OK, lint vert, 211/211 tests verts (181 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs