Tests de régression : données réelles hors dépôt (local-test-data gitignoré)

- 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)
This commit is contained in:
Siphonight 2026-09-05 19:18:14 +02:00
parent 34a7b588be
commit fd7e61b1a2
4 changed files with 87 additions and 117 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ Thumbs.db
# Logs # Logs
*.log *.log
local-test-data/

View File

@ -216,7 +216,7 @@ fun LabDialog(
/** /**
* Formatage d'une valeur de labo sans perdre de décimales significatives : * 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). * qui arrondit à 1 décimale, pensé pour des doses en mg).
*/ */
fun formatLabValue(v: Double): String = fun formatLabValue(v: Double): String =

View File

@ -4,59 +4,43 @@ import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
/** /**
* Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0, 2026-09-05) : * Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0) :
* 1 traitement EEn/ESE→**TFS** 5 mg (facteur stocké 0,72, fréquence 6 j), 9 doses * 1 traitement EEn + TFS 5 mg (facteur stocké, fréquence 6 j), 9 doses tous les
* (~6-7 j d'écart, une à 8 mg), 8 labs (4× E2 pg/mL, T en ng/dL + **1 labo T saisi * 6–7 j, 8 labs (E2 pg/mL, T ng/dL dont un saisi avec une mauvaise unité).
* 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).
* *
* Sert à valider v1.2.1 : courbe Home plausible, calibration PAR PÉRIODE D'ESTER * ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans
* (tout est sous EEn ici), lab T en unité aberrante neutralisé par la conversion. * `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 { class RegressionUserCase2Test {
companion object { companion object {
private val USER_JSON = """ /**
{"doseLogs":[ * Le workdir des tests unitaires Gradle est le dossier du module (app/) :
{"doseAmount":5.0,"id":11,"timestamp":1786104300000,"treatmentId":1}, * on cherche donc les données locales à plusieurs emplacements.
{"doseAmount":5.0,"id":10,"timestamp":1786721520000,"treatmentId":1}, */
{"doseAmount":5.0,"id":9,"timestamp":1787337600000,"treatmentId":1}, private val DATA_FILE: File = listOf(
{"doseAmount":5.0,"id":8,"timestamp":1787911680000,"treatmentId":1}, File("../local-test-data/backup-v1.2.0.json"),
{"doseAmount":8.0,"id":7,"timestamp":1788583980000,"treatmentId":1}, File("local-test-data/backup-v1.2.0.json"),
{"doseAmount":5.0,"id":6,"timestamp":1789193820000,"treatmentId":1}, File("app/local-test-data/backup-v1.2.0.json")
{"doseAmount":5.0,"id":5,"timestamp":1789725060000,"treatmentId":1}, ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.0.json")
{"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
} }
@Before @Before
fun setup() { 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")) { if (!PKProfileStore.hasProfile("EEN", "TFS")) {
val file = listOf( val file = listOf(
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
@ -67,7 +51,7 @@ class RegressionUserCase2Test {
} }
private fun importUserBackup(): BackupData = private fun importUserBackup(): BackupData =
Gson().fromJson(USER_JSON, BackupData::class.java) Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
@Test @Test
fun `second user backup parses with forecast interval and stored scale factor`() { fun `second user backup parses with forecast interval and stored scale factor`() {
@ -77,66 +61,58 @@ class RegressionUserCase2Test {
val tr = data.treatments.single() val tr = data.treatments.single()
assertEquals("TFS", tr.pkModel) assertEquals("TFS", tr.pkModel)
assertEquals("EEN", tr.esterType) assertEquals("EEN", tr.esterType)
assertEquals(0.72, tr.scaleFactor, 1e-9)
assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9) assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9)
} }
@Test @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 data = importUserBackup()
val curve = PharmacokineticEngine.computeCurve( val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs, data.treatments, data.doseLogs,
startMs = EXPORT_TIME - 24 * PharmacokineticEngine.HOUR_MS, startMs = data.exportedAt - 24 * PharmacokineticEngine.HOUR_MS,
endMs = EXPORT_TIME, endMs = data.exportedAt,
tConfig = data.tConfig tConfig = data.tConfig
) )
assertTrue(curve.isNotEmpty()) assertTrue(curve.isNotEmpty())
val last = curve.last() val last = curve.last()
// ÉTAT D'ÉQUILIBRE enanthate : t½ ≈ 6,7 j pour des doses tous les 6–7 j // État d'équilibre EEn : t½ ≈ 6,7 j, doses tous les 6–7 j → accumulation ×2 ;
// → accumulation ≈ ×2 (superposition des 9 doses). 5 mg × ~65 pg/mL/mg // avec le facteur stocké (~0,7) : ~240–280 pg/mL, cohérent avec les labs
// (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.
assertTrue("e2=${last.e2}", last.e2 in 200.0..350.0) 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) assertTrue("t=${last.t}", last.t in 0.3..0.55)
} }
@Test @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 data = importUserBackup()
val scales = PharmacokineticEngine.computeEsterScaleFactors( val scales = PharmacokineticEngine.computeEsterScaleFactors(
data.treatments, data.doseLogs, data.treatments, data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) } 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")) assertTrue("esters=${scales.keys}", scales.keys == setOf("EEN"))
val sf = scales["EEN"]!! assertTrue("sf=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0)
assertTrue("sf=$sf devrait être plausible (0,2–2)", sf in 0.2..2.0)
} }
@Test @Test
fun `mistyped T lab unit does not destroy the T axis`() { 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 // Un lab T avec une unité aberrante (pg/mL) doit être converti, pas brut
assertEquals(0.038, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9) assertEquals(0.04, 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)
} }
@Test @Test
fun `forecast doses follow the 6 day interval configured by the user`() { fun `forecast doses follow the configured interval`() {
val data = importUserBackup() val data = importUserBackup()
val intervalDays = data.treatments.single().forecastIntervalDays!!
val forecast = PharmacokineticEngine.generateForecastDoses( val forecast = PharmacokineticEngine.generateForecastDoses(
data.treatments.single(), data.doseLogs, data.treatments.single(), data.doseLogs,
toMs = EXPORT_TIME + 30 * 3_600_000L * 24, toMs = data.exportedAt + 30 * 24 * 3_600_000L,
nowMs = EXPORT_TIME 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 } val lastReal = data.doseLogs.maxOf { it.timestamp }
assertTrue(forecast.isNotEmpty()) 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 -> 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 data.treatments, data.doseLogs, data.labResults, data.tConfig
) )
assertTrue(result.calibratedEsters == 1) assertTrue(result.calibratedEsters == 1)
assertTrue(result.esterScales["EEN"]!! in 0.2..2.0) assertTrue(result.esterScales.values.single() in 0.2..2.0)
// Tout est sous EEn → le k T recalibré vit dans la map par ester ; // Un seul ester → le k T recalibré vit dans la map par ester ;
// le k stocké (0,09, calibré par l'utilisatrice) reste le fallback // le k stocké reste le fallback (inchangé dans les réglages)
assertTrue("k=${result.tKPerEster["EEN"]}", result.tKPerEster["EEN"]!! in 0.01..1.0)
assertTrue(result.tRecalibrated) 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)
} }
} }

