ContactHeuristicParserSpatialTest.kt 169 lignes · 6437 octets
package fr.ebii.card2vcf.contact

import fr.ebii.card2vcf.ocr.OcrBox
import fr.ebii.card2vcf.ocr.OcrLine
import fr.ebii.card2vcf.ocr.OcrResult
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ContactHeuristicParserSpatialTest {

    private fun ligne(
        text: String,
        top: Int,
        height: Int = 30,
        left: Int = 40,
        right: Int = 500,
        confidence: Float = 90f,
    ) = OcrLine(text, OcrBox(left, top, right, top + height), confidence)

    private fun ocr(
        lines: List<OcrLine>,
        phones: List<String> = emptyList(),
        emails: List<String> = emptyList(),
        urls: List<String> = emptyList(),
    ) = OcrResult(
        rawText = lines.joinToString("\n") { it.text },
        lines = lines.map { it.text },
        phones = phones,
        emails = emails,
        urls = urls,
        spatialLines = lines,
    )

    @Test fun adresseMultiLignesAssemblee() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("Sonia MARTIN", top = 100, height = 40),
                    ligne("561 allée des Noisetiers", top = 400),
                    ligne("69760 Limonest - France", top = 440),
                ),
            ),
        )
        assertEquals("561 allée des Noisetiers, 69760 Limonest - France", card.address)
    }

    @Test fun nomEnGrosCaracteresSansMajusculesDetecte() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("sonia martin", top = 100, height = 64),
                    ligne("directrice commerciale", top = 180, height = 28),
                ),
                emails = listOf("sonia.martin@voltea.fr"),
            ),
        )
        assertEquals("Martin", card.lastName)
        assertEquals("sonia", card.firstName?.lowercase())
        assertEquals("directrice commerciale", card.jobTitle)
    }

    @Test fun societeDominanteEnMajusculesSepareeDuNom() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("VOLTEA SOLUTIONS", top = 40, height = 50),
                    ligne("Sonia MARTIN", top = 200, height = 34),
                    ligne("Responsable grands comptes", top = 244, height = 28),
                ),
                emails = listOf("sonia.martin@voltea.fr"),
            ),
        )
        assertEquals("VOLTEA SOLUTIONS", card.company)
        assertEquals("Sonia Martin", card.fullName)
        assertEquals("Responsable grands comptes", card.jobTitle)
    }

    @Test fun nomToutEnMajusculesResteUnNomPasUneSociete() {
        // Très courant en France : « SONIA MARTIN » tout en capitales.
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("SONIA MARTIN", top = 100, height = 34),
                    ligne("Directrice commerciale", top = 150, height = 28),
                ),
                emails = listOf("sonia.martin@voltea.fr"),
            ),
        )
        assertEquals("Sonia Martin", card.fullName)
        assertEquals("Martin", card.lastName)
        assertEquals("Voltea", card.company) // depuis le domaine email, pas le nom
    }

    @Test fun deuxColonnesSansMelangeAdresseTelephone() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("Sonia MARTIN", top = 60, height = 40, left = 40, right = 900),
                    ligne("12 rue des Fleurs", top = 400, left = 40, right = 380),
                    ligne("75011 Paris", top = 440, left = 40, right = 380),
                    ligne("Tél. 04 75 35 12 34", top = 405, left = 600, right = 980),
                    ligne("sonia@voltea.fr", top = 445, left = 600, right = 980),
                ),
                phones = listOf("0475351234"),
                emails = listOf("sonia@voltea.fr"),
            ),
        )
        assertEquals("12 rue des Fleurs, 75011 Paris", card.address)
        assertEquals(listOf("0475351234"), card.phones)
    }

    @Test fun libelleTelephoneAvecValeurSurLaLigneSuivante() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("Sonia MARTIN", top = 60, height = 40),
                    ligne("Tél. :", top = 300),
                    ligne("04 75 35 12 34", top = 340),
                ),
            ),
        )
        assertTrue(card.phones.contains("0475351234"), "téléphone attendu: ${card.phones}")
    }

    @Test fun champsDouteuxMarquesSousLeSeuilDeConfiance() {
        val card = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne("Sonia MARTIN", top = 100, height = 40, confidence = 45f),
                    ligne("561 allée des Noisetiers", top = 400, confidence = 92f),
                    ligne("69760 Limonest - France", top = 440, confidence = 91f),
                ),
            ),
        )
        assertTrue("fullName" in card.champsDouteux, "nom à 45% doit être douteux: ${card.champsDouteux}")
        assertTrue("address" !in card.champsDouteux, "adresse à ~91% doit être sûre: ${card.champsDouteux}")
    }

    @Test fun fallbackSansBoxesAucunChampDouteux() {
        val card = ContactHeuristicParser.parse(
            OcrResult(rawText = "Sonia MARTIN\nDirectrice", lines = listOf("Sonia MARTIN", "Directrice")),
        )
        assertEquals(emptySet(), card.champsDouteux)
    }

    @Test fun cheminSpatialSeulementAvecBoxes() {
        val textes = listOf(
            "Sonia MARTIN",
            "561 allée des Noisetiers",
            "69760 Limonest - France",
        )
        val avecBoxes = ContactHeuristicParser.parse(
            ocr(
                listOf(
                    ligne(textes[0], top = 100, height = 40),
                    ligne(textes[1], top = 400),
                    ligne(textes[2], top = 440),
                ),
            ),
        )
        val sansBoxes = ContactHeuristicParser.parse(
            OcrResult(rawText = textes.joinToString("\n"), lines = textes),
        )
        // Spatial : adresse complète ; legacy : une seule ligne (la plus longue).
        assertEquals("561 allée des Noisetiers, 69760 Limonest - France", avecBoxes.address)
        assertEquals("561 allée des Noisetiers", sansBoxes.address)
    }
}