DepotModelesTest.kt 198 lignes · 6781 octets
package fr.ebii.card2vcf.modeles

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.MessageDigest
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [31])
class DepotModelesTest {

    private lateinit var serveur: MockWebServer
    private lateinit var context: Context
    private lateinit var prefs: PreferencesModeles
    private lateinit var depot: DepotModeles

    private val contenuFra = "traineddata factice pour les tests".repeat(400).toByteArray()

    private fun sha256(octets: ByteArray): String =
        MessageDigest.getInstance("SHA-256").digest(octets).joinToString("") { "%02x".format(it) }

    private fun modeleFra() = ModeleLocal(
        id = "fra",
        type = TypeModele.TESSDATA,
        tailleOctets = contenuFra.size.toLong(),
        sha256 = sha256(contenuFra),
    )

    private fun corpsBinaire(octets: ByteArray) = MockResponse().setBody(Buffer().write(octets))

    @Before
    fun setUp() {
        serveur = MockWebServer().apply { start() }
        context = ApplicationProvider.getApplicationContext()
        context.filesDir.deleteRecursively()
        prefs = PreferencesModeles(context).apply {
            setMiroir(serveur.url("/").toString().trimEnd('/'))
        }
        depot = DepotModeles(context, prefs)
    }

    @After
    fun tearDown() {
        serveur.shutdown()
    }

    private fun fichierTessdata(langue: String) =
        File(context.filesDir, "tesseract/tessdata/$langue.traineddata")

    @Test
    fun `un modele absent est signale absent`() {
        assertIs<EtatModele.Absent>(depot.etat(modeleFra()))
    }

    @Test
    fun `telechargement nominal ecrit le traineddata et passe a present`() = runTest {
        serveur.enqueue(corpsBinaire(contenuFra))

        depot.telecharger(modeleFra())

        val cible = fichierTessdata("fra")
        assertTrue(cible.isFile, "le traineddata doit être écrit")
        assertEquals(contenuFra.size.toLong(), cible.length())
        val etat = depot.etat(modeleFra())
        assertIs<EtatModele.Present>(etat)
        assertEquals(contenuFra.size.toLong(), etat.tailleOctets)
    }

    @Test
    fun `telechargement pose le stamp external pour proteger du recouvrement par les assets`() = runTest {
        serveur.enqueue(corpsBinaire(contenuFra))

        depot.telecharger(modeleFra())

        val stamp = File(context.filesDir, "tesseract/tessdata/.card2vcf-tessdata-variant")
        assertTrue(stamp.isFile, "le stamp doit exister")
        assertEquals("external", stamp.readText().trim())
    }

    @Test
    fun `le miroir configure est bien celui qui est interroge`() = runTest {
        serveur.enqueue(corpsBinaire(contenuFra))

        depot.telecharger(modeleFra())

        assertEquals("/fra.traineddata", serveur.takeRequest().path)
    }

    @Test
    fun `une empreinte invalide laisse le modele absent et ne laisse aucun fichier partiel`() = runTest {
        serveur.enqueue(corpsBinaire("contenu corrompu".toByteArray()))

        depot.telecharger(modeleFra())

        val etat = depot.etat(modeleFra())
        assertIs<EtatModele.Echec>(etat)
        assertFalse(fichierTessdata("fra").exists(), "aucun traineddata ne doit subsister")
        assertFalse(
            File(context.filesDir, "tesseract/tessdata/fra.traineddata.part").exists(),
            "le fichier partiel doit être nettoyé",
        )
    }

    @Test
    fun `une erreur reseau est rapportee en echec sans fichier residuel`() = runTest {
        serveur.enqueue(MockResponse().setResponseCode(404))

        depot.telecharger(modeleFra())

        assertIs<EtatModele.Echec>(depot.etat(modeleFra()))
        assertFalse(fichierTessdata("fra").exists())
        assertFalse(File(context.filesDir, "tesseract/tessdata/fra.traineddata.part").exists())
    }

    @Test
    fun `un echec n'empeche pas une relance reussie`() = runTest {
        serveur.enqueue(MockResponse().setResponseCode(500))
        depot.telecharger(modeleFra())
        assertIs<EtatModele.Echec>(depot.etat(modeleFra()))

        serveur.enqueue(corpsBinaire(contenuFra))
        depot.telecharger(modeleFra())

        assertIs<EtatModele.Present>(depot.etat(modeleFra()))
    }

    @Test
    fun `supprimer un modele le ramene a absent`() = runTest {
        serveur.enqueue(corpsBinaire(contenuFra))
        depot.telecharger(modeleFra())
        assertIs<EtatModele.Present>(depot.etat(modeleFra()))

        depot.supprimer(modeleFra())

        assertIs<EtatModele.Absent>(depot.etat(modeleFra()))
        assertFalse(fichierTessdata("fra").exists())
    }

    @Test
    fun `languesPretes ne retient que les langues selectionnees et presentes`() = runTest {
        serveur.enqueue(corpsBinaire(contenuFra))
        depot.telecharger(modeleFra())
        prefs.setLanguesSelectionnees(setOf("fra", "deu"))

        assertEquals(listOf("fra"), depot.languesPretes())
    }

    @Test
    fun `le modele vocal est decompresse dans filesDir`() = runTest {
        val zip = zipDeTest()
        val modele = ModeleLocal(
            id = "vosk-model-small-fr-0.22",
            type = TypeModele.VOSK,
            tailleOctets = zip.size.toLong(),
            sha256 = sha256(zip),
        )
        serveur.enqueue(corpsBinaire(zip))

        depot.telecharger(modele)

        val racine = File(context.filesDir, "vosk-model-small-fr-0.22")
        assertTrue(racine.isDirectory, "le modèle doit être décompressé")
        assertEquals("bonjour", File(racine, "am/final.mdl").readText())
        assertIs<EtatModele.Present>(depot.etat(modele))
        assertFalse(
            File(context.filesDir, "vosk-model-small-fr-0.22.zip.part").exists(),
            "l'archive téléchargée doit être nettoyée",
        )
    }

    /** Archive minimale imitant la structure d'un modèle Vosk. */
    private fun zipDeTest(): ByteArray {
        val sortie = ByteArrayOutputStream()
        ZipOutputStream(sortie).use { zos ->
            zos.putNextEntry(ZipEntry("vosk-model-small-fr-0.22/am/final.mdl"))
            zos.write("bonjour".toByteArray())
            zos.closeEntry()
        }
        return sortie.toByteArray()
    }
}