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:
parent
208ba56843
commit
c1c2386ab2
@ -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.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)
|
||||
- **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)
|
||||
@ -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
|
||||
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 # 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
|
||||
```
|
||||
|
||||
|
||||
@ -139,4 +139,20 @@ class ReminderScheduleTest {
|
||||
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -407,10 +407,9 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
|
||||
|
||||
## 8. Tests unitaires
|
||||
|
||||
**102 tests JVM, tous verts** → **v1.4.0** (`./gradlew testDebugUnitTest`) —
|
||||
**84 sans 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.
|
||||
**107 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **89 sans
|
||||
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.
|
||||
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/…`).
|
||||
|
||||
@ -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
|
||||
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).
|
||||
- **`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
|
||||
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
|
||||
@ -1140,11 +1147,13 @@ vérifie pas en tests JVM).
|
||||
|
||||
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 90 au total
|
||||
(v1.3.4), 72 si `local-test-data/` est absent (les 3 classes de régression
|
||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 107 au
|
||||
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
|
||||
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
|
||||
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
|
||||
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
|
||||
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
||||
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
|
||||
famille restent des ERREURS bloquantes ; les checks Compose 1.12+
|
||||
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
|
||||
|
||||
---
|
||||
*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
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user