Tests + docs v1.4.0 : TransfemScienceEngineTest (câblage moteur↔V3C : EB→pic article, EB/ESE→0, coupure PEP 10×t½, override TFS via computeCurve) ; ReminderScheduleTest : grille sous-quotidienne épinglée (limitation §19) ; compteurs 107/89 + paliers de test §16

This commit is contained in:
Siphonight 2026-09-06 22:32:14 +02:00
parent 208ba56843
commit c1c2386ab2
4 changed files with 167 additions and 11 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.4.0 — build Android ✅, **lint vert** ✅, **102 tests unitaires** ✅ (84 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.0 — build Android ✅, **lint vert** ✅, **107 tests unitaires** ✅ (89 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)
@ -79,7 +79,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 # 102 tests (84 sans les données locales) ./gradlew testDebugUnitTest # 107 tests (89 sans les données locales)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```

View File

@ -139,4 +139,20 @@ class ReminderScheduleTest {
assertEquals(null, PharmacokineticEngine.nextReminderFireFor( assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now)) injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now))
} }
@Test
fun `sub-daily grid fires at the reminder hour on slot days (known limitation)`() {
// Deux injections/jour (intervalle 0,5 j) avec UNE heure de rappel :
// l'app ne peut pas exprimer deux rappels/jour à des heures différentes
// — elle sonne à l'heure de rappel les JOURS de créneau (limitation
// documentée §19 de la doc de dev). Ici : dose à 08 h, maintenant 10 h,
// créneau suivant 20 h → l'alarme du jour sonne à 18 h (aujourd'hui).
val now = at(System.currentTimeMillis(), 10, 0)
val doses = listOf(
DoseLog(treatmentId = 1L, timestamp = now - 2 * HOUR_MS, doseAmount = 2.0)
)
val tr = injectionTreatment(intervalDays = 0.5, hour = 18, minute = 0)
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
assertEquals(at(now, 18, 0), fire)
}
} }

View File

@ -0,0 +1,127 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Test d'INTÉGRATION du câblage moteur ↔ modèles V3C (v1.4.0).
*
* Différence avec `TransfemScienceModelsTest` (qui vérifie la fidélité du
* modèle lui-même) : ici on vérifie que le MOTEUR route bien vers V3C —
* `concentrationOfDose`/`e2At`/`computeCurve` avec `pkModel = TFS` ou
* `modelOverride = TFS` — et que la coupure moteur (`cutoffHours`, privée)
* tient compte des demi-vies terminales V3C (10 × t½).
*
* Scénarios épinglés :
* 1. EB (benzoate, SANS table ODS) en modèle TFS → pic article 971 pg/mL
* @ 0,65 j pour 5 mg — prouve l'ajout des nouveaux esters end-to-end ;
* 2. le même traitement forcé en ESE (`modelOverride`) → 0 partout : les
* tables Estrannaise ne couvrent pas EB (comportement documenté, les
* presets EB/EC/ECS/PEP sont créés en TFS uniquement) ;
* 3. PEP 32,5 mg : contribution visible à 10 j ET à 100 j — avec l'ANCIENNE
* coupure (longueur de table ODS, et pas de table pour PEP → 24 h), le
* niveau à 100 j aurait été 0. C'est le fix de `cutoffHours` (10 × t½).
*/
class TransfemScienceEngineTest {
private companion object {
val BASE = System.currentTimeMillis()
const val DAY_MS = 86_400_000L
const val HOUR_MS = 3_600_000L
}
@Before
fun loadProfiles() {
// Les tables ODS ne sont PAS requises pour le chemin V3C, mais la
// route ESE (test 2) doit renvoyer 0 SANS crash même non initialisée
// (PKProfileStore.sample gère !initialized → 0). On charge quand même
// l'asset pour rester au plus près du comportement réel de l'app.
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
}
private fun treatment(ester: String, doseMg: Double): Treatment = Treatment(
id = 1L,
name = "Injection $ester",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = doseMg,
doseUnit = "mg",
isActive = true,
esterType = ester,
pkModel = PKModels.TRANSFEM_SCIENCE
)
@Test
fun `EB treatment routed through V3C reaches the article peak`() {
val tr = treatment(Esters.EB, doseMg = 5.0)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
// au pic de l'article : 971 pg/mL @ 0,65 j
val atPeak = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + (0.65 * DAY_MS).toLong())
assertTrue("EB@0,65j = $atPeak (attendu ~971)", atPeak in 950.0..990.0)
// 5 j après : 91,5 pg/mL (queue V3C exacte)
val at5d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 5 * DAY_MS)
assertTrue("EB@5j = $atPeak (attendu ~91)", at5d in 85.0..98.0)
}
@Test
fun `EB forced to ESE model yields zero (no ODS table for this ester)`() {
val tr = treatment(Esters.EB, doseMg = 5.0)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), listOf(dose), BASE, BASE + 5 * DAY_MS,
tConfig = TConfig(), modelOverride = PKModels.ESTRANNAISE
)
assertTrue(curve.all { it.e2 == 0.0 })
}
@Test
fun `PEP at 32_5 mg contributes far beyond 24h (V3C cutoff at 10 half-lives)`() {
val tr = treatment(Esters.PEP, doseMg = 32.5)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 32.5)
// à 10 j : proche du pic de l'article (34 pg/mL @ 18 j)
val at10d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 10 * DAY_MS)
assertTrue("PEP@10j = $at10d (attendu ~29,5)", at10d in 27.0..32.0)
// à 100 j : l'ANCIENNE coupure (pas de table ODS pour PEP → 24 h)
// aurait renvoyé 0 ; la nouvelle (10 × t½ ≈ 284 j) donne ~5,8 pg/mL
val at100d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 100 * DAY_MS)
assertTrue("PEP@100j = $at100d (attendu ~5,8)", at100d > 4.0)
// au-delà de la coupure (300 j > 10 × t½ = 284 j) : 0 par design
val at300d = PharmacokineticEngine.e2At(listOf(tr), listOf(dose), BASE + 300 * DAY_MS)
assertEquals(0.0, at300d, 1e-9)
}
@Test
fun `computeCurve with modelOverride TFS matches V3C on EV`() {
val tr = Treatment(
id = 1L, name = "EV", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM, doseAmount = 5.0, doseUnit = "mg",
isActive = true, esterType = Esters.EV, pkModel = PKModels.ESTRANNAISE
)
val dose = DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
// Override TFS → courbe V3C (pic article 295 pg/mL @ 2,1 j), même si
// le traitement est STOCKÉ en Estrannaise (le graphique superpose
// les deux modèles depuis le même traitement).
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), listOf(dose), BASE, BASE + 3 * DAY_MS,
stepMs = HOUR_MS, tConfig = TConfig(),
modelOverride = PKModels.TRANSFEM_SCIENCE
)
val peak = curve.maxOf { it.e2 }
assertTrue("pic TFS via moteur = $peak (attendu ~295)", peak in 285.0..305.0)
}
}

