From 851c185f697c9d70166b54cd891f16e75376d2ec Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sat, 5 Sep 2026 19:18:14 +0200 Subject: [PATCH] =?UTF-8?q?Tests=20de=20r=C3=A9gression=20:=20donn=C3=A9es?= =?UTF-8?q?=20r=C3=A9elles=20hors=20d=C3=A9p=C3=B4t=20(local-test-data=20g?= =?UTF-8?q?itignor=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RegressionUserCase{,2}Test chargent le backup réel depuis local-test-data/ (gitignoré) et sont IGNORÉS proprement (Assume) si le fichier est absent — plus aucune donnée de santé personnelle embarquée dans le dépôt - KDoc LabDialog : exemple 300.0 (au lieu d'une valeur réelle) - script d'anonymisation /tmp/scrub.py prêt pour filter-branch (historique) --- .gitignore | 1 + .../hormonetrack/ui/components/LabDialog.kt | 2 +- .../pk/RegressionUserCase2Test.kt | 114 +++++++----------- .../hormonetrack/pk/RegressionUserCaseTest.kt | 87 ++++++------- 4 files changed, 87 insertions(+), 117 deletions(-) diff --git a/.gitignore b/.gitignore index a045cac..44835e3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ Thumbs.db # Logs *.log +local-test-data/ diff --git a/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt b/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt index c8571bf..66f3b56 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt @@ -216,7 +216,7 @@ fun LabDialog( /** * Formatage d'une valeur de labo sans perdre de décimales significatives : - * 300.0 → "306", 0.45 → "0.45", 1.235 → "1.235" (contrairement à formatDose + * 300.0 → "300", 0.45 → "0.45", 1.235 → "1.235" (contrairement à formatDose * qui arrondit à 1 décimale, pensé pour des doses en mg). */ fun formatLabValue(v: Double): String = diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt index 4059ffa..2ee5f91 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt @@ -4,59 +4,43 @@ import com.google.gson.Gson import com.hormonetrack.data.backup.BackupData import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import java.io.File /** - * Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0, 2026-09-05) : - * 1 traitement EEn/ESE→**TFS** 5 mg (facteur stocké 0,72, fréquence 6 j), 9 doses - * (~6-7 j d'écart, une à 8 mg), 8 labs (4× E2 pg/mL, T en ng/dL + **1 labo T saisi - * par erreur en "pg/mL"** — signalé comme faute de frappe par l'utilisatrice, gardé - * tel quel dans le test pour vérifier qu'il ne casse plus l'axe T). + * Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0) : + * 1 traitement EEn + TFS 5 mg (facteur stocké, fréquence 6 j), 9 doses tous les + * 6–7 j, 8 labs (E2 pg/mL, T ng/dL dont un saisi avec une mauvaise unité). * - * Sert à valider v1.2.1 : courbe Home plausible, calibration PAR PÉRIODE D'ESTER - * (tout est sous EEn ici), lab T en unité aberrante neutralisé par la conversion. + * ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans + * `local-test-data/backup-v1.2.0.json` (gitignoré) ; tests **ignorés** si absent. + * + * Valide v1.2.1+ : courbe Home en **état d'équilibre EEn** (t½ ≈ 6,7 j pour des + * doses tous les 6–7 j → accumulation ×2), calibration PAR PÉRIODE D'ESTER, + * lab T en unité aberrante neutralisé, prévision à 6 j. */ class RegressionUserCase2Test { companion object { - private val USER_JSON = """ - {"doseLogs":[ - {"doseAmount":5.0,"id":11,"timestamp":1786104300000,"treatmentId":1}, - {"doseAmount":5.0,"id":10,"timestamp":1786721520000,"treatmentId":1}, - {"doseAmount":5.0,"id":9,"timestamp":1787337600000,"treatmentId":1}, - {"doseAmount":5.0,"id":8,"timestamp":1787911680000,"treatmentId":1}, - {"doseAmount":8.0,"id":7,"timestamp":1788583980000,"treatmentId":1}, - {"doseAmount":5.0,"id":6,"timestamp":1789193820000,"treatmentId":1}, - {"doseAmount":5.0,"id":5,"timestamp":1789725060000,"treatmentId":1}, - {"doseAmount":5.0,"id":4,"timestamp":1790264700000,"treatmentId":1}, - {"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}], - "exportedAt":1791213894346, - "labResults":[ - {"id":7,"marker":"E2","timestamp":1786704060000,"unit":"pg/mL","value":200.0}, - {"id":8,"marker":"T","timestamp":1786704060000,"unit":"pg/mL","value":40.0}, - {"id":5,"marker":"E2","timestamp":1788503580000,"unit":"pg/mL","value":175.0}, - {"id":6,"marker":"T","timestamp":1788503580000,"unit":"ng/dL","value":47.0}, - {"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0}, - {"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0}, - {"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0}, - {"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}], - "tConfig":{"base":6.0,"floor":0.2,"k":0.09}, - "treatments":[{ - "absorptionHours":152.0,"bioavailabilityFraction":1.0,"createdAt":1791206539840, - "doseAmount":5.0,"doseUnit":"mg","eliminationHalfLifeHours":150.0,"esterType":"EEN", - "forecastIntervalDays":6.0,"id":1,"isActive":true,"name":"Injection EEn 5mg 6d", - "pkModel":"TFS","reminderEnabled":false,"route":"INJECTION_IM","scaleFactor":0.72, - "type":"ESTRADIOL"}], - "version":1} - """.trimIndent() - - private const val EXPORT_TIME = 1_791_213_894_346L + /** + * Le workdir des tests unitaires Gradle est le dossier du module (app/) : + * on cherche donc les données locales à plusieurs emplacements. + */ + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.2.0.json"), + File("local-test-data/backup-v1.2.0.json"), + File("app/local-test-data/backup-v1.2.0.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.0.json") } @Before fun setup() { + assumeTrue( + "local-test-data/backup-v1.2.0.json absent — tests ignorés (données locales)", + DATA_FILE.exists() + ) if (!PKProfileStore.hasProfile("EEN", "TFS")) { val file = listOf( File("src/main/assets/pk_profiles.json"), @@ -67,7 +51,7 @@ class RegressionUserCase2Test { } private fun importUserBackup(): BackupData = - Gson().fromJson(USER_JSON, BackupData::class.java) + Gson().fromJson(DATA_FILE.readText(), BackupData::class.java) @Test fun `second user backup parses with forecast interval and stored scale factor`() { @@ -77,66 +61,58 @@ class RegressionUserCase2Test { val tr = data.treatments.single() assertEquals("TFS", tr.pkModel) assertEquals("EEN", tr.esterType) - assertEquals(0.72, tr.scaleFactor, 1e-9) assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9) } @Test - fun `home 24h estimate is plausible with the stored scale factor`() { + fun `home 24h estimate is plausible in steady state with the stored scale factor`() { val data = importUserBackup() val curve = PharmacokineticEngine.computeCurve( data.treatments, data.doseLogs, - startMs = EXPORT_TIME - 24 * PharmacokineticEngine.HOUR_MS, - endMs = EXPORT_TIME, + startMs = data.exportedAt - 24 * PharmacokineticEngine.HOUR_MS, + endMs = data.exportedAt, tConfig = data.tConfig ) assertTrue(curve.isNotEmpty()) val last = curve.last() - // ÉTAT D'ÉQUILIBRE enanthate : t½ ≈ 6,7 j pour des doses tous les 6–7 j - // → accumulation ≈ ×2 (superposition des 9 doses). 5 mg × ~65 pg/mL/mg - // (somme) × 0,72 ≈ 240–280 pg/mL — cohérent avec les labs de l'utilisatrice - // (300, 250). Sans calibration : 268/0,72 ≈ 365 = les « 370 » rapportés. + // État d'équilibre EEn : t½ ≈ 6,7 j, doses tous les 6–7 j → accumulation ×2 ; + // avec le facteur stocké (~0,7) : ~240–280 pg/mL, cohérent avec les labs assertTrue("e2=${last.e2}", last.e2 in 200.0..350.0) - // k = 0,09 (calibré par l'utilisatrice) → T ≈ 0,43 ng/mL à e2 ≈ 268 assertTrue("t=${last.t}", last.t in 0.3..0.55) } @Test - fun `per-ester calibration produces a single EEN factor from the labs of that period`() { + fun `per-ester calibration produces a single factor from the labs of that period`() { val data = importUserBackup() val scales = PharmacokineticEngine.computeEsterScaleFactors( data.treatments, data.doseLogs, data.labResults.filter { it.marker.equals("E2", true) } ) - // Toutes les doses sont EEn → un seul ester calibré, facteur plausible + // Toutes les doses sont du même ester → un seul facteur calibré assertTrue("esters=${scales.keys}", scales.keys == setOf("EEN")) - val sf = scales["EEN"]!! - assertTrue("sf=$sf devrait être plausible (0,2–2)", sf in 0.2..2.0) + assertTrue("sf=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0) } @Test fun `mistyped T lab unit does not destroy the T axis`() { - // Le lab T "40 pg/mL" (faute de frappe) doit être converti, pas renvoyé brut - assertEquals(0.038, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9) - // Et un axe de chart restera lisible : max(T estimé ≈ 0,7, lab converti 0,038) - val tDataMax = maxOf(0.7, 0.038) - assertTrue(tDataMax < 1.0) + // Un lab T avec une unité aberrante (pg/mL) doit être converti, pas brut + assertEquals(0.04, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9) } @Test - fun `forecast doses follow the 6 day interval configured by the user`() { + fun `forecast doses follow the configured interval`() { val data = importUserBackup() + val intervalDays = data.treatments.single().forecastIntervalDays!! val forecast = PharmacokineticEngine.generateForecastDoses( data.treatments.single(), data.doseLogs, - toMs = EXPORT_TIME + 30 * 3_600_000L * 24, - nowMs = EXPORT_TIME + toMs = data.exportedAt + 30 * 24 * 3_600_000L, + nowMs = data.exportedAt ) - // 1ʳᵉ dose projetée = dernière réelle (Aug 31) + 6 j ; toutes espacées de 6 j val lastReal = data.doseLogs.maxOf { it.timestamp } assertTrue(forecast.isNotEmpty()) - assertEquals(lastReal + 6L * 24 * 3_600_000L, forecast.first().timestamp) + assertEquals(lastReal + (intervalDays * 24 * 3_600_000L).toLong(), forecast.first().timestamp) forecast.zipWithNext { prev, cur -> - assertEquals(6L * 24 * 3_600_000L, cur.timestamp - prev.timestamp) + assertEquals((intervalDays * 24 * 3_600_000L).toLong(), cur.timestamp - prev.timestamp) } } @@ -147,11 +123,11 @@ class RegressionUserCase2Test { data.treatments, data.doseLogs, data.labResults, data.tConfig ) assertTrue(result.calibratedEsters == 1) - assertTrue(result.esterScales["EEN"]!! in 0.2..2.0) - // Tout est sous EEn → le k T recalibré vit dans la map par ester ; - // le k stocké (0,09, calibré par l'utilisatrice) reste le fallback - assertTrue("k=${result.tKPerEster["EEN"]}", result.tKPerEster["EEN"]!! in 0.01..1.0) + assertTrue(result.esterScales.values.single() in 0.2..2.0) + // Un seul ester → le k T recalibré vit dans la map par ester ; + // le k stocké reste le fallback (inchangé dans les réglages) assertTrue(result.tRecalibrated) - assertEquals(0.09, result.tConfig.k, 1e-9) + assertTrue(result.tKPerEster.values.single() in 0.01..1.0) + assertEquals(data.tConfig.k, result.tConfig.k, 1e-9) } } diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt index 4bf7c5f..74af5a2 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt @@ -3,44 +3,41 @@ package com.hormonetrack.pk import com.google.gson.Gson import com.hormonetrack.data.backup.BackupData import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import java.io.File /** - * Regression test pinned to the user's real exported data (v1.0.0 backup JSON): - * 1 EEn injection 5 mg (~5 days before export), 4 labs (2× E2 pg/mL, 2× T ng/dL), - * default T config. Original bug report: "charts don't generate". + * Régression épinglée sur le **1er export réel** de l'utilisatrice (v1.0.0). + * + * ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans + * `local-test-data/backup-v1.0.0.json` (gitignoré). Si le fichier est absent + * (clone neuf, CI), les tests de cette classe sont **ignorés** — jamais échoués. + * Pour les lancer : exporter son backup depuis l'app et le déposer à cet emplacement. */ class RegressionUserCaseTest { companion object { - // Exact export provided by the user (formatted for readability) - private val USER_JSON = """ - {"doseLogs":[{"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}], - "exportedAt":1791207257381, - "labResults":[ - {"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0}, - {"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0}, - {"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0}, - {"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}], - "tConfig":{"base":6.0,"floor":0.2,"k":0.19}, - "treatments":[{ - "absorptionHours":152.0,"bioavailabilityFraction":1.0,"createdAt":1791206539840, - "doseAmount":5.0,"doseUnit":"mg","eliminationHalfLifeHours":150.0,"esterType":"EEN", - "id":1,"isActive":true,"name":"Injection EEn — Estrannaise","pkModel":"ESE", - "reminderEnabled":false,"route":"INJECTION_IM","scaleFactor":1.0,"type":"ESTRADIOL"}], - "version":1} - """.trimIndent() - - private const val EXPORT_TIME = 1_791_207_257_381L + /** + * Le workdir des tests unitaires Gradle est le dossier du module (app/) : + * on cherche donc les données locales à plusieurs emplacements. + */ + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.0.0.json"), + File("local-test-data/backup-v1.0.0.json"), + File("app/local-test-data/backup-v1.0.0.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.0.0.json") } @Before fun setup() { + assumeTrue( + "local-test-data/backup-v1.0.0.json absent — tests ignorés (données locales)", + DATA_FILE.exists() + ) if (!PKProfileStore.hasProfile("EEN", "ESE")) { val file = listOf( File("src/main/assets/pk_profiles.json"), @@ -51,38 +48,32 @@ class RegressionUserCaseTest { } private fun importUserBackup(): BackupData = - Gson().fromJson(USER_JSON, BackupData::class.java) + Gson().fromJson(DATA_FILE.readText(), BackupData::class.java) @Test - fun `user backup JSON parses into the expected data`() { + fun `user backup parses into the expected data`() { val data = importUserBackup() assertEquals(1, data.treatments.size) assertEquals(1, data.doseLogs.size) assertEquals(4, data.labResults.size) assertEquals("EEN", data.treatments[0].esterType) assertEquals("ESE", data.treatments[0].pkModel) - assertEquals(1790766120000, data.doseLogs[0].timestamp) + assertEquals(4, data.labResults.size) + assertEquals(2, data.labResults.count { it.marker.equals("E2", true) }) + assertEquals(2, data.labResults.count { it.marker.equals("T", true) }) } @Test fun `charts generate for every range with the user data`() { val data = importUserBackup() - val tConfig = data.tConfig for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) { val curve = PharmacokineticEngine.computeCurve( data.treatments, data.doseLogs, - startMs = EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS, - endMs = EXPORT_TIME, - tConfig = tConfig + startMs = data.exportedAt - rangeHours * PharmacokineticEngine.HOUR_MS, + endMs = data.exportedAt, + tConfig = data.tConfig ) - assertTrue("24h range=$rangeHours curve must not be empty", curve.isNotEmpty()) - // grid is hour-aligned: the last point may land up to 1 h before endMs - assertTrue( - "last=${curve.last().timestamp} rangeHours=$rangeHours", - curve.last().timestamp in (EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS)..EXPORT_TIME - ) - // E2 must be positive and in a physiologically plausible band for - // 5 mg EEn at ~120 h (peak 31.4 pg/mL/mg around 152 h) + assertTrue("rangeHours=$rangeHours curve must not be empty", curve.isNotEmpty()) val last = curve.last() assertTrue("e2=${last.e2} at rangeHours=$rangeHours", last.e2 > 50.0 && last.e2 < 400.0) assertTrue("t=${last.t}", last.t > 0.0 && last.t < 1.0) @@ -92,8 +83,9 @@ class RegressionUserCaseTest { @Test fun `current level at export time is plausible`() { val data = importUserBackup() - val point = PharmacokineticEngine.levelAt(data.treatments, data.doseLogs, EXPORT_TIME, data.tConfig) - // ~121 h after 5 mg EEn: profile ≈ 27-30 pg/mL/mg → 135-150 pg/mL + val point = PharmacokineticEngine.levelAt( + data.treatments, data.doseLogs, data.exportedAt, data.tConfig + ) assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0) } @@ -109,28 +101,29 @@ class RegressionUserCaseTest { @Test fun `T labs in ng per dL convert to ng per mL`() { - assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9) - assertEquals(0.32, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9) + // Constantes choisies idempotentes face au script d'anonymisation d'historique + assertEquals(0.44, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9) + assertEquals(0.33, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9) assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, "ng/mL"), 1e-9) - assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(450.0, "ng/L"), 1e-9) + assertEquals(4.5, PharmacokineticEngine.convertTToNgMl(4500.0, "ng/L"), 1e-9) assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, ""), 1e-9) } @Test fun `T calibration works with ng per dL labs`() { val data = importUserBackup() - // synthetic post-dose T labs expressed in ng/dL + // Lab T synthétique post-dose exprimé en ng/dL, k planté à 0,25 val cfg = data.tConfig - val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, EXPORT_TIME) + val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, data.exportedAt) val trueK = 0.25 val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2) val lab = com.hormonetrack.data.model.LabResult( - marker = "T", value = tNgMl * 100.0, unit = "ng/dL", timestamp = EXPORT_TIME + marker = "T", value = tNgMl * 100.0, unit = "ng/dL", timestamp = data.exportedAt ) val calibrated = PharmacokineticEngine.computeTConfigCalibration( listOf(lab), data.treatments, data.doseLogs, cfg ) - assertNotNull(calibrated) + assertTrue(calibrated != null) assertEquals(trueK, calibrated!!.k, trueK * 0.15) } }