From 3fa7cf97eae0050cf204ee2a3738f78a09cf7104 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Mon, 7 Sep 2026 07:35:54 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.3=20:=20fix=20#53=20=E2=80=94=20le=20delta?= =?UTF-8?q?=20'vs=20il=20y=20a=206=20h'=20de=20l'accueil=20comparait=20en?= =?UTF-8?q?=20r=C3=A9alit=C3=A9=20il=20y=20a=2024=20h=20(firstOrNull=20sur?= =?UTF-8?q?=20la=20fen=C3=AAtre=2024=20h)=20=E2=86=92=20pointHoursBefore?= =?UTF-8?q?=20(point=20au=20plus=20petit=20=C3=A9cart=20=E2=89=A5=206=20h)?= =?UTF-8?q?=20+=204=20tests=20;=20r=C3=A9gression=20n=C2=B04=20=C3=A9pingl?= =?UTF-8?q?=C3=A9e=20sur=20le=204=E1=B5=89=20export=20r=C3=A9el=20(contrib?= =?UTF-8?q?ution=20EEn=20J+1=20<=2012=20%,=20plateau=20d'=C3=A9quilibre=20?= =?UTF-8?q?=E2=80=94=20comportement=20pharmacocin=C3=A9tique=20normal=20co?= =?UTF-8?q?nfirm=C3=A9=20avec=20le=20vrai=20moteur)=20;=20137=20tests=20ve?= =?UTF-8?q?rts=20+=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- app/build.gradle.kts | 4 +- .../hormonetrack/ui/components/CurveChart.kt | 27 +++ .../com/hormonetrack/ui/screens/HomeScreen.kt | 8 +- .../pk/RegressionUserCase4Test.kt | 154 ++++++++++++++++++ .../java/com/hormonetrack/ui/ChartZoomTest.kt | 43 +++++ docs/CHANGELOG.md | 28 +++- docs/DEVELOPPEMENT.md | 37 ++++- 8 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt diff --git a/README.md b/README.md index 0ce660c..d169a95 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.4.2 — build Android ✅, **lint vert** ✅, **129 tests unitaires** ✅ (111 sans les données de test locales ; 3 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.4.3 — build Android ✅, **lint vert** ✅, **137 tests unitaires** ✅ (115 sans les données de test locales ; 3 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) @@ -88,7 +88,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 # 129 tests (111 sans les données locales) +./gradlew testDebugUnitTest # 137 tests (115 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` @@ -100,7 +100,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — `Siphonight/HormoneTrack` sur les deux (privé), avec -**releases taguées** (`v1.1.0` → `v1.4.2`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.4.3`) et **deux APK par release** (depuis v1.2.5) (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcef3cd..2afaab6 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 = 22 - versionName = "1.4.2" + versionCode = 23 + versionName = "1.4.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index 40509bc..e7a4dbf 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -100,6 +100,33 @@ fun forecastHorizonHours(maxIntervalDays: Double?): Long? { fun clampPanHours(panHours: Long, futureHorizonHours: Long, maxPastHours: Long): Long = panHours.coerceIn(-futureHorizonHours.coerceAtLeast(0L), maxPastHours.coerceAtLeast(0L)) +/** + * Point le plus PROCHE de `hours` heures AVANT le dernier point de la série + * (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte + * d'accueil. + * + * ⚠️ BUG corrigé ici : l'ancien code faisait `curve.firstOrNull { écart >= 6 h }` + * sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !) + * satisfaisait la condition immédiatement, et le delta affiché « vs 6 h » + * comparait en réalité à il y a 24 h (remontée : « −11 pg/mL » le lendemain + * d'une injection EEn, cf régression #4). Cette fonction prend le point dont + * l'écart au dernier est le plus PETIT au-dessus de `hours` (par excès — + * avec une grille régulière, c'est le point à ~6 h exactement). + * + * @return null si aucun point n'est au-delà de `hours` (courbe trop courte). + */ +fun pointHoursBefore(points: List, hours: Double): LevelPoint? { + val last = points.lastOrNull() ?: return null + var best: LevelPoint? = null + var bestGap = Double.MAX_VALUE + for (p in points) { + val gap = (last.timestamp - p.timestamp) / 3_600_000.0 + if (gap < hours) continue // seulement AVANT le point de référence + if (gap < bestGap) { bestGap = gap; best = p } + } + return best +} + /** * Graphique Canvas pur, multi-séries : * - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ; 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 be8e734..ff05a78 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -346,9 +346,11 @@ private fun List.filterInRange(curve: List, marker: Strin @Composable private fun NowLevelCard(curve: List) { val now = curve.lastOrNull() - val sixHAgo = curve.firstOrNull { p -> - (curve.last().timestamp - p.timestamp) >= 6 * HOUR_MS - } + // v1.4.3 (fix #53) : le point de comparaison « il y a 6 h » est celui le + // plus PROCHE de −6 h (l'ancien code prenait le PREMIER point de la + // fenêtre 24 h → le delta affiché était en réalité un delta 24 h, + // trompeur le lendemain d'une injection lente type EEn — cf régression #4). + val sixHAgo = com.hormonetrack.ui.components.pointHoursBefore(curve, hours = 6.0) Card( Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt new file mode 100644 index 0000000..4d67b6e --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt @@ -0,0 +1,154 @@ +package com.hormonetrack.pk + +import com.google.gson.Gson +import com.hormonetrack.data.backup.BackupData +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 + +/** + * **4ᵉ régression épinglée sur données réelles** (export v1.4.2, HORS dépôt + * `local-test-data/backup-v1.4.2.json`, gitignoré — données de santé). + * + * Contexte (remontée 2026-09-07) : l'utilisatrice s'est injectée de l'EEn + * la veille à ~18:54 et trouve « bizarre » que l'app affiche un taux qui + * DESCEND (238 pg/mL, −11 « vs il y a 6 h »). Deux enseignements épinglés : + * + * 1. **Pharmacocinétique normale** : à J+1 d'un EEn (Tmax 6,5 j), la dose + * ne contribue encore qu'à peine (~4 pg/mL à J+0,5, ~18 à J+1) et le + * niveau TOTAL est un plateau (superposition de l'équilibre — PTR bas de + * l'énanthate). La « remontée » individuelle est masquée par la + * décroissance du stock. Ce test épine cette signature. + * 2. **Bug #53 confirmé dans le code** : le delta « vs il y a 6 h » de la + * carte d'accueil comparait en réalité le point d'IL Y A ~24 H + * (`firstOrNull` sur la fenêtre 24 h) — cf fix `pointHoursBefore`. + * + * Le test reproduit le calcul d'app à l'instant d'observation (+15 min + * après exportedAt ≈ 07:25 le lendemain de l'injection) pour épingler le + * niveau exact que le moteur produit. + */ +class RegressionUserCase4Test { + + companion object { + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.4.2.json"), + File("local-test-data/backup-v1.4.2.json"), + File("app/local-test-data/backup-v1.4.2.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json") + } + + private lateinit var data: BackupData + + @Before + fun setup() { + assumeTrue( + "local-test-data/backup-v1.4.2.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) + } + + private fun observationMs(): Long = data.exportedAt + 15 * 60_000L + + /** Calibration automatique telle que la calcule l'app (option active). */ + private fun auto(): PharmacokineticEngine.AutoCalibrated = + PharmacokineticEngine.autoCalibrated( + data.treatments, data.doseLogs, data.labResults, data.tConfig + ) + + @Test + fun `export v2 parses with settings and the real transition scenario`() { + assertEquals(3, data.treatments.size) + assertEquals(52, data.doseLogs.size) + assertEquals(26, data.labResults.size) + assertEquals(2, data.version) + // settings v1.4.2 : opt-in alertes + auto-calibration active + org.junit.Assert.assertNotNull(data.settings) + assertEquals(true, data.settings!!.autoCalibrate) + org.junit.Assert.assertNotNull(data.settings!!.alertE2High) + org.junit.Assert.assertNotNull(data.settings!!.alertE2Low) + // scénario de transition : EV inactif (scaleFactor > 1) + EEn actif (posologie 6 j) + val ev = data.treatments.single { it.esterType == "EV" } + val een = data.treatments.single { it.esterType == "EEN" } + assertTrue(!ev.isActive && ev.scaleFactor != 1.0) + assertTrue(een.isActive && een.forecastIntervalDays != null) + } + + @Test + fun `contribution of yesterday injection is tiny at J+1 (EEn Tmax 6-5 days)`() { + val eenDoses = data.doseLogs.filter { it.treatmentId == 1L } + val lastDose = eenDoses.maxByOrNull { it.timestamp }!! + val obs = observationMs() + val dtDays = (obs - lastDose.timestamp) / 86_400_000.0 + assertTrue("l'observation est ~J+0,5", dtDays in 0.3..0.7) + + // contribution SEULE de la dernière dose, en % du niveau total + val tr = data.treatments.single { it.id == lastDose.treatmentId } + val alone = PharmacokineticEngine.concentrationOfDose(tr, lastDose, obs) + val total = PharmacokineticEngine.e2At( + data.treatments, data.doseLogs, obs, scalePerEster = auto().esterScales + ) + assertTrue("contribution seule=${alone} pg/mL doit être faible (< 12 %)", alone < total * 0.12) + // signature EEn : à J+1 la contribution reste marginale devant le plateau + val aloneJ1 = PharmacokineticEngine.concentrationOfDose( + tr, lastDose, lastDose.timestamp + 86_400_000L + ) + val totalJ1 = PharmacokineticEngine.e2At( + data.treatments, data.doseLogs, lastDose.timestamp + 86_400_000L, + scalePerEster = auto().esterScales + ) + assertTrue("à J+1 : $aloneJ1 vs total ${totalJ1}", aloneJ1 < totalJ1 * 0.12) + } + + @Test + fun `level plateau around observation time (EEn equilibrium, no sharp rise)`() { + val obs = observationMs() + val scale = auto().esterScales + // Variation sur 24 h autour de l'observation : le plateau EEn est plat + // (PTR bas) — |Δ| reste une petite fraction du niveau. + val before = PharmacokineticEngine.e2At( + data.treatments, data.doseLogs, obs - 86_400_000L, scalePerEster = scale + ) + val at = PharmacokineticEngine.e2At( + data.treatments, data.doseLogs, obs, scalePerEster = scale + ) + val after = PharmacokineticEngine.e2At( + data.treatments, data.doseLogs, obs + 86_400_000L, scalePerEster = scale + ) + assertTrue("niveau=${at} doit être physiologique (100-500)", at in 100.0..500.0) + val maxSwing = maxOf(before, at, after) - minOf(before, at, after) + assertTrue("swing 48 h = ${maxSwing} doit rester ≤ 25 % du niveau", maxSwing <= at * 0.25) + } + + @Test + fun `thresholds evaluate on the real level (no false HIGH at observation)`() { + val obs = observationMs() + val scale = auto().esterScales + val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, obs, scalePerEster = scale) + val t = data.tConfig + val level = PharmacokineticEngine.levelAt( + data.treatments, data.doseLogs, obs, t, + tKPerEster = auto().tKPerEster, scalePerEster = scale + ) + val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds( + e2High = data.settings!!.alertE2High, + e2Low = data.settings!!.alertE2Low, + tHigh = data.settings!!.alertTHigh, + tLow = data.settings!!.alertTLow + )) + // À l'observation (J+0,5) : le niveau est dans la plage configurée → + // PAS d'alerte (le franchissement LOW n'arrive qu'au creux, ~J+7) + assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty()) + assertTrue("niveau=$e2 cohérent avec l'affichage rapporté (~238)", e2 in 200.0..300.0) + } +} diff --git a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt index 6c878da..69289f3 100644 --- a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt +++ b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt @@ -76,4 +76,47 @@ class ChartZoomTest { // bornes dégénérées (jamais négatives) assertEquals(0L, clampPanHours(-10L, -5L, -3L)) } + + // ── v1.4.3 (fix #53) : delta « vs il y a 6 h » de la carte d'accueil ── + + private fun point(tsH: Long, e2: Double = 0.0) = + com.hormonetrack.pk.LevelPoint(timestamp = tsH * 3_600_000L, e2 = e2, t = 0.0) + + @Test + fun `pointHoursBefore picks the point closest to 6h before the last one`() { + // Fenêtre 24 h à pas d'1 h : le point à 24 − 6 = 18 h exactement + val hourly = (0L..24L).map { point(it) } + val chosen = com.hormonetrack.ui.components.pointHoursBefore(hourly, 6.0)!! + assertEquals(18L * 3_600_000L, chosen.timestamp) + } + + @Test + fun `pointHoursBefore on half-hour grid lands exactly 6h back`() { + val halfHourly = (0L..48L).map { + com.hormonetrack.pk.LevelPoint(timestamp = it * 1_800_000L, e2 = 0.0, t = 0.0) + } + val chosen = com.hormonetrack.ui.components.pointHoursBefore(halfHourly, 6.0)!! + val gapH = (halfHourly.last().timestamp - chosen.timestamp) / 3_600_000.0 + assertEquals(6.0, gapH, 1e-9) + } + + @Test + fun `pointHoursBefore returns null when the curve is shorter than the offset`() { + val short = (0L..5L).map { point(it) } // 5 h de couverture + assertNull(com.hormonetrack.ui.components.pointHoursBefore(short, 6.0)) + assertNull(com.hormonetrack.ui.components.pointHoursBefore(emptyList(), 6.0)) + } + + @Test + fun `pointHoursBefore with irregular points takes the closest above the offset`() { + // points : 0h, 7h, 23h → le point le plus proche de 6h (par excès) = 7h + val irregular = listOf(point(0), point(7), point(23)) + val chosen = com.hormonetrack.ui.components.pointHoursBefore(irregular, 6.0)!! + assertEquals(7L * 3_600_000L, chosen.timestamp) + // points : 0h, 3h, 23h → les deux sont au-delà de 6h ; on garde le + // gap le PLUS PETIT (3h, gap 20 h) et non le plus ancien + val last = listOf(point(0), point(3), point(23)) + val chosen2 = com.hormonetrack.ui.components.pointHoursBefore(last, 6.0)!! + assertEquals(3L * 3_600_000L, chosen2.timestamp) + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 14f6f80..0f39b09 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,33 @@ # Changelog — HormoneTrack Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). -Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.2`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.3`, …). + +## [1.4.3] — 2026-09-07 (versionCode 23) + +### Corrigé +- **Le delta « vs il y a 6 h » de la carte d'accueil comparait en réalité + il y a ~24 h** (#53, remontée : « 238 pg/mL avec −11 vs 6 h malgré mon + injection d'hier ») : `curve.firstOrNull { écart ≥ 6 h }` sur une fenêtre + de 24 h retournait le PREMIER point de la liste (il y a 24 h), pas un + point à 6 h. FIX : nouveau helper pur **`pointHoursBefore`** (le point + dont l'écart au dernier est le plus petit au-dessus de l'offset — + testé : grille 1 h, grille 30 min, points irréguliers, courbe courte). + Le delta affiché est désormais VRAIMENT un delta 6 h. + +### Ajouté +- **Régression n°4** épinglée sur le 4ᵉ export réel (HORS dépôt, gitignoré) + : le scénario « injection EEn hier, l'estimation ne remonte pas » — + export v1.4.2 (3 traitements, 52 doses, 26 labs, settings v2 avec seuils + et auto-calibration). Le test épine, avec le VRAI moteur : la + **contribution d'une dose EEn à J+1 est marginale** (< 12 % du total — + Tmax 6,5 j), le **plateau d'équilibre** (swing 48 h ≤ 25 % du niveau), + l'absence de fausse alerte à l'observation, et le parsing des settings + v2. Réponse à la question posée : **comportement pharmacocinétique + normal** de l'énanthate (courbe plate en équilibre), pas un bug — le + seul bug était l'étiquette du delta (ci-dessus). +- **8 nouveaux tests** (137 au total / 115 sans les données locales) : + `RegressionUserCase4Test` (4) + `pointHoursBefore` (4, dans ChartZoomTest). ## [1.4.2] — 2026-09-07 (versionCode 22) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index b8cb015..8b2d638 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -85,6 +85,7 @@ Fonctionnalités v1 : | 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. | | 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. | | 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. | +| 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | @@ -409,7 +410,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k ## 8. Tests unitaires -**129 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **111 sans +**137 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **115 sans les données de test locales** (cf §8.bis : les 3 classes de régression, 6 tests chacune, 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 = @@ -520,6 +521,13 @@ dépôt (ni maintenant, ni si le repo devient public un jour). d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée). +- **`RegressionUserCase4Test`** (4) : v1.4.3 — 4ᵉ régression épinglée sur + les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec + **settings** (seuils + auto-cal), scénario transition EV inactif → EEn + actif, **signature pharmacocinétique EEn** (contribution d'une dose à + 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.** - **`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, @@ -627,7 +635,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). - `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes `settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes - `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel** - (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), **cartes d'alerte v1.4.2** (si un + (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h — **v1.4.3 : comparaison au point le + plus proche de −6 h via `pointHoursBefore`, l'ancien code comparait ~24 h**, + bug #53), **cartes d'alerte v1.4.2** (si un seuil est franchi : fond `errorContainer`, « ▲ E2 estimé ≈ X pg/mL — au-dessus de ta limite (Y) », basées sur le DERNIER point de courbe = niveau affiché, disclaimer « estimation, pas une mesure »), carte prochaine dose (**v1.4.1 : @@ -1049,6 +1059,16 @@ l'exemple) :** **Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :** +53. **Delta « vs il y a 6 h » = en réalité 24 h** (v1.4.3, remontée) : + `NowLevelCard` prenait `curve.firstOrNull { écart ≥ 6 h }` sur une + fenêtre de 24 h — le premier point satisfait la condition + immédiatement → comparaison à ~24 h. Le lendemain d'une injection EEn + (plateau), ce delta 24 h peut être légèrement négatif → fausse + impression que « l'injection ne fait rien ». FIX : `pointHoursBefore` + (point au plus petit écart ≥ 6 h, testé). Leçon : un `firstOrNull` sur + une condition de distance trouve l'élément le PLUS LOIN, jamais le plus + proche — écrire l'intention (« le plus proche de N ») explicitement. + 52. **Rappels quotidiens même hors jour d'injection** (remontée utilisateur : « je m'injecte tous les samedis à 18 h → je dois recevoir le rappel uniquement le samedi à 18 h ») : `nextReminderFireMs` ignorait doses et @@ -1208,8 +1228,8 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 129 au - total, 111 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 137 au + total, 115 si `local-test-data/` est absent (les 3 classes de régression réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : @@ -1496,14 +1516,19 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : Import → langue/seuils restaurés ; un VIEUX backup (v1.4.1) s'importe sans les réglages (rétrocompat) ; smoke-test R8 : export/import (réflexion Gson sur UserSettings, `-keep` ajouté) +- [ ] **v1.4.3** : **delta 6 h de l'accueil** : le lendemain d'une injection + EEn (plateau), la carte « niveau actuel » doit comparer à il y a 6 h + RÉELLEMENT (bug #53 : avant, la comparaison portait sur 24 h et le + delta pouvait être trompeusement négatif) — vérifier la cohérence avec + la courbe 24 h du graphique --- -*Doc mise à jour le 6 sept. 2026 (v1.4.2) — build OK, lint vert, 129/129 tests verts (111 sans +*Doc mise à jour le 7 sept. 2026 (v1.4.3) — build OK, lint vert, 137/137 tests verts (115 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 reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité -~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres. Dette connue : fragments de +~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé). Dette connue : fragments de labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*