diff --git a/README.md b/README.md index bd7f2b6..52478e3 100644 --- a/README.md +++ b/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 > 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 && 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 ``` diff --git a/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt b/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt index 1a54a4a..deec339 100644 --- a/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/hormonetrack/pk/TransfemScienceEngineTest.kt b/app/src/test/java/com/hormonetrack/pk/TransfemScienceEngineTest.kt new file mode 100644 index 0000000..e9784c6 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/TransfemScienceEngineTest.kt @@ -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) + } +} diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 2fb57bd..6500ee3 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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