diff --git a/README.en.md b/README.en.md index aa377b0..a57ed47 100644 --- a/README.en.md +++ b/README.en.md @@ -18,9 +18,9 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.** > for informational purposes — they are not measurements. Always rely on your > blood tests and on your endocrinologist's guidance. -- **Status**: v1.9.8 — Android build ✅, **lint clean** ✅, **249 unit tests** ✅ - (219 without the local test data; regressions pinned on real data - **not committed to the repo**), **13 Compose UI tests** ✅ (emulator), +- **Status**: v1.10.0 — Android build ✅, **lint clean** ✅, **259 unit tests** ✅ + (225 without the local test data; 6 regressions pinned on real data + **not committed to the repo**), **14 Compose UI tests** ✅ (emulator), smartwatch integration = notifications ✅, **private Gitea repo + releases with APK** ✅ - **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French) @@ -92,7 +92,9 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.** - **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment - injected every 7 days only rings on injection day, not every day + injected every 7 days only rings on injection day, not every day; **a dose + already logged on the day skips the notification** (v1.10.0 — the next + reminder resumes at the following slot) - **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low limits in Settings → warning card on the home screen + **notification every 15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) — @@ -127,8 +129,8 @@ Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME ./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 249 tests (219 without local test data) -./gradlew connectedDebugAndroidTest # 13 UI tests (emulator/device required) +./gradlew testDebugUnitTest # 259 tests (225 without local test data) +./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required) ./gradlew lint # clean lint required before a release ``` diff --git a/README.md b/README.md index 443c384..e7954ae 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,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.9.8 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), **13 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.10.0 — build Android ✅, **lint vert** ✅, **259 tests unitaires** ✅ (225 sans les données de test locales ; 6 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), 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) @@ -91,7 +91,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ; **les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les - 7 jours ne sonne que le jour d'injection, pas tous les jours + 7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà + loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant + repart au créneau suivant) - **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL) et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil + **notification toutes les 15 min même app fermée** (WorkManager, anti-spam, @@ -126,8 +128,8 @@ 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 # 249 tests (219 sans les données locales) -./gradlew connectedDebugAndroidTest # 13 tests UI (émulateur/appareil requis) +./gradlew testDebugUnitTest # 259 tests (225 sans les données locales) +./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis) ./gradlew lint # lint vert obligatoire avant release ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 446b467..e5b4e32 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,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 = 45 - versionName = "1.9.8" + versionCode = 46 + versionName = "1.10.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -170,4 +170,6 @@ dependencies { androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0") // JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif androidTestImplementation("junit:junit:4.13.2") + // UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest) + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") } diff --git a/app/src/androidTest/java/com/hormonetrack/uitest/LogDoseRequestTest.kt b/app/src/androidTest/java/com/hormonetrack/uitest/LogDoseRequestTest.kt new file mode 100644 index 0000000..e736539 --- /dev/null +++ b/app/src/androidTest/java/com/hormonetrack/uitest/LogDoseRequestTest.kt @@ -0,0 +1,84 @@ +package com.hormonetrack.uitest + +import android.content.Intent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.hormonetrack.MainActivity +import com.hormonetrack.R +import org.junit.After +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Test UI de la demande « logger une dose » venue d'une NOTIFICATION + * (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les + * extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver, + * cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre + * JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet + * Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au + * niveau activité alors que la consommation vivait dans les `remember` de + * Home, détruits à chaque changement d'onglet). + * + * Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le + * contrat du fix exige de régler l'état AVANT que la composition ne lise + * l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas + * dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest, + * indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est + * affiché dans les DEUX variantes du dialog (avec ou sans traitements en + * base) : le test reste data-indépendant. + */ +@RunWith(AndroidJUnit4::class) +class LogDoseRequestTest { + + @get:Rule + val composeRule = createEmptyComposeRule() + + private var scenario: ActivityScenario? = null + + private fun res(id: Int): String = UiTestEnv.targetContext.getString(id) + + @After + fun tearDown() { + scenario?.close() + } + + @Test + fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() { + val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply { + putExtra("open_log_dose", true) + putExtra("treatment_id", 1L) + } + scenario = ActivityScenario.launch(intent) + + val dialogTitle = res(R.string.add_dose) + composeRule.waitForIdle() + composeRule.onNodeWithText(dialogTitle).assertIsDisplayed() + + // Fermeture du dialog (BACK → onDismissRequest) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + composeRule.waitForIdle() + composeRule.onNodeWithText(dialogTitle).assertDoesNotExist() + + // Changer d'onglet puis revenir sur l'accueil : le dialog NE doit + // PAS se rouvrir (demande consommée une fois pour toutes) + composeRule.onNodeWithText(res(R.string.nav_doses)).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText(res(R.string.nav_home)).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText(dialogTitle).assertDoesNotExist() + } + + companion object { + @BeforeClass + @JvmStatic + fun setUpApp() = UiTestEnv.prepare() + } +} diff --git a/app/src/main/java/com/hormonetrack/MainActivity.kt b/app/src/main/java/com/hormonetrack/MainActivity.kt index ccd8bc5..4569b9f 100644 --- a/app/src/main/java/com/hormonetrack/MainActivity.kt +++ b/app/src/main/java/com/hormonetrack/MainActivity.kt @@ -22,6 +22,17 @@ class MainActivity : AppCompatActivity() { private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + /** + * Demande « logger une dose » venue d'une NOTIFICATION (extra + * `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux + * changements d'onglets — et consommé UNE FOIS par HomeScreen via + * [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le + * paramètre d'intent tel quel à la composition ; quitter l'onglet + * Accueil détruisait les `remember` locaux, le paramètre lui vivait au + * niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil). + */ + private val logDoseRequest = androidx.compose.runtime.mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val container = (application as HormoneTrackApp).container @@ -53,15 +64,11 @@ class MainActivity : AppCompatActivity() { } } - val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false - val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L + logDoseRequest.value = consumeLogDoseExtras(intent) setContent { HormoneTrackTheme { - HormoneTrackRoot( - initialOpenLogDose = openLogDose, - initialTreatmentId = treatmentId - ) + HormoneTrackRoot(logDoseRequest = logDoseRequest) } } } @@ -69,5 +76,22 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + // v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte + // ouvre aussi le dialog (avant : setIntent seul, la demande était + // silencieusement perdue). + consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it } + } + + /** + * Lit les extras de demande de log puis les RETIRE de l'intent : un + * recreate de l'activité (rotation, processus restauré) ne doit JAMAIS + * rouvrir le dialog d'une notification traitée il y a longtemps. + */ + private fun consumeLogDoseExtras(intent: Intent?): Long? { + val open = intent?.getBooleanExtra("open_log_dose", false) ?: false + val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L + intent?.removeExtra("open_log_dose") + intent?.removeExtra("treatment_id") + return if (open && id > 0) id else null } } diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt index 52ca5d1..e2e3450 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -20,8 +20,10 @@ import kotlin.math.ceil * classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après * un changement d'ester/dose/rythme, les prises intermédiaires reflètent * une transition, pas le régime ; - * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le - * premier créneau d'injection ≥ date de stabilisation du régime courant. + * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix + * #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même — + * l'instant où la prise de sang serait faite — pas sur le créneau + * d'injection qui le suit). * * CALCUL (tout est déduit de ce que l'app connaît déjà) : * - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie @@ -36,10 +38,13 @@ import kotlin.math.ceil * assumé et documenté : l'app ne trace pas l'historique des éditions de * Posologie/dose, la première dose du traitement est le meilleur * estimateur du début du régime (même sémantique que le « temps sous THS ») ; - * - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles - * + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux - * injections. La FORME suffit : calcul SANS calibration (le facteur - * d'échelle est multiplicatif, il ne déplace pas le minimum) ; + * - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe + * E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68 + * v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après + * l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont + * le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul + * SANS calibration (le facteur d'échelle est multiplicatif, il ne + * déplace pas le minimum) ; * - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de * recommandation quasi-immédiate), creux > dernière prise de sang * existante (ne jamais recommander un creux déjà mesuré), créneau ≥ @@ -165,16 +170,29 @@ object LabTiming { val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L var windowStart = nowMs for (slot in slots) { - // Le creux du créneau = minimum de la courbe entre l'injection - // précédente (ou maintenant) et ce créneau - val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp } - val trough = window.minByOrNull { it.e2 } + // ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du + // créneau (dernier point de courbe avant l'injection — principe 1, + // « le moment le plus comparable »). L'ancien MINIMUM DE LA + // FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters + // à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum + // local est le creux d'absorption, quelques heures après + // l'injection PRÉCÉDENTE) : la date proposée précédait la + // stabilisation affichée sur la MÊME carte, et le texte + // « juste avant ton injection du … » était faux de plusieurs + // jours (remontée v1.9.8, épinglé par RegressionUserCase6Test). + // Pour les esters à t½ courte (EV), le point pré-injection EST + // le minimum de fenêtre : comportement inchangé. + val trough = curve.lastOrNull { + it.timestamp > windowStart && it.timestamp < slot.timestamp + } if (trough != null && trough.timestamp >= minTroughMs && trough.timestamp > lastLabMs && // ON SAUTE au premier creux stabilisé (décision v1.8.0) : - // le creux d'un créneau est stabilisé si le CRÉNEAU l'est - slot.timestamp >= stabilizedAtMs + // le creux pré-injection ≈ instant du créneau, le garde + // exprime la stabilisation sur l'instant réellement proposé + // pour la prise de sang (trough < slot de ~1 pas de courbe). + trough.timestamp >= stabilizedAtMs ) { return LabRecommendation( troughMs = trough.timestamp, diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index 6ba57f8..4e02ac7 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -736,6 +736,32 @@ object PharmacokineticEngine { return current.copy(k = medianRounded(ks, 3)) } + /** + * Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs] + * (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ? + * + * v1.10.0 (demande utilisatrice) : si la dose prévue du jour est + * saisie AVANT l'heure de rappel, la notification ne doit PAS sonner + * pour une prise déjà faite — le rappel suivant repart au créneau + * SUIVANT de la grille (le receiver re-programme dans tous les cas). + * S'applique identiquement au fallback quotidien (oral sans Posologie : + * dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part). + * + * PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest). + */ + fun hasDoseLoggedOnDay( + treatmentId: Long, + doseLogs: List, + atMs: Long, + zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault() + ): Boolean { + val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate() + return doseLogs.any { + it.treatmentId == treatmentId && + java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day + } + } + /** * Prochain déclenchement de rappel pour UN traitement (v1.4.0). * diff --git a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt index e1d723b..14dbcd5 100644 --- a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt +++ b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt @@ -15,6 +15,7 @@ import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.PharmacokineticEngine +import com.hormonetrack.util.AppLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -208,19 +209,33 @@ class ReminderReceiver : BroadcastReceiver() { .setAutoCancel(true) .build() - val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(treatmentId.toInt(), notification) - // ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer // ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant // n'existait qu'après un reboot/une édition. Le calcul passe par le moteur // (grille Posologie si elle existe, sinon quotidien). + // v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée + // (prise saisie avant l'heure de rappel), la notification est SAUTÉE — + // elle ne doit pas sonner pour une prise déjà faite — et le rappel + // suivant repart au créneau suivant (re-schedule dans tous les cas). + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val pending = goAsync() CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { val db = AppDatabase.getInstance(context) + val doses = db.doseLogDao().getAllOnce() + if (PharmacokineticEngine.hasDoseLoggedOnDay( + treatmentId, doses, System.currentTimeMillis() + ) + ) { + AppLog.log( + "reminder", + "créneau déjà honoré (dose loguée aujourd'hui) → notification sautée (treatment=$treatmentId)" + ) + } else { + manager.notify(treatmentId.toInt(), notification) + } db.treatmentDao().getById(treatmentId)?.let { tr -> - AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce()) + AlarmScheduler(context).scheduleFor(tr, doses) } } finally { pending.finish() diff --git a/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt b/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt index f1569c1..e21010c 100644 --- a/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt +++ b/app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt @@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf { private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) @Composable -fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { +fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState) { val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp val context = androidx.compose.ui.platform.LocalContext.current @@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { ) { composable("home") { HomeScreen( - openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null, + // Demande de notification : état détenu par l'activité + // (survit aux onglets), consommé UNE fois par HomeScreen + // (fix #69 — dialog fantôme au retour sur l'accueil) + openLogDoseForTreatmentId = logDoseRequest.value, + onLogDoseConsumed = { logDoseRequest.value = null }, onOpenSettings = { navController.navigate("settings") }, onOpenCharts = { // Tap sur le mini-graphique de l'accueil → écran Graphiques 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 06ec016..665ee53 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -78,6 +78,7 @@ private const val HOUR_MS = 3_600_000L @Composable fun HomeScreen( openLogDoseForTreatmentId: Long?, + onLogDoseConsumed: () -> Unit, onOpenSettings: () -> Unit, onOpenCharts: () -> Unit, onOpenTreatment: (Long) -> Unit @@ -113,10 +114,16 @@ fun HomeScreen( var showLogDialog by remember { mutableStateOf(false) } var preselectId by remember { mutableStateOf(null) } + // Demande de notification : ouvrir le dialog UNE SEULE FOIS puis + // CONSOMMER la demande au niveau activité (fix #69 — l'ancien code + // relançait le dialog à chaque RÉ-ENTRÉE en composition de Home : + // quitter l'onglet détruit les remember locaux, le paramètre d'intent + // lui survivait → retour sur l'accueil = dialog fantôme à chaque fois). LaunchedEffect(openLogDoseForTreatmentId) { openLogDoseForTreatmentId?.takeIf { it > 0 }?.let { showLogDialog = true preselectId = it + onLogDoseConsumed() } } diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt new file mode 100644 index 0000000..e95f20e --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt @@ -0,0 +1,163 @@ +package com.hormonetrack.pk + +import com.google.gson.Gson +import com.hormonetrack.data.backup.BackupData +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt + * `local-test-data/backup-v1.9.8.json`, gitigné — données de santé). + * + * Contexte (remontée 2026-09-27) : « la recommandation suggère une date + * antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de + * la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne + * doivent JAMAIS précéder la date de stabilisation du régime courant + * (5 × t½ terminale) — c'est toute la valeur de la recommandation. + * + * Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif + * (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane + * (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données + * (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j). + * + * ⚠️ 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 RegressionUserCase6Test { + + companion object { + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.9.8.json"), + File("local-test-data/backup-v1.9.8.json"), + File("app/local-test-data/backup-v1.9.8.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json") + } + + private val DAY_MS = 86_400_000L + private val HOUR_MS = 3_600_000L + + private lateinit var data: BackupData + + @Before + fun setup() { + assumeTrue( + "local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)", + DATA_FILE.exists() + ) + data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java) + } + + /** Le porteur de la recommandation : E2 injectable actif à Posologie. */ + private fun carrier() = data.treatments.first { + it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel && + (it.forecastIntervalDays ?: 0.0) > 0.0 + } + + /** Structure attendue de l'export (les nombres viennent du FICHIER). */ + @Test + fun `backup parses with the expected shape`() { + val carrier = carrier() + assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0) + val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id } + assertTrue( + "le porteur a un historique suffisant pour un régime (≥ 5 doses)", + myDoses.size >= 5 + ) + assertTrue("labs présents", data.labResults.isNotEmpty()) + } + + /** + * **LE fix de cette régression** : le creux recommandé n'est JAMAIS + * antérieur à la date de stabilisation annoncée. Balayage de `now` sur + * ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date + * précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas + * seulement à l'instant de l'export. + */ + @Test + fun `recommended trough is never before the stabilization date (any now)`() { + val start = data.exportedAt - 60L * DAY_MS + val end = data.exportedAt + 60L * DAY_MS + var checked = 0 + var violations = 0 + var now = start + while (now <= end) { + val rec = LabTiming.nextBloodDrawRecommendation( + data.treatments, data.doseLogs, data.labResults, now + ) + if (rec != null) { + checked++ + if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) { + violations++ + println( + "VIOLATION now=$now : trough=${rec.troughMs} " + + "stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}" + ) + } + } + now += 6L * HOUR_MS + } + assertTrue( + "au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)", + checked > 0 + ) + assertEquals( + "creux/créneau toujours ≥ stabilisation ($violations violations)", + 0, violations + ) + } + + /** + * La recommandation à l'instant de l'export : le régime détecté doit + * couvrir la séquence régulière récente (logs à heures variables MAIS + * écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux + * recommandé doit être le PREMIER créneau post-stabilisation. + */ + @Test + fun `regime detection covers the recent regular sequence`() { + val carrier = carrier() + val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id } + val regimeStart = LabTiming.regimeStartMs(carrier, myDoses) + // Les 4 dernières doses sont régulières (même dose, écart dans la + // fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière… + // assertion data-driven : le régime couvre au moins les 4 doses + // les plus récentes (séquence régulière du fichier). + val sorted = myDoses.map { it.timestamp }.sorted() + assertTrue( + "le régime détecté couvre la séquence régulière récente " + + "(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})", + regimeStart <= sorted[sorted.size - 4] + ) + } + + /** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */ + @Test + fun `recommendation at export time is consistent`() { + val rec = LabTiming.nextBloodDrawRecommendation( + data.treatments, data.doseLogs, data.labResults, data.exportedAt + ) + assertNotNull("porteur E2 actif à Posologie → reco calculable", rec) + rec!!.let { + assertTrue("creux futur", it.troughMs > data.exportedAt) + assertTrue( + "creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})", + it.troughMs < it.injectionMs && + it.troughMs > it.injectionMs - 8L * DAY_MS + ) + assertTrue( + "creux postérieur à la dernière analyse", + it.troughMs > data.labResults.maxOf { l -> l.timestamp } + ) + assertTrue( + "INVARIANT central : creux ≥ stabilisation", + it.troughMs >= it.stabilizedAtMs + ) + } + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/ReminderLoggedDayTest.kt b/app/src/test/java/com/hormonetrack/pk/ReminderLoggedDayTest.kt new file mode 100644 index 0000000..0830935 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/ReminderLoggedDayTest.kt @@ -0,0 +1,82 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.DoseLog +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande + * utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la + * notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans + * la journée (le receiver saute la notif et re-programme le créneau suivant). + * + * Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits — + * aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES + * locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant). + */ +class ReminderLoggedDayTest { + + private val ZONE = ZoneId.of("Europe/Paris") + + /** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */ + private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli() + + private val T1 = 1L + private val T2 = 2L + private val HOUR_MS = 3_600_000L + + private fun dose(treatmentId: Long, atMs: Long) = + DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0) + + @Test + fun `dose logged the same day skips the reminder`() { + // Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant + // l'heure de rappel) : le créneau du jour est déjà honoré. + val doses = listOf(dose(T1, noon - 3 * HOUR_MS)) + assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE)) + } + + @Test + fun `dose logged yesterday does not skip today's reminder`() { + val doses = listOf(dose(T1, noon - 24 * HOUR_MS)) + assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE)) + } + + @Test + fun `midnight boundary is a calendar-day comparison in the given zone`() { + // 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip + val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli() + val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli() + assertFalse( + "23:50 la veille ne couvre pas le rappel de 00:10", + PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE) + ) + assertTrue( + "00:10 et 12:00 du MÊME jour = même jour calendaire", + PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE) + ) + } + + @Test + fun `a dose of another treatment never skips`() { + val doses = listOf(dose(T2, noon - 3 * HOUR_MS)) + assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE)) + } + + @Test + fun `no dose at all never skips`() { + assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE)) + } + + @Test + fun `several treatments mixed - only the carrier's own day counts`() { + val doses = listOf( + dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui + dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille + ) + assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE)) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 86325f7..736d8a8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,70 @@ 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.10.0] — 2026-09-27 (versionCode 46) + +### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68) + +- **Remontée** : « la recommandation suggère une date antérieure à la + stabilisation des taux » (données réelles de la remontée → 6ᵉ régression + épinglée `local-test-data/backup-v1.9.8.json`, gitigné). +- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière** + (créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈ + intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE** + (creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée + était gouvernée par l'injection N−1, elle-même antérieure à la + stabilisation, et le texte « juste avant ton injection du … » était faux de + plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32 + vs stabilisation 7 oct 04:03. +- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de + courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste + avant l'injection suivante, le moment le plus comparable »), rend la carte + cohérente (creux 46 min avant le créneau étiqueté) et le garde de + stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à + t½ courte (EV), le point pré-injection EST le minimum de fenêtre : + comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`). +- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur + ±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ; + les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix). + +### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69) + +- **Remontée** : tap sur la notification de rappel → le dialog de saisie + s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le + ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur). +- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais + la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet + détruit les `remember` locaux, le paramètre survivait → à chaque + ré-entrée en composition, le dialog se rouvrait. +- **Fix** : la demande est un état `MutableState` détenu par + MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ; + les extras sont retirés de l'intent après lecture (un recreate ne doit + jamais rouvrir une notification traitée) ; et un tap notification quand + l'app est déjà ouverte ouvre désormais aussi le dialog (avant : + silencieusement perdu). +- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× + + plus jamais après aller-retour d'onglets) + vérification émulateur. + +### Ajouté — la notification de rappel saute un créneau déjà honoré + +- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de + rappel, la notification ne doit pas sonner pour une prise déjà faite ; le + rappel suivant repart au créneau suivant (idem pour le quotidien des + oraux sans Posologie). +- **Implémentation** : garde au moment du déclenchement — + `PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL, + testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui → + notification sautée (AppLog) + re-programmation du créneau suivant dans + tous les cas. Robuste quel que soit le moment de la saisie. +- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille, + frontière minuit, autre traitement, liste vide, mélange) + vérification + A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans + dose → 1 notif). + +Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles) ++ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts** +(`check.sh`). Version web alignée au moment de la release (checklist 4.bis). + ## [1.9.8] — 2026-09-22 (versionCode 45) ### Ajouté — tests UI Compose, signature release dédiée, README anglais diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 4e50956..f348252 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). | | 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). | | 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. | | 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. | @@ -647,20 +648,29 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est comparable que sur un intervalle régulier). -- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles + - [PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux - injections ; FORME brute (sans calibration — le facteur ne déplace pas le - minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la - stabilisation, borné à 12. +- **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau — + dernier point de la courbe E2 prévisionnelle (doses réelles + + [PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant + l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière » + tombait juste APRÈS l'injection précédente pour les esters à montée + lente (EEn : pic ~J+5 ≈ intervalle 7 j → creux d'absorption) : date + antérieure à la stabilisation + texte « juste avant ton injection » faux + de plusieurs jours. Pour EV (t½ courte) le point pré-injection EST le + minimum de fenêtre → comportement inchangé. FORME brute (sans + calibration — le facteur ne déplace pas le minimum) ; horizons : au + moins 3 créneaux, étendu pour couvrir la stabilisation, borné à 12. - **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais - recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux - du créneau suivant — décision v1.8.0 « sauter »). + recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon + creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le + garde s'exprime sur le creux lui-même, instant réellement proposé). - **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick minute ; carte avec creux exact (« At the estimated trough »), créneau associé (+ mention « ou simplement la veille »), statut de stabilisation, disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée. -- Tests `LabTimingTest` (v1.9.7 : 2 tests de tolérance ± 24 h ajoutés, - le test v1.8.1 « interval change » ré-épinglé) + 1 test régression n°5. +- Tests `LabTimingTest` (17 — v1.9.7 : 2 tests de tolérance ± 24 h ; + sémantique pré-injection inchangée par le fix #68) + régressions n°5 et + **n°6** (data-driven `backup-v1.9.8.json` : invariant « creux ≥ + stabilisation » balayé sur `now` ± 60 j, pas 6 h). ### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt` @@ -948,14 +958,17 @@ 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`, `backup-v1.4.2.json` et `backup-v1.7.0.json` + `backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json` et + `backup-v1.9.8.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), `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) ; + épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit + **v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le + fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j) ; - `.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 ; @@ -969,9 +982,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour). dans le code (le scanner refuserait, et ce serait une fuite). - les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI - exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x, + exécute 225 tests au lieu de 259 (v1.10.0 ; chiffres historiques : 44/87 à v1.3.x, 150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0, - 181/211 à v1.7.1, 190/220 à v1.8.0) ; + 181/211 à v1.7.1, 190/220 à v1.8.0, 219/249 à v1.9.x) ; - 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 @@ -1003,7 +1016,7 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod ### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8) -**13 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil : +**14 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil : ```bash ./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36) @@ -1021,7 +1034,12 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod - **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes présentes + chips de langue littéraux (« Français »/« English ») + **version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions - jamais bumpées dans les tags — ne peut plus revenir silencieusement). + jamais bumpées dans les tags — ne peut plus revenir silencieusement) ; +- **`LogDoseRequestTest` (1, v1.10.0)** : le dialog de dose s'ouvre UNE fois + au lancement avec les extras de notification, puis ne se rouvre JAMAIS + après fermeture + aller-retour d'onglets (épinglage du fix #69). Lancement + manuel avec intent (règle vide + ActivityScenario) ; BACK = fermeture + indépendante des libellés (UiDevice, dep `uiautomator:2.3.0`). **Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) : - permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le @@ -1061,7 +1079,15 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity (`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis - reprogramme le prochain déclenchement** (goAsync + IO) + reprogramme le prochain déclenchement** (goAsync + IO). + **v1.10.0 — garde « créneau déjà honoré »** : au déclenchement, + `PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL — + 6 tests `ReminderLoggedDayTest`) consulte les doses : dose du jour déjà + loguée → **notification sautée** (prise saisie avant l'heure de rappel — + la notif ne sonne pas pour une prise faite) ; la re-programmation du + créneau SUIVANT reste exécutée dans tous les cas. Vérifié A/B sur + émulateur (`am broadcast -n …ReminderReceiver` : EEn avec dose du jour → + 0 notif, Fluoxetine sans dose → 1 notif). - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif @@ -1801,6 +1827,32 @@ pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. L quand un helper prend un fuseau pour FORMATTER, la même zone doit régler l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms. +**#68 (v1.10.0) — recommandation de prise de sang antérieure à la stabilisation** : +le « creux » était le minimum de la FENÊTRE ENTIÈRE (créneau N−1 → créneau N) — pour +un ester à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j), ce minimum tombe quelques +heures après l'injection PRÉCÉDENTE (creux d'absorption) : la date proposée était +gouvernée par l'injection N−1 (antérieure à la stabilisation) et le texte « juste +avant ton injection du … » était faux de plusieurs jours. Réel : creux 5 oct 02:32 +vs « pas stabilisée avant » 7 oct 04:03. FIX : creux = niveau PRÉ-INJECTION du +créneau (restaure le principe v1.8.0 ; pour EV le point pré-injection EST le min de +fenêtre → inchangé). Leçon : quand une valeur affichée est QUALIFIÉE par un texte +(« juste avant l'injection »), le calcul doit produire CETTE valeur — et un +minimum de fenêtre n'est pas un creux pré-injection si la courbe remonte dans +l'intervalle. Épinglé par RegressionUserCase6Test (balayage de `now` ±60 j : +l'invariant doit tenir à tout instant, pas seulement à l'instant du rapport). + +**#69 (v1.10.0) — dialog de dose fantôme après une notification** : tap +notification → dialog de saisie ; le fermer puis changer d'onglet et revenir sur +l'accueil le ROUVRAIT à chaque fois. Cause : la demande vivait au niveau ACTIVITÉ +(paramètre d'intent) tandis que la consommation vivait dans un LaunchedEffect de +Home — quitter l'onglet détruit les `remember` locaux, le paramètre survivait → +chaque ré-entrée en composition rouvrait. FIX : `MutableState` détenu par +MainActivity, consommé UNE fois ; extras retirés de l'intent après lecture (un +recreate ne rouvre pas une notification traitée) ; bonus : tap notification +app-ouverte ouvre désormais aussi le dialog. Leçon : une demande à consommer une +fois doit vivre à la MÊME durée de vie que sa consommation — jamais « paramètre +longue-vie + effet courte-vie ». Épinglé par le test UI LogDoseRequestTest. + ## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0) L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de @@ -1973,13 +2025,18 @@ de signature est requise — elle est désormais câblée : 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` — 223 au - total, 193 si `local-test-data/` est absent (les 5 classes de régression - réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 259 au + total, 225 si `local-test-data/` est absent (les 6 classes de régression + réelles sont skippées via `Assume`) ; **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 : - chaque commit = tests + lint ; release = + scan confidentialité + APK au - tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. + attrapé les crashs #47/#48 dès v1.3.1). + **v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest` + (14 tests §8.ter — émulateur requis). + **v1.10.0+ — miroir web** : si la session touche une logique miroirée + (lab-timing, moteur…), `bash scripts/check.sh` dans `~/projects/HormoneTrack-web` + doit être vert (180 tests + E2E) AVANT le tag. Paliers de test du projet : + chaque commit = tests + lint ; release = + tests UI + scan confidentialité + + APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. 3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des releases Gitea en sera extrait automatiquement par le script), + §14 si bug corrigé, + §2 (historique) si notable.