View File

@ -407,10 +407,9 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
## 8. Tests unitaires ## 8. Tests unitaires
**102 tests JVM, tous verts** → **v1.4.0** (`./gradlew testDebugUnitTest`) — **107 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **89 sans
**84 sans les données de test locales** (cf §8.bis : les 3 classes de les données de test locales** (cf §8.bis : les 3 classes de régression,
régression, 6 tests chacune, sont skippées via `Assume`). Dépendance : 6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -519,7 +518,15 @@ 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).
- **`ReminderScheduleTest`** (7) : v1.4.0 — fix #52 : rappel sur la GRILLE - **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C :
EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ;
EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté :
presets EB/EC/ECS/PEP en TFS uniquement) ; PEP 32,5 mg → contribution
à 10 j ET 100 j (l'ancienne coupure 24 h du fallback table aurait donné 0)
et 0 au-delà de 10 × t½ (par design) ; `computeCurve(modelOverride=TFS)`
sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test
du dispatch `concentrationOfDose` + de `cutoffHours`.
- **`ReminderScheduleTest`** (8) : v1.4.0 — fix #52 : rappel sur la GRILLE
Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure
choisie, PAS demain), heure déjà passée le jour du créneau → créneau choisie, PAS demain), heure déjà passée le jour du créneau → créneau
suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien
@ -1140,11 +1147,13 @@ 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` — 90 au total 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 107 au
(v1.3.4), 72 si `local-test-data/` est absent (les 3 classes de régression total, 89 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). attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
chaque commit = tests + lint ; release = + scan confidentialité + APK au
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
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.
@ -1298,6 +1307,10 @@ Volontaires (v1) :
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ; téléphone** (export/import backup, graphiques) avant chaque publication ;
signé clé debug → upgradable sans perte, mais pas une signature « officielle » signé clé debug → upgradable sans perte, mais pas une signature « officielle »
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
`ReminderScheduleTest`) ; deux heures de rappel distinctes = évolution
- **Lint (v1.3.4)** : `./gradlew lint` est vert — `StringFormatMatches` et - **Lint (v1.3.4)** : `./gradlew lint` est vert — `StringFormatMatches` et
famille restent des ERREURS bloquantes ; les checks Compose 1.12+ famille restent des ERREURS bloquantes ; les checks Compose 1.12+
théoriques (`NonObservableLocale`, `LocalContextGetResourceValueCall`, théoriques (`NonObservableLocale`, `LocalContextGetResourceValueCall`,
@ -1407,7 +1420,7 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
comparables à un EEn à 5 mg comparables à un EEn à 5 mg
--- ---
*Doc mise à jour le 6 sept. 2026 (v1.4.0) — build OK, lint vert, 102/102 tests verts (84 sans *Doc mise à jour le 6 sept. 2026 (v1.4.0) — build OK, lint vert, 107/107 tests verts (89 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs