test: tests unitaires JVM — CrisisGuard (sécurité vitale) + VectorIndex + Chunker

Premier harnais de tests du projet. testImplementation junit:4.13.2, src/test/.

CrisisGuardTest — NON-RÉGRESSION DE SÉCURITÉ : 26 formulations d'idéation qui
DOIVENT déclencher (toutes les familles de motifs, casse, variantes STT collées
validées device), 10 messages de détresse ordinaire qui ne doivent PAS déclencher
(tristesse, insomnie, colère, deuil d'autrui, « envie de dormir »…), 2 limites
ASSUMÉES documentées (sensibilité d'abord : « en finir avec ces insomnies » et
mention indirecte du suicide déclenchent), invariants de la réponse (112,
honnêteté « programme », anti-isolement). Toute retouche des PATTERNS doit garder
ces tests verts ; le clinicien ÉTEND les cas, ne supprime pas de positif.

VectorIndexTest : classement cosinus, top-k, seuil, index vide/dim incohérente.
ChunkerTest : tag source, découpe+overlap sous la limite ubatch, texte vide.

./gradlew :app:testDebugUnitTest → 11 tests, 0 échec, <5 s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-11 08:44:09 +02:00
parent d834ea4955
commit e5b48e4e0f
4 changed files with 188 additions and 0 deletions

View File

@ -109,6 +109,10 @@ android {
} }
dependencies { dependencies {
// Tests unitaires JVM (src/test/) — logique pure : CrisisGuard, VectorIndex, Chunker.
// Lancer : ./gradlew :app:testDebugUnitTest
testImplementation("junit:junit:4.13.2")
// Android // Android
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")

View File

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

View File

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

View File

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