v1.8.1 : fix stabilisation (changements récents vus) + suggestion sur l'Accueil

Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
me disait stabilisée depuis février » — le proxy « 1ʳᵉ dose du traitement »
était aveugle aux changements récents.

- LabTiming.regimeStartMs (nouveau, internal) : le régime courant = la
  séquence terminale de doses à (ester effectif, dose mg, écart
  inter-doses) CONSTANTS — l'écart est comparé exactement (7 j ± 1 h casse
  le régime) : tout changement récent réinitialise la stabilisation et la
  reco saute au premier creux post-stabilisation. KDoc complet (règle +
  justification conservatrice des intervalles irréguliers).
- LabRecommendation.ester = l'ESTER EFFECTIF de la dernière dose (override
  compris) — l'encart/carte racontent le bon ester.
- HomeScreen : carte compacte « Prochaine prise de sang (suggestion) »
  (creux daté + créneau associé + mention de stabilisation SEULEMENT si le
  régime n'était pas déjà stable) — même calcul que la page Analyses
  (demande v1.8.1 : suggestion aussi sur l'Accueil).
- 3 nouveaux tests (223 verts / 193 sans données locales) + lint :
  changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
- Validé émulateur §16.ter (release, seed réel v1.7.0) : la carte passe de
  « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 —
  premier creux fiable après ton dernier changement », creux 27/09 22:43,
  0 crash. versionCode 36 / versionName 1.8.1. Web sync v1.8.1.
This commit is contained in:
Siphonight 2026-09-17 19:48:20 +02:00
parent 8c19c8f295
commit 2ec9f63f93
8 changed files with 228 additions and 17 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.8.0 — build Android ✅, **lint vert** ✅, **220 tests unitaires** ✅ (190 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Statut** : v1.8.1 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **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)
@ -66,8 +66,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Prochaine prise de sang suggérée (v1.8.0)** : la page Analyses recommande le
**creux estimé juste avant l'injection suivante** (moment le plus comparable),
**au premier creux où ton ester est stabilisé** (~5 demi-vies terminales après
le dernier changement) — déduit des courbes et de la Posologie ; requiert un
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
injectable E2 actif à Posologie (une invite invite à la renseigner sinon) ;
estimations, jamais un avis médical
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
@ -116,7 +116,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 220 tests (190 sans les données locales)
./gradlew testDebugUnitTest # 223 tests (193 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```

View File

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

View File

@ -130,9 +130,14 @@ object LabTiming {
// ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
val regimeStartMs = myDoses.minOfOrNull { it.timestamp } ?: return null
if (myDoses.isEmpty()) return null
val regimeStartMs = regimeStartMs(carrier, myDoses)
val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui
// qui gouverne les creux futurs et le texte de la carte
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
val intervalDays = carrier.forecastIntervalDays!!
@ -172,7 +177,7 @@ object LabTiming {
return LabRecommendation(
troughMs = trough.timestamp,
injectionMs = slot.timestamp,
ester = carrier.esterType,
ester = activeEster,
terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs,
stabilizedAtMs = stabilizedAtMs,
@ -184,6 +189,65 @@ object LabTiming {
return null // stabilisation hors horizon (ester ultra-long) → carte cachée
}
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
*
* **Critique remontée** (v1.8.0) : « je suis passée sous un autre ester,
* j'ai changé de dosage ET de posologie, et l'app me disait "stabilisée
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
* PAS les changements récents de dose ni d'intervalle.
*
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
* consécutives est constant** (= l'écart des deux doses les plus
* récentes). On remonte depuis la dose la plus récente tant que ces
* conditions tiennent ; le régime commence à la dose la plus ancienne
* de la séquence.
*
* Pourquoi chaque composante :
* - **ester effectif** (override par dose compris) : un switch EV → EEn
* réinitialise la stabilisation ;
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
* vrai changement de créneau.
*
* @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent)
* @return timestamp de la 1ʳᵉ dose du régime courant
*/
internal fun regimeStartMs(carrier: Treatment, myDoses: List<DoseLog>): Long {
val sorted = myDoses.sortedBy { it.timestamp }
var idx = sorted.size - 1
var regimeStart = sorted[idx].timestamp
var current = sorted[idx]
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
var gapExpected = -1L
while (idx > 0) {
val prev = sorted[idx - 1]
val gap = current.timestamp - prev.timestamp
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
// Remonter seulement si même (ester, dose) ET même écart
val sameGap = gapExpected < 0L || gap == gapExpected
if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp
current = prev
idx--
}
return regimeStart
}
/**
* Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable
* ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la

View File

@ -48,6 +48,7 @@ import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.ChartOptions
@ -222,6 +223,69 @@ fun HomeScreen(
NextDoseCard(activeTreatments, doseLogs)
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────
// Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE
// sur l'accueil : creux daté + créneau associé + disclaimer court.
// Cachée si non calculable (pas d'injectable actif à Posologie) ;
// l'invite « renseigne une Posologie » reste sur la page Analyses
// (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick
) {
value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis()
)
}
}
labRecommendation?.let { rec ->
val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
Text(
stringResource(
R.string.labrec_before_injection,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.injectionMs))
),
style = MaterialTheme.typography.bodyMedium
)
if (!rec.wasAlreadyStabilized) {
// Mention SEULEMENT si le régime n'était pas encore
// stabilisé (info utile — sinon le texte est identique
// à chaque ouverture)
Text(
stringResource(
R.string.labrec_not_stabilized,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs))
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)

View File

@ -151,6 +151,53 @@ class LabTimingTest {
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
}
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ─────────────
// Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et
// l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
// du traitement) ne détectait aucun de ces changements. NOUVELLE règle :
// le régime = séquence terminale à (ester, dose, écart inter-doses)
// constants ; tout changement réinitialise la stabilisation.
@Test
fun `recent dose change resets stabilization`() {
// 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime
// courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du
// créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j)
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(NOW + 26 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent ester override resets stabilization`() {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override d'ESTER (EEn → EV) : switch d'ester = nouveau régime
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV"
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals("EV", rec.ester)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `Estrannaise carrier reads its half-life from the ODS table`() {
// EV / tables ODS : t½ estimée depuis la pente terminale de la table —

View File

@ -40,6 +40,8 @@ class RegressionUserCase5Test {
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json")
}
private val DAY_MS = 86_400_000L
private lateinit var data: BackupData
@Before
@ -170,8 +172,9 @@ class RegressionUserCase5Test {
)
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
assertTrue(
"le créneau d'injection suit le creux de près",
rec.injectionMs > rec.troughMs && rec.injectionMs - rec.troughMs <= 24L * 3_600_000L
"le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})",
rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L &&
rec.troughMs < rec.injectionMs
)
assertTrue(
"le creux recommandé est postérieur à la dernière prise de sang",

View File

@ -3,6 +3,31 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.8.1] — 2026-09-17 (versionCode 36)
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
- **Bug remonté** : la recommandation disait « stabilisée depuis février »
alors que l'ester, le dosage ET la posologie venaient de changer —
l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle
aux changements récents).
- **Fix** : le régime courant = la **séquence terminale de doses** à
(ester effectif, dose mg, **écart inter-doses**) constants — tout
changement récent réinitialise la stabilisation : la recommandation saute
au premier creux **après** la stabilisation du nouveau régime (règle des
~5 demi-vies conservée, intervalle comparé exactement). La carte porte
désormais l'**ester effectif** de la dernière dose (override compris).
- **Nouveau : suggestion aussi sur l'Accueil** (demande) — carte compacte
(creux daté + créneau associé), avec la mention de stabilisation
seulement si le régime n'était pas déjà stable.
- Validé émulateur sur l'export réel : la carte passe de « stabilisé depuis
le 06/02 » (v1.8.0) à « **EEN pas stabilisé avant le 29/09 — premier
creux fiable après ton dernier changement** », creux recommandé 27/09
22:43, 0 crash.
- 3 nouveaux tests (223 au total / 193 sans les données locales) :
changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
- **Web** : même fix en miroir + encart Accueil (web v1.8.1, versions sync).
## [1.8.0] — 2026-09-17 (versionCode 35)
### Prochaine prise de sang recommandée (page Analyses)

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement |
|---|---|
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
| 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. |
| 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). |
| 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). |
@ -617,9 +618,16 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
≥ 1 % du pic, 48 h précédentes — est partagée).
- **Début du régime courant** : 1ʳᵉ dose du traitement actif — PROXY assumé
(pas d'historique des éditions de Posologie/dose). Stabilisation =
régimeStart + 5 × t½.
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
stabilisée depuis février ») : le régime courant = la **séquence terminale
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
constant** (= l'écart des deux doses les plus récentes, comparé
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier).
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
injections ; FORME brute (sans calibration — le facteur ne déplace pas le
@ -632,11 +640,11 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- 8 tests `LabTimingTest` + 1 test régression n°5.
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
## 8. Tests unitaires
**220 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **190 sans
**223 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
les données de test locales** (cf §8.bis : les 5 classes de régression,
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -714,9 +722,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 190 tests au lieu de 220 (v1.8.0 ; chiffres historiques : 44/87 à v1.3.x,
exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0,
181/211 à v1.7.1) ;
181/211 à v1.7.1, 190/220 à v1.8.0) ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous