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 fdd3fee8f6
commit 851c185f69
4 changed files with 87 additions and 117 deletions

1
.gitignore vendored
View File

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

View File

@ -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 =

View File

@ -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)
}
}

View File

@ -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)
}
}