v1.10.0 : fix #68 (reco de prise de sang avant stabilisation), fix #69 (dialog fantôme), rappel sauté si dose déjà loggée
- #68 : le « creux » était le minimum de la fenêtre entière — pour un ester à montée lente (EEn, pic ~J+5 ≈ intervalle 7 j) il tombait juste après l'injection PRÉCÉDENTE : date antérieure à la stabilisation affichée sur la même carte, « 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 (principe v1.8.0 restauré ; EV inchangé). Miroir web appliqué. Régression n°6 data-driven (backup-v1.9.8.json, balayage de now ±60 j) ; les 17 LabTimingTest passent inchangés. - #69 : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le rouvrait à chaque fois (demande au niveau activité, consommation dans les remember de Home). FIX : MutableState<Long?> dans MainActivity consommé UNE fois + extras retirés de l'intent ; bonus : tap notification app-ouverte ouvre aussi le dialog. Test UI LogDoseRequestTest. - Rappel sauté si dose déjà loggée (demande) : garde au déclenchement — hasDoseLoggedOnDay (pur, jour calendaire LOCAL) dans ReminderReceiver, re-programmation du créneau suivant dans tous les cas. 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine → 1). 259 tests JVM + 14 UI + lint verts ; web 180 tests + E2E verts (check.sh). Validations émulateur §16.ter sur APK RELEASE avec données réelles seedées (reco : creux 11 oct 20:08 ≥ stab 7 oct 06:03 ; dialog 1× puis plus jamais ; changelog 1.10.0 affiché ; 0 crash).
This commit is contained in:
parent
2548c6207b
commit
f8c8192a2a
14
README.en.md
14
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
|
> for informational purposes — they are not measurements. Always rely on your
|
||||||
> blood tests and on your endocrinologist's guidance.
|
> blood tests and on your endocrinologist's guidance.
|
||||||
|
|
||||||
- **Status**: v1.9.8 — Android build ✅, **lint clean** ✅, **249 unit tests** ✅
|
- **Status**: v1.10.0 — Android build ✅, **lint clean** ✅, **259 unit tests** ✅
|
||||||
(219 without the local test data; regressions pinned on real data
|
(225 without the local test data; 6 regressions pinned on real data
|
||||||
**not committed to the repo**), **13 Compose UI tests** ✅ (emulator),
|
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
||||||
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||||
with APK** ✅
|
with APK** ✅
|
||||||
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
|
- **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
|
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
|
||||||
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
||||||
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
|
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
|
- **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
|
limits in Settings → warning card on the home screen + **notification every
|
||||||
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
||||||
@ -127,8 +129,8 @@ Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
echo "sdk.dir=/path/to/android-sdk" > local.properties # or 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 # 249 tests (219 without local test data)
|
./gradlew testDebugUnitTest # 259 tests (225 without local test data)
|
||||||
./gradlew connectedDebugAndroidTest # 13 UI tests (emulator/device required)
|
./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
|
||||||
./gradlew lint # clean lint required before a release
|
./gradlew lint # clean lint required before a release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
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
|
> à 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.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)
|
- **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)
|
||||||
@ -91,7 +91,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
||||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
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
|
**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)
|
- **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 +
|
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,
|
**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 <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 # 249 tests (219 sans les données locales)
|
./gradlew testDebugUnitTest # 259 tests (225 sans les données locales)
|
||||||
./gradlew connectedDebugAndroidTest # 13 tests UI (émulateur/appareil requis)
|
./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -33,8 +33,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 = 45
|
versionCode = 46
|
||||||
versionName = "1.9.8"
|
versionName = "1.10.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -170,4 +170,6 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
||||||
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
||||||
androidTestImplementation("junit:junit:4.13.2")
|
androidTestImplementation("junit:junit:4.13.2")
|
||||||
|
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
|
||||||
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<MainActivity>? = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
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<Long?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val container = (application as HormoneTrackApp).container
|
val container = (application as HormoneTrackApp).container
|
||||||
@ -53,15 +64,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
logDoseRequest.value = consumeLogDoseExtras(intent)
|
||||||
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
HormoneTrackTheme {
|
HormoneTrackTheme {
|
||||||
HormoneTrackRoot(
|
HormoneTrackRoot(logDoseRequest = logDoseRequest)
|
||||||
initialOpenLogDose = openLogDose,
|
|
||||||
initialTreatmentId = treatmentId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,5 +76,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,8 +20,10 @@ import kotlin.math.ceil
|
|||||||
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
||||||
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
||||||
* une transition, pas le régime ;
|
* une transition, pas le régime ;
|
||||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le
|
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
|
||||||
* premier créneau d'injection ≥ date de stabilisation du régime courant.
|
* #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à) :
|
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
|
||||||
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
|
* - **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
|
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
||||||
* Posologie/dose, la première dose du traitement est le meilleur
|
* 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 ») ;
|
* 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
|
* - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
|
||||||
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux
|
* E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
|
||||||
* injections. La FORME suffit : calcul SANS calibration (le facteur
|
* v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
|
||||||
* d'échelle est multiplicatif, il ne déplace pas le minimum) ;
|
* 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
|
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
|
||||||
* recommandation quasi-immédiate), creux > dernière prise de sang
|
* recommandation quasi-immédiate), creux > dernière prise de sang
|
||||||
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
|
* 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
|
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
|
||||||
var windowStart = nowMs
|
var windowStart = nowMs
|
||||||
for (slot in slots) {
|
for (slot in slots) {
|
||||||
// Le creux du créneau = minimum de la courbe entre l'injection
|
// ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
|
||||||
// précédente (ou maintenant) et ce créneau
|
// créneau (dernier point de courbe avant l'injection — principe 1,
|
||||||
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp }
|
// « le moment le plus comparable »). L'ancien MINIMUM DE LA
|
||||||
val trough = window.minByOrNull { it.e2 }
|
// 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 &&
|
if (trough != null &&
|
||||||
trough.timestamp >= minTroughMs &&
|
trough.timestamp >= minTroughMs &&
|
||||||
trough.timestamp > lastLabMs &&
|
trough.timestamp > lastLabMs &&
|
||||||
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
|
// 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
|
// le creux pré-injection ≈ instant du créneau, le garde
|
||||||
slot.timestamp >= stabilizedAtMs
|
// 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(
|
return LabRecommendation(
|
||||||
troughMs = trough.timestamp,
|
troughMs = trough.timestamp,
|
||||||
|
|||||||
@ -736,6 +736,32 @@ object PharmacokineticEngine {
|
|||||||
return current.copy(k = medianRounded(ks, 3))
|
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<DoseLog>,
|
||||||
|
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).
|
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import com.hormonetrack.data.AppDatabase
|
|||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -208,19 +209,33 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.notify(treatmentId.toInt(), notification)
|
|
||||||
|
|
||||||
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
||||||
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
// 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
|
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
||||||
// (grille Posologie si elle existe, sinon quotidien).
|
// (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()
|
val pending = goAsync()
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val db = AppDatabase.getInstance(context)
|
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 ->
|
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
||||||
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
|
AlarmScheduler(context).scheduleFor(tr, doses)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
|
|||||||
@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
|
|||||||
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
|
||||||
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
|
||||||
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
|||||||
) {
|
) {
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
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") },
|
onOpenSettings = { navController.navigate("settings") },
|
||||||
onOpenCharts = {
|
onOpenCharts = {
|
||||||
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
||||||
|
|||||||
@ -78,6 +78,7 @@ private const val HOUR_MS = 3_600_000L
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
openLogDoseForTreatmentId: Long?,
|
openLogDoseForTreatmentId: Long?,
|
||||||
|
onLogDoseConsumed: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCharts: () -> Unit,
|
onOpenCharts: () -> Unit,
|
||||||
onOpenTreatment: (Long) -> Unit
|
onOpenTreatment: (Long) -> Unit
|
||||||
@ -113,10 +114,16 @@ fun HomeScreen(
|
|||||||
var showLogDialog by remember { mutableStateOf(false) }
|
var showLogDialog by remember { mutableStateOf(false) }
|
||||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
var preselectId by remember { mutableStateOf<Long?>(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) {
|
LaunchedEffect(openLogDoseForTreatmentId) {
|
||||||
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
||||||
showLogDialog = true
|
showLogDialog = true
|
||||||
preselectId = it
|
preselectId = it
|
||||||
|
onLogDoseConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
163
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
163
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,70 @@
|
|||||||
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.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<Long?>` 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)
|
## [1.9.8] — 2026-09-22 (versionCode 45)
|
||||||
|
|
||||||
### Ajouté — tests UI Compose, signature release dédiée, README anglais
|
### Ajouté — tests UI Compose, signature release dédiée, README anglais
|
||||||
|
|||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| 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<Long?>` 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). |
|
| 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.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. |
|
| 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
|
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
|
||||||
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
||||||
comparable que sur un intervalle régulier).
|
comparable que sur un intervalle régulier).
|
||||||
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
|
- **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
|
||||||
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
|
dernier point de la courbe E2 prévisionnelle (doses réelles +
|
||||||
injections ; FORME brute (sans calibration — le facteur ne déplace pas le
|
[PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
|
||||||
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la
|
l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
|
||||||
stabilisation, borné à 12.
|
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
|
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
|
||||||
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux
|
recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
|
||||||
du créneau suivant — décision v1.8.0 « sauter »).
|
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
|
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
|
||||||
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
||||||
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
||||||
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
||||||
- Tests `LabTimingTest` (v1.9.7 : 2 tests de tolérance ± 24 h ajoutés,
|
- Tests `LabTimingTest` (17 — v1.9.7 : 2 tests de tolérance ± 24 h ;
|
||||||
le test v1.8.1 « interval change » ré-épinglé) + 1 test régression n°5.
|
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`
|
### 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`,
|
- 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`, `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) ;
|
(copiés tels quels depuis l'export JSON de l'app) ;
|
||||||
`RegressionUserCaseTest` lit v1.0.0,
|
`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), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
|
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
|
||||||
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
|
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.
|
- `.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 ;
|
||||||
@ -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).
|
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 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,
|
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
|
- 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
|
||||||
@ -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)
|
### 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
|
```bash
|
||||||
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
|
./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
|
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
|
||||||
présentes + chips de langue littéraux (« Français »/« English ») +
|
présentes + chips de langue littéraux (« Français »/« English ») +
|
||||||
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
|
**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) :
|
**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
|
- 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
|
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
|
||||||
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
|
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
|
||||||
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
|
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
|
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
|
||||||
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
|
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
|
||||||
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
|
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
|
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.
|
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<Long?>` 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)
|
## 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
|
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`,
|
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 259 au
|
||||||
total, 193 si `local-test-data/` est absent (les 5 classes de régression
|
total, 225 si `local-test-data/` est absent (les 6 classes de régression
|
||||||
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert
|
réelles sont skippées via `Assume`) ; **lint vert
|
||||||
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
||||||
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
|
attrapé les crashs #47/#48 dès v1.3.1).
|
||||||
chaque commit = tests + lint ; release = + scan confidentialité + APK au
|
**v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
|
||||||
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
|
(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
|
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
|
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
|
||||||
corrigé, + §2 (historique) si notable.
|
corrigé, + §2 (historique) si notable.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user