v1.4.4 : fix #54 — prévision inaccessible depuis v1.4.1 : (a) captures FIGÉES dans pointerInput(Unit) (futurePanHorizon lu à 0 pour toujours → pan futur interdit) → rememberUpdatedState + garde de source ChartScreenSourceGuardTest ; (b) toggle sans effet visible (endMs=now → 0 dose générée) → forecastExtensionHours (extension jusqu'au 1er créneau, sans déplacer le début) ; 3 tests (140 verts) + lint ; validé émulateur (drag gauche → panHours<0, courbe dessinée au-delà de maintenant)
This commit is contained in:
parent
755d8f0007
commit
54b1cec532
11
README.md
11
README.md
@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
> à 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.4.3 — build Android ✅, **lint vert** ✅, **137 tests unitaires** ✅ (115 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
- **Statut** : v1.4.4 — build Android ✅, **lint vert** ✅, **140 tests unitaires** ✅ (118 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
- **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)
|
||||||
@ -39,8 +39,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
patch et voie orale
|
patch et voie orale
|
||||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||||
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) ;
|
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) ;
|
||||||
**l'activation ne déplace pas le graphique** et la projection se parcourt en tirant
|
**l'activation ne déplace pas le début du graphique** mais ÉTEND la fenêtre
|
||||||
vers la gauche, jusqu'à **1 an** (v1.4.1)
|
jusqu'à la prochaine dose (v1.4.4) et la projection se parcourt en tirant
|
||||||
|
vers la gauche, jusqu'à **1 an**
|
||||||
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
||||||
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
||||||
**intervalle en jours entre dosages** affiché
|
**intervalle en jours entre dosages** affiché
|
||||||
@ -88,7 +89,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 137 tests (115 sans les données locales)
|
./gradlew testDebugUnitTest # 140 tests (118 sans les données locales)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -100,7 +101,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
|
|||||||
|
|
||||||
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||||
**releases taguées** (`v1.1.0` → `v1.4.3`) et **deux APK par release** (depuis v1.2.5)
|
**releases taguées** (`v1.1.0` → `v1.4.4`) et **deux APK par release** (depuis v1.2.5)
|
||||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||||
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
||||||
|
|
||||||
|
|||||||
@ -17,8 +17,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 23
|
versionCode = 24
|
||||||
versionName = "1.4.3"
|
versionName = "1.4.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -90,6 +90,38 @@ fun forecastHorizonHours(maxIntervalDays: Double?): Long? {
|
|||||||
return (maxIntervalDays * 24.0 * 12.0).toLong().coerceIn(24L * 30, 24L * 365)
|
return (maxIntervalDays * 24.0 * 12.0).toLong().coerceIn(24L * 30, 24L * 365)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
|
||||||
|
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
|
||||||
|
*
|
||||||
|
* ⚠️ Historique : en v1.4.1, activer la prévision ne déplaçait plus la
|
||||||
|
* fenêtre (le saut « tout à droite » de v1.4.0 avait été rejeté) MAIS la
|
||||||
|
* projection ne devenait visible qu'en tirant vers la gauche — car
|
||||||
|
* `endMs = now` faisait que `generateForecastDoses(..., toMs = now, ...)`
|
||||||
|
* ne générait AUCUNE dose : le chip semblait inerte (« le toggle ne fait
|
||||||
|
* plus rien », remontée v1.4.3). La solution : étendre la fenêtre à droite
|
||||||
|
* SANS déplacer son début (l'historique visible reste à sa place, seule la
|
||||||
|
* largeur augmente) jusqu'à couvrir au moins la prochaine dose projetée,
|
||||||
|
* sinon d'un tiers de la plage actuelle.
|
||||||
|
*
|
||||||
|
* @param rangeHours plage courante (24 h / 7 j / 30 j…)
|
||||||
|
* @param horizonHours horizon de prévision ([forecastHorizonHours], ≤ 0 = pas de prévision)
|
||||||
|
* @param firstSlotGapHours écart « maintenant → premier créneau projeté »
|
||||||
|
* (null = aucun traitement avec Posologie)
|
||||||
|
* @return heures à ajouter à `endMs` (0 = rien).
|
||||||
|
*/
|
||||||
|
fun forecastExtensionHours(
|
||||||
|
rangeHours: Long,
|
||||||
|
horizonHours: Long,
|
||||||
|
firstSlotGapHours: Long?
|
||||||
|
): Long {
|
||||||
|
if (horizonHours <= 0L) return 0L
|
||||||
|
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
|
||||||
|
// la plage actuelle — jamais au-delà de l'horizon.
|
||||||
|
val toFirstSlot = (firstSlotGapHours ?: 0L) + 12L
|
||||||
|
return maxOf(rangeHours / 3, toFirstSlot).coerceIn(0L, horizonHours)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
|
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
|
||||||
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
|
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableLongStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@ -127,20 +128,44 @@ fun ChartScreen() {
|
|||||||
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
|
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
|
||||||
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
|
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
|
||||||
|
|
||||||
// ── Fenêtre temporelle (v1.4.1) ──────────────────────────────────────────
|
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4 — fix #54) ──────────────
|
||||||
// endMs = now − panHours (panHours > 0 = passé, < 0 = futur via prévision).
|
// endMs = now − panHours + extension (panHours > 0 = passé, < 0 = futur
|
||||||
// ⚠️ ACTIVER la prévision ne déplace PLUS la fenêtre (ancien bug UX : le
|
// via prévision). Le drag vers la gauche parcourt la projection (borné
|
||||||
// graphique sautait « tout à droite » dans le futur) : la courbe projetée
|
// par l'horizon) ; ⚠️ ACTIVER la prévision ne déplace JAMAIS le début de
|
||||||
// s'étend au-delà de la fenêtre courante et on la parcourt en tirant vers
|
// la fenêtre (pas de saut, v1.4.1) mais l'ÉTEND à droite d'après
|
||||||
// la gauche (panHours négatif), borné par l'horizon. La fenêtre reste où
|
// [forecastExtensionHours] : jusqu'au premier créneau projeté (+12 h de
|
||||||
// l'utilisateur l'avait laissée.
|
// contexte), sinon 1/3 de la plage — sinon `generateForecastDoses`
|
||||||
|
// (borné à endMs) ne générait AUCUNE dose et le chip semblait inerte.
|
||||||
val nowMs = System.currentTimeMillis()
|
val nowMs = System.currentTimeMillis()
|
||||||
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
|
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
|
||||||
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
|
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
|
||||||
val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
|
val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
|
||||||
val endMs = nowMs - panHours * HOUR_MS
|
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
|
||||||
|
val firstSlotGapH = treatments.flatMap { tr ->
|
||||||
|
PharmacokineticEngine.generateForecastDoses(
|
||||||
|
tr, doseLogs, nowMs + forecastHorizonHours * HOUR_MS, nowMs
|
||||||
|
)
|
||||||
|
}.minOfOrNull { it.timestamp }?.let { (it - nowMs) / HOUR_MS }
|
||||||
|
com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours, forecastHorizonHours, firstSlotGapH
|
||||||
|
)
|
||||||
|
} else 0L
|
||||||
|
val endMs = nowMs - panHours * HOUR_MS + forecastExtensionH * HOUR_MS
|
||||||
val startMs = endMs - rangeHours * HOUR_MS
|
val startMs = endMs - rangeHours * HOUR_MS
|
||||||
|
|
||||||
|
// ⚠️ FIX #54 (v1.4.4) — captures FIGÉES dans pointerInput(Unit) : la
|
||||||
|
// closure du gestionnaire de gestes est créée UNE SEULE FOIS ; les vals
|
||||||
|
// calculées ci-dessus y étaient capturées à leur valeur de la PREMIÈRE
|
||||||
|
// composition (showForecast = false au lancement → futurePanHorizon = 0)
|
||||||
|
// → le clamp `coerceIn(0, maxPan)` INTERDISAIT le pan vers le futur pour
|
||||||
|
// toujours : le chip activé + le drag vers la gauche ne généraient JAMAIS
|
||||||
|
// la projection (« le toggle ne fait rien »). rememberUpdatedState expose
|
||||||
|
// ces valeurs comme des états lus À JOUR par la closure (les MutableState
|
||||||
|
// — panHours, rangeHours, showForecast — n'ont pas ce problème).
|
||||||
|
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
|
||||||
|
val gestureMaxPan by rememberUpdatedState(maxPanHours)
|
||||||
|
val gestureExtension by rememberUpdatedState(forecastExtensionH)
|
||||||
|
|
||||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
||||||
val curves by produceState(
|
val curves by produceState(
|
||||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
emptyList<Pair<String, List<LevelPoint>>>(),
|
||||||
@ -324,7 +349,7 @@ fun ChartScreen() {
|
|||||||
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
|
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
|
||||||
if (hoursDragged != 0L) {
|
if (hoursDragged != 0L) {
|
||||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
panHours = com.hormonetrack.ui.components.clampPanHours(
|
||||||
panHours + hoursDragged, futurePanHorizon, maxPanHours
|
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Zoom autour du point focal : le temps sous le doigt
|
// Zoom autour du point focal : le temps sous le doigt
|
||||||
@ -334,12 +359,15 @@ fun ChartScreen() {
|
|||||||
val newRange = (rangeHours / zoom).toLong()
|
val newRange = (rangeHours / zoom).toLong()
|
||||||
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
|
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
|
||||||
if (newRange != rangeHours) {
|
if (newRange != rangeHours) {
|
||||||
val endAnchor = nowMs - panHours * HOUR_MS
|
// L'ancre de droite inclut l'extension de
|
||||||
|
// prévision (v1.4.4) : cohérent avec endMs
|
||||||
|
val endAnchor = nowMs - panHours * HOUR_MS +
|
||||||
|
gestureExtension * HOUR_MS
|
||||||
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
||||||
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
||||||
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
|
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
|
||||||
panHours = com.hormonetrack.ui.components.clampPanHours(
|
panHours = com.hormonetrack.ui.components.clampPanHours(
|
||||||
newPan, futurePanHorizon, maxPanHours
|
newPan, gestureFutureHorizon, gestureMaxPan
|
||||||
)
|
)
|
||||||
rangeHours = newRange
|
rangeHours = newRange
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
package com.hormonetrack.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **Garde de source** (v1.4.4, bug #54) : les valeurs de FENÊTRE du graphique
|
||||||
|
* (`futurePanHorizon`, `maxPanHours`, `forecastExtensionH`) sont des `val`
|
||||||
|
* calculées à chaque recomposition. Le gestionnaire de gestes vit dans
|
||||||
|
* `pointerInput(Unit)` — sa closure est créée UNE SEULE FOIS : si ces vals y
|
||||||
|
* sont lues directement, la closure garde leur valeur de la PREMIÈRE
|
||||||
|
* composition (stale) → `futurePanHorizon = 0` au lancement → le pan vers le
|
||||||
|
* futur (prévision) devenait DÉFINITIVEMENT impossible, même après activation
|
||||||
|
* du chip (remontée v1.4.3 : « le toggle ne génère pas les prévisions »).
|
||||||
|
*
|
||||||
|
* La correction (cf ChartScreen.kt) expose ces valeurs via
|
||||||
|
* `rememberUpdatedState` sous des alias `gesture*` que la closure lit à jour.
|
||||||
|
* Le compilateur/lint Compose ne détecte PAS ce pattern de capture figée :
|
||||||
|
* ce test est le filet — il fait échouer toute réintroduction avec un
|
||||||
|
* message explicite.
|
||||||
|
*/
|
||||||
|
class ChartScreenSourceGuardTest {
|
||||||
|
|
||||||
|
private fun chartSource(): String {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt"),
|
||||||
|
File("app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt")
|
||||||
|
)
|
||||||
|
val f = candidates.firstOrNull { it.exists() }
|
||||||
|
?: error("ChartScreen.kt introuvable (contextes testés : $candidates)")
|
||||||
|
return f.readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait le corps du gestionnaire de gestes (closure `detectTransformGestures`). */
|
||||||
|
private fun gestureBlock(src: String): String {
|
||||||
|
// ⚠️ cibler l'APPEL (suivi d'une accolade) et non l'import du même nom
|
||||||
|
val start = src.indexOf("detectTransformGestures {")
|
||||||
|
if (start < 0) error("detectTransformGestures introuvable dans ChartScreen.kt — l'API a-t-elle changé ? Mets ce garde à jour.")
|
||||||
|
// compter les accolades depuis la première ouverte après le marqueur
|
||||||
|
val open = src.indexOf('{', start)
|
||||||
|
var depth = 0
|
||||||
|
for (i in open until src.length) {
|
||||||
|
when (src[i]) {
|
||||||
|
'{' -> depth++
|
||||||
|
'}' -> { depth--; if (depth == 0) return src.substring(start, i + 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error("bloc detectTransformGestures non fermé ?")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `window vals are exposed via rememberUpdatedState (not read stale in the gesture closure)`() {
|
||||||
|
val src = chartSource()
|
||||||
|
val block = gestureBlock(src)
|
||||||
|
// 1. les alias gesture* existent et sont déclarés via rememberUpdatedState
|
||||||
|
for (alias in listOf("gestureFutureHorizon", "gestureMaxPan", "gestureExtension")) {
|
||||||
|
assertTrue(
|
||||||
|
"alias $alias manquant — cf fix #54 : pointerInput(Unit) fige les captures",
|
||||||
|
Regex("val\\s+$alias\\s+by\\s+rememberUpdatedState").containsMatchIn(src)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 2. la closure du geste ne lit JAMAIS les vals brutes (captures stale)
|
||||||
|
for (stale in listOf("futurePanHorizon", "maxPanHours", "forecastExtensionH")) {
|
||||||
|
val leak = Regex("""\b$stale\b""").findAll(block).any()
|
||||||
|
if (leak) {
|
||||||
|
error("BUG #54 réintroduit : la closure detectTransformGestures lit " +
|
||||||
|
"`$stale` directement (valeur figée à la 1re composition). " +
|
||||||
|
"Passe par l'alias rememberUpdatedState (gesture*) — cf ChartScreen.kt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. la closure lit bien les alias à jour
|
||||||
|
assertTrue(
|
||||||
|
"la closure du geste doit utiliser les alias gesture* (lectures à jour)",
|
||||||
|
Regex("""\bgesture(FutureHorizon|MaxPan|Extension)\b""").containsMatchIn(block)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -119,4 +119,35 @@ class ChartZoomTest {
|
|||||||
val chosen2 = com.hormonetrack.ui.components.pointHoursBefore(last, 6.0)!!
|
val chosen2 = com.hormonetrack.ui.components.pointHoursBefore(last, 6.0)!!
|
||||||
assertEquals(3L * 3_600_000L, chosen2.timestamp)
|
assertEquals(3L * 3_600_000L, chosen2.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v1.4.4 (fix #54) : extension de fenêtre à l'activation de la prévision ──
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forecast extension - covers at least the next projected slot plus context`() {
|
||||||
|
val H = 24L * 3600_000L / 3600_000L // pas utilisé ; les valeurs sont en heures
|
||||||
|
// Posologie 6 j : prochain créneau à ~130 h → extension = 130+12 = 142 h
|
||||||
|
// (la prochaine dose devient VISIBLE dès l'activation du chip)
|
||||||
|
assertEquals(142L, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 24L, horizonHours = 24L * 365, firstSlotGapHours = 130L))
|
||||||
|
// fenêtre large (7 j) : 1/3 = 56 h vs prochain créneau à 20 h (+12) = 32 h
|
||||||
|
// → on garde le plus grand (56 h) pour garder du contexte
|
||||||
|
assertEquals(56L, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 24L * 7, horizonHours = 24L * 365, firstSlotGapHours = 20L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forecast extension - bounded by horizon, zero without forecast`() {
|
||||||
|
// jamais au-delà de l'horizon
|
||||||
|
assertEquals(24L * 72, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 24L * 7, horizonHours = 24L * 72, firstSlotGapHours = 24L * 100))
|
||||||
|
// horizon ≤ 0 ou premier créneau inconnu : garde-fous
|
||||||
|
assertEquals(0L, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 24L, horizonHours = 0L, firstSlotGapHours = 130L))
|
||||||
|
// sans créneau connu : garde 12 h de contexte (max(8, 12) = 12)
|
||||||
|
assertEquals(12L, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 24L, horizonHours = 24L * 365, firstSlotGapHours = null))
|
||||||
|
// créneau immédiat (gap 0) : max(2, 12) = 12 h de contexte
|
||||||
|
assertEquals(12L, com.hormonetrack.ui.components.forecastExtensionHours(
|
||||||
|
rangeHours = 6L, horizonHours = 24L, firstSlotGapHours = 0L))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,38 @@
|
|||||||
# Changelog — HormoneTrack
|
# Changelog — HormoneTrack
|
||||||
|
|
||||||
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.3`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.4`, …).
|
||||||
|
|
||||||
|
## [1.4.4] — 2026-09-07 (versionCode 24)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **La prévision était inaccessible depuis la v1.4.1** (#54, remontée : « en
|
||||||
|
1.4.3 je ne peux même pas drag le graphique vers la gauche pour voir les
|
||||||
|
prévisions ; le toggle activé ne génère pas les prévisions ») : **deux
|
||||||
|
causes emboîtées** —
|
||||||
|
1. **Captures FIGÉES dans le gestionnaire de gestes** : la closure de
|
||||||
|
`pointerInput(Unit)` est créée une seule fois et les `val` calculées
|
||||||
|
(`futurePanHorizon`…) y étaient figées à leur valeur de la PREMIÈRE
|
||||||
|
composition (0, chip désactivé) → le clamp interdisait DÉFINITIVEMENT
|
||||||
|
le pan vers le futur. FIX : les vals de fenêtre sont exposées au geste
|
||||||
|
via `rememberUpdatedState` (alias `gesture*`, lectures à jour).
|
||||||
|
**Garde de non-régression** : `ChartScreenSourceGuardTest` fait échouer
|
||||||
|
toute réintroduction de lecture figée dans la closure (le lint Compose
|
||||||
|
ne détecte pas ce pattern).
|
||||||
|
2. **Toggle sans effet visible** : à l'activation, `endMs = now` faisait
|
||||||
|
que `generateForecastDoses(..., toMs = now, ...)` ne générait AUCUNE
|
||||||
|
dose. FIX : à l'activation, la fenêtre s'ÉTEND à droite (sans déplacer
|
||||||
|
son début — pas de saut) jusqu'au premier créneau projeté (+12 h de
|
||||||
|
contexte), sinon 1/3 de la plage, borné par l'horizon
|
||||||
|
(`forecastExtensionHours`, testé). Désactiver → retour à la fenêtre
|
||||||
|
standard. Le drag vers la gauche continue au-delà (jusqu'à 1 an).
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **3 nouveaux tests** (140 au total / 118 sans les données locales) :
|
||||||
|
extension de fenêtre (bornes, garde-fous) et garde de source anti-captures
|
||||||
|
figées. Validation émulateur : drag gauche → panHours < 0 (« Revenir à
|
||||||
|
maintenant » apparaît) et la courbe est dessinée au-delà de la ligne
|
||||||
|
« maintenant » (analyse du screencap).
|
||||||
|
|
||||||
## [1.4.3] — 2026-09-07 (versionCode 23)
|
## [1.4.3] — 2026-09-07 (versionCode 23)
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,7 @@ Fonctionnalités v1 :
|
|||||||
| 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. |
|
| 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. |
|
||||||
| 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. |
|
| 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. |
|
||||||
| 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. |
|
| 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. |
|
||||||
|
| 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. |
|
||||||
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
|
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
|
||||||
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
|
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
|
||||||
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
|
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
|
||||||
@ -410,7 +411,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
|
|||||||
|
|
||||||
## 8. Tests unitaires
|
## 8. Tests unitaires
|
||||||
|
|
||||||
**137 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **115 sans
|
**140 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **118 sans
|
||||||
les données de test locales** (cf §8.bis : les 3 classes de régression,
|
les données de test locales** (cf §8.bis : les 3 classes de régression,
|
||||||
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
||||||
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
||||||
@ -533,6 +534,11 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
|
|||||||
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
|
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
|
||||||
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
|
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
|
||||||
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
|
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
|
||||||
|
- **`ChartScreenSourceGuardTest`** (1) : v1.4.4 — **garde de SOURCE** contre
|
||||||
|
le bug #54 : vérifie que les vals de fenêtre du graphique sont exposées au
|
||||||
|
gestionnaire de gestes via `rememberUpdatedState` (alias `gesture*`) et que
|
||||||
|
la closure `detectTransformGestures` ne les lit jamais en direct (captures
|
||||||
|
figées). Le lint Compose ne détecte pas ce pattern — ce test est le filet.
|
||||||
- **`RegressionUserCase4Test`** (4) : v1.4.3 — 4ᵉ régression épinglée sur
|
- **`RegressionUserCase4Test`** (4) : v1.4.3 — 4ᵉ régression épinglée sur
|
||||||
les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec
|
les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec
|
||||||
**settings** (seuils + auto-cal), scénario transition EV inactif → EEn
|
**settings** (seuils + auto-cal), scénario transition EV inactif → EEn
|
||||||
@ -1071,6 +1077,23 @@ l'exemple) :**
|
|||||||
|
|
||||||
**Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :**
|
**Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :**
|
||||||
|
|
||||||
|
54. **Prévision inaccessible : captures figées dans `pointerInput(Unit)`**
|
||||||
|
(v1.4.4, remontée « le toggle activé ne génère pas les prévisions, je ne
|
||||||
|
peux même pas dragger vers la gauche ») : la closure du gestionnaire de
|
||||||
|
gestes est créée UNE FOIS (`pointerInput(Unit)`) ; les `val` calculées
|
||||||
|
de la composition (`futurePanHorizon`, `maxPanHours`, `forecastExtensionH`)
|
||||||
|
y étaient FIGÉES à leur valeur initiale (0, chip désactivé) → le clamp
|
||||||
|
interdisait le pan vers le futur POUR TOUJOURS, même après activation du
|
||||||
|
chip. Fix : `rememberUpdatedState` (les `MutableState` n'ont pas le
|
||||||
|
problème : c'est la fermeture `by` qui lit à jour). + cause secondaire :
|
||||||
|
`generateForecastDoses(toMs = endMs = now)` ne générait rien → extension
|
||||||
|
contrôlée à l'activation (cf §10). **Garde de source** :
|
||||||
|
`ChartScreenSourceGuardTest` (extraire la closure et interdire les
|
||||||
|
lectures des vals brutes). Leçon : dans `pointerInput(Unit)`, TOUTE val
|
||||||
|
calculée lue par la closure doit passer par `rememberUpdatedState` —
|
||||||
|
seul `pointerInput(key)` re-keyé relance la closure (et perd les gestes
|
||||||
|
en cours).
|
||||||
|
|
||||||
53. **Delta « vs il y a 6 h » = en réalité 24 h** (v1.4.3, remontée) :
|
53. **Delta « vs il y a 6 h » = en réalité 24 h** (v1.4.3, remontée) :
|
||||||
`NowLevelCard` prenait `curve.firstOrNull { écart ≥ 6 h }` sur une
|
`NowLevelCard` prenait `curve.firstOrNull { écart ≥ 6 h }` sur une
|
||||||
fenêtre de 24 h — le premier point satisfait la condition
|
fenêtre de 24 h — le premier point satisfait la condition
|
||||||
@ -1240,8 +1263,8 @@ vérifie pas en tests JVM).
|
|||||||
|
|
||||||
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` — 137 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 140 au
|
||||||
total, 115 si `local-test-data/` est absent (les 3 classes de régression
|
total, 118 si `local-test-data/` est absent (les 3 classes de régression
|
||||||
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
|
réelles sont skippées via `Assume`, 6 tests chacune) ; **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). Paliers de test du projet :
|
||||||
|
|||||||
@ -178,10 +178,11 @@ Pics de référence (dose unique de 5 mg, IM) :
|
|||||||
- **Prévision** : configure la **Posologie** (intervalle en jours) dans un traitement
|
- **Prévision** : configure la **Posologie** (intervalle en jours) dans un traitement
|
||||||
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
|
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
|
||||||
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées).
|
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées).
|
||||||
**Activer le chip ne déplace pas le graphique** : tire vers la GAUCHE pour parcourir
|
**Activer le chip étend le graphique jusqu'à ta prochaine dose** (sans déplacer
|
||||||
la projection dans le futur (jusqu'à 1 an selon la Posologie) ; « Revenir à
|
l'historique visible) — v1.4.4 : le chip a un effet immédiat ; tire vers la GAUCHE
|
||||||
maintenant » pour revenir. Si tu désactives le chip alors que tu es dans le futur,
|
pour parcourir la projection dans le futur (jusqu'à 1 an selon la Posologie) ;
|
||||||
l'app revient automatiquement à maintenant
|
« Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es
|
||||||
|
dans le futur, l'app revient automatiquement à maintenant
|
||||||
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
|
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
|
||||||
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
|
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
|
||||||
d'œil les hauts et les bas entre deux injections
|
d'œil les hauts et les bas entre deux injections
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user