diff --git a/kazeia-android/app/build.gradle.kts b/kazeia-android/app/build.gradle.kts index f722338..b0ca83d 100644 --- a/kazeia-android/app/build.gradle.kts +++ b/kazeia-android/app/build.gradle.kts @@ -109,6 +109,10 @@ android { } dependencies { + // Tests unitaires JVM (src/test/) — logique pure : CrisisGuard, VectorIndex, Chunker. + // Lancer : ./gradlew :app:testDebugUnitTest + testImplementation("junit:junit:4.13.2") + // Android implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.appcompat:appcompat:1.7.0") diff --git a/kazeia-android/app/src/test/java/com/kazeia/rag/ChunkerTest.kt b/kazeia-android/app/src/test/java/com/kazeia/rag/ChunkerTest.kt new file mode 100644 index 0000000..bcc7260 --- /dev/null +++ b/kazeia-android/app/src/test/java/com/kazeia/rag/ChunkerTest.kt @@ -0,0 +1,35 @@ +package com.kazeia.rag + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChunkerTest { + + @Test + fun `texte court - un seul chunk tague par la source`() { + val chunks = Chunker.chunk("Une phrase simple.", source = "doc") + assertEquals(1, chunks.size) + assertTrue(chunks[0].startsWith("[Source: doc]")) + assertTrue(chunks[0].contains("Une phrase simple.")) + } + + @Test + fun `texte long - decoupe avec overlap, sous la limite ubatch`() { + val sentence = "Ceci est une phrase de longueur raisonnable pour le test. " + val long = sentence.repeat(60) // ~3500 chars + val chunks = Chunker.chunk(long, source = "long", maxChars = 1200, overlap = 200) + assertTrue("attendu >1 chunk", chunks.size > 1) + // Contrainte embedder : chaque chunk doit rester ~sous 512 tokens (~2000 chars marge incluse) + for (c in chunks) assertTrue("chunk trop long: ${c.length}", c.length < 2000) + // L'overlap recopie la fin du chunk N au début du N+1 → du texte partagé existe + val tail = chunks[0].takeLast(80).substringAfter("] ") + assertTrue(tail.isNotEmpty()) + } + + @Test + fun `texte vide ou blanc - aucun chunk`() { + assertTrue(Chunker.chunk("", "x").isEmpty()) + assertTrue(Chunker.chunk(" \n\n ", "x").isEmpty()) + } +} diff --git a/kazeia-android/app/src/test/java/com/kazeia/rag/VectorIndexTest.kt b/kazeia-android/app/src/test/java/com/kazeia/rag/VectorIndexTest.kt new file mode 100644 index 0000000..09eb3e8 --- /dev/null +++ b/kazeia-android/app/src/test/java/com/kazeia/rag/VectorIndexTest.kt @@ -0,0 +1,41 @@ +package com.kazeia.rag + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class VectorIndexTest { + + private fun unit(vararg v: Float): FloatArray = l2normalize(v.copyOf()) + + @Test + fun `classement par cosinus decroissant et top-k`() { + val idx = VectorIndex(3) + idx.add(1L, unit(1f, 0f, 0f)) + idx.add(2L, unit(0.9f, 0.1f, 0f)) + idx.add(3L, unit(0f, 1f, 0f)) + val hits = idx.search(unit(1f, 0f, 0f), k = 2, threshold = 0f) + assertEquals(2, hits.size) + assertEquals(1L, hits[0].chunkId) // identique → score max + assertEquals(2L, hits[1].chunkId) // proche → 2ᵉ + assertTrue(hits[0].score > hits[1].score) + } + + @Test + fun `le seuil filtre le hors-sujet`() { + val idx = VectorIndex(3) + idx.add(1L, unit(1f, 0f, 0f)) + idx.add(2L, unit(0f, 1f, 0f)) // orthogonal → cos = 0 + val hits = idx.search(unit(1f, 0f, 0f), k = 5, threshold = 0.5f) + assertEquals(1, hits.size) + assertEquals(1L, hits[0].chunkId) + } + + @Test + fun `index vide ou dimension incoherente - resultat vide, pas de crash`() { + val idx = VectorIndex(3) + assertTrue(idx.search(unit(1f, 0f, 0f), 3, 0f).isEmpty()) + idx.add(1L, unit(1f, 0f, 0f)) + assertTrue(idx.search(FloatArray(5), 3, 0f).isEmpty()) // mauvaise dim + } +} diff --git a/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt b/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt new file mode 100644 index 0000000..0499d1f --- /dev/null +++ b/kazeia-android/app/src/test/java/com/kazeia/safety/CrisisGuardTest.kt @@ -0,0 +1,108 @@ +package com.kazeia.safety + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Non-régression de la détection de crise — SÉCURITÉ VITALE. + * + * Règle d'or : on privilégie la SENSIBILITÉ (rater une vraie crise est le seul + * vrai risque ; un faux positif reste une réponse bienveillante). Toute retouche + * de CrisisGuard.PATTERNS doit garder ces tests verts. Si le clinicien affine + * les motifs, il ÉTEND ce fichier — il ne supprime pas de cas positif. + */ +class CrisisGuardTest { + + // ── POSITIFS : chaque famille de motifs doit déclencher ──────────────── + + private val mustDetect = listOf( + // suicide explicite + "je veux me suicider", + "je pense au suicide depuis quelques jours", + "JE VEUX ME SUICIDER", // insensible à la casse + // me tuer / me foutre en l'air + "j'ai envie de me tuer", + "je vais me foutre en l'air", + "me foutre en l air", // variante STT sans apostrophe + // en finir + "je n'en peux plus, je veux en finir", + "jai envie den finir", // sortie STT collée (validé device) + "des fois je me dis que ca vaudrait mieux den finir", + // mettre fin à mes jours / ma vie + "je songe à mettre fin à mes jours", + "mettre fin à ma vie serait plus simple", + // envie de mourir / plus envie de vivre + "j'ai envie de mourir", + "je n'ai plus envie de vivre", + "pas envie de vivre aujourd'hui", + // ça ne sert à rien de vivre/continuer + "ça ne sert à rien de continuer", + "ca sert à rien de vivre", + // vaudrait mieux que je parte/disparaisse/meure / tout arrêter + "il vaudrait mieux que je disparaisse", + "ça vaudrait mieux que je meure", + "tout ça ne sert à rien, ça vaudrait mieux tout arrêter", + "ça vaudrait mieux d'arrêter", // cas du bench qualité (Q5) + // auto-agression + "je pense à me faire du mal", // validé device + "je recommence à me mutiler", + "je vais m'automutiler ce soir", + // épuisement vital / fardeau + "je n'ai plus la force de vivre", + "ils seraient mieux sans moi" + ) + + @Test + fun `detecte toutes les formulations d ideation`() { + for (msg in mustDetect) { + assertTrue("DOIT déclencher mais ne déclenche pas : « $msg »", CrisisGuard.detect(msg)) + } + } + + // ── NÉGATIFS : la détresse ordinaire ne doit PAS déclencher ──────────── + + private val mustNotDetect = listOf( + "je me sens un peu triste et fatigué aujourd'hui", // validé device (pas de faux positif) + "je suis très angoissé, je n'arrive plus à dormir", + "j'ai envie de dormir tout le temps", // « envie de … » ≠ « envie de mourir » + "je me sens seul, personne ne me comprend", + "je suis en colère contre mon frère", + "j'ai raté mon examen, je suis nul", + "la journée a été dure mais ça va aller", + "je n'ai plus la force de faire le ménage", // « plus la force de … » ≠ « … de vivre » + "mon père est mort l'année dernière, il me manque", // parler de la mort d'autrui ≠ idéation + "ce film parle d'un homme qui veut tout plaquer" + ) + + @Test + fun `ne declenche pas sur la detresse ordinaire`() { + for (msg in mustNotDetect) { + assertFalse("NE DOIT PAS déclencher mais déclenche : « $msg »", CrisisGuard.detect(msg)) + } + } + + // ── COMPORTEMENTS LIMITES DOCUMENTÉS (choix sensibilité-d'abord) ────── + // Ces cas déclenchent alors qu'ils sont ambigus. C'est ASSUMÉ : on préfère + // une réponse de sécurité en trop qu'une crise ratée. Si le clinicien veut + // les affiner, ces tests explicitent l'état actuel. + + @Test + fun `limite assumee - en finir avec un sujet banal declenche (sensibilite d abord)`() { + assertTrue(CrisisGuard.detect("je veux en finir avec ces insomnies")) + } + + @Test + fun `limite assumee - suicide en contexte indirect declenche`() { + assertTrue(CrisisGuard.detect("mon cousin a parlé de suicide à la télé")) + } + + // ── RÉPONSE DE SÉCURITÉ : invariants ────────────────────────────────── + + @Test + fun `la reponse de securite contient le numero d urgence et l honnetete programme`() { + assertTrue(CrisisGuard.RESPONSE.contains("112")) + assertTrue(CrisisGuard.RESPONSE.contains("programme")) // honnêteté : pas un humain + assertTrue(CrisisGuard.RESPONSE.contains("pas seul")) // anti-isolement + } +}