View File

@ -3,44 +3,41 @@ package com.hormonetrack.pk
import com.google.gson.Gson import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
/** /**
* Regression test pinned to the user's real exported data (v1.0.0 backup JSON): * Régression épinglée sur le **1er export réel** de l'utilisatrice (v1.0.0).
* 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". * ⚠️ 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 { class RegressionUserCaseTest {
companion object { companion object {
// Exact export provided by the user (formatted for readability) /**
private val USER_JSON = """ * Le workdir des tests unitaires Gradle est le dossier du module (app/) :
{"doseLogs":[{"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}], * on cherche donc les données locales à plusieurs emplacements.
"exportedAt":1791207257381, */
"labResults":[ private val DATA_FILE: File = listOf(
{"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0}, File("../local-test-data/backup-v1.0.0.json"),
{"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0}, File("local-test-data/backup-v1.0.0.json"),
{"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0}, File("app/local-test-data/backup-v1.0.0.json")
{"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}], ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.0.0.json")
"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
} }
@Before @Before
fun setup() { 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")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = listOf( val file = listOf(
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
@ -51,38 +48,32 @@ class RegressionUserCaseTest {
} }
private fun importUserBackup(): BackupData = private fun importUserBackup(): BackupData =
Gson().fromJson(USER_JSON, BackupData::class.java) Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
@Test @Test
fun `user backup JSON parses into the expected data`() { fun `user backup parses into the expected data`() {
val data = importUserBackup() val data = importUserBackup()
assertEquals(1, data.treatments.size) assertEquals(1, data.treatments.size)
assertEquals(1, data.doseLogs.size) assertEquals(1, data.doseLogs.size)
assertEquals(4, data.labResults.size) assertEquals(4, data.labResults.size)
assertEquals("EEN", data.treatments[0].esterType) assertEquals("EEN", data.treatments[0].esterType)
assertEquals("ESE", data.treatments[0].pkModel) 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 @Test
fun `charts generate for every range with the user data`() { fun `charts generate for every range with the user data`() {
val data = importUserBackup() val data = importUserBackup()
val tConfig = data.tConfig
for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) { for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) {
val curve = PharmacokineticEngine.computeCurve( val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs, data.treatments, data.doseLogs,
startMs = EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS, startMs = data.exportedAt - rangeHours * PharmacokineticEngine.HOUR_MS,
endMs = EXPORT_TIME, endMs = data.exportedAt,
tConfig = tConfig tConfig = data.tConfig
) )
assertTrue("24h range=$rangeHours curve must not be empty", curve.isNotEmpty()) assertTrue("rangeHours=$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)
val last = curve.last() val last = curve.last()
assertTrue("e2=${last.e2} at rangeHours=$rangeHours", last.e2 > 50.0 && last.e2 < 400.0) 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) assertTrue("t=${last.t}", last.t > 0.0 && last.t < 1.0)
@ -92,8 +83,9 @@ class RegressionUserCaseTest {
@Test @Test
fun `current level at export time is plausible`() { fun `current level at export time is plausible`() {
val data = importUserBackup() val data = importUserBackup()
val point = PharmacokineticEngine.levelAt(data.treatments, data.doseLogs, EXPORT_TIME, data.tConfig) val point = PharmacokineticEngine.levelAt(
// ~121 h after 5 mg EEn: profile ≈ 27-30 pg/mL/mg → 135-150 pg/mL data.treatments, data.doseLogs, data.exportedAt, data.tConfig
)
assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0) assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0)
} }
@ -109,28 +101,29 @@ class RegressionUserCaseTest {
@Test @Test
fun `T labs in ng per dL convert to ng per mL`() { fun `T labs in ng per dL convert to ng per mL`() {
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9) // Constantes choisies idempotentes face au script d'anonymisation d'historique
assertEquals(0.32, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9) 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(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) assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, ""), 1e-9)
} }
@Test @Test
fun `T calibration works with ng per dL labs`() { fun `T calibration works with ng per dL labs`() {
val data = importUserBackup() 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 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 trueK = 0.25
val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2) val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2)
val lab = com.hormonetrack.data.model.LabResult( 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( val calibrated = PharmacokineticEngine.computeTConfigCalibration(
listOf(lab), data.treatments, data.doseLogs, cfg listOf(lab), data.treatments, data.doseLogs, cfg
) )
assertNotNull(calibrated) assertTrue(calibrated != null)
assertEquals(trueK, calibrated!!.k, trueK * 0.15) assertEquals(trueK, calibrated!!.k, trueK * 0.15)
} }
} }