v1.7.1 : fix « une seule note sur deux » dans les Analyses (Android) + régression n°5

- 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).
This commit is contained in:
Siphonight 2026-09-17 12:29:44 +02:00
parent 2c58418604
commit 5d81900a2d
7 changed files with 325 additions and 18 deletions

View File

@ -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 > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > 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) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.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) - **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 <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./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 ./gradlew lint # lint vert obligatoire avant release
``` ```

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 33 versionCode = 34
versionName = "1.7.0" versionName = "1.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -62,6 +62,44 @@ fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>
} }
.sortedByDescending { it.first } .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<LabResult>): List<String> {
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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabsScreen() { fun LabsScreen() {
@ -114,9 +152,12 @@ fun LabsScreen() {
supportingContent = { supportingContent = {
Column { Column {
Text(fmt.format(Instant.ofEpochMilli(ts))) Text(fmt.format(Instant.ofEpochMilli(ts)))
// Notes du groupe (toutes identiques en pratique) // Notes du groupe (v1.7.1) : TOUTES les notes
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let { // distinctes, préfixées du marqueur quand elles
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) // 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)
} }
} }
}, },

View File

@ -2,18 +2,27 @@ package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.groupLabsForDisplay import com.hormonetrack.ui.screens.groupLabsForDisplay
import com.hormonetrack.ui.screens.labNotesForDisplay
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même * 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. * 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 { class LabsGroupingTest {
private fun lab(marker: String, ts: Long, value: Double) = 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) 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 @Test
fun `E2 and T at the same timestamp are grouped into one blood draw`() { fun `E2 and T at the same timestamp are grouped into one blood draw`() {
val ts = 1_788_621_894_346L val ts = 1_788_621_894_346L
@ -53,4 +62,64 @@ class LabsGroupingTest {
val groups = groupLabsForDisplay(labs) val groups = groupLabsForDisplay(labs)
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() }) 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())
}
} }

View File

@ -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())
}
}

View File

@ -3,6 +3,27 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). 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`, …). 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) ## [1.7.0] — 2026-09-13 (versionCode 33)
### Sauvegarde automatique quotidienne (opt-in) ### Sauvegarde automatique quotidienne (opt-in)

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | 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). | | 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.** | | 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). | | 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 ## 8. Tests unitaires
**200 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **177 sans **211 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **181 sans
les données de test locales** (cf §8.bis : les 4 classes de régression, les données de test locales** (cf §8.bis : les 5 classes de régression,
6/6/6/5 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. 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 = 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/…`). `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`, - 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.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 `backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json`
l'export JSON de l'app) ; `RegressionUserCaseTest` lit v1.0.0, (copiés tels quels depuis l'export JSON de l'app) ;
`RegressionUserCaseTest` lit v1.0.0,
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec **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. - `.gitignore` contient `local-test-data/` → jamais commités.
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
1. `git check-ignore -v local-test-data/…` → la règle matche ; 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). dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le - 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 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, 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) ; 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 - le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous - **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é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 (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. 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 ; 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** - **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction (1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante 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 à 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 « pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse
alerte à l'observation. **Tout nouvel export = une régression data-driven.** 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`) : - **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) :
HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le
clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas, 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 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 avec le toggle T), au-dessus des labels numériques ; portées web
(versions sync) (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 à - [ ] **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 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 — 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) 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 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 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 maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs