amelioration OCR carte couleur + ascenseur agenda + fix synchro
EBO <eric.bouhana@softalys.com> committé le 2026-09-12 21:38
c15e097fef4df215170660034904d37e4b6f63a0
1 parent(s)
14 fichiers modifiés
+301
-13
M
README.md
+13
-1
@@ -66,7 +66,7 @@ Spec détaillée : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressource
| 66 | 66 | |
| 67 | 67 | 1. Cadrez la carte et capturez (CameraX). |
| 68 | 68 | 2. Correction géométrique (OpenCV) + orientation EXIF. |
| 69 | -3. OCR Tesseract (7 langues) + post-traitement. | |
| 69 | +3. Prétraitement contraste (gris + CLAHE + binarisation adaptative, repli sur l’image brute) puis OCR Tesseract (7 langues) + post-traitement. | |
| 70 | 70 | 4. Structuration **heuristique** (pas de modèle de langage). |
| 71 | 71 | 5. Vérifiez le brouillon, puis : |
| 72 | 72 | - **Enregistrer** → carnet local (SQLite) |
@@ -100,6 +100,18 @@ cd android
| 100 | 100 | |
| 101 | 101 | Premier build d’une variante : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide. |
| 102 | 102 | |
| 103 | +### Suivi des modèles (le modèle a-t-il changé ?) | |
| 104 | + | |
| 105 | +Les modèles sont **figés au build** : jamais re-téléchargés tant que le cache existe, jamais mis à jour au runtime. Les dépôts upstream `tessdata_fast`/`tessdata` sont d’ailleurs stables depuis des années ; les gains de précision viennent de la variante `full`, du prétraitement d’image (`OcrPreprocessor`) et des montées de version de Tesseract4Android — pas de nouveaux traineddata. | |
| 106 | + | |
| 107 | +```bash | |
| 108 | +cd android | |
| 109 | +./gradlew tessdataStatus # variante fast | |
| 110 | +./gradlew tessdataStatus -Ptessdata=full # variante full | |
| 111 | +``` | |
| 112 | + | |
| 113 | +Pour chaque langue : état vs upstream GitHub (`à jour` / `DIFFÉRENT` / `cache absent`), taille et SHA-256. Le build embarque aussi `model-info.txt` (taille + SHA-256 par langue) dans les assets, à côté de `variant.txt`. Pour forcer un re-téléchargement : supprimer `android/tessdata-cache/` puis relancer un build. | |
| 114 | + | |
| 103 | 115 | ### Chargement ultérieur (sans rebuild / sans Internet dans l’APK) |
| 104 | 116 | |
| 105 | 117 | Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` : |
M
android/app/build.gradle.kts
+54
-0
@@ -1,4 +1,5 @@
| 1 | 1 | import java.net.URI |
| 2 | +import java.security.MessageDigest | |
| 2 | 3 | import java.util.Properties |
| 3 | 4 | |
| 4 | 5 | plugins { |
@@ -80,6 +81,18 @@ android {
| 80 | 81 | } |
| 81 | 82 | } |
| 82 | 83 | |
| 84 | +fun fileSha256(file: File): String = | |
| 85 | + MessageDigest.getInstance("SHA-256").digest(file.readBytes()) | |
| 86 | + .joinToString("") { "%02x".format(it) } | |
| 87 | + | |
| 88 | +/** SHA-1 « git blob » — l'empreinte qu'expose l'API GitHub pour un fichier. */ | |
| 89 | +fun gitBlobSha1(file: File): String { | |
| 90 | + val md = MessageDigest.getInstance("SHA-1") | |
| 91 | + md.update("blob ${file.length()}\u0000".toByteArray()) | |
| 92 | + md.update(file.readBytes()) | |
| 93 | + return md.digest().joinToString("") { "%02x".format(it) } | |
| 94 | +} | |
| 95 | + | |
| 83 | 96 | val prepareTessdata by tasks.registering { |
| 84 | 97 | group = "build" |
| 85 | 98 | description = "Télécharge (cache) et prépare les traineddata Tessdata ($tessdataVariant)" |
@@ -107,6 +120,12 @@ val prepareTessdata by tasks.registering {
| 107 | 120 | cached.copyTo(outDir.resolve("$lang.traineddata"), overwrite = true) |
| 108 | 121 | } |
| 109 | 122 | outDir.resolve("variant.txt").writeText(tessdataVariant) |
| 123 | + outDir.resolve("model-info.txt").writeText( | |
| 124 | + tessdataLanguages.joinToString("\n") { lang -> | |
| 125 | + val f = outDir.resolve("$lang.traineddata") | |
| 126 | + "$lang ${f.length()} sha256=${fileSha256(f)}" | |
| 127 | + } + "\n" | |
| 128 | + ) | |
| 110 | 129 | outDir.resolve("README.md").writeText( |
| 111 | 130 | "Généré au build (variante=$tessdataVariant). Ne pas éditer.\n" + |
| 112 | 131 | "Cache : android/tessdata-cache/$tessdataVariant/\n" |
@@ -122,6 +141,41 @@ tasks.matching {
| 122 | 141 | } |
| 123 | 142 | tasks.named("preBuild").configure { dependsOn(prepareTessdata) } |
| 124 | 143 | |
| 144 | +/** Compare le cache local des traineddata avec l'upstream GitHub (lecture seule). | |
| 145 | + * Usage : ./gradlew tessdataStatus [-Ptessdata=full] */ | |
| 146 | +val tessdataStatus by tasks.registering { | |
| 147 | + group = "verification" | |
| 148 | + description = "Empreintes des traineddata en cache ($tessdataVariant) vs upstream GitHub" | |
| 149 | + doLast { | |
| 150 | + val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata" | |
| 151 | + val cache = tessdataCacheDir.asFile | |
| 152 | + val shaRegex = Regex(""""sha"\s*:\s*"([0-9a-f]{40})"""") | |
| 153 | + for (lang in tessdataLanguages) { | |
| 154 | + val cached = cache.resolve("$lang.traineddata") | |
| 155 | + if (!cached.isFile) { | |
| 156 | + logger.lifecycle("$lang : cache absent (${cached.path}) — sera téléchargé au prochain build") | |
| 157 | + continue | |
| 158 | + } | |
| 159 | + val localSha = gitBlobSha1(cached) | |
| 160 | + val api = "https://api.github.com/repos/tesseract-ocr/$repo/contents/$lang.traineddata?ref=main" | |
| 161 | + val remoteSha = runCatching { | |
| 162 | + URI(api).toURL().openStream().use { it.readBytes().toString(Charsets.UTF_8) } | |
| 163 | + .let { shaRegex.find(it)?.groupValues?.get(1) } | |
| 164 | + }.getOrNull() | |
| 165 | + val etat = when (remoteSha) { | |
| 166 | + null -> "upstream injoignable" | |
| 167 | + localSha -> "à jour" | |
| 168 | + else -> "DIFFÉRENT (upstream $remoteSha)" | |
| 169 | + } | |
| 170 | + logger.lifecycle("$lang : $etat — ${cached.length()} octets, sha256=${fileSha256(cached)}") | |
| 171 | + } | |
| 172 | + logger.lifecycle( | |
| 173 | + "Rappel : les modèles sont figés au build ; pour forcer un refresh, " + | |
| 174 | + "supprimer ${cache.path} puis relancer un build." | |
| 175 | + ) | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 125 | 179 | dependencies { |
| 126 | 180 | implementation(platform("androidx.compose:compose-bom:2024.09.02")) |
| 127 | 181 | implementation("androidx.compose.ui:ui") |
A
android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/CarteColoreeOcrInstrumentedTest.kt
+54
-0
@@ -0,0 +1,54 @@
| 1 | +package fr.ebii.card2vcf.ocr | |
| 2 | + | |
| 3 | +import android.graphics.Bitmap | |
| 4 | +import android.graphics.Canvas | |
| 5 | +import android.graphics.Color | |
| 6 | +import android.graphics.Paint | |
| 7 | +import android.graphics.Typeface | |
| 8 | +import androidx.test.ext.junit.runners.AndroidJUnit4 | |
| 9 | +import org.junit.Assert.assertTrue | |
| 10 | +import org.junit.Test | |
| 11 | +import org.junit.runner.RunWith | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Carte colorée synthétique : texte clair sur fond foncé, cas où le seuillage | |
| 15 | + * global interne de Tesseract décroche sans le prétraitement OcrPreprocessor | |
| 16 | + * (inversion de polarité + binarisation adaptative). | |
| 17 | + */ | |
| 18 | +@RunWith(AndroidJUnit4::class) | |
| 19 | +class CarteColoreeOcrInstrumentedTest { | |
| 20 | + | |
| 21 | + @Test | |
| 22 | + fun reconnaitTexteClairSurFondColore() { | |
| 23 | + val context = androidx.test.platform.app.InstrumentationRegistry | |
| 24 | + .getInstrumentation().targetContext | |
| 25 | + | |
| 26 | + val bmp = Bitmap.createBitmap(1600, 1000, Bitmap.Config.ARGB_8888) | |
| 27 | + val canvas = Canvas(bmp) | |
| 28 | + canvas.drawColor(Color.rgb(26, 75, 140)) // bleu soutenu | |
| 29 | + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 30 | + color = Color.WHITE | |
| 31 | + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) | |
| 32 | + textSize = 80f | |
| 33 | + } | |
| 34 | + canvas.drawText("Sonia MARTIN", 120f, 300f, paint) | |
| 35 | + canvas.drawText("Voltea Solutions", 120f, 430f, paint) | |
| 36 | + paint.textSize = 64f | |
| 37 | + canvas.drawText("Tel. 04 75 35 12 34", 120f, 600f, paint) | |
| 38 | + canvas.drawText("sonia.martin@voltea.fr", 120f, 720f, paint) | |
| 39 | + | |
| 40 | + val ocr = TesseractOcrEngine(context).recognize(bmp) | |
| 41 | + | |
| 42 | + println("=== OCR RAW (carte colorée) ===\n${ocr.rawText}") | |
| 43 | + println("=== phones=${ocr.phones} emails=${ocr.emails}") | |
| 44 | + | |
| 45 | + assertTrue( | |
| 46 | + "téléphone attendu absent: ${ocr.phones}", | |
| 47 | + ocr.phones.contains("0475351234"), | |
| 48 | + ) | |
| 49 | + assertTrue( | |
| 50 | + "email attendu absent: ${ocr.emails}", | |
| 51 | + ocr.emails.contains("sonia.martin@voltea.fr"), | |
| 52 | + ) | |
| 53 | + } | |
| 54 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPostProcessor.kt
+17
-6
@@ -13,11 +13,16 @@ object OcrPostProcessor {
| 13 | 13 | .map { it.trim() } |
| 14 | 14 | .filter { it.isNotEmpty() } |
| 15 | 15 | val joined = lines.joinToString("\n") |
| 16 | - // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes). | |
| 17 | - val phoneSource = joined | |
| 18 | - .replace(Regex("""(?<=\d)O(?=\d)"""), "0") | |
| 19 | - .replace(Regex("""\bO(?=\d)"""), "0") | |
| 20 | - val phones = phoneRegex.findAll(phoneSource) | |
| 16 | + // Extraction ligne par ligne : évite d'absorber le code postal ou le numéro | |
| 17 | + // de rue d'une ligne voisine (\s matche aussi \n dans phoneRegex). | |
| 18 | + val phones = lines | |
| 19 | + .asSequence() | |
| 20 | + // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes). | |
| 21 | + .map { | |
| 22 | + it.replace(Regex("""(?<=\d)O(?=\d)"""), "0") | |
| 23 | + .replace(Regex("""\bO(?=\d)"""), "0") | |
| 24 | + } | |
| 25 | + .flatMap { phoneRegex.findAll(it) } | |
| 21 | 26 | .map { normalizePhone(it.value) } |
| 22 | 27 | .filter { isPlausiblePhone(it) } |
| 23 | 28 | .distinct() |
@@ -61,7 +66,13 @@ object OcrPostProcessor {
| 61 | 66 | |
| 62 | 67 | private fun isPlausiblePhone(phone: String): Boolean { |
| 63 | 68 | val digits = phone.filter { it.isDigit() } |
| 64 | - return digits.length in 10..15 | |
| 69 | + // Avec indicatif international : 11-15 chiffres. Sans : format national FR | |
| 70 | + // strict (10 chiffres, 0 initial) pour rejeter les fusions adresse+téléphone. | |
| 71 | + return if (phone.startsWith("+")) { | |
| 72 | + digits.length in 11..15 | |
| 73 | + } else { | |
| 74 | + digits.length == 10 && digits.first() == '0' | |
| 75 | + } | |
| 65 | 76 | } |
| 66 | 77 | |
| 67 | 78 | private fun normalizeUrl(raw: String): String { |
A
android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPreprocessor.kt
+66
-0
@@ -0,0 +1,66 @@
| 1 | +package fr.ebii.card2vcf.ocr | |
| 2 | + | |
| 3 | +import android.graphics.Bitmap | |
| 4 | +import fr.ebii.card2vcf.scan.OpenCvScanEngine | |
| 5 | +import org.opencv.android.Utils | |
| 6 | +import org.opencv.core.Core | |
| 7 | +import org.opencv.core.Mat | |
| 8 | +import org.opencv.core.Size | |
| 9 | +import org.opencv.imgproc.Imgproc | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Prépare une image de carte pour Tesseract : gris → CLAHE → inversion si | |
| 13 | + * texte clair sur fond sombre → binarisation adaptative. Le seuillage global | |
| 14 | + * (Otsu) interne de Tesseract échoue sur les fonds colorés ou dégradés ; | |
| 15 | + * le seuillage adaptatif local rend le texte exploitable dans ces cas. | |
| 16 | + */ | |
| 17 | +object OcrPreprocessor { | |
| 18 | + | |
| 19 | + /** Largeur visée pour l'OCR : sous ~1800 px les petites lignes (téléphones) | |
| 20 | + * passent sous la taille de glyphe optimale de Tesseract. */ | |
| 21 | + private const val TARGET_WIDTH = 1800.0 | |
| 22 | + private const val MAX_UPSCALE = 3.0 | |
| 23 | + private const val BLOCK_SIZE = 35 | |
| 24 | + private const val THRESH_C = 15.0 | |
| 25 | + private const val CLAHE_CLIP = 2.5 | |
| 26 | + | |
| 27 | + /** Retourne l'image binarisée, ou null si OpenCV est indisponible. */ | |
| 28 | + fun prepare(source: Bitmap): Bitmap? { | |
| 29 | + if (!OpenCvScanEngine.ensureOpenCv()) return null | |
| 30 | + val src = Mat() | |
| 31 | + Utils.bitmapToMat(source, src) | |
| 32 | + val gray = Mat() | |
| 33 | + try { | |
| 34 | + when (src.channels()) { | |
| 35 | + 4 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY) | |
| 36 | + 3 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY) | |
| 37 | + else -> src.copyTo(gray) | |
| 38 | + } | |
| 39 | + | |
| 40 | + val scale = (TARGET_WIDTH / gray.cols()).coerceAtMost(MAX_UPSCALE) | |
| 41 | + if (scale > 1.0) { | |
| 42 | + Imgproc.resize(gray, gray, Size(), scale, scale, Imgproc.INTER_CUBIC) | |
| 43 | + } | |
| 44 | + | |
| 45 | + Imgproc.createCLAHE(CLAHE_CLIP, Size(8.0, 8.0)).apply(gray, gray) | |
| 46 | + | |
| 47 | + // Texte clair sur fond sombre → inversion pour retomber sur du noir sur blanc. | |
| 48 | + if (Core.mean(gray).`val`[0] < 127.0) { | |
| 49 | + Core.bitwise_not(gray, gray) | |
| 50 | + } | |
| 51 | + | |
| 52 | + Imgproc.adaptiveThreshold( | |
| 53 | + gray, gray, 255.0, | |
| 54 | + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, | |
| 55 | + BLOCK_SIZE, THRESH_C, | |
| 56 | + ) | |
| 57 | + | |
| 58 | + val out = Bitmap.createBitmap(gray.cols(), gray.rows(), Bitmap.Config.ARGB_8888) | |
| 59 | + Utils.matToBitmap(gray, out) | |
| 60 | + return out | |
| 61 | + } finally { | |
| 62 | + gray.release() | |
| 63 | + src.release() | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+15
-2
@@ -13,9 +13,22 @@ class TesseractOcrEngine(
| 13 | 13 | |
| 14 | 14 | override fun recognize(bitmap: Bitmap): OcrResult { |
| 15 | 15 | val dataPath = ensureTessData() |
| 16 | - return OcrOrientation.recognizeBest(bitmap) { candidate -> | |
| 17 | - recognizeOnce(candidate, dataPath) | |
| 16 | + // Passe 1 : image binarisée (contraste local) — décisive sur cartes colorées. | |
| 17 | + val prepared = OcrPreprocessor.prepare(bitmap) | |
| 18 | + val onPrepared = prepared?.let { candidate -> | |
| 19 | + runCatching { | |
| 20 | + OcrOrientation.recognizeBest(candidate) { recognizeOnce(it, dataPath) } | |
| 21 | + }.getOrNull().also { candidate.recycle() } | |
| 18 | 22 | } |
| 23 | + if (onPrepared != null && (onPrepared.emails.isNotEmpty() || onPrepared.phones.isNotEmpty())) { | |
| 24 | + return onPrepared | |
| 25 | + } | |
| 26 | + // Passe 2 : image originale, puis meilleur score des deux. | |
| 27 | + val onOriginal = runCatching { | |
| 28 | + OcrOrientation.recognizeBest(bitmap) { recognizeOnce(it, dataPath) } | |
| 29 | + }.getOrNull() | |
| 30 | + return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) } | |
| 31 | + ?: throw OcrException("OCR vide — reprenez la photo") | |
| 19 | 32 | } |
| 20 | 33 | |
| 21 | 34 | private fun recognizeOnce(bitmap: Bitmap, dataPath: String): OcrResult { |
M
android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+19
-2
@@ -50,6 +50,13 @@ class SyncEngine(
| 50 | 50 | private val api: AilianceApi, |
| 51 | 51 | private val db: CrmDatabase, |
| 52 | 52 | private val imageStore: fr.ebii.card2vcf.data.ContactImageStore? = null, |
| 53 | + /** | |
| 54 | + * Identité « serveur|utilisateur » à laquelle appartient le watermark. Si elle diffère de | |
| 55 | + * celle enregistrée (changement de serveur ou de compte), le pull repart de l'époque : | |
| 56 | + * sans cela, un watermark hérité d'un autre serveur masque toute donnée plus ancienne | |
| 57 | + * (ex. données de démo antidatées → « 0 reçu » définitif). `null` = pas de contrôle. | |
| 58 | + */ | |
| 59 | + private val serverIdentity: String? = null, | |
| 53 | 60 | ) { |
| 54 | 61 | private val agenda = AgendaSyncCoordinator(db) |
| 55 | 62 |
@@ -127,11 +134,20 @@ class SyncEngine(
| 127 | 134 | |
| 128 | 135 | // ---- watermark ---- |
| 129 | 136 | |
| 130 | - private suspend fun watermark(): String = | |
| 131 | - db.syncMetaDao().get(WATERMARK_KEY)?.value ?: DEFAULT_WATERMARK | |
| 137 | + private suspend fun watermark(): String { | |
| 138 | + val stored = db.syncMetaDao().get(WATERMARK_KEY)?.value ?: return DEFAULT_WATERMARK | |
| 139 | + if (serverIdentity != null) { | |
| 140 | + val origin = db.syncMetaDao().get(WATERMARK_ORIGIN_KEY)?.value | |
| 141 | + if (origin != serverIdentity) return DEFAULT_WATERMARK | |
| 142 | + } | |
| 143 | + return stored | |
| 144 | + } | |
| 132 | 145 | |
| 133 | 146 | private suspend fun setWatermark(serverTime: String) { |
| 134 | 147 | db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_KEY, value = serverTime)) |
| 148 | + if (serverIdentity != null) { | |
| 149 | + db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_ORIGIN_KEY, value = serverIdentity)) | |
| 150 | + } | |
| 135 | 151 | } |
| 136 | 152 | |
| 137 | 153 | // ---- push ---- |
@@ -667,6 +683,7 @@ class SyncEngine(
| 667 | 683 | |
| 668 | 684 | private companion object { |
| 669 | 685 | const val WATERMARK_KEY = "watermark" |
| 686 | + const val WATERMARK_ORIGIN_KEY = "watermark_origin" | |
| 670 | 687 | const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z" |
| 671 | 688 | const val ENTITY_CONTACT_MEDIA = "contact_media" |
| 672 | 689 |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+1
-0
@@ -114,6 +114,7 @@ fun Card2vcfNavHost(
| 114 | 114 | AilianceApiClient(baseUrl, apiKey), |
| 115 | 115 | database, |
| 116 | 116 | fr.ebii.card2vcf.data.ContactImageStore(context.applicationContext), |
| 117 | + serverIdentity = "$baseUrl|${credentialsStore.userName.orEmpty()}", | |
| 117 | 118 | ) |
| 118 | 119 | } else { |
| 119 | 120 | null |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+3
-1
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.Row
| 13 | 13 | import androidx.compose.foundation.layout.fillMaxSize |
| 14 | 14 | import androidx.compose.foundation.layout.fillMaxWidth |
| 15 | 15 | import androidx.compose.foundation.layout.padding |
| 16 | +import androidx.compose.foundation.rememberScrollState | |
| 16 | 17 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 17 | 18 | import androidx.compose.foundation.text.KeyboardOptions |
| 19 | +import androidx.compose.foundation.verticalScroll | |
| 18 | 20 | import androidx.compose.material.icons.Icons |
| 19 | 21 | import androidx.compose.material.icons.automirrored.outlined.ArrowBack |
| 20 | 22 | import androidx.compose.material3.AlertDialog |
@@ -120,7 +122,7 @@ private fun LoggedInContent(
| 120 | 122 | onRequestCalendarPermission: () -> Unit, |
| 121 | 123 | ) { |
| 122 | 124 | Column( |
| 123 | - Modifier.fillMaxSize().padding(18.dp), | |
| 125 | + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp), | |
| 124 | 126 | verticalArrangement = Arrangement.spacedBy(10.dp), |
| 125 | 127 | ) { |
| 126 | 128 | Text( |
M
android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrPostProcessingTest.kt
+30
-0
@@ -29,6 +29,36 @@ class OcrPostProcessingTest {
| 29 | 29 | assertEquals(listOf("ligne1", "ligne2"), r.lines) |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | + @Test fun neFusionnePasTelephoneEtCodePostalSurLignesVoisines() { | |
| 33 | + val text = """ | |
| 34 | + Jean Dupont | |
| 35 | + Tél. 04 75 35 12 34 | |
| 36 | + 07200 Aubenas | |
| 37 | + """.trimIndent() | |
| 38 | + val r = OcrPostProcessor.process(text) | |
| 39 | + assertEquals(listOf("0475351234"), r.phones) | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test fun neFusionnePasNumeroDeRueEtTelephone() { | |
| 43 | + val text = """ | |
| 44 | + 245 | |
| 45 | + 04 75 35 12 34 | |
| 46 | + """.trimIndent() | |
| 47 | + val r = OcrPostProcessor.process(text) | |
| 48 | + assertEquals(listOf("0475351234"), r.phones) | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test fun ignoreCodePostalSeul() { | |
| 52 | + val r = OcrPostProcessor.process("07200 Aubenas") | |
| 53 | + assertEquals(emptyList(), r.phones) | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test fun rejetteLongueursImplausiblesSansIndicatif() { | |
| 57 | + // 9 chiffres, 11 chiffres, ou 10 chiffres sans 0 initial : pas des numéros FR | |
| 58 | + val r = OcrPostProcessor.process("123 45 67 89\n0 12 34 56 78 90\n61 23 45 67 89") | |
| 59 | + assertEquals(emptyList(), r.phones) | |
| 60 | + } | |
| 61 | + | |
| 32 | 62 | @Test fun extraitCarteDimoCommeSurAppareil() { |
| 33 | 63 | val text = """ |
| 34 | 64 | dimo |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+2
-0
@@ -22,6 +22,7 @@ class FakeAilianceApi : AilianceApi {
| 22 | 22 | |
| 23 | 23 | var statusQueries = mutableListOf<String?>() |
| 24 | 24 | var pullQueries = mutableListOf<String?>() |
| 25 | + val pullSince = mutableListOf<String>() | |
| 25 | 26 | |
| 26 | 27 | override fun authCle(nom: String, motDePasse: String) = |
| 27 | 28 | AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305")) |
@@ -33,6 +34,7 @@ class FakeAilianceApi : AilianceApi {
| 33 | 34 | |
| 34 | 35 | override fun syncPull(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncPullResponse> { |
| 35 | 36 | pullQueries += ressourcesQuery |
| 37 | + pullSince += sinceIso | |
| 36 | 38 | return pullResult |
| 37 | 39 | } |
| 38 | 40 |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+19
-0
@@ -39,6 +39,25 @@ class SyncEngineTest {
| 39 | 39 | fun tearDown() = db.close() |
| 40 | 40 | |
| 41 | 41 | @Test |
| 42 | + fun watermark_ignoreLorsqueLeServeurOuLeCompteChange() = runBlocking { | |
| 43 | + // Synchro réussie avec l'identité A : watermark posé à serverTime. | |
| 44 | + api.pullResult = AilianceApiClient.ApiResult.Ok( | |
| 45 | + SyncPullResponse(serverTime = "2026-09-11T22:00:00Z"), | |
| 46 | + ) | |
| 47 | + val engineA = SyncEngine(api, db, serverIdentity = "https://prod.example|alice") | |
| 48 | + engineA.syncNow() | |
| 49 | + val engineA2 = SyncEngine(api, db, serverIdentity = "https://prod.example|alice") | |
| 50 | + engineA2.syncNow() | |
| 51 | + assertEquals("2026-09-11T22:00:00Z", api.pullSince.last()) | |
| 52 | + | |
| 53 | + // Même base locale, mais serveur différent : le pull doit repartir de l'époque, | |
| 54 | + // sinon les données plus anciennes que le watermark hérité ne redescendent jamais. | |
| 55 | + val engineB = SyncEngine(api, db, serverIdentity = "https://demo.example|alice") | |
| 56 | + engineB.syncNow() | |
| 57 | + assertEquals("1970-01-01T00:00:00Z", api.pullSince.last()) | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Test | |
| 42 | 61 | fun checkStatus_returnsTotalFromFake() = runBlocking { |
| 43 | 62 | api.statusResult = AilianceApiClient.ApiResult.Ok( |
| 44 | 63 | SyncStatusResponse( |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModelTest.kt
+5
-0
@@ -33,6 +33,11 @@ class ProjetsViewModelTest {
| 33 | 33 | val ctx = ApplicationProvider.getApplicationContext<Context>() |
| 34 | 34 | db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) |
| 35 | 35 | .allowMainThreadQueries() |
| 36 | + // Exécuteurs synchrones : les DAO suspend s'exécutent inline, donc le | |
| 37 | + // viewModelScope.launch de createProjet se termine avant les assertions | |
| 38 | + // (sinon course entre le pool Room et runBlocking → test flaky). | |
| 39 | + .setQueryExecutor { it.run() } | |
| 40 | + .setTransactionExecutor { it.run() } | |
| 36 | 41 | .build() |
| 37 | 42 | } |
| 38 | 43 |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+3
-1
@@ -234,7 +234,9 @@ class SyncChromeViewModelTest {
| 234 | 234 | fun syncNow_clearsPreviousSummaryOnGlobalPullFailure() = runBlocking { |
| 235 | 235 | val vm = newViewModel() |
| 236 | 236 | vm.syncNow() |
| 237 | - awaitUntil { vm.syncSummary.value != null } | |
| 237 | + // Attendre la fin complète du 1er syncNow (pas seulement le résumé) : ses écritures | |
| 238 | + // tardives (_error, _syncing) s'intercaleraient sinon avec le 2e syncNow → flaky. | |
| 239 | + awaitUntil { vm.syncSummary.value != null && !vm.syncing.value } | |
| 238 | 240 | api.pullResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible") |
| 239 | 241 | |
| 240 | 242 | vm.syncNow() |
GitRust