Notes vocales iOS + fixtures de contrat + CI locale Dagger Port iOS des notes vocales (parité Android) : - capture audio (AudioNoteRecorder, MoteurAudioCapture, écriture WAV) et transcription on-device via Speech, ou serveur au choix - migrations SQLite v4→v6 : colonnes audio/transcription sur interactions, nouvelle table notes_projet et son DAO - SyncEngine : opérations interaction_audio, note_projet et note_projet_audio (create/delete, upload/download avec ré-enfilement), relance de transcription - notes vocales dans la fiche contact et le détail projet - permissions micro et reconnaissance vocale dans Info.plist - écran « À propos » iOS - 15 suites XCTest Fixtures de contrat partagées serveur ⇄ Android ⇄ iOS : Server/tests/fixtures_contrat.rs génère sync_pull.json, interaction_note_vocale.json et note_projet.json dans contrats/ depuis un pull réel, et les copie vers les ressources de test des deux clients. Chaque client les relit via ContratSyncFixturesTest : toute dérive de schéma fait échouer la CI des trois côtés. Vérification locale avant push : module Dagger (.dagger/) avec serveur (clippy -D warnings + nextest), android (gradlew testDebugUnitTest) et tout (les deux en parallèle). Hook outils/hooks/pre-push passe par Dagger si Docker répond, sinon exécute les tests en direct. iOS non couvert (exige macOS). Testabilité Android : - interface EnregistreurAudio injectable dans EnregistrementNoteSheet, sinon Robolectric reste bloqué en phase « enregistrement » faute de matériel - FakeAilianceApi mutualisé, doublon de SettingsViewModelTest supprimé - tests Compose UI de la feuille d'enregistrement et du pager de contacts
EBO <eric@ebii.fr> committé le 2026-09-22 20:16
0e46d04f9b07d6869a624dce23cbbf25d48b64a7
1 parent(s)
64 fichiers modifiés
+5048
-89
M
android/app/build.gradle.kts
+2
-0
@@ -272,6 +272,8 @@ dependencies {
| 272 | 272 | testImplementation("org.robolectric:robolectric:4.13") |
| 273 | 273 | testImplementation("androidx.test:core:1.6.1") |
| 274 | 274 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") |
| 275 | + testImplementation("androidx.compose.ui:ui-test-junit4") | |
| 276 | + debugImplementation("androidx.compose.ui:ui-test-manifest") | |
| 275 | 277 | |
| 276 | 278 | implementation("com.squareup.okhttp3:okhttp:4.12.0") |
| 277 | 279 | testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") |
M
android/app/src/main/java/fr/ebii/card2vcf/audio/AudioNoteRecorder.kt
+3
-3
@@ -23,7 +23,7 @@ data class ResultatEnregistrement(val chemin: String, val dureeMsec: Long)
| 23 | 23 | * |
| 24 | 24 | * La demande de permission RECORD_AUDIO est à la charge de l'UI (P2-A4). |
| 25 | 25 | */ |
| 26 | -class AudioNoteRecorder { | |
| 26 | +class AudioNoteRecorder : EnregistreurAudio { | |
| 27 | 27 | |
| 28 | 28 | companion object { |
| 29 | 29 | private const val TAG = "AudioNoteRecorder" |
@@ -41,7 +41,7 @@ class AudioNoteRecorder {
| 41 | 41 | * Démarre l'enregistrement vers [fichierCible]. |
| 42 | 42 | * [onPcm] est appelé sur le thread d'enregistrement à chaque buffer capturé. |
| 43 | 43 | */ |
| 44 | - fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) { | |
| 44 | + override fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) { | |
| 45 | 45 | check(!enCours) { "Enregistrement déjà en cours" } |
| 46 | 46 | dernierResultat = null |
| 47 | 47 | enCours = true |
@@ -55,7 +55,7 @@ class AudioNoteRecorder {
| 55 | 55 | * Stoppe l'enregistrement et attend la fin du thread (max 3 s). |
| 56 | 56 | * @return Le résultat de l'enregistrement, ou null si non démarré. |
| 57 | 57 | */ |
| 58 | - fun arreter(): ResultatEnregistrement? { | |
| 58 | + override fun arreter(): ResultatEnregistrement? { | |
| 59 | 59 | enCours = false |
| 60 | 60 | threadEnregistrement?.join(3_000) |
| 61 | 61 | threadEnregistrement = null |
A
android/app/src/main/java/fr/ebii/card2vcf/audio/EnregistreurAudio.kt
+19
-0
@@ -0,0 +1,19 @@
| 1 | +package fr.ebii.card2vcf.audio | |
| 2 | + | |
| 3 | +import java.io.File | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Contrat de capture audio utilisé par l'UI. | |
| 7 | + * | |
| 8 | + * [AudioNoteRecorder] en est l'implémentation réelle (AudioRecord). L'interface existe | |
| 9 | + * pour que la feuille d'enregistrement reste pilotable en test : `AudioRecord` n'a aucun | |
| 10 | + * matériel derrière lui sous Robolectric, si bien qu'un test de rendu attendrait | |
| 11 | + * indéfiniment la fin d'un enregistrement qui n'a jamais démarré. | |
| 12 | + */ | |
| 13 | +interface EnregistreurAudio { | |
| 14 | + /** Lance l'enregistrement vers [fichierCible] ; [onPcm] reçoit les échantillons lus. */ | |
| 15 | + fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) | |
| 16 | + | |
| 17 | + /** Stoppe l'enregistrement et retourne le fichier produit, ou `null` si rien n'a été capté. */ | |
| 18 | + fun arreter(): ResultatEnregistrement? | |
| 19 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheet.kt
+9
-2
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
| 45 | 45 | import androidx.core.content.ContextCompat |
| 46 | 46 | import fr.ebii.card2vcf.R |
| 47 | 47 | import fr.ebii.card2vcf.audio.AudioNoteRecorder |
| 48 | +import fr.ebii.card2vcf.audio.EnregistreurAudio | |
| 48 | 49 | import fr.ebii.card2vcf.audio.TranscripteurVosk |
| 49 | 50 | import fr.ebii.card2vcf.ui.composants.ChampTexte |
| 50 | 51 | import fr.ebii.card2vcf.ui.theme.Bordure |
@@ -85,11 +86,17 @@ fun EnregistrementNoteSheet(
| 85 | 86 | voskDisponible: Boolean, |
| 86 | 87 | onSauvegarder: (sujet: String, texte: String, audioPath: String?) -> Unit, |
| 87 | 88 | onDismiss: () -> Unit, |
| 89 | + /** | |
| 90 | + * Fabrique de l'enregistreur. Injectable pour les tests de rendu : sous Robolectric, | |
| 91 | + * `AudioRecord` n'a aucun matériel derrière lui et l'écran resterait bloqué en phase | |
| 92 | + * « enregistrement en cours ». | |
| 93 | + */ | |
| 94 | + enregistreurFactory: () -> EnregistreurAudio = { AudioNoteRecorder() }, | |
| 88 | 95 | ) { |
| 89 | 96 | val context = LocalContext.current |
| 90 | 97 | val modeLocal = !modeServeur && voskDisponible |
| 91 | 98 | |
| 92 | - val recorder = remember { AudioNoteRecorder() } | |
| 99 | + val recorder = remember { enregistreurFactory() } | |
| 93 | 100 | val transcripteur = remember { if (modeLocal) TranscripteurVosk(context.applicationContext) else null } |
| 94 | 101 | |
| 95 | 102 | var micAccorde by remember { |
@@ -284,7 +291,7 @@ private fun EnCoursContent(
| 284 | 291 | } |
| 285 | 292 | |
| 286 | 293 | @Composable |
| 287 | -private fun EditionContent( | |
| 294 | +internal fun EditionContent( | |
| 288 | 295 | sujet: String, |
| 289 | 296 | texteTranscrit: String, |
| 290 | 297 | modeServeur: Boolean, |
A
android/app/src/test/java/fr/ebii/card2vcf/sync/ContratSyncFixturesTest.kt
+75
-0
@@ -0,0 +1,75 @@
| 1 | +package fr.ebii.card2vcf.sync | |
| 2 | + | |
| 3 | +import org.junit.Assert.assertEquals | |
| 4 | +import org.junit.Assert.assertNotNull | |
| 5 | +import org.junit.Assert.assertNull | |
| 6 | +import org.junit.Assert.assertTrue | |
| 7 | +import org.junit.Test | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Décode les fixtures de contrat de synchronisation avec les vrais DTO. | |
| 11 | + * | |
| 12 | + * Si un champ serveur est renommé, ce test devient rouge immédiatement. | |
| 13 | + * Preuve de rougeur (vérifiée) : en renommant `transcription_erreur` → `transcription_error` | |
| 14 | + * dans `interaction_note_vocale.json`, l'assertion suivante échoue : | |
| 15 | + * expected:<Modèle ASR indisponible> but was:<null> | |
| 16 | + */ | |
| 17 | +class ContratSyncFixturesTest { | |
| 18 | + | |
| 19 | + private fun fixture(nom: String): String { | |
| 20 | + val stream = javaClass.classLoader!!.getResourceAsStream("contrats/$nom") | |
| 21 | + ?: error("Fixture introuvable : contrats/$nom") | |
| 22 | + return stream.bufferedReader().readText() | |
| 23 | + } | |
| 24 | + | |
| 25 | + // ---- sync_pull.json ---- | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + fun syncPull_decodeSyncPullResponse() { | |
| 29 | + val json = fixture("sync_pull.json") | |
| 30 | + val pull = syncJson.decodeFromString<SyncPullResponse>(json) | |
| 31 | + | |
| 32 | + assertTrue("contacts doit contenir au moins un élément", pull.contacts.isNotEmpty()) | |
| 33 | + assertTrue("interactions doit contenir au moins un élément", pull.interactions.isNotEmpty()) | |
| 34 | + assertTrue("notes doit contenir au moins un élément", pull.notes.isNotEmpty()) | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test | |
| 38 | + fun syncPull_contactChampsCritiques() { | |
| 39 | + val json = fixture("sync_pull.json") | |
| 40 | + val pull = syncJson.decodeFromString<SyncPullResponse>(json) | |
| 41 | + | |
| 42 | + val contact = pull.contacts.first() | |
| 43 | + assertNotNull("id contact ne doit pas être null", contact.id) | |
| 44 | + assertNotNull("creeLe contact ne doit pas être null", contact.creeLe) | |
| 45 | + } | |
| 46 | + | |
| 47 | + // ---- interaction_note_vocale.json ---- | |
| 48 | + | |
| 49 | + @Test | |
| 50 | + fun interaction_decodeInteractionDto() { | |
| 51 | + val json = fixture("interaction_note_vocale.json") | |
| 52 | + val dto = syncJson.decodeFromString<InteractionDto>(json) | |
| 53 | + | |
| 54 | + assertEquals("note_vocale", dto.typeInteraction) | |
| 55 | + assertEquals("vocale_contrat_1.wav", dto.pieceJointe) | |
| 56 | + assertEquals("echec", dto.transcription) | |
| 57 | + // Champ critique : si transcription_erreur est renommé côté serveur, | |
| 58 | + // cette assertion devient rouge : expected:<Modèle ASR indisponible> but was:<null> | |
| 59 | + assertEquals("Modèle ASR indisponible", dto.transcriptionErreur) | |
| 60 | + } | |
| 61 | + | |
| 62 | + // ---- note_projet.json ---- | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + fun noteProjet_decodeNoteProjetDto() { | |
| 66 | + val json = fixture("note_projet.json") | |
| 67 | + val dto = syncJson.decodeFromString<NoteProjetDto>(json) | |
| 68 | + | |
| 69 | + assertEquals("projet-contrat-1", dto.projetId) | |
| 70 | + assertEquals("note_contrat_1.wav", dto.audio) | |
| 71 | + assertEquals("terminee", dto.transcription) | |
| 72 | + // transcription_erreur doit être null pour cette note (transcription réussie) | |
| 73 | + assertNull("transcriptionErreur doit être null pour une transcription réussie", dto.transcriptionErreur) | |
| 74 | + } | |
| 75 | +} |
M
android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+17
-3
@@ -1,7 +1,20 @@
| 1 | 1 | package fr.ebii.card2vcf.sync |
| 2 | 2 | |
| 3 | -/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. Partagé par [SyncEngineTest] et [AgendaSyncEngineTest]. */ | |
| 3 | +/** | |
| 4 | + * Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. | |
| 5 | + * Partagé par tous les tests qui en ont besoin. | |
| 6 | + * | |
| 7 | + * Deux champs permettent de surcharger les réponses critiques pour [SettingsViewModelTest] : | |
| 8 | + * - [authCleOverride] : si non null, remplace la réponse par défaut de [authCle]. | |
| 9 | + * - [getRessourcesCatalogueOverride] : si non null, remplace [getRessourcesCatalogue]. | |
| 10 | + */ | |
| 4 | 11 | class FakeAilianceApi : AilianceApi { |
| 12 | + /** Si non null, [authCle] retourne cette valeur au lieu du Ok par défaut. */ | |
| 13 | + var authCleOverride: AilianceApiClient.ApiResult<AuthCleResponse>? = null | |
| 14 | + | |
| 15 | + /** Si non null, [getRessourcesCatalogue] retourne cette valeur au lieu du Ok vide par défaut. */ | |
| 16 | + var getRessourcesCatalogueOverride: AilianceApiClient.ApiResult<RessourcesCatalogueDto>? = null | |
| 17 | + | |
| 5 | 18 | var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> = |
| 6 | 19 | AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0)) |
| 7 | 20 | var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> = |
@@ -25,7 +38,7 @@ class FakeAilianceApi : AilianceApi {
| 25 | 38 | val pullSince = mutableListOf<String>() |
| 26 | 39 | |
| 27 | 40 | override fun authCle(nom: String, motDePasse: String) = |
| 28 | - AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305")) | |
| 41 | + authCleOverride ?: AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305")) | |
| 29 | 42 | |
| 30 | 43 | override fun syncStatus(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncStatusResponse> { |
| 31 | 44 | statusQueries += ressourcesQuery |
@@ -101,7 +114,8 @@ class FakeAilianceApi : AilianceApi {
| 101 | 114 | override fun getRdv(id: String) = error("not used") |
| 102 | 115 | override fun updateRdv(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}") |
| 103 | 116 | override fun deleteRdv(id: String) = AilianceApiClient.ApiResult.Ok(Unit) |
| 104 | - override fun getRessourcesCatalogue() = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()) | |
| 117 | + override fun getRessourcesCatalogue() = | |
| 118 | + getRessourcesCatalogueOverride ?: AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()) | |
| 105 | 119 | override fun listReservations(genre: String, resourceId: String) = AilianceApiClient.ApiResult.Ok(emptyList<ReservationDto>()) |
| 106 | 120 | |
| 107 | 121 | override fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String> { |
A
android/app/src/test/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheetTest.kt
+175
-0
@@ -0,0 +1,175 @@
| 1 | +package fr.ebii.card2vcf.ui.audio | |
| 2 | + | |
| 3 | +import android.Manifest | |
| 4 | +import android.app.Application | |
| 5 | +import androidx.compose.ui.test.assertIsDisplayed | |
| 6 | +import androidx.compose.ui.test.assertIsEnabled | |
| 7 | +import androidx.compose.ui.test.assertIsNotEnabled | |
| 8 | +import androidx.compose.ui.test.junit4.createComposeRule | |
| 9 | +import androidx.compose.ui.test.onAllNodesWithText | |
| 10 | +import androidx.compose.ui.test.onNodeWithText | |
| 11 | +import androidx.compose.ui.test.performClick | |
| 12 | +import androidx.test.core.app.ApplicationProvider | |
| 13 | +import kotlinx.coroutines.Dispatchers | |
| 14 | +import kotlinx.coroutines.ExperimentalCoroutinesApi | |
| 15 | +import kotlinx.coroutines.test.resetMain | |
| 16 | +import kotlinx.coroutines.test.setMain | |
| 17 | +import org.junit.After | |
| 18 | +import org.junit.Before | |
| 19 | +import org.junit.Rule | |
| 20 | +import fr.ebii.card2vcf.audio.EnregistreurAudio | |
| 21 | +import fr.ebii.card2vcf.audio.ResultatEnregistrement | |
| 22 | +import java.io.File | |
| 23 | +import org.junit.Test | |
| 24 | +import org.junit.runner.RunWith | |
| 25 | +import org.robolectric.RobolectricTestRunner | |
| 26 | +import org.robolectric.Shadows.shadowOf | |
| 27 | +import org.robolectric.annotation.Config | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Tests de rendu de [EnregistrementNoteSheet] et [EditionContent]. | |
| 31 | + * | |
| 32 | + * Ils couvrent le bug réel du 2026-09-15 : sans préremplissage du sujet, le bouton | |
| 33 | + * « Sauvegarder » restait désactivé sans explication et l'enregistrement était perdu à la | |
| 34 | + * fermeture de la feuille (5 fichiers WAV orphelins retrouvés sur l'appareil de test). | |
| 35 | + * | |
| 36 | + * La preuve de rougeur de chaque test est documentée sur le test lui-même. | |
| 37 | + */ | |
| 38 | +@OptIn(ExperimentalCoroutinesApi::class) | |
| 39 | +@RunWith(RobolectricTestRunner::class) | |
| 40 | +@Config(sdk = [31], qualifiers = "fr") | |
| 41 | +class EnregistrementNoteSheetTest { | |
| 42 | + | |
| 43 | + @get:Rule | |
| 44 | + val composeTestRule = createComposeRule() | |
| 45 | + | |
| 46 | + @Before | |
| 47 | + fun setUp() { | |
| 48 | + Dispatchers.setMain(Dispatchers.Unconfined) | |
| 49 | + } | |
| 50 | + | |
| 51 | + @After | |
| 52 | + fun tearDown() { | |
| 53 | + Dispatchers.resetMain() | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * L'avertissement "La transcription ne distingue pas les interlocuteurs" est affiché | |
| 58 | + * en permanence, quelle que soit la phase (y compris sans permission micro). | |
| 59 | + */ | |
| 60 | + @Test | |
| 61 | + fun avertissement_toujours_affiche() { | |
| 62 | + // Pas de permission micro → phase EN_ATTENTE_PERMISSION ; avertissement toujours visible | |
| 63 | + composeTestRule.setContent { | |
| 64 | + EnregistrementNoteSheet( | |
| 65 | + modeServeur = false, | |
| 66 | + voskDisponible = false, | |
| 67 | + onSauvegarder = { _, _, _ -> }, | |
| 68 | + onDismiss = {}, | |
| 69 | + // AudioRecord n'a aucun matériel derrière lui sous Robolectric : sans ce | |
| 70 | + // faux, l'écran resterait bloqué en phase « enregistrement en cours ». | |
| 71 | + enregistreurFactory = { FauxEnregistreur() }, | |
| 72 | + ) | |
| 73 | + } | |
| 74 | + | |
| 75 | + composeTestRule.waitUntil(5_000) { | |
| 76 | + composeTestRule.onAllNodesWithText("La transcription ne distingue pas les interlocuteurs") | |
| 77 | + .fetchSemanticsNodes().isNotEmpty() | |
| 78 | + } | |
| 79 | + | |
| 80 | + composeTestRule.onNodeWithText("La transcription ne distingue pas les interlocuteurs") | |
| 81 | + .assertIsDisplayed() | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Le sujet prérempli par [genererSujetParDefaut] (« Notes du … ») rend le bouton | |
| 86 | + * « Sauvegarder » actif : c'est ce qui empêche la perte d'un enregistrement. | |
| 87 | + * | |
| 88 | + * Preuve de rougeur : remplacer l'argument `sujet` par `""` (état d'avant le | |
| 89 | + * préremplissage) → `assertIsEnabled` échoue avec | |
| 90 | + * « Failed to assert the following: (is enabled) ». | |
| 91 | + * | |
| 92 | + * Limite assumée : la transition complète EN_COURS → EDITION n'est pas rejouable sous | |
| 93 | + * Robolectric. Le `onStop` enchaîne `withContext(Dispatchers.IO)` puis un retour sur le | |
| 94 | + * dispatcher Compose, pendant qu'une boucle `delay(100)` de minuterie tourne encore : | |
| 95 | + * l'horloge de test n'atteint jamais l'état d'inactivité et `waitUntil` expire. On teste | |
| 96 | + * donc l'état d'édition tel qu'il est construit, avec le vrai générateur de sujet. | |
| 97 | + */ | |
| 98 | + @Test | |
| 99 | + fun bouton_sauvegarder_actif_avec_sujet_prerempli() { | |
| 100 | + val sujetParDefaut = genererSujetParDefaut("Notes du %1\$s", maintenant = 1_789_500_000_000L) | |
| 101 | + | |
| 102 | + composeTestRule.setContent { | |
| 103 | + EditionContent( | |
| 104 | + sujet = sujetParDefaut, | |
| 105 | + texteTranscrit = "", | |
| 106 | + modeServeur = false, | |
| 107 | + onSujetChange = {}, | |
| 108 | + onTexteChange = {}, | |
| 109 | + onSauvegarder = {}, | |
| 110 | + onAnnuler = {}, | |
| 111 | + ) | |
| 112 | + } | |
| 113 | + | |
| 114 | + assert(sujetParDefaut.isNotBlank()) { "le sujet par défaut ne doit jamais être vide" } | |
| 115 | + composeTestRule.onNodeWithText("Sauvegarder").assertIsEnabled() | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * Vérifie que [EditionContent] désactive le bouton « Sauvegarder » quand le sujet | |
| 120 | + * est vide — c'est l'état réel sans préremplissage (le bug original). | |
| 121 | + */ | |
| 122 | + @Test | |
| 123 | + fun bouton_sauvegarder_desactive_si_sujet_vide() { | |
| 124 | + composeTestRule.setContent { | |
| 125 | + EditionContent( | |
| 126 | + sujet = "", | |
| 127 | + texteTranscrit = "", | |
| 128 | + modeServeur = false, | |
| 129 | + onSujetChange = {}, | |
| 130 | + onTexteChange = {}, | |
| 131 | + onSauvegarder = {}, | |
| 132 | + onAnnuler = {}, | |
| 133 | + ) | |
| 134 | + } | |
| 135 | + | |
| 136 | + composeTestRule.onNodeWithText("Sauvegarder").assertIsNotEnabled() | |
| 137 | + } | |
| 138 | + | |
| 139 | + /** | |
| 140 | + * Vérifie que le bouton « Annuler » appelle bien [onAnnuler]. | |
| 141 | + */ | |
| 142 | + @Test | |
| 143 | + fun bouton_annuler_appelle_onAnnuler() { | |
| 144 | + var annule = false | |
| 145 | + composeTestRule.setContent { | |
| 146 | + EditionContent( | |
| 147 | + sujet = "Test", | |
| 148 | + texteTranscrit = "", | |
| 149 | + modeServeur = false, | |
| 150 | + onSujetChange = {}, | |
| 151 | + onTexteChange = {}, | |
| 152 | + onSauvegarder = {}, | |
| 153 | + onAnnuler = { annule = true }, | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + | |
| 157 | + composeTestRule.onNodeWithText("Annuler").performClick() | |
| 158 | + | |
| 159 | + assert(annule) { "onAnnuler doit être appelé au clic du bouton Annuler" } | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +/** | |
| 164 | + * Enregistreur de test : ne touche à aucun matériel et rend immédiatement un résultat, | |
| 165 | + * ce qui permet à la feuille de passer en phase d'édition sous Robolectric. | |
| 166 | + */ | |
| 167 | +private class FauxEnregistreur : EnregistreurAudio { | |
| 168 | + override fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) { | |
| 169 | + fichierCible.parentFile?.mkdirs() | |
| 170 | + fichierCible.writeBytes(ByteArray(0)) | |
| 171 | + } | |
| 172 | + | |
| 173 | + override fun arreter(): ResultatEnregistrement? = | |
| 174 | + ResultatEnregistrement(chemin = "/tmp/faux-note-vocale.wav", dureeMsec = 1_500L) | |
| 175 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreenTest.kt
+175
-0
@@ -0,0 +1,175 @@
| 1 | +package fr.ebii.card2vcf.ui.contact | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import androidx.compose.ui.test.assertCountEquals | |
| 5 | +import androidx.compose.ui.test.assertIsDisplayed | |
| 6 | +import androidx.compose.ui.test.junit4.createComposeRule | |
| 7 | +import androidx.compose.ui.test.onAllNodesWithText | |
| 8 | +import androidx.compose.ui.test.onNodeWithText | |
| 9 | +import androidx.room.Room | |
| 10 | +import androidx.test.core.app.ApplicationProvider | |
| 11 | +import fr.ebii.card2vcf.crm.ContactSort | |
| 12 | +import fr.ebii.card2vcf.data.AudioNoteStore | |
| 13 | +import fr.ebii.card2vcf.data.ContactImageStore | |
| 14 | +import fr.ebii.card2vcf.data.ContactRepository | |
| 15 | +import fr.ebii.card2vcf.data.CrmContactEntity | |
| 16 | +import fr.ebii.card2vcf.data.CrmDatabase | |
| 17 | +import fr.ebii.card2vcf.sync.SyncCredentialsStore | |
| 18 | +import kotlinx.coroutines.Dispatchers | |
| 19 | +import kotlinx.coroutines.ExperimentalCoroutinesApi | |
| 20 | +import kotlinx.coroutines.runBlocking | |
| 21 | +import kotlinx.coroutines.test.resetMain | |
| 22 | +import kotlinx.coroutines.test.setMain | |
| 23 | +import org.junit.After | |
| 24 | +import org.junit.Before | |
| 25 | +import org.junit.Rule | |
| 26 | +import org.junit.Test | |
| 27 | +import org.junit.runner.RunWith | |
| 28 | +import org.robolectric.RobolectricTestRunner | |
| 29 | +import org.robolectric.annotation.Config | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Tests de rendu de [ContactPagerScreen] — preuves de rougeur : | |
| 33 | + * | |
| 34 | + * Preuve de rougeur exécutée le 2026-09-18 : en neutralisant le `scrollToPage(page)` du | |
| 35 | + * LaunchedEffect de [ContactPagerScreen] (le pager reste alors figé sur la page 0, état | |
| 36 | + * exact de la régression du commit 068f21d), la suite tombe à : | |
| 37 | + * | |
| 38 | + * ContactPagerScreenTest > pager_liste_vide_puis_chargee_positionne_contact_demande FAILED | |
| 39 | + * ContactPagerScreenTest > pager_ne_revient_pas_sur_premier_contact_apres_rafraichissement FAILED | |
| 40 | + * 2 tests completed, 2 failed | |
| 41 | + * | |
| 42 | + * Ces deux tests détectent donc réellement le bug — contrairement au test de la fonction | |
| 43 | + * pure `pageCible`, qui restait vert puisque la faute vit dans le cycle de vie de | |
| 44 | + * `rememberPagerState`, pas dans le calcul de l'index. | |
| 45 | + */ | |
| 46 | +@OptIn(ExperimentalCoroutinesApi::class) | |
| 47 | +@RunWith(RobolectricTestRunner::class) | |
| 48 | +@Config(sdk = [31]) | |
| 49 | +class ContactPagerScreenTest { | |
| 50 | + | |
| 51 | + @get:Rule | |
| 52 | + val composeTestRule = createComposeRule() | |
| 53 | + | |
| 54 | + private lateinit var db: CrmDatabase | |
| 55 | + private lateinit var repository: ContactRepository | |
| 56 | + private lateinit var credentialsStore: SyncCredentialsStore | |
| 57 | + private lateinit var audioStore: AudioNoteStore | |
| 58 | + | |
| 59 | + @Before | |
| 60 | + fun setUp() { | |
| 61 | + Dispatchers.setMain(Dispatchers.Unconfined) | |
| 62 | + val ctx = ApplicationProvider.getApplicationContext<Context>() | |
| 63 | + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) | |
| 64 | + .allowMainThreadQueries() | |
| 65 | + .setQueryExecutor { it.run() } | |
| 66 | + .setTransactionExecutor { it.run() } | |
| 67 | + .build() | |
| 68 | + repository = ContactRepository( | |
| 69 | + dao = db.crmContactDao(), | |
| 70 | + images = ContactImageStore(ctx), | |
| 71 | + syncOpDao = db.syncOpDao(), | |
| 72 | + ) | |
| 73 | + credentialsStore = SyncCredentialsStore( | |
| 74 | + ctx.getSharedPreferences("pager_screen_test", Context.MODE_PRIVATE), | |
| 75 | + ) | |
| 76 | + audioStore = AudioNoteStore(ctx) | |
| 77 | + } | |
| 78 | + | |
| 79 | + @After | |
| 80 | + fun tearDown() { | |
| 81 | + db.close() | |
| 82 | + Dispatchers.resetMain() | |
| 83 | + } | |
| 84 | + | |
| 85 | + private fun viewModel(initialContactId: Long) = ContactPagerViewModel( | |
| 86 | + repository = repository, | |
| 87 | + database = db, | |
| 88 | + credentialsStore = credentialsStore, | |
| 89 | + audioStore = audioStore, | |
| 90 | + initialContactId = initialContactId, | |
| 91 | + sort = ContactSort.LAST_NAME, | |
| 92 | + engineFabrique = { null }, | |
| 93 | + ) | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * Scénario "flux vide puis rempli" : le pager doit afficher la fiche demandée | |
| 97 | + * (Zorro, page 1) et non la première de la liste (Alice, page 0). | |
| 98 | + */ | |
| 99 | + @Test | |
| 100 | + fun pager_liste_vide_puis_chargee_positionne_contact_demande() { | |
| 101 | + // id=2 → Zorro (tri lastName : "Alpha" < "Zeta" → Alice page 0, Zorro page 1) | |
| 102 | + val vm = viewModel(initialContactId = 2L) | |
| 103 | + | |
| 104 | + composeTestRule.setContent { | |
| 105 | + ContactPagerScreen( | |
| 106 | + viewModel = vm, | |
| 107 | + onBack = {}, | |
| 108 | + onEdit = {}, | |
| 109 | + onDuplicates = {}, | |
| 110 | + modeServeur = false, | |
| 111 | + voskDisponible = false, | |
| 112 | + ) | |
| 113 | + } | |
| 114 | + | |
| 115 | + // Initialement vide — aucun contact affiché | |
| 116 | + composeTestRule.onAllNodesWithText("Zorro Zeta").assertCountEquals(0) | |
| 117 | + | |
| 118 | + // Chargement : Room émet → LaunchedEffect scrolle vers la page de Zorro | |
| 119 | + runBlocking { | |
| 120 | + db.crmContactDao().insert(CrmContactEntity(id = 1L, fullName = "Alice Alpha", lastName = "Alpha")) | |
| 121 | + db.crmContactDao().insert(CrmContactEntity(id = 2L, fullName = "Zorro Zeta", lastName = "Zeta")) | |
| 122 | + } | |
| 123 | + | |
| 124 | + composeTestRule.waitUntil(5_000) { | |
| 125 | + composeTestRule.onAllNodesWithText("Zorro Zeta").fetchSemanticsNodes().isNotEmpty() | |
| 126 | + } | |
| 127 | + | |
| 128 | + composeTestRule.onNodeWithText("Zorro Zeta").assertIsDisplayed() | |
| 129 | + composeTestRule.onAllNodesWithText("Alice Alpha").assertCountEquals(0) | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** | |
| 133 | + * Après positionnement initial sur Zorro, un rafraîchissement de la liste | |
| 134 | + * (ajout d'un troisième contact) ne doit PAS ramener le pager sur Alice. | |
| 135 | + * Le drapeau `positionne` bloque le re-scroll. | |
| 136 | + */ | |
| 137 | + @Test | |
| 138 | + fun pager_ne_revient_pas_sur_premier_contact_apres_rafraichissement() { | |
| 139 | + runBlocking { | |
| 140 | + db.crmContactDao().insert(CrmContactEntity(id = 1L, fullName = "Alice Alpha", lastName = "Alpha")) | |
| 141 | + db.crmContactDao().insert(CrmContactEntity(id = 2L, fullName = "Zorro Zeta", lastName = "Zeta")) | |
| 142 | + } | |
| 143 | + | |
| 144 | + val vm = viewModel(initialContactId = 2L) | |
| 145 | + | |
| 146 | + composeTestRule.setContent { | |
| 147 | + ContactPagerScreen( | |
| 148 | + viewModel = vm, | |
| 149 | + onBack = {}, | |
| 150 | + onEdit = {}, | |
| 151 | + onDuplicates = {}, | |
| 152 | + modeServeur = false, | |
| 153 | + voskDisponible = false, | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + | |
| 157 | + // Positionnement initial sur Zorro | |
| 158 | + composeTestRule.waitUntil(5_000) { | |
| 159 | + composeTestRule.onAllNodesWithText("Zorro Zeta").fetchSemanticsNodes().isNotEmpty() | |
| 160 | + } | |
| 161 | + composeTestRule.onNodeWithText("Zorro Zeta").assertIsDisplayed() | |
| 162 | + | |
| 163 | + // Ajout d'un troisième contact → Room émet une nouvelle liste | |
| 164 | + runBlocking { | |
| 165 | + db.crmContactDao().insert(CrmContactEntity(id = 3L, fullName = "Michel Moyen", lastName = "Moyen")) | |
| 166 | + } | |
| 167 | + composeTestRule.waitForIdle() | |
| 168 | + | |
| 169 | + // Michel s'insère entre Alice (Alpha) et Zorro (Zeta) → Zorro passe à la page 2. | |
| 170 | + // Le drapeau `positionne = true` empêche le re-scroll vers Zorro : | |
| 171 | + // le pager reste sur la page 1 qui affiche maintenant Michel. | |
| 172 | + composeTestRule.onNodeWithText("Michel Moyen").assertIsDisplayed() | |
| 173 | + composeTestRule.onAllNodesWithText("Zorro Zeta").assertCountEquals(0) | |
| 174 | + } | |
| 175 | +} |
M
android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+3
-54
@@ -3,10 +3,10 @@ package fr.ebii.card2vcf.ui.settings
| 3 | 3 | import android.content.Context |
| 4 | 4 | import androidx.test.core.app.ApplicationProvider |
| 5 | 5 | import fr.ebii.card2vcf.sync.AgendaSyncCoordinator |
| 6 | -import fr.ebii.card2vcf.sync.AilianceApi | |
| 7 | 6 | import fr.ebii.card2vcf.sync.AilianceApiClient |
| 8 | 7 | import fr.ebii.card2vcf.sync.AuthCleResponse |
| 9 | 8 | import fr.ebii.card2vcf.sync.CalendarBindingsStore |
| 9 | +import fr.ebii.card2vcf.sync.FakeAilianceApi | |
| 10 | 10 | import fr.ebii.card2vcf.sync.FakeCalendarBridge |
| 11 | 11 | import fr.ebii.card2vcf.sync.RessourceItemDto |
| 12 | 12 | import fr.ebii.card2vcf.sync.RessourcesCatalogueDto |
@@ -29,57 +29,6 @@ import org.junit.runner.RunWith
| 29 | 29 | import org.robolectric.RobolectricTestRunner |
| 30 | 30 | import org.robolectric.annotation.Config |
| 31 | 31 | |
| 32 | -/** Fake [AilianceApi] : `authCle` (login) et `getRessourcesCatalogue` (section calendriers) importent pour [SettingsViewModel]. */ | |
| 33 | -private class FakeAilianceApi( | |
| 34 | - private val authResult: AilianceApiClient.ApiResult<AuthCleResponse> = | |
| 35 | - AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"), | |
| 36 | - private val catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> = | |
| 37 | - AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()), | |
| 38 | -) : AilianceApi { | |
| 39 | - override fun authCle(nom: String, motDePasse: String) = authResult | |
| 40 | - override fun syncStatus(sinceIso: String, ressourcesQuery: String?) = error("not used") | |
| 41 | - override fun syncPull(sinceIso: String, ressourcesQuery: String?) = error("not used") | |
| 42 | - override fun createContact(jsonBody: String) = error("not used") | |
| 43 | - override fun updateContact(id: String, jsonBody: String) = error("not used") | |
| 44 | - override fun deleteContact(id: String) = error("not used") | |
| 45 | - override fun createEntreprise(jsonBody: String) = error("not used") | |
| 46 | - override fun updateEntreprise(id: String, jsonBody: String) = error("not used") | |
| 47 | - override fun deleteEntreprise(id: String) = error("not used") | |
| 48 | - override fun createProjet(jsonBody: String) = error("not used") | |
| 49 | - override fun updateProjet(id: String, jsonBody: String) = error("not used") | |
| 50 | - override fun deleteProjet(id: String) = error("not used") | |
| 51 | - override fun createTache(projetId: String, jsonBody: String) = error("not used") | |
| 52 | - override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = error("not used") | |
| 53 | - override fun deleteTache(projetId: String, tacheId: String) = error("not used") | |
| 54 | - override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used") | |
| 55 | - override fun createInteraction(contactId: String, jsonBody: String) = error("not used") | |
| 56 | - override fun deleteInteraction(iid: String) = error("not used") | |
| 57 | - override fun relancerTranscriptionInteraction(contactServerId: String, iid: String) = error("not used") | |
| 58 | - override fun relancerTranscriptionNote(projetServerId: String, nid: String) = error("not used") | |
| 59 | - override fun listRdv() = error("not used") | |
| 60 | - override fun createRdv(jsonBody: String) = error("not used") | |
| 61 | - override fun getRdv(id: String) = error("not used") | |
| 62 | - override fun updateRdv(id: String, jsonBody: String) = error("not used") | |
| 63 | - override fun deleteRdv(id: String) = error("not used") | |
| 64 | - override fun getRessourcesCatalogue() = catalogueResult | |
| 65 | - override fun listReservations(genre: String, resourceId: String) = error("not used") | |
| 66 | - override fun createReservation(genre: String, resourceId: String, jsonBody: String) = error("not used") | |
| 67 | - override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) = error("not used") | |
| 68 | - override fun deleteReservation(genre: String, resourceId: String, reservationId: String) = error("not used") | |
| 69 | - override fun listIndisponibilites(genre: String, resourceId: String) = error("not used") | |
| 70 | - override fun uploadContactCarte(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 71 | - override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 72 | - override fun downloadContactCarte(id: String) = error("not used") | |
| 73 | - override fun downloadContactPhoto(id: String) = error("not used") | |
| 74 | - override fun uploadInteractionAudio(contactId: String, iid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 75 | - override fun downloadInteractionAudio(contactId: String, iid: String) = error("not used") | |
| 76 | - override fun createNoteProjet(projetId: String, jsonBody: String) = error("not used") | |
| 77 | - override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String) = error("not used") | |
| 78 | - override fun deleteNoteProjet(projetId: String, nid: String) = error("not used") | |
| 79 | - override fun uploadNoteAudio(projetId: String, nid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used") | |
| 80 | - override fun downloadNoteAudio(projetId: String, nid: String) = error("not used") | |
| 81 | -} | |
| 82 | - | |
| 83 | 32 | @OptIn(ExperimentalCoroutinesApi::class) |
| 84 | 33 | @RunWith(RobolectricTestRunner::class) |
| 85 | 34 | @Config(sdk = [31]) |
@@ -114,8 +63,8 @@ class SettingsViewModelTest {
| 114 | 63 | credentialsStore = store, |
| 115 | 64 | calendarBindingsStore = calendarBindingsStore, |
| 116 | 65 | calendarBridge = calendarBridge, |
| 117 | - apiFactory = { FakeAilianceApi(authResult = authResult) }, | |
| 118 | - authenticatedApiFactory = { _, _ -> FakeAilianceApi(catalogueResult = catalogueResult) }, | |
| 66 | + apiFactory = { FakeAilianceApi().also { it.authCleOverride = authResult } }, | |
| 67 | + authenticatedApiFactory = { _, _ -> FakeAilianceApi().also { it.getRessourcesCatalogueOverride = catalogueResult } }, | |
| 119 | 68 | ioDispatcher = testDispatcher, |
| 120 | 69 | ) |
| 121 | 70 |
A
android/app/src/test/resources/contrats/interaction_note_vocale.json
+16
-0
@@ -0,0 +1,16 @@
| 1 | +{ | |
| 2 | + "id": "interaction-vocale-contrat-1", | |
| 3 | + "contact_id": "contact-contrat-1", | |
| 4 | + "type_interaction": "note_vocale", | |
| 5 | + "sujet": "Note vocale contrat", | |
| 6 | + "description": "", | |
| 7 | + "statut": "fait", | |
| 8 | + "prevu_le": null, | |
| 9 | + "fait_le": null, | |
| 10 | + "piece_jointe": "vocale_contrat_1.wav", | |
| 11 | + "cree_par": "alice", | |
| 12 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 13 | + "mis_a_jour_le": null, | |
| 14 | + "transcription": "echec", | |
| 15 | + "transcription_erreur": "Modèle ASR indisponible" | |
| 16 | +} | |
| 16 | < \ No newline at end of file |
A
android/app/src/test/resources/contrats/note_projet.json
+12
-0
@@ -0,0 +1,12 @@
| 1 | +{ | |
| 2 | + "id": "note-projet-contrat-1", | |
| 3 | + "projet_id": "projet-contrat-1", | |
| 4 | + "titre": "Note vocale projet contrat", | |
| 5 | + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n", | |
| 6 | + "auteur": "alice", | |
| 7 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 8 | + "maj_le": null, | |
| 9 | + "audio": "note_contrat_1.wav", | |
| 10 | + "transcription": "terminee", | |
| 11 | + "transcription_erreur": null | |
| 12 | +} | |
| 12 | < \ No newline at end of file |
A
android/app/src/test/resources/contrats/sync_pull.json
+140
-0
@@ -0,0 +1,140 @@
| 1 | +{ | |
| 2 | + "server_time": "2026-09-18T08:42:51.508932169Z", | |
| 3 | + "contacts": [ | |
| 4 | + { | |
| 5 | + "id": "contact-contrat-1", | |
| 6 | + "prenom": "Ada", | |
| 7 | + "nom": "Lovelace", | |
| 8 | + "entreprise_id": null, | |
| 9 | + "fonction": "", | |
| 10 | + "emails": [], | |
| 11 | + "telephones": [], | |
| 12 | + "adresses": [], | |
| 13 | + "statut": "prospect", | |
| 14 | + "etape": "nouveau", | |
| 15 | + "date_rencontre": null, | |
| 16 | + "notes": "", | |
| 17 | + "photo": null, | |
| 18 | + "carte_visite": null, | |
| 19 | + "cree_par": "alice", | |
| 20 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 21 | + "derniere_action": null, | |
| 22 | + "tags": [], | |
| 23 | + "mis_a_jour_le": null, | |
| 24 | + "civilite": "", | |
| 25 | + "date_naissance": null, | |
| 26 | + "site_web": "", | |
| 27 | + "profils_sociaux": [] | |
| 28 | + } | |
| 29 | + ], | |
| 30 | + "entreprises": [], | |
| 31 | + "projets": [ | |
| 32 | + { | |
| 33 | + "id": "projet-contrat-1", | |
| 34 | + "nom": "Projet contrat", | |
| 35 | + "description": "", | |
| 36 | + "workflow_id": "veille", | |
| 37 | + "cree_par": "alice", | |
| 38 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 39 | + "derniere_action": null, | |
| 40 | + "taches": [], | |
| 41 | + "notes": [ | |
| 42 | + { | |
| 43 | + "id": "note-projet-contrat-1", | |
| 44 | + "titre": "Note vocale projet contrat", | |
| 45 | + "fichier": "note-contrat-1.md", | |
| 46 | + "ordre": 0, | |
| 47 | + "auteur": "alice", | |
| 48 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 49 | + "maj_le": null, | |
| 50 | + "audio": "note_contrat_1.wav", | |
| 51 | + "transcription": "terminee", | |
| 52 | + "transcription_erreur": null | |
| 53 | + } | |
| 54 | + ], | |
| 55 | + "liens": [], | |
| 56 | + "fichiers": [], | |
| 57 | + "apercu_liens_riche": true, | |
| 58 | + "gestion_contacts": false, | |
| 59 | + "contacts_lies": [], | |
| 60 | + "entreprises_liees": [], | |
| 61 | + "membres": [], | |
| 62 | + "mis_a_jour_le": null | |
| 63 | + } | |
| 64 | + ], | |
| 65 | + "taches": [], | |
| 66 | + "interactions": [ | |
| 67 | + { | |
| 68 | + "id": "interaction-vocale-contrat-1", | |
| 69 | + "contact_id": "contact-contrat-1", | |
| 70 | + "type_interaction": "note_vocale", | |
| 71 | + "sujet": "Note vocale contrat", | |
| 72 | + "description": "", | |
| 73 | + "statut": "fait", | |
| 74 | + "prevu_le": null, | |
| 75 | + "fait_le": null, | |
| 76 | + "piece_jointe": "vocale_contrat_1.wav", | |
| 77 | + "cree_par": "alice", | |
| 78 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 79 | + "mis_a_jour_le": null, | |
| 80 | + "transcription": "echec", | |
| 81 | + "transcription_erreur": "Modèle ASR indisponible" | |
| 82 | + } | |
| 83 | + ], | |
| 84 | + "notes": [ | |
| 85 | + { | |
| 86 | + "id": "note-projet-contrat-1", | |
| 87 | + "projet_id": "projet-contrat-1", | |
| 88 | + "titre": "Note vocale projet contrat", | |
| 89 | + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n", | |
| 90 | + "auteur": "alice", | |
| 91 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 92 | + "maj_le": null, | |
| 93 | + "audio": "note_contrat_1.wav", | |
| 94 | + "transcription": "terminee", | |
| 95 | + "transcription_erreur": null | |
| 96 | + } | |
| 97 | + ], | |
| 98 | + "rdv": [], | |
| 99 | + "reservations": [], | |
| 100 | + "indisponibilites": [], | |
| 101 | + "tombstones": [], | |
| 102 | + "workflows": [ | |
| 103 | + { | |
| 104 | + "id": "veille", | |
| 105 | + "nom": "Veille", | |
| 106 | + "description": "Suivi de sujets à explorer et synthétiser", | |
| 107 | + "colonnes": [ | |
| 108 | + { | |
| 109 | + "id": "explorer", | |
| 110 | + "nom": "À explorer", | |
| 111 | + "ordre": 0 | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "id": "lecture", | |
| 115 | + "nom": "En lecture", | |
| 116 | + "ordre": 1 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "id": "synthetiser", | |
| 120 | + "nom": "À synthétiser", | |
| 121 | + "ordre": 2 | |
| 122 | + }, | |
| 123 | + { | |
| 124 | + "id": "synthetise", | |
| 125 | + "nom": "Synthétisé", | |
| 126 | + "ordre": 3 | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "id": "archive", | |
| 130 | + "nom": "Archive", | |
| 131 | + "ordre": 4 | |
| 132 | + } | |
| 133 | + ], | |
| 134 | + "personnalise": false, | |
| 135 | + "gestion_contacts": false, | |
| 136 | + "cree_par": "systeme", | |
| 137 | + "cree_le": "2026-09-18T08:42:51.507116089Z" | |
| 138 | + } | |
| 139 | + ] | |
| 140 | +} | |
| 140 | < \ No newline at end of file |
A
ios/Card2vcf/Audio/AudioNoteRecorder.swift
+100
-0
@@ -0,0 +1,100 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// Résultat d'un enregistrement terminé. | |
| 4 | +struct ResultatEnregistrement { | |
| 5 | + let chemin: String | |
| 6 | + let dureeMsec: Int64 | |
| 7 | +} | |
| 8 | + | |
| 9 | +/// Erreurs de AudioNoteRecorder. | |
| 10 | +enum AudioNoteRecorderErreur: Error, Equatable { | |
| 11 | + case enregistrementDejaEnCours | |
| 12 | +} | |
| 13 | + | |
| 14 | +/// Enregistre un fichier WAV (PCM 16 kHz mono 16 bits) via un MoteurAudioCapture. | |
| 15 | +/// | |
| 16 | +/// Le moteur audio est injecté par le constructeur (MoteurAudioEngine par défaut), | |
| 17 | +/// ce qui permet d'utiliser un faux en test sans accès au matériel audio. | |
| 18 | +/// | |
| 19 | +/// Durée maximale : 10 minutes avec arrêt automatique. | |
| 20 | +/// | |
| 21 | +/// API : | |
| 22 | +/// - `demarrer(fichierCible:transcripteur:)` — lance l'enregistrement. | |
| 23 | +/// - `arreter()` — stoppe et retourne le résultat. | |
| 24 | +final class AudioNoteRecorder { | |
| 25 | + private let moteur: MoteurAudioCapture | |
| 26 | + private let dureeMaxSecondes: Double | |
| 27 | + | |
| 28 | + private var enregistrementEnCours = false | |
| 29 | + private var ecriture: EcritureWav? | |
| 30 | + private var fichierCourant: URL? | |
| 31 | + private var transcripteurCourant: (any TranscripteurLocal)? | |
| 32 | + private var debutEnregistrement: Date? | |
| 33 | + private var timerArret: DispatchWorkItem? | |
| 34 | + | |
| 35 | + init(moteur: MoteurAudioCapture = MoteurAudioEngine(), | |
| 36 | + dureeMaxSecondes: Double = 10 * 60) { | |
| 37 | + self.moteur = moteur | |
| 38 | + self.dureeMaxSecondes = dureeMaxSecondes | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// `true` si un enregistrement est en cours. | |
| 42 | + var estEnCours: Bool { enregistrementEnCours } | |
| 43 | + | |
| 44 | + // MARK: - API publique | |
| 45 | + | |
| 46 | + /// Démarre l'enregistrement vers `fichierCible`. | |
| 47 | + /// `transcripteur` est alimenté en streaming avec chaque buffer capturé. | |
| 48 | + /// - Throws: `AudioNoteRecorderErreur.enregistrementDejaEnCours` si déjà actif. | |
| 49 | + func demarrer(fichierCible: URL, transcripteur: (any TranscripteurLocal)? = nil) throws { | |
| 50 | + guard !enregistrementEnCours else { throw AudioNoteRecorderErreur.enregistrementDejaEnCours } | |
| 51 | + enregistrementEnCours = true | |
| 52 | + debutEnregistrement = Date() | |
| 53 | + fichierCourant = fichierCible | |
| 54 | + transcripteurCourant = transcripteur | |
| 55 | + ecriture = try EcritureWav(url: fichierCible) | |
| 56 | + | |
| 57 | + // Arrêt automatique après dureeMaxSecondes | |
| 58 | + let arret = DispatchWorkItem { [weak self] in | |
| 59 | + self?.arreter() | |
| 60 | + } | |
| 61 | + timerArret = arret | |
| 62 | + DispatchQueue.main.asyncAfter(deadline: .now() + dureeMaxSecondes, execute: arret) | |
| 63 | + | |
| 64 | + do { | |
| 65 | + try moteur.demarrer { [weak self] echantillons in | |
| 66 | + self?.traiterEchantillons(echantillons) | |
| 67 | + } | |
| 68 | + } catch { | |
| 69 | + // Rollback : le moteur n'a pas pu démarrer | |
| 70 | + enregistrementEnCours = false | |
| 71 | + timerArret?.cancel() | |
| 72 | + timerArret = nil | |
| 73 | + ecriture = nil | |
| 74 | + throw error | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// Stoppe l'enregistrement. | |
| 79 | + /// - Returns: Résultat avec chemin et durée, `nil` si pas d'enregistrement actif. | |
| 80 | + @discardableResult | |
| 81 | + func arreter() -> ResultatEnregistrement? { | |
| 82 | + guard enregistrementEnCours else { return nil } | |
| 83 | + enregistrementEnCours = false | |
| 84 | + timerArret?.cancel() | |
| 85 | + timerArret = nil | |
| 86 | + moteur.arreter() | |
| 87 | + let dureeMsec = Int64((Date().timeIntervalSince(debutEnregistrement ?? Date())) * 1000) | |
| 88 | + try? ecriture?.fermer() | |
| 89 | + ecriture = nil | |
| 90 | + guard let chemin = fichierCourant?.path else { return nil } | |
| 91 | + return ResultatEnregistrement(chemin: chemin, dureeMsec: dureeMsec) | |
| 92 | + } | |
| 93 | + | |
| 94 | + // MARK: - Traitement interne | |
| 95 | + | |
| 96 | + private func traiterEchantillons(_ echantillons: [Int16]) { | |
| 97 | + ecriture?.ecrireEchantillons(echantillons) | |
| 98 | + _ = transcripteurCourant?.accepterEchantillons(echantillons) | |
| 99 | + } | |
| 100 | +} |
A
ios/Card2vcf/Audio/EcritureWav.swift
+100
-0
@@ -0,0 +1,100 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// Écrit un fichier WAV PCM (16 kHz mono 16 bits par défaut). | |
| 4 | +/// | |
| 5 | +/// Séparée d'AudioNoteRecorder pour être testable sans dépendance AVFoundation. | |
| 6 | +/// L'en-tête RIFF/fmt/data est réécrit à la clôture avec les tailles exactes. | |
| 7 | +/// | |
| 8 | +/// Port direct de `EcritureWav.kt` (Android). | |
| 9 | +final class EcritureWav { | |
| 10 | + private let handle: FileHandle | |
| 11 | + private let frequence: Int | |
| 12 | + private var octetsData: Int64 = 0 | |
| 13 | + | |
| 14 | + init(url: URL, frequence: Int = 16_000) throws { | |
| 15 | + self.frequence = frequence | |
| 16 | + // Crée le fichier s'il n'existe pas encore | |
| 17 | + if !FileManager.default.fileExists(atPath: url.path) { | |
| 18 | + FileManager.default.createFile(atPath: url.path, contents: nil) | |
| 19 | + } | |
| 20 | + handle = try FileHandle(forWritingTo: url) | |
| 21 | + // Réserve 44 octets pour l'en-tête ; sera réécrit lors de fermer() | |
| 22 | + handle.write(Data(count: 44)) | |
| 23 | + } | |
| 24 | + | |
| 25 | + /// Écrit des échantillons PCM 16 bits signés (little-endian). | |
| 26 | + func ecrireEchantillons(_ data: [Int16]) { | |
| 27 | + guard !data.isEmpty else { return } | |
| 28 | + var bytes = Data(capacity: data.count * 2) | |
| 29 | + data.forEach { sample in | |
| 30 | + var le = sample.littleEndian | |
| 31 | + Swift.withUnsafeBytes(of: &le) { bytes.append(contentsOf: $0) } | |
| 32 | + } | |
| 33 | + handle.write(bytes) | |
| 34 | + octetsData += Int64(data.count * 2) | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// Finalise l'en-tête WAV et ferme le fichier. | |
| 38 | + /// - Returns: Nombre d'octets de données PCM écrits. | |
| 39 | + /// - Throws: `EcritureWavErreur.tailleTropGrande` si les données dépassent Int32.max. | |
| 40 | + @discardableResult | |
| 41 | + func fermer() throws -> Int64 { | |
| 42 | + // 10 min à 16 kHz 16 bits mono ≈ 18,3 Mo — largement sous Int32.max. | |
| 43 | + // La garde protège contre un enregistrement pathologiquement long ou un bug de comptage. | |
| 44 | + guard octetsData <= Int64(Int32.max) else { | |
| 45 | + handle.closeFile() | |
| 46 | + throw EcritureWavErreur.tailleTropGrande(octetsData) | |
| 47 | + } | |
| 48 | + let tailleData = Int32(octetsData) | |
| 49 | + // tailleFichier = octets après "RIFF"+int32 = 4(WAVE)+8(fmt tag+size)+16(fmt)+8(data tag+size)+data | |
| 50 | + let tailleFichier = tailleData + 36 | |
| 51 | + let canaux: Int16 = 1 | |
| 52 | + let bitsParEchantillon: Int16 = 16 | |
| 53 | + let byteRate = Int32(frequence) * Int32(canaux) * Int32(bitsParEchantillon) / 8 | |
| 54 | + let blockAlign = canaux * (bitsParEchantillon / 8) | |
| 55 | + | |
| 56 | + var entete = Data(capacity: 44) | |
| 57 | + entete.appendAscii("RIFF") | |
| 58 | + entete.appendInt32LE(tailleFichier) | |
| 59 | + entete.appendAscii("WAVE") | |
| 60 | + entete.appendAscii("fmt ") | |
| 61 | + entete.appendInt32LE(16) // taille du chunk fmt | |
| 62 | + entete.appendInt16LE(1) // format PCM | |
| 63 | + entete.appendInt16LE(canaux) | |
| 64 | + entete.appendInt32LE(Int32(frequence)) | |
| 65 | + entete.appendInt32LE(byteRate) | |
| 66 | + entete.appendInt16LE(blockAlign) | |
| 67 | + entete.appendInt16LE(bitsParEchantillon) | |
| 68 | + entete.appendAscii("data") | |
| 69 | + entete.appendInt32LE(tailleData) | |
| 70 | + | |
| 71 | + handle.seek(toFileOffset: 0) | |
| 72 | + handle.write(entete) | |
| 73 | + handle.closeFile() | |
| 74 | + return Int64(tailleData) | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +// MARK: - Erreurs | |
| 79 | + | |
| 80 | +enum EcritureWavErreur: Error, Equatable { | |
| 81 | + case tailleTropGrande(Int64) | |
| 82 | +} | |
| 83 | + | |
| 84 | +// MARK: - Helpers d'encodage little-endian | |
| 85 | + | |
| 86 | +private extension Data { | |
| 87 | + mutating func appendAscii(_ string: String) { | |
| 88 | + append(contentsOf: string.utf8) | |
| 89 | + } | |
| 90 | + | |
| 91 | + mutating func appendInt16LE(_ value: Int16) { | |
| 92 | + var le = value.littleEndian | |
| 93 | + Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) } | |
| 94 | + } | |
| 95 | + | |
| 96 | + mutating func appendInt32LE(_ value: Int32) { | |
| 97 | + var le = value.littleEndian | |
| 98 | + Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) } | |
| 99 | + } | |
| 100 | +} |
A
ios/Card2vcf/Audio/MoteurAudioCapture.swift
+94
-0
@@ -0,0 +1,94 @@
| 1 | +import AVFoundation | |
| 2 | + | |
| 3 | +/// Abstraction du moteur d'enregistrement audio. | |
| 4 | +/// | |
| 5 | +/// Isole AVAudioEngine pour rendre AudioNoteRecorder testable sans simulateur audio. | |
| 6 | +/// L'implémentation réelle est MoteurAudioEngine ; un faux est fourni dans les tests. | |
| 7 | +protocol MoteurAudioCapture: AnyObject { | |
| 8 | + /// Démarre la capture. | |
| 9 | + /// Le callback reçoit les échantillons PCM Int16 à 16 kHz mono déjà convertis. | |
| 10 | + /// - Throws: si le moteur ne peut pas démarrer (permission refusée, matériel absent). | |
| 11 | + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws | |
| 12 | + | |
| 13 | + /// Arrête la capture et libère les ressources. | |
| 14 | + func arreter() | |
| 15 | +} | |
| 16 | + | |
| 17 | +// MARK: - Implémentation réelle | |
| 18 | + | |
| 19 | +/// Implémentation de MoteurAudioCapture basée sur AVAudioEngine. | |
| 20 | +/// | |
| 21 | +/// Convertit automatiquement le format natif du matériel en PCM 16 kHz mono Int16 | |
| 22 | +/// via AVAudioConverter si nécessaire. | |
| 23 | +final class MoteurAudioEngine: MoteurAudioCapture { | |
| 24 | + private let engine = AVAudioEngine() | |
| 25 | + private let formatCible: AVAudioFormat | |
| 26 | + | |
| 27 | + init() { | |
| 28 | + formatCible = AVAudioFormat( | |
| 29 | + commonFormat: .pcmFormatInt16, | |
| 30 | + sampleRate: 16_000, | |
| 31 | + channels: 1, | |
| 32 | + interleaved: true | |
| 33 | + )! | |
| 34 | + } | |
| 35 | + | |
| 36 | + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws { | |
| 37 | + let inputNode = engine.inputNode | |
| 38 | + let formatEntree = inputNode.outputFormat(forBus: 0) | |
| 39 | + | |
| 40 | + guard let convertisseur = AVAudioConverter(from: formatEntree, to: formatCible) else { | |
| 41 | + throw MoteurAudioErreur.conversionImpossible | |
| 42 | + } | |
| 43 | + | |
| 44 | + let tailleBuffer: AVAudioFrameCount = 1_024 | |
| 45 | + inputNode.installTap(onBus: 0, bufferSize: tailleBuffer, format: formatEntree) { [weak self] buffer, _ in | |
| 46 | + guard let self else { return } | |
| 47 | + self.convertirEtEnvoyer(buffer: buffer, | |
| 48 | + formatEntree: formatEntree, | |
| 49 | + convertisseur: convertisseur, | |
| 50 | + onEchantillons: onEchantillons) | |
| 51 | + } | |
| 52 | + try engine.start() | |
| 53 | + } | |
| 54 | + | |
| 55 | + func arreter() { | |
| 56 | + engine.inputNode.removeTap(onBus: 0) | |
| 57 | + engine.stop() | |
| 58 | + } | |
| 59 | + | |
| 60 | + // MARK: - Conversion PCM | |
| 61 | + | |
| 62 | + private func convertirEtEnvoyer(buffer: AVAudioPCMBuffer, | |
| 63 | + formatEntree: AVAudioFormat, | |
| 64 | + convertisseur: AVAudioConverter, | |
| 65 | + onEchantillons: @escaping ([Int16]) -> Void) { | |
| 66 | + let frameCount = AVAudioFrameCount( | |
| 67 | + ceil(Double(buffer.frameLength) * formatCible.sampleRate / formatEntree.sampleRate) | |
| 68 | + ) | |
| 69 | + guard let sortie = AVAudioPCMBuffer(pcmFormat: formatCible, frameCapacity: frameCount) else { return } | |
| 70 | + | |
| 71 | + var erreurConversion: NSError? | |
| 72 | + var bufferFourni = false | |
| 73 | + let status = convertisseur.convert(to: sortie, error: &erreurConversion) { _, outStatus in | |
| 74 | + if bufferFourni { | |
| 75 | + outStatus.pointee = .noDataNow | |
| 76 | + return nil | |
| 77 | + } | |
| 78 | + bufferFourni = true | |
| 79 | + outStatus.pointee = .haveData | |
| 80 | + return buffer | |
| 81 | + } | |
| 82 | + guard status != .error, let ptr = sortie.int16ChannelData else { return } | |
| 83 | + let count = Int(sortie.frameLength) | |
| 84 | + guard count > 0 else { return } | |
| 85 | + let echantillons = Array(UnsafeBufferPointer(start: ptr[0], count: count)) | |
| 86 | + onEchantillons(echantillons) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +// MARK: - Erreurs | |
| 91 | + | |
| 92 | +enum MoteurAudioErreur: Error { | |
| 93 | + case conversionImpossible | |
| 94 | +} |
A
ios/Card2vcf/Audio/TranscripteurLocal.swift
+35
-0
@@ -0,0 +1,35 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// État du moteur de transcription locale. | |
| 4 | +enum EtatTranscripteur { | |
| 5 | + /// Le moteur est disponible et autorisé. | |
| 6 | + case disponible | |
| 7 | + /// Le moteur est absent, non autorisé, ou indisponible sur cet appareil. | |
| 8 | + case indisponible | |
| 9 | +} | |
| 10 | + | |
| 11 | +/// Interface de transcription locale (ASR sur l'appareil). | |
| 12 | +/// | |
| 13 | +/// Implémentée par `TranscripteurSpeech` (SFSpeechRecognizer on-device). | |
| 14 | +/// Un faux (`FakeTranscripteurLocal`) est fourni dans les tests. | |
| 15 | +/// | |
| 16 | +/// Quand SFSpeechRecognizer est indisponible ou que la permission est refusée, | |
| 17 | +/// `etat` vaut `.indisponible` et l'UI doit proposer le mode serveur. | |
| 18 | +/// | |
| 19 | +/// Port direct de `TranscripteurLocal.kt` (Android). | |
| 20 | +protocol TranscripteurLocal: AnyObject { | |
| 21 | + /// État courant du moteur de transcription. | |
| 22 | + var etat: EtatTranscripteur { get } | |
| 23 | + | |
| 24 | + /// Alimente le moteur avec un buffer PCM capturé par AudioNoteRecorder. | |
| 25 | + /// Appelé sur le thread de capture. | |
| 26 | + /// - Returns: Résultat partiel si disponible, `nil` sinon. | |
| 27 | + func accepterEchantillons(_ data: [Int16]) -> String? | |
| 28 | + | |
| 29 | + /// Retourne le résultat final après la fin de l'enregistrement. | |
| 30 | + /// - Returns: Le texte transcrit (peut être vide). | |
| 31 | + func finaliser() -> String | |
| 32 | + | |
| 33 | + /// Réinitialise l'état interne pour une nouvelle session d'enregistrement. | |
| 34 | + func reinitialiser() | |
| 35 | +} |
A
ios/Card2vcf/Audio/TranscripteurSpeech.swift
+99
-0
@@ -0,0 +1,99 @@
| 1 | +import AVFoundation | |
| 2 | +import Speech | |
| 3 | + | |
| 4 | +/// Transcription locale via SFSpeechRecognizer (on-device, fr_FR). | |
| 5 | +/// | |
| 6 | +/// `requiresOnDeviceRecognition = true` garantit que les données audio | |
| 7 | +/// ne quittent pas l'appareil. | |
| 8 | +/// | |
| 9 | +/// Gère gracieusement le refus de permission et l'indisponibilité du moteur : | |
| 10 | +/// `etat` vaut `.indisponible` dans ces cas, et `accepterEchantillons` retourne `nil`. | |
| 11 | +final class TranscripteurSpeech: TranscripteurLocal { | |
| 12 | + | |
| 13 | + private let reconnaissance: SFSpeechRecognizer? | |
| 14 | + private var requete: SFSpeechAudioBufferRecognitionRequest? | |
| 15 | + private var tache: SFSpeechRecognitionTask? | |
| 16 | + private var dernierPartiel: String = "" | |
| 17 | + private var texteFinal: String = "" | |
| 18 | + | |
| 19 | + // Float32 non-entrelacé : format le plus compatible avec SFSpeechRecognizer. | |
| 20 | + private let formatFloat: AVAudioFormat = AVAudioFormat( | |
| 21 | + commonFormat: .pcmFormatFloat32, | |
| 22 | + sampleRate: 16_000, | |
| 23 | + channels: 1, | |
| 24 | + interleaved: false | |
| 25 | + )! | |
| 26 | + | |
| 27 | + init() { | |
| 28 | + reconnaissance = SFSpeechRecognizer(locale: Locale(identifier: "fr_FR")) | |
| 29 | + reconnaissance?.defaultTaskHint = .dictation | |
| 30 | + } | |
| 31 | + | |
| 32 | + // MARK: - TranscripteurLocal | |
| 33 | + | |
| 34 | + var etat: EtatTranscripteur { | |
| 35 | + guard let r = reconnaissance, r.isAvailable else { return .indisponible } | |
| 36 | + guard SFSpeechRecognizer.authorizationStatus() == .authorized else { return .indisponible } | |
| 37 | + return .disponible | |
| 38 | + } | |
| 39 | + | |
| 40 | + func accepterEchantillons(_ data: [Int16]) -> String? { | |
| 41 | + guard etat == .disponible else { return nil } | |
| 42 | + | |
| 43 | + // Démarre la tâche de reconnaissance au premier appel de la session. | |
| 44 | + if requete == nil { | |
| 45 | + demarrerTache() | |
| 46 | + } | |
| 47 | + | |
| 48 | + if let buffer = creerBuffer(depuis: data) { | |
| 49 | + requete?.append(buffer) | |
| 50 | + } | |
| 51 | + | |
| 52 | + return dernierPartiel.isEmpty ? nil : dernierPartiel | |
| 53 | + } | |
| 54 | + | |
| 55 | + func finaliser() -> String { | |
| 56 | + requete?.endAudio() | |
| 57 | + return texteFinal.isEmpty ? dernierPartiel : texteFinal | |
| 58 | + } | |
| 59 | + | |
| 60 | + func reinitialiser() { | |
| 61 | + tache?.cancel() | |
| 62 | + tache = nil | |
| 63 | + requete = nil | |
| 64 | + dernierPartiel = "" | |
| 65 | + texteFinal = "" | |
| 66 | + } | |
| 67 | + | |
| 68 | + // MARK: - Privé | |
| 69 | + | |
| 70 | + private func demarrerTache() { | |
| 71 | + let req = SFSpeechAudioBufferRecognitionRequest() | |
| 72 | + req.requiresOnDeviceRecognition = true | |
| 73 | + req.shouldReportPartialResults = true | |
| 74 | + requete = req | |
| 75 | + | |
| 76 | + tache = reconnaissance?.recognitionTask(with: req) { [weak self] result, _ in | |
| 77 | + guard let self, let result else { return } | |
| 78 | + let texte = result.bestTranscription.formattedString | |
| 79 | + if result.isFinal { | |
| 80 | + self.texteFinal = texte | |
| 81 | + self.dernierPartiel = "" | |
| 82 | + } else { | |
| 83 | + self.dernierPartiel = texte | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + /// Convertit un tableau Int16 en AVAudioPCMBuffer Float32 pour SFSpeechRecognizer. | |
| 89 | + private func creerBuffer(depuis data: [Int16]) -> AVAudioPCMBuffer? { | |
| 90 | + let frameCount = AVAudioFrameCount(data.count) | |
| 91 | + guard let buffer = AVAudioPCMBuffer(pcmFormat: formatFloat, frameCapacity: frameCount), | |
| 92 | + let channelData = buffer.floatChannelData else { return nil } | |
| 93 | + buffer.frameLength = frameCount | |
| 94 | + for (i, sample) in data.enumerated() { | |
| 95 | + channelData[0][i] = Float(sample) / 32_768.0 | |
| 96 | + } | |
| 97 | + return buffer | |
| 98 | + } | |
| 99 | +} |
A
ios/Card2vcf/Data/Audio/AudioNoteStore.swift
+46
-0
@@ -0,0 +1,46 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// Stockage stable des fichiers WAV pour les notes vocales (interactions et notes de projet). | |
| 4 | +/// Analogue à `AudioNoteStore` Android : les fichiers sont rangés sous `Application Support/audio/` | |
| 5 | +/// (pas dans un dossier temporaire purgeable), identifiés par le `localId` de l'entité. | |
| 6 | +struct AudioNoteStore { | |
| 7 | + let rootDirectory: URL | |
| 8 | + | |
| 9 | + /// Initialise le store sur un dossier existant ou à créer. | |
| 10 | + init(rootDirectory: URL) { | |
| 11 | + self.rootDirectory = rootDirectory | |
| 12 | + } | |
| 13 | + | |
| 14 | + /// Store par défaut sous `Application Support/audio/`. | |
| 15 | + static func defaultStore() throws -> AudioNoteStore { | |
| 16 | + let appSupport = try FileManager.default.url( | |
| 17 | + for: .applicationSupportDirectory, | |
| 18 | + in: .userDomainMask, | |
| 19 | + appropriateFor: nil, | |
| 20 | + create: true | |
| 21 | + ) | |
| 22 | + let dir = appSupport.appendingPathComponent("audio", isDirectory: true) | |
| 23 | + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 24 | + return AudioNoteStore(rootDirectory: dir) | |
| 25 | + } | |
| 26 | + | |
| 27 | + // MARK: - Chemins stables | |
| 28 | + | |
| 29 | + func pathForInteraction(localId: Int64) -> String { | |
| 30 | + rootDirectory.appendingPathComponent("interaction_\(localId).wav").path | |
| 31 | + } | |
| 32 | + | |
| 33 | + func pathForNote(localId: Int64) -> String { | |
| 34 | + rootDirectory.appendingPathComponent("note_\(localId).wav").path | |
| 35 | + } | |
| 36 | + | |
| 37 | + // MARK: - Lecture / suppression | |
| 38 | + | |
| 39 | + func readBytes(atPath path: String) -> Data? { | |
| 40 | + try? Data(contentsOf: URL(fileURLWithPath: path)) | |
| 41 | + } | |
| 42 | + | |
| 43 | + func deleteFile(atPath path: String) { | |
| 44 | + try? FileManager.default.removeItem(atPath: path) | |
| 45 | + } | |
| 46 | +} |
M
ios/Card2vcf/Data/InteractionDao.swift
+6
-0
@@ -9,6 +9,9 @@ protocol InteractionDao {
| 9 | 9 | /// SELECT * FROM interactions WHERE serverId = :serverId |
| 10 | 10 | func getByServerId(_ serverId: String) async throws -> InteractionEntity? |
| 11 | 11 | |
| 12 | + /// SELECT * FROM interactions WHERE localId = :localId | |
| 13 | + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity? | |
| 14 | + | |
| 12 | 15 | /// DELETE FROM interactions WHERE serverId = :serverId |
| 13 | 16 | func deleteByServerId(_ serverId: String) async throws |
| 14 | 17 |
@@ -19,6 +22,9 @@ protocol InteractionDao {
| 19 | 22 | /// SELECT * FROM interactions WHERE contactServerId = :contactServerId ORDER BY createdAt DESC |
| 20 | 23 | func fetchByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] |
| 21 | 24 | |
| 25 | + /// DELETE FROM interactions WHERE localId = :localId | |
| 26 | + func deleteByLocalId(_ localId: Int64) async throws | |
| 27 | + | |
| 22 | 28 | /// SELECT * FROM interactions |
| 23 | 29 | func listAll() async throws -> [InteractionEntity] |
| 24 | 30 | } |
M
ios/Card2vcf/Data/InteractionEntity.swift
+5
-0
@@ -11,4 +11,9 @@ struct InteractionEntity: Equatable {
| 11 | 11 | var creePar: String = "" |
| 12 | 12 | var createdAt: Int64 = 0 |
| 13 | 13 | var updatedAt: Int64? = nil |
| 14 | + /// Chemin local du fichier WAV (géré par l'app, jamais écrasé par le pull). | |
| 15 | + var audioPath: String? = nil | |
| 16 | + /// Statut de transcription : `en_attente`, `terminee`, `echec`, ou nil. | |
| 17 | + var transcriptionStatut: String? = nil | |
| 18 | + var transcriptionErreur: String? = nil | |
| 14 | 19 | } |
A
ios/Card2vcf/Data/NoteProjetDao.swift
+23
-0
@@ -0,0 +1,23 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// Port du Room `NoteProjetDao` (Android). | |
| 4 | +protocol NoteProjetDao { | |
| 5 | + /// INSERT OR REPLACE INTO notes_projet ; retourne le rowId. | |
| 6 | + @discardableResult | |
| 7 | + func upsert(_ entity: NoteProjetEntity) async throws -> Int64 | |
| 8 | + | |
| 9 | + /// SELECT * FROM notes_projet WHERE serverId = :serverId | |
| 10 | + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity? | |
| 11 | + | |
| 12 | + /// DELETE FROM notes_projet WHERE serverId = :serverId | |
| 13 | + func deleteByServerId(_ serverId: String) async throws | |
| 14 | + | |
| 15 | + /// DELETE FROM notes_projet WHERE localId = :localId | |
| 16 | + func deleteByLocalId(_ localId: Int64) async throws | |
| 17 | + | |
| 18 | + /// SELECT * FROM notes_projet WHERE projetServerId = :projetServerId | |
| 19 | + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity] | |
| 20 | + | |
| 21 | + /// SELECT * FROM notes_projet | |
| 22 | + func listAll() async throws -> [NoteProjetEntity] | |
| 23 | +} |
A
ios/Card2vcf/Data/NoteProjetEntity.swift
+20
-0
@@ -0,0 +1,20 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +/// Room entity `notes_projet` (mirror de l'entit\u{e9} Android `NoteProjetEntity`). | |
| 4 | +struct NoteProjetEntity: Equatable { | |
| 5 | + var localId: Int64 = 0 | |
| 6 | + var serverId: String? = nil | |
| 7 | + var projetServerId: String = "" | |
| 8 | + var titre: String = "" | |
| 9 | + /// Corps markdown de la note (JSON : `contenu`). | |
| 10 | + var texte: String = "" | |
| 11 | + /// Chemin local du fichier audio, conserv\u{e9} lors des mises \u{e0} jour serveur. | |
| 12 | + var audioPath: String? = nil | |
| 13 | + /// Statut de transcription : `en_attente`, `terminee`, `echec`, ou nil. | |
| 14 | + var transcriptionStatut: String? = nil | |
| 15 | + var transcriptionErreur: String? = nil | |
| 16 | + var auteur: String = "" | |
| 17 | + var createdAt: Int64 = 0 | |
| 18 | + /// nil sur les notes jamais mises \u{e0} jour. | |
| 19 | + var updatedAt: Int64? = nil | |
| 20 | +} |
M
ios/Card2vcf/Data/Sqlite/Card2vcfDatabase.swift
+2
-0
@@ -18,6 +18,7 @@ final class Card2vcfDatabase {
| 18 | 18 | let rdvDao: any RdvDao |
| 19 | 19 | let reservationDao: any ReservationDao |
| 20 | 20 | let indisponibiliteDao: any IndisponibiliteDao |
| 21 | + let noteProjetDao: any NoteProjetDao | |
| 21 | 22 | |
| 22 | 23 | init(inMemory: Bool = false) { |
| 23 | 24 | let db = SqliteDatabase(inMemory: inMemory) |
@@ -33,5 +34,6 @@ final class Card2vcfDatabase {
| 33 | 34 | rdvDao = SqliteRdvDao(db: db) |
| 34 | 35 | reservationDao = SqliteReservationDao(db: db) |
| 35 | 36 | indisponibiliteDao = SqliteIndisponibiliteDao(db: db) |
| 37 | + noteProjetDao = SqliteNoteProjetDao(db: db) | |
| 36 | 38 | } |
| 37 | 39 | } |
M
ios/Card2vcf/Data/Sqlite/SqliteDatabase.swift
+63
-2
@@ -19,14 +19,22 @@ struct DatabaseError: Error, CustomStringConvertible {
| 19 | 19 | /// Owner of the sqlite3 connection. Mirrors the Room schema of the Android |
| 20 | 20 | /// `CrmDatabase` (version 3) with destructive migration semantics. |
| 21 | 21 | actor SqliteDatabase { |
| 22 | - static let schemaVersion: Int32 = 4 | |
| 22 | + static let schemaVersion: Int32 = 6 | |
| 23 | 23 | static let fileName = "card2vcf.sqlite" |
| 24 | 24 | |
| 25 | 25 | private let inMemory: Bool |
| 26 | + private let customPath: String? | |
| 26 | 27 | private var handle: OpaquePointer? |
| 27 | 28 | |
| 28 | 29 | init(inMemory: Bool = false) { |
| 29 | 30 | self.inMemory = inMemory |
| 31 | + self.customPath = nil | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Initializer for tests that need a specific file path (e.g. migration tests). | |
| 35 | + init(filePath: String) { | |
| 36 | + self.inMemory = false | |
| 37 | + self.customPath = filePath | |
| 30 | 38 | } |
| 31 | 39 | |
| 32 | 40 | deinit { |
@@ -95,6 +103,8 @@ actor SqliteDatabase {
| 95 | 103 | let path: String |
| 96 | 104 | if inMemory { |
| 97 | 105 | path = ":memory:" |
| 106 | + } else if let customPath { | |
| 107 | + path = customPath | |
| 98 | 108 | } else { |
| 99 | 109 | path = try Self.defaultPath() |
| 100 | 110 | } |
@@ -137,7 +147,7 @@ actor SqliteDatabase {
| 137 | 147 | } |
| 138 | 148 | } |
| 139 | 149 | |
| 140 | - /// Mirrors Android: additive migrations from v3 on (Room `MIGRATION_3_4`), | |
| 150 | + /// Mirrors Android: additive migrations from v3 on (Room `MIGRATION_3_4`, `MIGRATION_4_5`), | |
| 141 | 151 | /// destructive fallback (`fallbackToDestructiveMigration()`) for anything older. |
| 142 | 152 | private static func migrateIfNeeded(_ db: OpaquePointer) throws { |
| 143 | 153 | let stmt = try SqliteStatement(db: db, sql: "PRAGMA user_version") |
@@ -149,6 +159,23 @@ actor SqliteDatabase {
| 149 | 159 | // v3→v4: `lastError` column on `sync_ops` (per-op push failure state). |
| 150 | 160 | if version == 3 { |
| 151 | 161 | try exec(db, "ALTER TABLE sync_ops ADD COLUMN lastError TEXT;") |
| 162 | + version = 4 | |
| 163 | + } | |
| 164 | + // v4→v5: `notes_projet` table. | |
| 165 | + if version == 4 { | |
| 166 | + try exec(db, notesProjetCreateSql) | |
| 167 | + version = 5 | |
| 168 | + } | |
| 169 | + // v5→v6: audio + transcription columns on `interactions`. | |
| 170 | + // Guard on table existence: some legacy test databases (v4→v5 path) may lack `interactions`. | |
| 171 | + if version == 5 { | |
| 172 | + let checkStmt = try SqliteStatement(db: db, | |
| 173 | + sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='interactions'") | |
| 174 | + if try checkStmt.step() { | |
| 175 | + try exec(db, "ALTER TABLE interactions ADD COLUMN audioPath TEXT;") | |
| 176 | + try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionStatut TEXT;") | |
| 177 | + try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionErreur TEXT;") | |
| 178 | + } | |
| 152 | 179 | try exec(db, "PRAGMA user_version = \(schemaVersion);") |
| 153 | 180 | return |
| 154 | 181 | } |
@@ -157,6 +184,22 @@ actor SqliteDatabase {
| 157 | 184 | try exec(db, "PRAGMA user_version = \(schemaVersion);") |
| 158 | 185 | } |
| 159 | 186 | |
| 187 | + private static let notesProjetCreateSql = """ | |
| 188 | + CREATE TABLE notes_projet ( | |
| 189 | + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | |
| 190 | + serverId TEXT, | |
| 191 | + projetServerId TEXT NOT NULL, | |
| 192 | + titre TEXT NOT NULL, | |
| 193 | + texte TEXT NOT NULL, | |
| 194 | + audioPath TEXT, | |
| 195 | + transcriptionStatut TEXT, | |
| 196 | + transcriptionErreur TEXT, | |
| 197 | + auteur TEXT NOT NULL, | |
| 198 | + createdAt INTEGER NOT NULL, | |
| 199 | + updatedAt INTEGER | |
| 200 | + ); | |
| 201 | + """ | |
| 202 | + | |
| 160 | 203 | // MARK: - Schema (Room v4 mirror) |
| 161 | 204 | |
| 162 | 205 | private static let dropAllSql = """ |
@@ -170,6 +213,7 @@ actor SqliteDatabase {
| 170 | 213 | DROP TABLE IF EXISTS projets; |
| 171 | 214 | DROP TABLE IF EXISTS taches; |
| 172 | 215 | DROP TABLE IF EXISTS interactions; |
| 216 | + DROP TABLE IF EXISTS notes_projet; | |
| 173 | 217 | DROP TABLE IF EXISTS workflows; |
| 174 | 218 | DROP TABLE IF EXISTS sync_ops; |
| 175 | 219 | DROP TABLE IF EXISTS sync_meta; |
@@ -297,6 +341,23 @@ actor SqliteDatabase {
| 297 | 341 | description TEXT NOT NULL, |
| 298 | 342 | creePar TEXT NOT NULL, |
| 299 | 343 | createdAt INTEGER NOT NULL, |
| 344 | + updatedAt INTEGER, | |
| 345 | + audioPath TEXT, | |
| 346 | + transcriptionStatut TEXT, | |
| 347 | + transcriptionErreur TEXT | |
| 348 | + ); | |
| 349 | + | |
| 350 | + CREATE TABLE notes_projet ( | |
| 351 | + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | |
| 352 | + serverId TEXT, | |
| 353 | + projetServerId TEXT NOT NULL, | |
| 354 | + titre TEXT NOT NULL, | |
| 355 | + texte TEXT NOT NULL, | |
| 356 | + audioPath TEXT, | |
| 357 | + transcriptionStatut TEXT, | |
| 358 | + transcriptionErreur TEXT, | |
| 359 | + auteur TEXT NOT NULL, | |
| 360 | + createdAt INTEGER NOT NULL, | |
| 300 | 361 | updatedAt INTEGER |
| 301 | 362 | ); |
| 302 | 363 |
M
ios/Card2vcf/Data/Sqlite/SqliteInteractionDao.swift
+24
-2
@@ -4,7 +4,8 @@ struct SqliteInteractionDao: InteractionDao {
| 4 | 4 | let db: SqliteDatabase |
| 5 | 5 | |
| 6 | 6 | private static let columns = """ |
| 7 | - localId, serverId, contactServerId, type, sujet, description, creePar, createdAt, updatedAt | |
| 7 | + localId, serverId, contactServerId, type, sujet, description, creePar, createdAt, updatedAt, | |
| 8 | + audioPath, transcriptionStatut, transcriptionErreur | |
| 8 | 9 | """ |
| 9 | 10 | |
| 10 | 11 | private static func map(_ row: SqliteRow) -> InteractionEntity { |
@@ -18,13 +19,19 @@ struct SqliteInteractionDao: InteractionDao {
| 18 | 19 | e.creePar = row.text(6) |
| 19 | 20 | e.createdAt = row.int64(7) |
| 20 | 21 | e.updatedAt = row.int64OrNil(8) |
| 22 | + e.audioPath = row.textOrNil(9) | |
| 23 | + e.transcriptionStatut = row.textOrNil(10) | |
| 24 | + e.transcriptionErreur = row.textOrNil(11) | |
| 21 | 25 | return e |
| 22 | 26 | } |
| 23 | 27 | |
| 24 | 28 | @discardableResult |
| 25 | 29 | func upsert(_ entity: InteractionEntity) async throws -> Int64 { |
| 26 | 30 | try await db.insert( |
| 27 | - "INSERT OR REPLACE INTO interactions (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 31 | + """ | |
| 32 | + INSERT OR REPLACE INTO interactions (\(Self.columns)) | |
| 33 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 34 | + """, | |
| 28 | 35 | [ |
| 29 | 36 | .rowId(entity.localId), |
| 30 | 37 | .optText(entity.serverId), |
@@ -35,6 +42,9 @@ struct SqliteInteractionDao: InteractionDao {
| 35 | 42 | .text(entity.creePar), |
| 36 | 43 | .int(entity.createdAt), |
| 37 | 44 | .optInt(entity.updatedAt), |
| 45 | + .optText(entity.audioPath), | |
| 46 | + .optText(entity.transcriptionStatut), | |
| 47 | + .optText(entity.transcriptionErreur), | |
| 38 | 48 | ] |
| 39 | 49 | ) |
| 40 | 50 | } |
@@ -47,10 +57,22 @@ struct SqliteInteractionDao: InteractionDao {
| 47 | 57 | ).first |
| 48 | 58 | } |
| 49 | 59 | |
| 60 | + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity? { | |
| 61 | + try await db.query( | |
| 62 | + "SELECT \(Self.columns) FROM interactions WHERE localId = ?", | |
| 63 | + [.int(localId)], | |
| 64 | + map: Self.map | |
| 65 | + ).first | |
| 66 | + } | |
| 67 | + | |
| 50 | 68 | func deleteByServerId(_ serverId: String) async throws { |
| 51 | 69 | try await db.write("DELETE FROM interactions WHERE serverId = ?", [.text(serverId)]) |
| 52 | 70 | } |
| 53 | 71 | |
| 72 | + func deleteByLocalId(_ localId: Int64) async throws { | |
| 73 | + try await db.write("DELETE FROM interactions WHERE localId = ?", [.int(localId)]) | |
| 74 | + } | |
| 75 | + | |
| 54 | 76 | func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] { |
| 55 | 77 | try await db.query( |
| 56 | 78 | "SELECT \(Self.columns) FROM interactions WHERE contactServerId = ?", |
A
ios/Card2vcf/Data/Sqlite/SqliteNoteProjetDao.swift
+74
-0
@@ -0,0 +1,74 @@
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +struct SqliteNoteProjetDao: NoteProjetDao { | |
| 4 | + let db: SqliteDatabase | |
| 5 | + | |
| 6 | + private static let columns = """ | |
| 7 | + localId, serverId, projetServerId, titre, texte, audioPath, \ | |
| 8 | + transcriptionStatut, transcriptionErreur, auteur, createdAt, updatedAt | |
| 9 | + """ | |
| 10 | + | |
| 11 | + private static func map(_ row: SqliteRow) -> NoteProjetEntity { | |
| 12 | + var e = NoteProjetEntity() | |
| 13 | + e.localId = row.int64(0) | |
| 14 | + e.serverId = row.textOrNil(1) | |
| 15 | + e.projetServerId = row.text(2) | |
| 16 | + e.titre = row.text(3) | |
| 17 | + e.texte = row.text(4) | |
| 18 | + e.audioPath = row.textOrNil(5) | |
| 19 | + e.transcriptionStatut = row.textOrNil(6) | |
| 20 | + e.transcriptionErreur = row.textOrNil(7) | |
| 21 | + e.auteur = row.text(8) | |
| 22 | + e.createdAt = row.int64(9) | |
| 23 | + e.updatedAt = row.int64OrNil(10) | |
| 24 | + return e | |
| 25 | + } | |
| 26 | + | |
| 27 | + @discardableResult | |
| 28 | + func upsert(_ entity: NoteProjetEntity) async throws -> Int64 { | |
| 29 | + try await db.insert( | |
| 30 | + "INSERT OR REPLACE INTO notes_projet (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 31 | + [ | |
| 32 | + .rowId(entity.localId), | |
| 33 | + .optText(entity.serverId), | |
| 34 | + .text(entity.projetServerId), | |
| 35 | + .text(entity.titre), | |
| 36 | + .text(entity.texte), | |
| 37 | + .optText(entity.audioPath), | |
| 38 | + .optText(entity.transcriptionStatut), | |
| 39 | + .optText(entity.transcriptionErreur), | |
| 40 | + .text(entity.auteur), | |
| 41 | + .int(entity.createdAt), | |
| 42 | + .optInt(entity.updatedAt), | |
| 43 | + ] | |
| 44 | + ) | |
| 45 | + } | |
| 46 | + | |
| 47 | + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity? { | |
| 48 | + try await db.query( | |
| 49 | + "SELECT \(Self.columns) FROM notes_projet WHERE serverId = ?", | |
| 50 | + [.text(serverId)], | |
| 51 | + map: Self.map | |
| 52 | + ).first | |
| 53 | + } | |
| 54 | + | |
| 55 | + func deleteByServerId(_ serverId: String) async throws { | |
| 56 | + try await db.write("DELETE FROM notes_projet WHERE serverId = ?", [.text(serverId)]) | |
| 57 | + } | |
| 58 | + | |
| 59 | + func deleteByLocalId(_ localId: Int64) async throws { | |
| 60 | + try await db.write("DELETE FROM notes_projet WHERE localId = ?", [.int(localId)]) | |
| 61 | + } | |
| 62 | + | |
| 63 | + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity] { | |
| 64 | + try await db.query( | |
| 65 | + "SELECT \(Self.columns) FROM notes_projet WHERE projetServerId = ? ORDER BY createdAt DESC", | |
| 66 | + [.text(projetServerId)], | |
| 67 | + map: Self.map | |
| 68 | + ) | |
| 69 | + } | |
| 70 | + | |
| 71 | + func listAll() async throws -> [NoteProjetEntity] { | |
| 72 | + try await db.query("SELECT \(Self.columns) FROM notes_projet", map: Self.map) | |
| 73 | + } | |
| 74 | +} |
M
ios/Card2vcf/Info.plist
+4
-0
@@ -38,6 +38,10 @@
| 38 | 38 | <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec PicLead).</string> |
| 39 | 39 | <key>NSPhotoLibraryAddUsageDescription</key> |
| 40 | 40 | <string>Permet d'enregistrer l'image de la carte scannée si vous le demandez.</string> |
| 41 | + <key>NSMicrophoneUsageDescription</key> | |
| 42 | + <string>Le microphone sert à enregistrer des notes vocales sur vos contacts et projets. L'audio reste sur l'appareil et n'est transmis au serveur que si vous activez la transcription serveur.</string> | |
| 43 | + <key>NSSpeechRecognitionUsageDescription</key> | |
| 44 | + <string>La reconnaissance vocale on-device transcrit vos notes audio en texte. Aucune donnée n'est envoyée à Apple ni à un tiers : la transcription s'effectue entièrement sur l'appareil.</string> | |
| 41 | 45 | <key>NSAppTransportSecurity</key> |
| 42 | 46 | <dict> |
| 43 | 47 | <!-- HTTP clair refusé par défaut dans l'app (ServerUrlPolicy) ; l'exception ATS |
M
ios/Card2vcf/Sync/AilianceApi.swift
+40
-0
@@ -80,6 +80,46 @@ protocol AilianceApi {
| 80 | 80 | func downloadContactCarte(id: String) async -> AilianceApiClient.ApiResult<Data> |
| 81 | 81 | |
| 82 | 82 | func downloadContactPhoto(id: String) async -> AilianceApiClient.ApiResult<Data> |
| 83 | + | |
| 84 | + /// POST /api/contacts/:contactId/interactions/:interactionId/audio (multipart, champ `file`). | |
| 85 | + /// Retourne le corps brut (interaction mise à jour au format JSON). | |
| 86 | + func uploadInteractionAudio( | |
| 87 | + contactId: String, | |
| 88 | + interactionId: String, | |
| 89 | + bytes: Data, | |
| 90 | + filename: String | |
| 91 | + ) async -> AilianceApiClient.ApiResult<String> | |
| 92 | + | |
| 93 | + /// GET /api/contacts/:contactId/interactions/:interactionId/audio | |
| 94 | + func downloadInteractionAudio(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<Data> | |
| 95 | + | |
| 96 | + /// POST /api/projets/:projetId/notes (JSON body `CreateNoteProjetRequest`). | |
| 97 | + func createNoteProjet(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> | |
| 98 | + | |
| 99 | + /// POST /api/projets/:projetId/notes/:noteId/audio (multipart, champ `file`). | |
| 100 | + func uploadNoteProjetAudio( | |
| 101 | + projetId: String, | |
| 102 | + noteId: String, | |
| 103 | + bytes: Data, | |
| 104 | + filename: String | |
| 105 | + ) async -> AilianceApiClient.ApiResult<String> | |
| 106 | + | |
| 107 | + /// GET /api/projets/:projetId/notes/:noteId/audio | |
| 108 | + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Data> | |
| 109 | + | |
| 110 | + /// DELETE /api/interactions/:interactionId | |
| 111 | + func deleteInteraction(interactionId: String) async -> AilianceApiClient.ApiResult<Void> | |
| 112 | + | |
| 113 | + /// DELETE /api/projets/:projetId/notes/:noteId | |
| 114 | + func deleteNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Void> | |
| 115 | + | |
| 116 | + /// POST /api/contacts/:contactId/interactions/:interactionId/transcription | |
| 117 | + /// Relance la transcription côté serveur ; retourne le corps JSON de l'entité mise à jour. | |
| 118 | + func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<String> | |
| 119 | + | |
| 120 | + /// POST /api/projets/:projetId/notes/:noteId/transcription | |
| 121 | + /// Relance la transcription côté serveur ; retourne le corps JSON de l'entité mise à jour. | |
| 122 | + func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<String> | |
| 83 | 123 | } |
| 84 | 124 | |
| 85 | 125 | /// Kotlin default parameter values, mirrored as overloads. |
M
ios/Card2vcf/Sync/AilianceApiClient.swift
+52
-0
@@ -215,6 +215,58 @@ final class AilianceApiClient: AilianceApi {
| 215 | 215 | await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/photo", nil, useBearer: true)) |
| 216 | 216 | } |
| 217 | 217 | |
| 218 | + func uploadInteractionAudio( | |
| 219 | + contactId: String, | |
| 220 | + interactionId: String, | |
| 221 | + bytes: Data, | |
| 222 | + filename: String | |
| 223 | + ) async -> ApiResult<String> { | |
| 224 | + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio" | |
| 225 | + return await uploadMultipart(path, bytes, filename, "audio/wav") | |
| 226 | + } | |
| 227 | + | |
| 228 | + func downloadInteractionAudio(contactId: String, interactionId: String) async -> ApiResult<Data> { | |
| 229 | + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio" | |
| 230 | + return await executeBytes(buildRequest("GET", path, nil, useBearer: true)) | |
| 231 | + } | |
| 232 | + | |
| 233 | + func createNoteProjet(projetId: String, jsonBody: String) async -> ApiResult<String> { | |
| 234 | + await post("/api/projets/\(Self.encodePath(projetId))/notes", jsonBody, parse: Self.text) | |
| 235 | + } | |
| 236 | + | |
| 237 | + func uploadNoteProjetAudio( | |
| 238 | + projetId: String, | |
| 239 | + noteId: String, | |
| 240 | + bytes: Data, | |
| 241 | + filename: String | |
| 242 | + ) async -> ApiResult<String> { | |
| 243 | + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio" | |
| 244 | + return await uploadMultipart(path, bytes, filename, "audio/wav") | |
| 245 | + } | |
| 246 | + | |
| 247 | + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> ApiResult<Data> { | |
| 248 | + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio" | |
| 249 | + return await executeBytes(buildRequest("GET", path, nil, useBearer: true)) | |
| 250 | + } | |
| 251 | + | |
| 252 | + func deleteInteraction(interactionId: String) async -> ApiResult<Void> { | |
| 253 | + await delete("/api/interactions/\(Self.encodePath(interactionId))") | |
| 254 | + } | |
| 255 | + | |
| 256 | + func deleteNoteProjet(projetId: String, noteId: String) async -> ApiResult<Void> { | |
| 257 | + await delete("/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))") | |
| 258 | + } | |
| 259 | + | |
| 260 | + func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> ApiResult<String> { | |
| 261 | + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/transcription" | |
| 262 | + return await post(path, "{}", parse: Self.text) | |
| 263 | + } | |
| 264 | + | |
| 265 | + func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> ApiResult<String> { | |
| 266 | + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/transcription" | |
| 267 | + return await post(path, "{}", parse: Self.text) | |
| 268 | + } | |
| 269 | + | |
| 218 | 270 | // MARK: - Internals |
| 219 | 271 | |
| 220 | 272 | private func uploadMultipart( |
M
ios/Card2vcf/Sync/CrmDatabase.swift
+1
-0
@@ -15,6 +15,7 @@ protocol CrmDatabase {
| 15 | 15 | var indisponibiliteDao: IndisponibiliteDao { get } |
| 16 | 16 | var syncMetaDao: SyncMetaDao { get } |
| 17 | 17 | var syncOpDao: SyncOpDao { get } |
| 18 | + var noteProjetDao: NoteProjetDao { get } | |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | /// The SQLite facade exposes exactly these DAO properties. |
M
ios/Card2vcf/Sync/SyncEngine.swift
+268
-19
@@ -107,6 +107,63 @@ final class SyncEngine {
| 107 | 107 | } |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | + /// Vide la file de push sans effectuer de pull (mode « transcription serveur » : | |
| 111 | + /// l'audio part dès la fin de l'enregistrement). Équivalent de `pousserEnAttente` Android. | |
| 112 | + func pousserEnAttente() async throws -> SyncResult { | |
| 113 | + let push = try await pushOps() | |
| 114 | + return SyncResult( | |
| 115 | + success: true, | |
| 116 | + pushed: push.succeeded, | |
| 117 | + pushFailures: push.failures | |
| 118 | + ) | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Relance la transcription d'une interaction sur le serveur (appel direct, hors file). | |
| 122 | + /// Retourne `nil` en cas de succès, le message d'erreur sinon. | |
| 123 | + func relancerTranscriptionInteraction(localId: Int64) async throws -> String? { | |
| 124 | + guard let interaction = try await db.interactionDao.getByLocalId(localId), | |
| 125 | + let interactionId = interaction.serverId else { | |
| 126 | + return "Impossible de retrouver l'interaction." | |
| 127 | + } | |
| 128 | + let result = await api.relancerTranscriptionInteraction( | |
| 129 | + contactId: interaction.contactServerId, | |
| 130 | + interactionId: interactionId | |
| 131 | + ) | |
| 132 | + switch result { | |
| 133 | + case .ok: | |
| 134 | + var updated = interaction | |
| 135 | + updated.transcriptionStatut = "en_attente" | |
| 136 | + updated.transcriptionErreur = nil | |
| 137 | + try await db.interactionDao.upsert(updated) | |
| 138 | + return nil | |
| 139 | + case .err(_, let message): | |
| 140 | + return "Relance impossible : \(message)" | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + /// Relance la transcription d'une note de projet sur le serveur (appel direct, hors file). | |
| 145 | + /// Retourne `nil` en cas de succès, le message d'erreur sinon. | |
| 146 | + func relancerTranscriptionNoteProjet(localId: Int64) async throws -> String? { | |
| 147 | + guard let note = try await db.noteProjetDao.listAll().first(where: { $0.localId == localId }), | |
| 148 | + let noteId = note.serverId else { | |
| 149 | + return "Impossible de retrouver la note." | |
| 150 | + } | |
| 151 | + let result = await api.relancerTranscriptionNoteProjet( | |
| 152 | + projetId: note.projetServerId, | |
| 153 | + noteId: noteId | |
| 154 | + ) | |
| 155 | + switch result { | |
| 156 | + case .ok: | |
| 157 | + var updated = note | |
| 158 | + updated.transcriptionStatut = "en_attente" | |
| 159 | + updated.transcriptionErreur = nil | |
| 160 | + try await db.noteProjetDao.upsert(updated) | |
| 161 | + return nil | |
| 162 | + case .err(_, let message): | |
| 163 | + return "Relance impossible : \(message)" | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 110 | 167 | /// Count of local items not yet pushed (« calendrier local en avance » banner). |
| 111 | 168 | /// With `bindings`/`bridge`: adds card2vcf-tagged calendar events without a local entity |
| 112 | 169 | /// (orphans not yet cleaned up on the calendar side, see `AgendaSyncCoordinator.orphanEventCount`). |
@@ -213,6 +270,12 @@ final class SyncEngine {
| 213 | 270 | outcome = try await pushTacheOp(op) |
| 214 | 271 | case "interaction": |
| 215 | 272 | outcome = try await pushInteractionOp(op) |
| 273 | + case "interaction_audio": | |
| 274 | + outcome = try await pushInteractionAudioOp(op) | |
| 275 | + case "note_projet": | |
| 276 | + outcome = try await pushNoteProjetOp(op) | |
| 277 | + case "note_projet_audio": | |
| 278 | + outcome = try await pushNoteProjetAudioOp(op) | |
| 216 | 279 | case AgendaSyncCoordinator.kindRdv: |
| 217 | 280 | outcome = try await pushRdvOp(op) |
| 218 | 281 | case AgendaSyncCoordinator.entityReservation: |
@@ -403,11 +466,144 @@ final class SyncEngine {
| 403 | 466 | } |
| 404 | 467 | } |
| 405 | 468 | |
| 406 | - /// `op.serverId` carries the target contact's `serverId` (interactions have no update/delete). | |
| 469 | + /// `op.serverId` = contactServerId pour create, interactionServerId pour delete. | |
| 470 | + /// `op.localId` = interaction locale (create uniquement). | |
| 407 | 471 | private func pushInteractionOp(_ op: SyncOpEntity) async throws -> PushOutcome { |
| 408 | - if op.op != "create" { return PushOutcome(ok: true) } | |
| 409 | - guard let contactServerId = op.serverId else { return PushOutcome(ok: false) } | |
| 410 | - return Self.outcome(await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson)) | |
| 472 | + switch op.op { | |
| 473 | + case "create": | |
| 474 | + guard let contactServerId = op.serverId else { return PushOutcome(ok: false) } | |
| 475 | + let result = await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson) | |
| 476 | + if case .ok(let body) = result, let localId = op.localId { | |
| 477 | + if let interactionServerId = Self.parseCreatedId(body) { | |
| 478 | + if var entity = try await db.interactionDao.getByLocalId(localId) { | |
| 479 | + entity.serverId = interactionServerId | |
| 480 | + try await db.interactionDao.upsert(entity) | |
| 481 | + // Tente l'upload audio immédiatement ; enfile un retry en cas d'échec. | |
| 482 | + if let audioPath = entity.audioPath, let bytes = Self.readFile(audioPath) { | |
| 483 | + let uploadResult = await api.uploadInteractionAudio( | |
| 484 | + contactId: contactServerId, | |
| 485 | + interactionId: interactionServerId, | |
| 486 | + bytes: bytes, | |
| 487 | + filename: "note.wav" | |
| 488 | + ) | |
| 489 | + if !uploadResult.isOk { | |
| 490 | + try await enqueueInteractionAudioRetry( | |
| 491 | + interactionServerId: interactionServerId | |
| 492 | + ) | |
| 493 | + } | |
| 494 | + } | |
| 495 | + } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + return Self.outcome(result) | |
| 499 | + case "delete": | |
| 500 | + guard let interactionId = op.serverId else { return PushOutcome(ok: false) } | |
| 501 | + return Self.outcome(await api.deleteInteraction(interactionId: interactionId)) | |
| 502 | + default: | |
| 503 | + return PushOutcome(ok: true) | |
| 504 | + } | |
| 505 | + } | |
| 506 | + | |
| 507 | + /// Upload de l'audio d'une interaction (op de retry `interaction_audio`). | |
| 508 | + private func pushInteractionAudioOp(_ op: SyncOpEntity) async throws -> PushOutcome { | |
| 509 | + guard let interactionServerId = op.serverId, | |
| 510 | + let interaction = try await db.interactionDao.getByServerId(interactionServerId), | |
| 511 | + let audioPath = interaction.audioPath, | |
| 512 | + let bytes = Self.readFile(audioPath) else { return PushOutcome(ok: true) } | |
| 513 | + let result = await api.uploadInteractionAudio( | |
| 514 | + contactId: interaction.contactServerId, | |
| 515 | + interactionId: interactionServerId, | |
| 516 | + bytes: bytes, | |
| 517 | + filename: "note.wav" | |
| 518 | + ) | |
| 519 | + return Self.outcome(result) | |
| 520 | + } | |
| 521 | + | |
| 522 | + private func enqueueInteractionAudioRetry(interactionServerId: String) async throws { | |
| 523 | + let already = try await db.syncOpDao.listAll().contains { | |
| 524 | + $0.entityType == "interaction_audio" && $0.serverId == interactionServerId | |
| 525 | + } | |
| 526 | + if already { return } | |
| 527 | + var entity = SyncOpEntity(entityType: "interaction_audio", op: "upload") | |
| 528 | + entity.payloadJson = "{}" | |
| 529 | + entity.serverId = interactionServerId | |
| 530 | + entity.createdAt = AgendaSyncCoordinator.currentMillis() | |
| 531 | + try await db.syncOpDao.insert(entity) | |
| 532 | + } | |
| 533 | + | |
| 534 | + /// Création ou suppression d'une note de projet. | |
| 535 | + /// Create : `op.serverId` = projetServerId (parent), `op.localId` = note locale. | |
| 536 | + /// Delete : `op.serverId` = noteServerId, `op.payloadJson` = `{"projetServerId":"…"}`. | |
| 537 | + private func pushNoteProjetOp(_ op: SyncOpEntity) async throws -> PushOutcome { | |
| 538 | + switch op.op { | |
| 539 | + case "create": | |
| 540 | + guard let projetServerId = op.serverId else { return PushOutcome(ok: false) } | |
| 541 | + let result = await api.createNoteProjet(projetId: projetServerId, jsonBody: op.payloadJson) | |
| 542 | + if case .ok(let body) = result, let localId = op.localId { | |
| 543 | + if let noteServerId = Self.parseCreatedId(body) { | |
| 544 | + // NoteProjetDao n'a pas getByLocalId ; on passe par listAll. | |
| 545 | + if let note = try await db.noteProjetDao.listAll().first(where: { $0.localId == localId }) { | |
| 546 | + var updated = note | |
| 547 | + updated.serverId = noteServerId | |
| 548 | + try await db.noteProjetDao.upsert(updated) | |
| 549 | + // Upload audio si présent. | |
| 550 | + if let audioPath = updated.audioPath, let bytes = Self.readFile(audioPath) { | |
| 551 | + let uploadResult = await api.uploadNoteProjetAudio( | |
| 552 | + projetId: projetServerId, | |
| 553 | + noteId: noteServerId, | |
| 554 | + bytes: bytes, | |
| 555 | + filename: "note.wav" | |
| 556 | + ) | |
| 557 | + if !uploadResult.isOk { | |
| 558 | + try await enqueueNoteProjetAudioRetry( | |
| 559 | + projetServerId: projetServerId, | |
| 560 | + noteServerId: noteServerId | |
| 561 | + ) | |
| 562 | + } | |
| 563 | + } | |
| 564 | + } | |
| 565 | + } | |
| 566 | + } | |
| 567 | + return Self.outcome(result) | |
| 568 | + case "delete": | |
| 569 | + guard let noteId = op.serverId else { return PushOutcome(ok: false) } | |
| 570 | + guard let ref = try? SyncJson.decode(NoteDeleteRef.self, from: op.payloadJson), | |
| 571 | + let projetServerId = ref.projetServerId else { return PushOutcome(ok: false) } | |
| 572 | + return Self.outcome(await api.deleteNoteProjet(projetId: projetServerId, noteId: noteId)) | |
| 573 | + default: | |
| 574 | + return PushOutcome(ok: true) | |
| 575 | + } | |
| 576 | + } | |
| 577 | + | |
| 578 | + private struct NoteDeleteRef: Codable { | |
| 579 | + var projetServerId: String? | |
| 580 | + } | |
| 581 | + | |
| 582 | + /// Upload de l'audio d'une note de projet (op de retry `note_projet_audio`). | |
| 583 | + private func pushNoteProjetAudioOp(_ op: SyncOpEntity) async throws -> PushOutcome { | |
| 584 | + guard let noteServerId = op.serverId, | |
| 585 | + let note = try await db.noteProjetDao.getByServerId(noteServerId), | |
| 586 | + let audioPath = note.audioPath, | |
| 587 | + let bytes = Self.readFile(audioPath) else { return PushOutcome(ok: true) } | |
| 588 | + let result = await api.uploadNoteProjetAudio( | |
| 589 | + projetId: note.projetServerId, | |
| 590 | + noteId: noteServerId, | |
| 591 | + bytes: bytes, | |
| 592 | + filename: "note.wav" | |
| 593 | + ) | |
| 594 | + return Self.outcome(result) | |
| 595 | + } | |
| 596 | + | |
| 597 | + private func enqueueNoteProjetAudioRetry(projetServerId: String, noteServerId: String) async throws { | |
| 598 | + let already = try await db.syncOpDao.listAll().contains { | |
| 599 | + $0.entityType == "note_projet_audio" && $0.serverId == noteServerId | |
| 600 | + } | |
| 601 | + if already { return } | |
| 602 | + var entity = SyncOpEntity(entityType: "note_projet_audio", op: "upload") | |
| 603 | + entity.payloadJson = "{}" | |
| 604 | + entity.serverId = noteServerId | |
| 605 | + entity.createdAt = AgendaSyncCoordinator.currentMillis() | |
| 606 | + try await db.syncOpDao.insert(entity) | |
| 411 | 607 | } |
| 412 | 608 | |
| 413 | 609 | private func pushRdvOp(_ op: SyncOpEntity) async throws -> PushOutcome { |
@@ -516,13 +712,15 @@ final class SyncEngine {
| 516 | 712 | for dto in pull.projets { try await applyProjet(dto) } |
| 517 | 713 | for dto in pull.taches { try await applyTache(dto) } |
| 518 | 714 | for dto in pull.interactions { try await applyInteraction(dto) } |
| 715 | + for dto in pull.notes { try await applyNoteProjet(dto) } | |
| 519 | 716 | for dto in pull.workflows { try await applyWorkflow(dto) } |
| 520 | 717 | for dto in pull.rdv { try await agenda.applyRdvPull(dto) } |
| 521 | 718 | for dto in pull.reservations { try await agenda.applyReservationPull(dto) } |
| 522 | 719 | for dto in pull.indisponibilites { try await agenda.applyIndisponibilitePull(dto) } |
| 523 | 720 | for dto in pull.tombstones { try await applyTombstone(dto) } |
| 524 | 721 | return pull.contacts.count + pull.entreprises.count + pull.projets.count + pull.taches.count |
| 525 | - + pull.interactions.count + pull.rdv.count + pull.reservations.count + pull.indisponibilites.count | |
| 722 | + + pull.interactions.count + pull.notes.count + pull.rdv.count | |
| 723 | + + pull.reservations.count + pull.indisponibilites.count | |
| 526 | 724 | } |
| 527 | 725 | |
| 528 | 726 | private func applyContact(_ dto: ContactDto) async throws { |
@@ -702,20 +900,69 @@ final class SyncEngine {
| 702 | 900 | } |
| 703 | 901 | |
| 704 | 902 | private func applyInteraction(_ dto: InteractionDto) async throws { |
| 705 | - let existing = Set( | |
| 706 | - try await db.interactionDao.listByContactServerId(dto.contactId).compactMap { $0.serverId } | |
| 707 | - ) | |
| 708 | - if !LwwMerger.shouldInsertInteraction(existingServerIds: existing, remoteServerId: dto.id) { return } | |
| 709 | - var entity = InteractionEntity() | |
| 710 | - entity.serverId = dto.id | |
| 711 | - entity.contactServerId = dto.contactId | |
| 712 | - entity.type = dto.typeInteraction | |
| 713 | - entity.sujet = dto.sujet | |
| 714 | - entity.description = dto.description | |
| 715 | - entity.creePar = dto.creePar | |
| 716 | - entity.createdAt = parseIsoToEpochMs(dto.creeLe) | |
| 717 | - entity.updatedAt = dto.misAJourLe.map { parseIsoToEpochMs($0) } | |
| 718 | - try await db.interactionDao.upsert(entity) | |
| 903 | + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe) | |
| 904 | + let existing = try await db.interactionDao.getByServerId(dto.id) | |
| 905 | + guard let existing else { | |
| 906 | + // Insert : nouvelle interaction inconnue localement. | |
| 907 | + var entity = InteractionEntity() | |
| 908 | + entity.serverId = dto.id | |
| 909 | + entity.contactServerId = dto.contactId | |
| 910 | + entity.type = dto.typeInteraction | |
| 911 | + entity.sujet = dto.sujet | |
| 912 | + entity.description = dto.description | |
| 913 | + entity.creePar = dto.creePar | |
| 914 | + entity.createdAt = parseIsoToEpochMs(dto.creeLe) | |
| 915 | + entity.updatedAt = dto.misAJourLe.map { parseIsoToEpochMs($0) } | |
| 916 | + entity.transcriptionStatut = dto.transcription | |
| 917 | + entity.transcriptionErreur = dto.transcriptionErreur | |
| 918 | + // audioPath : nil à l'insert (fichier local pas encore présent). | |
| 919 | + try await db.interactionDao.upsert(entity) | |
| 920 | + return | |
| 921 | + } | |
| 922 | + // Mise à jour LWW : seulement si le distant est plus récent. | |
| 923 | + let localTs = existing.updatedAt ?? existing.createdAt | |
| 924 | + guard remoteTs > localTs else { return } | |
| 925 | + var updated = existing | |
| 926 | + updated.type = dto.typeInteraction | |
| 927 | + updated.sujet = dto.sujet | |
| 928 | + updated.description = dto.description | |
| 929 | + updated.creePar = dto.creePar | |
| 930 | + updated.updatedAt = remoteTs | |
| 931 | + updated.transcriptionStatut = dto.transcription | |
| 932 | + updated.transcriptionErreur = dto.transcriptionErreur | |
| 933 | + // audioPath local préservé : ne pas écraser avec le nom serveur (pieceJointe). | |
| 934 | + try await db.interactionDao.upsert(updated) | |
| 935 | + } | |
| 936 | + | |
| 937 | + private func applyNoteProjet(_ dto: NoteProjetDto) async throws { | |
| 938 | + let remoteTs = parseIsoToEpochMs(dto.majLe ?? dto.creeLe) | |
| 939 | + let existing = try await db.noteProjetDao.getByServerId(dto.id) | |
| 940 | + guard let existing else { | |
| 941 | + var entity = NoteProjetEntity() | |
| 942 | + entity.serverId = dto.id | |
| 943 | + entity.projetServerId = dto.projetId | |
| 944 | + entity.titre = dto.titre | |
| 945 | + entity.texte = dto.contenu | |
| 946 | + entity.auteur = dto.auteur | |
| 947 | + entity.transcriptionStatut = dto.transcription | |
| 948 | + entity.transcriptionErreur = dto.transcriptionErreur | |
| 949 | + entity.createdAt = parseIsoToEpochMs(dto.creeLe) | |
| 950 | + entity.updatedAt = remoteTs | |
| 951 | + try await db.noteProjetDao.upsert(entity) | |
| 952 | + return | |
| 953 | + } | |
| 954 | + // LWW : mise à jour seulement si le distant est plus récent. | |
| 955 | + let localTs = existing.updatedAt ?? existing.createdAt | |
| 956 | + guard remoteTs > localTs else { return } | |
| 957 | + var updated = existing | |
| 958 | + updated.titre = dto.titre | |
| 959 | + updated.texte = dto.contenu | |
| 960 | + updated.auteur = dto.auteur | |
| 961 | + updated.transcriptionStatut = dto.transcription | |
| 962 | + updated.transcriptionErreur = dto.transcriptionErreur | |
| 963 | + // audioPath local préservé : ne pas écraser avec le nom de fichier serveur. | |
| 964 | + updated.updatedAt = remoteTs | |
| 965 | + try await db.noteProjetDao.upsert(updated) | |
| 719 | 966 | } |
| 720 | 967 | |
| 721 | 968 | private func applyWorkflow(_ dto: WorkflowDto) async throws { |
@@ -743,6 +990,8 @@ final class SyncEngine {
| 743 | 990 | try await db.tacheDao.deleteByServerId(dto.id) |
| 744 | 991 | case "interaction": |
| 745 | 992 | try await db.interactionDao.deleteByServerId(dto.id) |
| 993 | + case "note": | |
| 994 | + try await db.noteProjetDao.deleteByServerId(dto.id) | |
| 746 | 995 | default: |
| 747 | 996 | try await agenda.applyAgendaTombstone(dto) |
| 748 | 997 | } |
M
ios/Card2vcf/Sync/SyncModels.swift
+59
-0
@@ -87,6 +87,7 @@ struct SyncPullResponse: Codable, Equatable {
| 87 | 87 | var indisponibilites: [IndisponibiliteDto] = [] |
| 88 | 88 | var tombstones: [TombstoneDto] = [] |
| 89 | 89 | var workflows: [WorkflowDto] = [] |
| 90 | + var notes: [NoteProjetDto] = [] | |
| 90 | 91 | } |
| 91 | 92 | |
| 92 | 93 | extension SyncPullResponse { |
@@ -103,6 +104,7 @@ extension SyncPullResponse {
| 103 | 104 | indisponibilites = try c.decodeIfPresent([IndisponibiliteDto].self, forKey: .indisponibilites) ?? [] |
| 104 | 105 | tombstones = try c.decodeIfPresent([TombstoneDto].self, forKey: .tombstones) ?? [] |
| 105 | 106 | workflows = try c.decodeIfPresent([WorkflowDto].self, forKey: .workflows) ?? [] |
| 107 | + notes = try c.decodeIfPresent([NoteProjetDto].self, forKey: .notes) ?? [] | |
| 106 | 108 | } |
| 107 | 109 | } |
| 108 | 110 |
@@ -268,6 +270,35 @@ extension TacheSyncDto {
| 268 | 270 | } |
| 269 | 271 | } |
| 270 | 272 | |
| 273 | +struct NoteProjetDto: Codable, Equatable { | |
| 274 | + var id: String | |
| 275 | + var projetId: String | |
| 276 | + var titre: String | |
| 277 | + var contenu: String | |
| 278 | + var auteur: String = "" | |
| 279 | + var creeLe: String | |
| 280 | + var majLe: String? = nil | |
| 281 | + var audio: String? = nil | |
| 282 | + var transcription: String? = nil | |
| 283 | + var transcriptionErreur: String? = nil | |
| 284 | +} | |
| 285 | + | |
| 286 | +extension NoteProjetDto { | |
| 287 | + init(from decoder: Decoder) throws { | |
| 288 | + let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 289 | + id = try c.decode(String.self, forKey: .id) | |
| 290 | + projetId = try c.decode(String.self, forKey: .projetId) | |
| 291 | + titre = try c.decode(String.self, forKey: .titre) | |
| 292 | + contenu = try c.decode(String.self, forKey: .contenu) | |
| 293 | + auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? "" | |
| 294 | + creeLe = try c.decode(String.self, forKey: .creeLe) | |
| 295 | + majLe = try c.decodeIfPresent(String.self, forKey: .majLe) | |
| 296 | + audio = try c.decodeIfPresent(String.self, forKey: .audio) | |
| 297 | + transcription = try c.decodeIfPresent(String.self, forKey: .transcription) | |
| 298 | + transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur) | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 271 | 302 | struct InteractionDto: Codable, Equatable { |
| 272 | 303 | var id: String |
| 273 | 304 | var contactId: String |
@@ -277,6 +308,11 @@ struct InteractionDto: Codable, Equatable {
| 277 | 308 | var creePar: String = "" |
| 278 | 309 | var creeLe: String |
| 279 | 310 | var misAJourLe: String? = nil |
| 311 | + /// Nom de fichier côté serveur (pas un chemin local). | |
| 312 | + var pieceJointe: String? = nil | |
| 313 | + /// Statut de transcription : `en_attente`, `terminee`, `echec`. | |
| 314 | + var transcription: String? = nil | |
| 315 | + var transcriptionErreur: String? = nil | |
| 280 | 316 | } |
| 281 | 317 | |
| 282 | 318 | extension InteractionDto { |
@@ -290,6 +326,9 @@ extension InteractionDto {
| 290 | 326 | creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? "" |
| 291 | 327 | creeLe = try c.decode(String.self, forKey: .creeLe) |
| 292 | 328 | misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe) |
| 329 | + pieceJointe = try c.decodeIfPresent(String.self, forKey: .pieceJointe) | |
| 330 | + transcription = try c.decodeIfPresent(String.self, forKey: .transcription) | |
| 331 | + transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur) | |
| 293 | 332 | } |
| 294 | 333 | } |
| 295 | 334 |
@@ -531,6 +570,8 @@ struct MoveTacheRequest: Codable, Equatable {
| 531 | 570 | struct CreateInteractionRequest: Codable, Equatable { |
| 532 | 571 | var sujet: String |
| 533 | 572 | var description: String = "" |
| 573 | + var typeInteraction: String = "note" | |
| 574 | + var demandeTranscription: Bool = false | |
| 534 | 575 | } |
| 535 | 576 | |
| 536 | 577 | extension CreateInteractionRequest { |
@@ -538,6 +579,24 @@ extension CreateInteractionRequest {
| 538 | 579 | let c = try decoder.container(keyedBy: CodingKeys.self) |
| 539 | 580 | sujet = try c.decode(String.self, forKey: .sujet) |
| 540 | 581 | description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" |
| 582 | + typeInteraction = try c.decodeIfPresent(String.self, forKey: .typeInteraction) ?? "note" | |
| 583 | + demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false | |
| 584 | + } | |
| 585 | +} | |
| 586 | + | |
| 587 | +/// Create note de projet payload (body of `POST /api/projets/:id/notes`). | |
| 588 | +struct CreateNoteProjetRequest: Codable, Equatable { | |
| 589 | + var titre: String | |
| 590 | + var contenu: String = "" | |
| 591 | + var demandeTranscription: Bool = false | |
| 592 | +} | |
| 593 | + | |
| 594 | +extension CreateNoteProjetRequest { | |
| 595 | + init(from decoder: Decoder) throws { | |
| 596 | + let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 597 | + titre = try c.decode(String.self, forKey: .titre) | |
| 598 | + contenu = try c.decodeIfPresent(String.self, forKey: .contenu) ?? "" | |
| 599 | + demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false | |
| 541 | 600 | } |
| 542 | 601 | } |
| 543 | 602 |
A
ios/Card2vcf/UI/Audio/EnregistrementNoteSheet.swift
+258
-0
@@ -0,0 +1,258 @@
| 1 | +import SwiftUI | |
| 2 | + | |
| 3 | +/// Feuille d'enregistrement de note vocale. | |
| 4 | +/// | |
| 5 | +/// Commune à la fiche contact (crée une `InteractionEntity` de type `note_vocale`) | |
| 6 | +/// et à la section Notes du projet (crée une `NoteProjetEntity`). | |
| 7 | +/// La logique de sauvegarde est fournie par `sauvegarder`. | |
| 8 | +struct EnregistrementNoteSheet: View { | |
| 9 | + @StateObject private var viewModel: EnregistrementNoteViewModel | |
| 10 | + @Environment(\.dismiss) private var dismiss | |
| 11 | + | |
| 12 | + /// - Parameters: | |
| 13 | + /// - modeTranscription: mode lu depuis `PreferencesAudio` par défaut. | |
| 14 | + /// - sauvegarder: `(sujet, description, audioPath, demandeTranscription) async -> envoye`. | |
| 15 | + init( | |
| 16 | + modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription, | |
| 17 | + sauvegarder: @escaping (String, String, String, Bool) async -> Bool | |
| 18 | + ) { | |
| 19 | + _viewModel = StateObject(wrappedValue: EnregistrementNoteViewModel( | |
| 20 | + modeTranscription: modeTranscription, | |
| 21 | + sauvegarder: sauvegarder | |
| 22 | + )) | |
| 23 | + } | |
| 24 | + | |
| 25 | + var body: some View { | |
| 26 | + VStack(alignment: .leading, spacing: 0) { | |
| 27 | + barreHaut | |
| 28 | + | |
| 29 | + switch viewModel.phase { | |
| 30 | + case .demandePermission: | |
| 31 | + chargementView(label: "Vérification des permissions…") | |
| 32 | + case .permissionMicRefusee: | |
| 33 | + permissionRefuseeView( | |
| 34 | + icone: "mic.slash.circle", | |
| 35 | + titre: "Microphone", | |
| 36 | + message: "L'accès au microphone est requis pour enregistrer une note vocale. Activez-le dans Réglages → Confidentialité." | |
| 37 | + ) | |
| 38 | + case .permissionSpeechRefusee: | |
| 39 | + permissionRefuseeView( | |
| 40 | + icone: "waveform.slash", | |
| 41 | + titre: "Reconnaissance vocale", | |
| 42 | + message: "Autorisez la reconnaissance vocale pour activer la transcription sur l'appareil. Activez-la dans Réglages → Confidentialité." | |
| 43 | + ) | |
| 44 | + case .pret: | |
| 45 | + pretView | |
| 46 | + case .enregistrement: | |
| 47 | + enregistrementView | |
| 48 | + case .edition: | |
| 49 | + editionView | |
| 50 | + case .sauvegarde: | |
| 51 | + chargementView(label: "Sauvegarde en cours…") | |
| 52 | + case .termine(let message): | |
| 53 | + termineView(message: message) | |
| 54 | + } | |
| 55 | + } | |
| 56 | + .background(C2VColor.fond) | |
| 57 | + .task { | |
| 58 | + await viewModel.demanderPermissions() | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + // MARK: - Barre haute | |
| 63 | + | |
| 64 | + private var barreHaut: some View { | |
| 65 | + ZStack { | |
| 66 | + HStack { | |
| 67 | + Button("Annuler") { | |
| 68 | + viewModel.annuler() | |
| 69 | + dismiss() | |
| 70 | + } | |
| 71 | + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError)) | |
| 72 | + Spacer() | |
| 73 | + } | |
| 74 | + Text("Note vocale") | |
| 75 | + .font(C2VFont.titleMedium) | |
| 76 | + .foregroundColor(C2VColor.encre) | |
| 77 | + } | |
| 78 | + .padding(.horizontal, 12) | |
| 79 | + .padding(.vertical, 10) | |
| 80 | + .background(C2VColor.surface) | |
| 81 | + } | |
| 82 | + | |
| 83 | + // MARK: - Bandeau d'avertissement permanent | |
| 84 | + | |
| 85 | + private var bandeauAvertissement: some View { | |
| 86 | + HStack(spacing: 8) { | |
| 87 | + Image(systemName: "exclamationmark.triangle.fill") | |
| 88 | + .font(.system(size: 13)) | |
| 89 | + Text("La transcription ne distingue pas les interlocuteurs") | |
| 90 | + .font(C2VFont.bodySmall) | |
| 91 | + } | |
| 92 | + .foregroundColor(C2VColor.brandError) | |
| 93 | + .padding(.horizontal, 16) | |
| 94 | + .padding(.vertical, 8) | |
| 95 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 96 | + .background(C2VColor.brandErrorSoft) | |
| 97 | + } | |
| 98 | + | |
| 99 | + // MARK: - Phase : prêt | |
| 100 | + | |
| 101 | + private var pretView: some View { | |
| 102 | + VStack(spacing: 0) { | |
| 103 | + bandeauAvertissement | |
| 104 | + Spacer() | |
| 105 | + VStack(spacing: 20) { | |
| 106 | + Text("Appuyez pour démarrer l'enregistrement") | |
| 107 | + .font(C2VFont.bodyMedium) | |
| 108 | + .foregroundColor(C2VColor.texteFaible) | |
| 109 | + .multilineTextAlignment(.center) | |
| 110 | + .padding(.horizontal, 32) | |
| 111 | + Button { | |
| 112 | + viewModel.demarrer() | |
| 113 | + } label: { | |
| 114 | + Image(systemName: "mic.circle.fill") | |
| 115 | + .font(.system(size: 84)) | |
| 116 | + .foregroundColor(C2VColor.ink) | |
| 117 | + } | |
| 118 | + .buttonStyle(.plain) | |
| 119 | + } | |
| 120 | + Spacer() | |
| 121 | + } | |
| 122 | + .frame(maxWidth: .infinity) | |
| 123 | + } | |
| 124 | + | |
| 125 | + // MARK: - Phase : enregistrement | |
| 126 | + | |
| 127 | + private var enregistrementView: some View { | |
| 128 | + VStack(spacing: 0) { | |
| 129 | + bandeauAvertissement | |
| 130 | + Spacer() | |
| 131 | + VStack(spacing: 20) { | |
| 132 | + Text(viewModel.dureeFormatee) | |
| 133 | + .font(.system(size: 60, weight: .light, design: .monospaced)) | |
| 134 | + .foregroundColor(C2VColor.encre) | |
| 135 | + if !viewModel.transcriptionEnDirect.isEmpty { | |
| 136 | + Text(viewModel.transcriptionEnDirect) | |
| 137 | + .font(C2VFont.bodyMedium) | |
| 138 | + .foregroundColor(C2VColor.texteFaible) | |
| 139 | + .multilineTextAlignment(.center) | |
| 140 | + .padding(.horizontal, 24) | |
| 141 | + } | |
| 142 | + Button { | |
| 143 | + viewModel.arreter() | |
| 144 | + } label: { | |
| 145 | + VStack(spacing: 8) { | |
| 146 | + Image(systemName: "stop.circle.fill") | |
| 147 | + .font(.system(size: 72)) | |
| 148 | + .foregroundColor(C2VColor.brandError) | |
| 149 | + Text("Arrêter") | |
| 150 | + .font(C2VFont.labelMedium) | |
| 151 | + .foregroundColor(C2VColor.brandError) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + .buttonStyle(.plain) | |
| 155 | + } | |
| 156 | + Spacer() | |
| 157 | + } | |
| 158 | + .frame(maxWidth: .infinity) | |
| 159 | + } | |
| 160 | + | |
| 161 | + // MARK: - Phase : édition | |
| 162 | + | |
| 163 | + private var editionView: some View { | |
| 164 | + ScrollView { | |
| 165 | + VStack(alignment: .leading, spacing: 0) { | |
| 166 | + bandeauAvertissement | |
| 167 | + VStack(alignment: .leading, spacing: 12) { | |
| 168 | + Text("Sujet") | |
| 169 | + .font(C2VFont.labelMedium) | |
| 170 | + .foregroundColor(C2VColor.texteFaible) | |
| 171 | + C2VTextField( | |
| 172 | + label: "", | |
| 173 | + placeholder: "Sujet de la note", | |
| 174 | + text: $viewModel.sujet | |
| 175 | + ) | |
| 176 | + Text("Texte / transcription") | |
| 177 | + .font(C2VFont.labelMedium) | |
| 178 | + .foregroundColor(C2VColor.texteFaible) | |
| 179 | + C2VTextField( | |
| 180 | + label: "", | |
| 181 | + placeholder: "Texte (optionnel)", | |
| 182 | + text: $viewModel.descriptionNote, | |
| 183 | + minLines: 3 | |
| 184 | + ) | |
| 185 | + Button("Enregistrer") { | |
| 186 | + viewModel.sauvegarderNote() | |
| 187 | + } | |
| 188 | + .buttonStyle(C2VPrimaryButtonStyle()) | |
| 189 | + .padding(.top, 4) | |
| 190 | + } | |
| 191 | + .padding(16) | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + // MARK: - Phase : chargement | |
| 197 | + | |
| 198 | + private func chargementView(label: String) -> some View { | |
| 199 | + VStack(spacing: 16) { | |
| 200 | + Spacer() | |
| 201 | + ProgressView() | |
| 202 | + Text(label) | |
| 203 | + .font(C2VFont.bodyMedium) | |
| 204 | + .foregroundColor(C2VColor.texteFaible) | |
| 205 | + Spacer() | |
| 206 | + } | |
| 207 | + .frame(maxWidth: .infinity) | |
| 208 | + } | |
| 209 | + | |
| 210 | + // MARK: - Phase : terminé | |
| 211 | + | |
| 212 | + private func termineView(message: String) -> some View { | |
| 213 | + VStack(spacing: 20) { | |
| 214 | + Spacer() | |
| 215 | + Image(systemName: "checkmark.circle.fill") | |
| 216 | + .font(.system(size: 64)) | |
| 217 | + .foregroundColor(C2VColor.brandGreen) | |
| 218 | + Text(message) | |
| 219 | + .font(C2VFont.bodyLarge) | |
| 220 | + .foregroundColor(C2VColor.ink) | |
| 221 | + .multilineTextAlignment(.center) | |
| 222 | + .padding(.horizontal, 32) | |
| 223 | + Button("Fermer") { dismiss() } | |
| 224 | + .buttonStyle(C2VPrimaryButtonStyle()) | |
| 225 | + .padding(.horizontal, 32) | |
| 226 | + Spacer() | |
| 227 | + } | |
| 228 | + .frame(maxWidth: .infinity) | |
| 229 | + } | |
| 230 | + | |
| 231 | + // MARK: - Permission refusée | |
| 232 | + | |
| 233 | + private func permissionRefuseeView(icone: String, titre: String, message: String) -> some View { | |
| 234 | + VStack(spacing: 20) { | |
| 235 | + Spacer() | |
| 236 | + Image(systemName: icone) | |
| 237 | + .font(.system(size: 60)) | |
| 238 | + .foregroundColor(C2VColor.brandError) | |
| 239 | + Text("Permission \(titre) refusée") | |
| 240 | + .font(C2VFont.titleMedium) | |
| 241 | + .foregroundColor(C2VColor.encre) | |
| 242 | + Text(message) | |
| 243 | + .font(C2VFont.bodyMedium) | |
| 244 | + .foregroundColor(C2VColor.texteFaible) | |
| 245 | + .multilineTextAlignment(.center) | |
| 246 | + .padding(.horizontal, 32) | |
| 247 | + Button("Ouvrir les Réglages") { | |
| 248 | + if let url = URL(string: UIApplication.openSettingsURLString) { | |
| 249 | + UIApplication.shared.open(url) | |
| 250 | + } | |
| 251 | + } | |
| 252 | + .buttonStyle(C2VOutlineButtonStyle()) | |
| 253 | + .padding(.horizontal, 32) | |
| 254 | + Spacer() | |
| 255 | + } | |
| 256 | + .frame(maxWidth: .infinity) | |
| 257 | + } | |
| 258 | +} |
A
ios/Card2vcf/UI/Audio/EnregistrementNoteViewModel.swift
+198
-0
@@ -0,0 +1,198 @@
| 1 | +import AVFoundation | |
| 2 | +import Foundation | |
| 3 | +import Speech | |
| 4 | + | |
| 5 | +/// Phases du cycle de vie de la feuille d'enregistrement. | |
| 6 | +enum PhaseEnregistrement: Equatable { | |
| 7 | + case demandePermission | |
| 8 | + case permissionMicRefusee | |
| 9 | + case permissionSpeechRefusee | |
| 10 | + case pret | |
| 11 | + case enregistrement | |
| 12 | + case edition | |
| 13 | + case sauvegarde | |
| 14 | + case termine(message: String) | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// ViewModel de la feuille d'enregistrement de note vocale. | |
| 18 | +/// | |
| 19 | +/// Commun à la fiche contact (interaction `note_vocale`) et à la section Notes du projet | |
| 20 | +/// (`NoteProjetEntity`). Le contexte de sauvegarde est injecté via `sauvegarder`. | |
| 21 | +/// | |
| 22 | +/// Les méthodes statiques `sujetParDefaut` et `messageConfirmation` sont pures et | |
| 23 | +/// testables sans accès au matériel audio. | |
| 24 | +@MainActor | |
| 25 | +final class EnregistrementNoteViewModel: ObservableObject { | |
| 26 | + @Published private(set) var phase: PhaseEnregistrement = .demandePermission | |
| 27 | + @Published private(set) var dureeSecondes: Int = 0 | |
| 28 | + @Published private(set) var transcriptionEnDirect: String = "" | |
| 29 | + @Published var sujet: String = "" | |
| 30 | + @Published var descriptionNote: String = "" | |
| 31 | + | |
| 32 | + let modeTranscription: ModeTranscription | |
| 33 | + | |
| 34 | + private let recorder: AudioNoteRecorder | |
| 35 | + private let transcripteurFactory: () -> (any TranscripteurLocal)? | |
| 36 | + private var transcripteurActif: (any TranscripteurLocal)? | |
| 37 | + private var audioPathTemp: URL? | |
| 38 | + private var timerChrono: Timer? | |
| 39 | + private let sauvegarderAction: (String, String, String, Bool) async -> Bool | |
| 40 | + | |
| 41 | + /// - Parameters: | |
| 42 | + /// - modeTranscription: mode persisté ou `.serveur` si SFSpeechRecognizer indisponible. | |
| 43 | + /// - recorder: injecteur de moteur audio (faux en tests). | |
| 44 | + /// - transcripteurFactory: crée le transcripteur à l'enregistrement (faux en tests). | |
| 45 | + /// - sauvegarder: `(sujet, description, audioPath, demandeTranscription) async -> envoye`. | |
| 46 | + /// Retourne `true` si l'audio a été effectivement envoyé au serveur (push immédiat OK). | |
| 47 | + init( | |
| 48 | + modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription, | |
| 49 | + recorder: AudioNoteRecorder = AudioNoteRecorder(), | |
| 50 | + transcripteurFactory: @escaping () -> (any TranscripteurLocal)? = { TranscripteurSpeech() }, | |
| 51 | + sauvegarder: @escaping (String, String, String, Bool) async -> Bool | |
| 52 | + ) { | |
| 53 | + self.modeTranscription = modeTranscription | |
| 54 | + self.recorder = recorder | |
| 55 | + self.transcripteurFactory = transcripteurFactory | |
| 56 | + self.sauvegarderAction = sauvegarder | |
| 57 | + } | |
| 58 | + | |
| 59 | + // MARK: - Fonctions pures (testables) | |
| 60 | + | |
| 61 | + /// Génère le sujet par défaut : « Notes du jj/MM/yyyy à HH:mm ». | |
| 62 | + nonisolated static func sujetParDefaut(maintenant: Date = Date()) -> String { | |
| 63 | + let f = DateFormatter() | |
| 64 | + f.dateFormat = "dd/MM/yyyy 'à' HH:mm" | |
| 65 | + f.locale = Locale(identifier: "fr_FR") | |
| 66 | + return "Notes du \(f.string(from: maintenant))" | |
| 67 | + } | |
| 68 | + | |
| 69 | + /// Message de confirmation adapté au mode et au résultat d'envoi. | |
| 70 | + nonisolated static func messageConfirmation(mode: ModeTranscription, envoye: Bool) -> String { | |
| 71 | + switch mode { | |
| 72 | + case .appareil: | |
| 73 | + return "Note vocale enregistrée" | |
| 74 | + case .serveur: | |
| 75 | + return envoye | |
| 76 | + ? "Note vocale envoyée au serveur — la transcription est en cours" | |
| 77 | + : "Note vocale enregistrée — elle sera envoyée à la prochaine synchronisation" | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + // MARK: - Durée formatée mm:ss | |
| 82 | + | |
| 83 | + var dureeFormatee: String { | |
| 84 | + String(format: "%02d:%02d", dureeSecondes / 60, dureeSecondes % 60) | |
| 85 | + } | |
| 86 | + | |
| 87 | + // MARK: - Permissions | |
| 88 | + | |
| 89 | + func demanderPermissions() async { | |
| 90 | + // Microphone | |
| 91 | + let micOK: Bool | |
| 92 | + if #available(iOS 17.0, *) { | |
| 93 | + micOK = await AVAudioApplication.requestRecordPermission() | |
| 94 | + } else { | |
| 95 | + micOK = await withCheckedContinuation { cont in | |
| 96 | + AVAudioSession.sharedInstance().requestRecordPermission { | |
| 97 | + cont.resume(returning: $0) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | + guard micOK else { | |
| 102 | + phase = .permissionMicRefusee | |
| 103 | + return | |
| 104 | + } | |
| 105 | + | |
| 106 | + // Reconnaissance vocale (seulement en mode appareil) | |
| 107 | + if modeTranscription == .appareil { | |
| 108 | + let speechOK = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in | |
| 109 | + SFSpeechRecognizer.requestAuthorization { | |
| 110 | + cont.resume(returning: $0 == .authorized) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + guard speechOK else { | |
| 114 | + phase = .permissionSpeechRefusee | |
| 115 | + return | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + phase = .pret | |
| 120 | + } | |
| 121 | + | |
| 122 | + // MARK: - Enregistrement | |
| 123 | + | |
| 124 | + func demarrer() { | |
| 125 | + let tempURL = FileManager.default.temporaryDirectory | |
| 126 | + .appendingPathComponent("note_vocale_\(UUID().uuidString).wav") | |
| 127 | + audioPathTemp = tempURL | |
| 128 | + | |
| 129 | + let t: (any TranscripteurLocal)? = (modeTranscription == .appareil) | |
| 130 | + ? transcripteurFactory() | |
| 131 | + : nil | |
| 132 | + transcripteurActif = t | |
| 133 | + | |
| 134 | + do { | |
| 135 | + try recorder.demarrer(fichierCible: tempURL, transcripteur: t) | |
| 136 | + } catch { | |
| 137 | + phase = .permissionMicRefusee | |
| 138 | + return | |
| 139 | + } | |
| 140 | + | |
| 141 | + phase = .enregistrement | |
| 142 | + dureeSecondes = 0 | |
| 143 | + timerChrono = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | |
| 144 | + Task { @MainActor [weak self] in | |
| 145 | + guard let self, case .enregistrement = self.phase else { return } | |
| 146 | + self.dureeSecondes += 1 | |
| 147 | + // Mise à jour du texte partiel en mode appareil | |
| 148 | + if let partial = self.transcripteurActif?.accepterEchantillons([]), | |
| 149 | + !partial.isEmpty { | |
| 150 | + self.transcriptionEnDirect = partial | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + func arreter() { | |
| 157 | + timerChrono?.invalidate() | |
| 158 | + timerChrono = nil | |
| 159 | + _ = recorder.arreter() | |
| 160 | + // Récupère le texte final de transcription | |
| 161 | + if let t = transcripteurActif { | |
| 162 | + let final = t.finaliser() | |
| 163 | + if !final.isEmpty { | |
| 164 | + descriptionNote = final | |
| 165 | + } | |
| 166 | + } | |
| 167 | + transcripteurActif = nil | |
| 168 | + sujet = Self.sujetParDefaut() | |
| 169 | + phase = .edition | |
| 170 | + } | |
| 171 | + | |
| 172 | + // MARK: - Sauvegarde / annulation | |
| 173 | + | |
| 174 | + func sauvegarderNote() { | |
| 175 | + guard case .edition = phase, let audioURL = audioPathTemp else { return } | |
| 176 | + phase = .sauvegarde | |
| 177 | + let s = sujet.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 178 | + let finalSujet = s.isEmpty ? Self.sujetParDefaut() : s | |
| 179 | + let d = descriptionNote | |
| 180 | + let path = audioURL.path | |
| 181 | + let demandeTranscription = (modeTranscription == .serveur) | |
| 182 | + Task { | |
| 183 | + let envoye = await sauvegarderAction(finalSujet, d, path, demandeTranscription) | |
| 184 | + phase = .termine(message: Self.messageConfirmation(mode: modeTranscription, envoye: envoye)) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + /// Annule l'enregistrement : arrête le moteur et supprime le fichier temporaire. | |
| 189 | + func annuler() { | |
| 190 | + timerChrono?.invalidate() | |
| 191 | + timerChrono = nil | |
| 192 | + if recorder.estEnCours { _ = recorder.arreter() } | |
| 193 | + if let tmp = audioPathTemp { | |
| 194 | + try? FileManager.default.removeItem(at: tmp) | |
| 195 | + audioPathTemp = nil | |
| 196 | + } | |
| 197 | + } | |
| 198 | +} |
A
ios/Card2vcf/UI/Audio/ModeTranscription.swift
+34
-0
@@ -0,0 +1,34 @@
| 1 | +import Foundation | |
| 2 | +import Speech | |
| 3 | + | |
| 4 | +/// Mode de transcription des notes vocales. | |
| 5 | +enum ModeTranscription: String { | |
| 6 | + /// Transcription on-device via SFSpeechRecognizer (fr_FR, données non transmises). | |
| 7 | + case appareil | |
| 8 | + /// Transcription déléguée au serveur (upload audio, transcription côté serveur). | |
| 9 | + case serveur | |
| 10 | +} | |
| 11 | + | |
| 12 | +/// Préférence locale du mode de transcription (UserDefaults). | |
| 13 | +enum PreferencesAudio { | |
| 14 | + private static let cleMode = "transcription_mode" | |
| 15 | + | |
| 16 | + /// `true` si SFSpeechRecognizer est disponible pour fr_FR sur cet appareil. | |
| 17 | + /// Lorsque `false`, le mode est forcé à `.serveur`. | |
| 18 | + static var speechRecognitionDisponible: Bool { | |
| 19 | + SFSpeechRecognizer(locale: Locale(identifier: "fr_FR"))?.isAvailable == true | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// Mode persisté. Retourne toujours `.serveur` si la reconnaissance vocale est indisponible. | |
| 23 | + static var modeTranscription: ModeTranscription { | |
| 24 | + get { | |
| 25 | + if !speechRecognitionDisponible { return .serveur } | |
| 26 | + let raw = UserDefaults.standard.string(forKey: cleMode) | |
| 27 | + ?? ModeTranscription.appareil.rawValue | |
| 28 | + return ModeTranscription(rawValue: raw) ?? .appareil | |
| 29 | + } | |
| 30 | + set { | |
| 31 | + UserDefaults.standard.set(newValue.rawValue, forKey: cleMode) | |
| 32 | + } | |
| 33 | + } | |
| 34 | +} |
A
ios/Card2vcf/UI/Audio/NoteVocaleRow.swift
+143
-0
@@ -0,0 +1,143 @@
| 1 | +import AVFoundation | |
| 2 | +import SwiftUI | |
| 3 | + | |
| 4 | +/// Ligne d'affichage d'une note vocale (interaction `note_vocale` ou `NoteProjetEntity`). | |
| 5 | +/// | |
| 6 | +/// Affiche : sujet, description/transcription, badge de statut (`en_attente` / `echec`) | |
| 7 | +/// avec le motif d'erreur quand il existe, lecture audio si le fichier est présent localement, | |
| 8 | +/// et les actions « Relancer la transcription » (echec uniquement) et « Supprimer ». | |
| 9 | +struct NoteVocaleRow: View { | |
| 10 | + let sujet: String | |
| 11 | + let description: String | |
| 12 | + let audioPath: String? | |
| 13 | + let transcriptionStatut: String? | |
| 14 | + let transcriptionErreur: String? | |
| 15 | + /// Rappel de relance — fourni uniquement quand le statut vaut `echec`. | |
| 16 | + var onRelancer: (() -> Void)? = nil | |
| 17 | + /// Rappel de suppression avec confirmation intégrée. | |
| 18 | + var onSupprimer: (() -> Void)? = nil | |
| 19 | + | |
| 20 | + @State private var joueur: AVAudioPlayer? | |
| 21 | + @State private var enLecture = false | |
| 22 | + @State private var confirmSupprimer = false | |
| 23 | + | |
| 24 | + var body: some View { | |
| 25 | + VStack(alignment: .leading, spacing: 4) { | |
| 26 | + // Titre + badge statut | |
| 27 | + HStack(alignment: .top, spacing: 8) { | |
| 28 | + Image(systemName: "waveform") | |
| 29 | + .foregroundColor(C2VColor.texteFaible) | |
| 30 | + .font(.system(size: 14)) | |
| 31 | + Text(sujet) | |
| 32 | + .font(C2VFont.bodyMedium) | |
| 33 | + .foregroundColor(C2VColor.ink) | |
| 34 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 35 | + if let statut = transcriptionStatut, statut != "terminee" { | |
| 36 | + statutBadge(statut) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + // Description / texte transcrit | |
| 41 | + if !description.isEmpty { | |
| 42 | + Text(description) | |
| 43 | + .font(C2VFont.bodySmall) | |
| 44 | + .foregroundColor(C2VColor.texteFaible) | |
| 45 | + } | |
| 46 | + | |
| 47 | + // Motif d'erreur (quand echec) | |
| 48 | + if let erreur = transcriptionErreur, !erreur.isEmpty { | |
| 49 | + HStack(spacing: 4) { | |
| 50 | + Image(systemName: "exclamationmark.circle") | |
| 51 | + .font(.system(size: 11)) | |
| 52 | + Text("Erreur : \(erreur)") | |
| 53 | + .font(C2VFont.bodySmall) | |
| 54 | + } | |
| 55 | + .foregroundColor(C2VColor.brandError) | |
| 56 | + } | |
| 57 | + | |
| 58 | + // Lecteur audio (fichier local uniquement) | |
| 59 | + if let path = audioPath, FileManager.default.fileExists(atPath: path) { | |
| 60 | + lecteurAudio(path: path) | |
| 61 | + } | |
| 62 | + | |
| 63 | + // Actions | |
| 64 | + if transcriptionStatut == "echec", let relancer = onRelancer { | |
| 65 | + Button("Relancer la transcription") { relancer() } | |
| 66 | + .buttonStyle(C2VTextButtonStyle()) | |
| 67 | + .font(C2VFont.labelSmall) | |
| 68 | + } | |
| 69 | + if onSupprimer != nil { | |
| 70 | + Button("Supprimer") { confirmSupprimer = true } | |
| 71 | + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError)) | |
| 72 | + .font(C2VFont.labelSmall) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 76 | + .confirmationDialog( | |
| 77 | + "Supprimer cette note vocale ?", | |
| 78 | + isPresented: $confirmSupprimer, | |
| 79 | + titleVisibility: .visible | |
| 80 | + ) { | |
| 81 | + Button("Supprimer", role: .destructive) { onSupprimer?() } | |
| 82 | + Button("Annuler", role: .cancel) {} | |
| 83 | + } | |
| 84 | + .onDisappear { | |
| 85 | + joueur?.stop() | |
| 86 | + joueur = nil | |
| 87 | + enLecture = false | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + // MARK: - Badge statut | |
| 92 | + | |
| 93 | + @ViewBuilder | |
| 94 | + private func statutBadge(_ statut: String) -> some View { | |
| 95 | + let (label, color): (String, Color) = switch statut { | |
| 96 | + case "en_attente": ("En attente", C2VColor.brandWarn) | |
| 97 | + case "echec": ("Échec", C2VColor.brandError) | |
| 98 | + default: (statut, C2VColor.texteFaible) | |
| 99 | + } | |
| 100 | + Text(label) | |
| 101 | + .font(C2VFont.labelSmall) | |
| 102 | + .foregroundColor(color) | |
| 103 | + .padding(.horizontal, 6) | |
| 104 | + .padding(.vertical, 2) | |
| 105 | + .overlay(Rectangle().stroke(color, lineWidth: 1)) | |
| 106 | + } | |
| 107 | + | |
| 108 | + // MARK: - Lecteur audio | |
| 109 | + | |
| 110 | + private func lecteurAudio(path: String) -> some View { | |
| 111 | + Button { | |
| 112 | + toggleLecture(path: path) | |
| 113 | + } label: { | |
| 114 | + HStack(spacing: 6) { | |
| 115 | + Image(systemName: enLecture ? "pause.circle" : "play.circle") | |
| 116 | + .font(.system(size: 22)) | |
| 117 | + Text(enLecture ? "Pause" : "Écouter") | |
| 118 | + .font(C2VFont.labelSmall) | |
| 119 | + } | |
| 120 | + .foregroundColor(C2VColor.ink) | |
| 121 | + } | |
| 122 | + .buttonStyle(.plain) | |
| 123 | + .padding(.top, 2) | |
| 124 | + } | |
| 125 | + | |
| 126 | + private func toggleLecture(path: String) { | |
| 127 | + if enLecture { | |
| 128 | + joueur?.pause() | |
| 129 | + enLecture = false | |
| 130 | + } else { | |
| 131 | + do { | |
| 132 | + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) | |
| 133 | + try AVAudioSession.sharedInstance().setActive(true) | |
| 134 | + let url = URL(fileURLWithPath: path) | |
| 135 | + joueur = try AVAudioPlayer(contentsOf: url) | |
| 136 | + joueur?.play() | |
| 137 | + enLecture = true | |
| 138 | + } catch { | |
| 139 | + enLecture = false | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | +} |
M
ios/Card2vcf/UI/Contact/ContactPagerScreen.swift
+184
-0
@@ -112,6 +112,9 @@ private struct ContactFichePage: View {
| 112 | 112 | @State private var notesDraft: String |
| 113 | 113 | @State private var showSystemContact = false |
| 114 | 114 | @State private var confirmDelete = false |
| 115 | + @State private var interactionsNoteVocale: [InteractionEntity] = [] | |
| 116 | + @State private var afficherEnregistrement = false | |
| 117 | + @State private var relanceErreur: String? | |
| 115 | 118 | |
| 116 | 119 | init( |
| 117 | 120 | contact: CrmContactEntity, |
@@ -160,6 +163,9 @@ private struct ContactFichePage: View {
| 160 | 163 | notesEditor |
| 161 | 164 | |
| 162 | 165 | C2VDivider() |
| 166 | + notesVocalesSection | |
| 167 | + | |
| 168 | + C2VDivider() | |
| 163 | 169 | actions |
| 164 | 170 | } |
| 165 | 171 | .padding(.horizontal, 18) |
@@ -178,6 +184,19 @@ private struct ContactFichePage: View {
| 178 | 184 | Button("Supprimer", role: .destructive, action: onDelete) |
| 179 | 185 | Button("Annuler", role: .cancel) {} |
| 180 | 186 | } |
| 187 | + .sheet(isPresented: $afficherEnregistrement) { | |
| 188 | + let contactServerId = contact.serverId | |
| 189 | + EnregistrementNoteSheet { sujet, desc, path, transcription in | |
| 190 | + await ContactFichePage.sauvegarderInteractionVocale( | |
| 191 | + contactServerId: contactServerId, | |
| 192 | + sujet: sujet, description: desc, | |
| 193 | + audioPath: path, demandeTranscription: transcription | |
| 194 | + ) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + .task(id: contact.id) { | |
| 198 | + await chargerInteractions() | |
| 199 | + } | |
| 181 | 200 | } |
| 182 | 201 | |
| 183 | 202 | private var phones: [String] { contact.phones.filter { $0.isNotBlank } } |
@@ -261,4 +280,169 @@ private struct ContactFichePage: View {
| 261 | 280 | .frame(maxWidth: .infinity, alignment: .leading) |
| 262 | 281 | } |
| 263 | 282 | } |
| 283 | + | |
| 284 | + // MARK: - Notes vocales | |
| 285 | + | |
| 286 | + private var notesVocalesSection: some View { | |
| 287 | + VStack(alignment: .leading, spacing: 6) { | |
| 288 | + HStack { | |
| 289 | + Text("Notes vocales") | |
| 290 | + .font(C2VFont.labelMedium) | |
| 291 | + .foregroundColor(C2VColor.texteFaible) | |
| 292 | + Spacer() | |
| 293 | + Button { | |
| 294 | + afficherEnregistrement = true | |
| 295 | + } label: { | |
| 296 | + Label("Nouvelle note vocale", systemImage: "mic.badge.plus") | |
| 297 | + .font(C2VFont.labelSmall) | |
| 298 | + .foregroundColor(C2VColor.ink) | |
| 299 | + } | |
| 300 | + .buttonStyle(.plain) | |
| 301 | + } | |
| 302 | + if let erreur = relanceErreur { | |
| 303 | + Text(erreur) | |
| 304 | + .font(C2VFont.bodySmall) | |
| 305 | + .foregroundColor(C2VColor.brandError) | |
| 306 | + } | |
| 307 | + if interactionsNoteVocale.isEmpty { | |
| 308 | + Text("Aucune note vocale") | |
| 309 | + .font(C2VFont.bodyMedium) | |
| 310 | + .foregroundColor(C2VColor.texteFaible) | |
| 311 | + } else { | |
| 312 | + ForEach(interactionsNoteVocale, id: \.localId) { interaction in | |
| 313 | + NoteVocaleRow( | |
| 314 | + sujet: interaction.sujet, | |
| 315 | + description: interaction.description, | |
| 316 | + audioPath: interaction.audioPath, | |
| 317 | + transcriptionStatut: interaction.transcriptionStatut, | |
| 318 | + transcriptionErreur: interaction.transcriptionErreur, | |
| 319 | + onRelancer: { | |
| 320 | + Task { await relancerTranscription(localId: interaction.localId) } | |
| 321 | + }, | |
| 322 | + onSupprimer: { | |
| 323 | + Task { await supprimerInteraction(interaction) } | |
| 324 | + } | |
| 325 | + ) | |
| 326 | + C2VDivider() | |
| 327 | + } | |
| 328 | + } | |
| 329 | + } | |
| 330 | + } | |
| 331 | + | |
| 332 | + private func chargerInteractions() async { | |
| 333 | + guard let serverId = contact.serverId else { return } | |
| 334 | + let all = (try? await Card2vcfDatabase.shared.interactionDao | |
| 335 | + .fetchByContactServerId(serverId)) ?? [] | |
| 336 | + interactionsNoteVocale = all | |
| 337 | + .filter { $0.type == "note_vocale" } | |
| 338 | + .sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) } | |
| 339 | + } | |
| 340 | + | |
| 341 | + private func relancerTranscription(localId: Int64) async { | |
| 342 | + relanceErreur = nil | |
| 343 | + guard let baseUrl = SyncCredentialsStore().baseUrl, | |
| 344 | + let apiKey = SyncCredentialsStore().apiKey else { | |
| 345 | + relanceErreur = "Relance impossible : serveur non configuré." | |
| 346 | + return | |
| 347 | + } | |
| 348 | + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) | |
| 349 | + let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared) | |
| 350 | + if let erreur = try? await engine.relancerTranscriptionInteraction(localId: localId) { | |
| 351 | + relanceErreur = erreur | |
| 352 | + } | |
| 353 | + await chargerInteractions() | |
| 354 | + } | |
| 355 | + | |
| 356 | + private func supprimerInteraction(_ interaction: InteractionEntity) async { | |
| 357 | + let db = Card2vcfDatabase.shared | |
| 358 | + // 1. Supprimer le fichier audio local s'il existe. | |
| 359 | + if let path = interaction.audioPath { | |
| 360 | + try? FileManager.default.removeItem(atPath: path) | |
| 361 | + } | |
| 362 | + // 2. Op de synchronisation uniquement si l'entité a déjà un serverId. | |
| 363 | + if let serverId = interaction.serverId { | |
| 364 | + var op = SyncOpEntity(entityType: "interaction", op: "delete") | |
| 365 | + op.serverId = serverId | |
| 366 | + op.createdAt = ContactRepository.currentMillis() | |
| 367 | + _ = try? await db.syncOpDao.insert(op) | |
| 368 | + } | |
| 369 | + // 3. Suppression locale. | |
| 370 | + try? await db.interactionDao.deleteByLocalId(interaction.localId) | |
| 371 | + await chargerInteractions() | |
| 372 | + } | |
| 373 | + | |
| 374 | + // MARK: - Sauvegarde note vocale (statique pour capture sûre) | |
| 375 | + | |
| 376 | + static func sauvegarderInteractionVocale( | |
| 377 | + contactServerId: String?, | |
| 378 | + sujet: String, | |
| 379 | + description: String, | |
| 380 | + audioPath: String, | |
| 381 | + demandeTranscription: Bool | |
| 382 | + ) async -> Bool { | |
| 383 | + guard let contactServerId else { return false } | |
| 384 | + let db = Card2vcfDatabase.shared | |
| 385 | + let now = ContactRepository.currentMillis() | |
| 386 | + | |
| 387 | + // Insertion | |
| 388 | + var entity = InteractionEntity() | |
| 389 | + entity.contactServerId = contactServerId | |
| 390 | + entity.type = "note_vocale" | |
| 391 | + entity.sujet = sujet | |
| 392 | + entity.description = description | |
| 393 | + entity.creePar = SyncUserContext.userName | |
| 394 | + entity.createdAt = now | |
| 395 | + if demandeTranscription { entity.transcriptionStatut = "en_attente" } | |
| 396 | + let localId = (try? await db.interactionDao.upsert(entity)) ?? 0 | |
| 397 | + | |
| 398 | + // Déplacement vers le store stable | |
| 399 | + if let store = try? AudioNoteStore.defaultStore(), localId > 0 { | |
| 400 | + let stablePath = store.pathForInteraction(localId: localId) | |
| 401 | + if FileManager.default.fileExists(atPath: audioPath), | |
| 402 | + (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil { | |
| 403 | + var withPath = entity | |
| 404 | + withPath.localId = localId | |
| 405 | + withPath.audioPath = stablePath | |
| 406 | + _ = try? await db.interactionDao.upsert(withPath) | |
| 407 | + } | |
| 408 | + } | |
| 409 | + | |
| 410 | + // Op de synchronisation | |
| 411 | + var op = SyncOpEntity(entityType: "interaction", op: "create") | |
| 412 | + op.payloadJson = encodeInteractionPayload( | |
| 413 | + sujet: sujet, description: description, | |
| 414 | + demandeTranscription: demandeTranscription | |
| 415 | + ) | |
| 416 | + op.serverId = contactServerId | |
| 417 | + op.localId = localId | |
| 418 | + op.createdAt = now | |
| 419 | + _ = try? await db.syncOpDao.insert(op) | |
| 420 | + | |
| 421 | + // Push immédiat en mode serveur | |
| 422 | + if demandeTranscription, | |
| 423 | + let baseUrl = SyncCredentialsStore().baseUrl, | |
| 424 | + let apiKey = SyncCredentialsStore().apiKey { | |
| 425 | + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) | |
| 426 | + let engine = SyncEngine(api: api, db: db) | |
| 427 | + let result = try? await engine.pousserEnAttente() | |
| 428 | + return result?.success == true | |
| 429 | + } | |
| 430 | + return false | |
| 431 | + } | |
| 432 | + | |
| 433 | + private static func encodeInteractionPayload( | |
| 434 | + sujet: String, | |
| 435 | + description: String, | |
| 436 | + demandeTranscription: Bool | |
| 437 | + ) -> String { | |
| 438 | + let obj: [String: Any] = [ | |
| 439 | + "demande_transcription": demandeTranscription, | |
| 440 | + "description": description, | |
| 441 | + "sujet": sujet, | |
| 442 | + "type_interaction": "note_vocale" | |
| 443 | + ] | |
| 444 | + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), | |
| 445 | + let text = String(data: data, encoding: .utf8) else { return "{}" } | |
| 446 | + return text | |
| 447 | + } | |
| 264 | 448 | } |
M
ios/Card2vcf/UI/Projets/ProjetDetailScreen.swift
+235
-0
@@ -6,6 +6,9 @@ import SwiftUI
| 6 | 6 | struct ProjetDetailScreen: View { |
| 7 | 7 | @StateObject private var viewModel: ProjetDetailViewModel |
| 8 | 8 | let onBack: () -> Void |
| 9 | + @State private var afficherEnregistrement = false | |
| 10 | + @State private var relanceErreur: String? | |
| 11 | + @State private var noteTextASupprimer: NoteProjetEntity? | |
| 9 | 12 | |
| 10 | 13 | init(serverId: String, onBack: @escaping () -> Void) { |
| 11 | 14 | _viewModel = StateObject(wrappedValue: ProjetDetailViewModel(projetServerId: serverId)) |
@@ -58,6 +61,10 @@ struct ProjetDetailScreen: View {
| 58 | 61 | |
| 59 | 62 | crSection |
| 60 | 63 | |
| 64 | + C2VDivider().padding(.vertical, 12) | |
| 65 | + | |
| 66 | + notesSection | |
| 67 | + | |
| 61 | 68 | Spacer().frame(height: 24) |
| 62 | 69 | } |
| 63 | 70 | .padding(.horizontal, 18) |
@@ -66,6 +73,32 @@ struct ProjetDetailScreen: View {
| 66 | 73 | .background(C2VColor.fond) |
| 67 | 74 | .toolbar(.hidden, for: .navigationBar) |
| 68 | 75 | .task { await viewModel.refresh() } |
| 76 | + .sheet(isPresented: $afficherEnregistrement) { | |
| 77 | + let projetServerId = viewModel.projetServerId | |
| 78 | + EnregistrementNoteSheet { sujet, desc, path, transcription in | |
| 79 | + await ProjetDetailScreen.sauvegarderNoteProjetVocale( | |
| 80 | + projetServerId: projetServerId, | |
| 81 | + sujet: sujet, description: desc, | |
| 82 | + audioPath: path, demandeTranscription: transcription | |
| 83 | + ) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + .confirmationDialog( | |
| 87 | + "Supprimer cette note ?", | |
| 88 | + isPresented: Binding( | |
| 89 | + get: { noteTextASupprimer != nil }, | |
| 90 | + set: { if !$0 { noteTextASupprimer = nil } } | |
| 91 | + ), | |
| 92 | + titleVisibility: .visible | |
| 93 | + ) { | |
| 94 | + Button("Supprimer", role: .destructive) { | |
| 95 | + if let note = noteTextASupprimer { | |
| 96 | + noteTextASupprimer = nil | |
| 97 | + Task { await supprimerNote(note) } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + Button("Annuler", role: .cancel) { noteTextASupprimer = nil } | |
| 101 | + } | |
| 69 | 102 | } |
| 70 | 103 | |
| 71 | 104 | private var crSection: some View { |
@@ -134,4 +167,206 @@ struct ProjetDetailScreen: View {
| 134 | 167 | .trimmingCharacters(in: .whitespaces) |
| 135 | 168 | return composed.isEmpty ? "—" : composed |
| 136 | 169 | } |
| 170 | + | |
| 171 | + // MARK: - Section Notes | |
| 172 | + | |
| 173 | + private var notesSection: some View { | |
| 174 | + VStack(alignment: .leading, spacing: 0) { | |
| 175 | + HStack { | |
| 176 | + Text("Notes") | |
| 177 | + .font(C2VFont.labelMedium) | |
| 178 | + .foregroundColor(C2VColor.texteFaible) | |
| 179 | + Spacer() | |
| 180 | + Button { | |
| 181 | + afficherEnregistrement = true | |
| 182 | + } label: { | |
| 183 | + Label("Note vocale", systemImage: "mic.badge.plus") | |
| 184 | + .font(C2VFont.labelSmall) | |
| 185 | + .foregroundColor(C2VColor.ink) | |
| 186 | + } | |
| 187 | + .buttonStyle(.plain) | |
| 188 | + } | |
| 189 | + Spacer().frame(height: 6) | |
| 190 | + if let erreur = relanceErreur { | |
| 191 | + Text(erreur) | |
| 192 | + .font(C2VFont.bodySmall) | |
| 193 | + .foregroundColor(C2VColor.brandError) | |
| 194 | + .padding(.bottom, 4) | |
| 195 | + } | |
| 196 | + if viewModel.notesProjet.isEmpty { | |
| 197 | + Text("Aucune note pour ce projet") | |
| 198 | + .font(C2VFont.bodyMedium) | |
| 199 | + .foregroundColor(C2VColor.texteFaible) | |
| 200 | + } else { | |
| 201 | + ForEach(viewModel.notesProjet, id: \.localId) { note in | |
| 202 | + if note.audioPath != nil || note.transcriptionStatut != nil { | |
| 203 | + NoteVocaleRow( | |
| 204 | + sujet: note.titre, | |
| 205 | + description: note.texte, | |
| 206 | + audioPath: note.audioPath, | |
| 207 | + transcriptionStatut: note.transcriptionStatut, | |
| 208 | + transcriptionErreur: note.transcriptionErreur, | |
| 209 | + onRelancer: { | |
| 210 | + Task { await relancerTranscriptionNote(localId: note.localId) } | |
| 211 | + }, | |
| 212 | + onSupprimer: { | |
| 213 | + Task { await supprimerNote(note) } | |
| 214 | + } | |
| 215 | + ) | |
| 216 | + .padding(.vertical, 8) | |
| 217 | + } else { | |
| 218 | + VStack(alignment: .leading, spacing: 4) { | |
| 219 | + Text(note.titre) | |
| 220 | + .font(C2VFont.labelMedium) | |
| 221 | + .foregroundColor(C2VColor.ink) | |
| 222 | + Text("\(note.auteur) · \(noteDate(note))") | |
| 223 | + .font(C2VFont.bodySmall) | |
| 224 | + .foregroundColor(C2VColor.texteFaible) | |
| 225 | + if note.texte.isNotBlank { | |
| 226 | + Text(renderMarkdown(note.texte)) | |
| 227 | + .font(C2VFont.bodyMedium) | |
| 228 | + .foregroundColor(C2VColor.ink) | |
| 229 | + } | |
| 230 | + Button("Supprimer") { noteTextASupprimer = note } | |
| 231 | + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError)) | |
| 232 | + .font(C2VFont.labelSmall) | |
| 233 | + } | |
| 234 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 235 | + .padding(.vertical, 8) | |
| 236 | + } | |
| 237 | + C2VDivider() | |
| 238 | + } | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + private func noteDate(_ note: NoteProjetEntity) -> String { | |
| 244 | + let millis = note.updatedAt ?? note.createdAt | |
| 245 | + let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1000) | |
| 246 | + return Self.noteDateFormatter.string(from: date) | |
| 247 | + } | |
| 248 | + | |
| 249 | + private static let noteDateFormatter: DateFormatter = { | |
| 250 | + let f = DateFormatter() | |
| 251 | + f.dateStyle = .medium | |
| 252 | + f.timeStyle = .none | |
| 253 | + f.locale = Locale(identifier: "fr_FR") | |
| 254 | + return f | |
| 255 | + }() | |
| 256 | + | |
| 257 | + private func renderMarkdown(_ text: String) -> AttributedString { | |
| 258 | + (try? AttributedString(markdown: text)) ?? AttributedString(text) | |
| 259 | + } | |
| 260 | + | |
| 261 | + // MARK: - Relance et suppression | |
| 262 | + | |
| 263 | + private func relancerTranscriptionNote(localId: Int64) async { | |
| 264 | + relanceErreur = nil | |
| 265 | + guard let baseUrl = SyncCredentialsStore().baseUrl, | |
| 266 | + let apiKey = SyncCredentialsStore().apiKey else { | |
| 267 | + relanceErreur = "Relance impossible : serveur non configuré." | |
| 268 | + return | |
| 269 | + } | |
| 270 | + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) | |
| 271 | + let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared) | |
| 272 | + if let erreur = try? await engine.relancerTranscriptionNoteProjet(localId: localId) { | |
| 273 | + relanceErreur = erreur | |
| 274 | + } | |
| 275 | + await viewModel.refresh() | |
| 276 | + } | |
| 277 | + | |
| 278 | + private func supprimerNote(_ note: NoteProjetEntity) async { | |
| 279 | + let db = Card2vcfDatabase.shared | |
| 280 | + // 1. Supprimer le fichier audio local s'il existe. | |
| 281 | + if let path = note.audioPath { | |
| 282 | + try? FileManager.default.removeItem(atPath: path) | |
| 283 | + } | |
| 284 | + // 2. Op de synchronisation — encodage du projetServerId dans le payload. | |
| 285 | + if let serverId = note.serverId { | |
| 286 | + let payload: [String: Any] = ["projetServerId": note.projetServerId] | |
| 287 | + let payloadJson = (try? JSONSerialization.data(withJSONObject: payload)) | |
| 288 | + .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" | |
| 289 | + var op = SyncOpEntity(entityType: "note_projet", op: "delete") | |
| 290 | + op.serverId = serverId | |
| 291 | + op.payloadJson = payloadJson | |
| 292 | + op.createdAt = ContactRepository.currentMillis() | |
| 293 | + _ = try? await db.syncOpDao.insert(op) | |
| 294 | + } | |
| 295 | + // 3. Suppression locale. | |
| 296 | + try? await db.noteProjetDao.deleteByLocalId(note.localId) | |
| 297 | + await viewModel.refresh() | |
| 298 | + } | |
| 299 | + | |
| 300 | + // MARK: - Sauvegarde note vocale (statique pour capture sûre) | |
| 301 | + | |
| 302 | + static func sauvegarderNoteProjetVocale( | |
| 303 | + projetServerId: String, | |
| 304 | + sujet: String, | |
| 305 | + description: String, | |
| 306 | + audioPath: String, | |
| 307 | + demandeTranscription: Bool | |
| 308 | + ) async -> Bool { | |
| 309 | + let db = Card2vcfDatabase.shared | |
| 310 | + let now = ContactRepository.currentMillis() | |
| 311 | + | |
| 312 | + // Insertion de la note | |
| 313 | + var entity = NoteProjetEntity() | |
| 314 | + entity.projetServerId = projetServerId | |
| 315 | + entity.titre = sujet | |
| 316 | + entity.texte = description | |
| 317 | + entity.auteur = SyncUserContext.userName | |
| 318 | + entity.createdAt = now | |
| 319 | + if demandeTranscription { entity.transcriptionStatut = "en_attente" } | |
| 320 | + let localId = (try? await db.noteProjetDao.upsert(entity)) ?? 0 | |
| 321 | + | |
| 322 | + // Déplacement vers le store stable | |
| 323 | + if let store = try? AudioNoteStore.defaultStore(), localId > 0 { | |
| 324 | + let stablePath = store.pathForNote(localId: localId) | |
| 325 | + if FileManager.default.fileExists(atPath: audioPath), | |
| 326 | + (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil { | |
| 327 | + var withPath = entity | |
| 328 | + withPath.localId = localId | |
| 329 | + withPath.audioPath = stablePath | |
| 330 | + _ = try? await db.noteProjetDao.upsert(withPath) | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + // Op de synchronisation | |
| 335 | + let payload = encodeNoteProjetPayload( | |
| 336 | + titre: sujet, contenu: description, | |
| 337 | + demandeTranscription: demandeTranscription | |
| 338 | + ) | |
| 339 | + var op = SyncOpEntity(entityType: "note_projet", op: "create") | |
| 340 | + op.payloadJson = payload | |
| 341 | + op.serverId = projetServerId | |
| 342 | + op.localId = localId | |
| 343 | + op.createdAt = now | |
| 344 | + _ = try? await db.syncOpDao.insert(op) | |
| 345 | + | |
| 346 | + // Push immédiat en mode serveur | |
| 347 | + if demandeTranscription, | |
| 348 | + let baseUrl = SyncCredentialsStore().baseUrl, | |
| 349 | + let apiKey = SyncCredentialsStore().apiKey { | |
| 350 | + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) | |
| 351 | + let engine = SyncEngine(api: api, db: db) | |
| 352 | + let result = try? await engine.pousserEnAttente() | |
| 353 | + return result?.success == true | |
| 354 | + } | |
| 355 | + return false | |
| 356 | + } | |
| 357 | + | |
| 358 | + private static func encodeNoteProjetPayload( | |
| 359 | + titre: String, | |
| 360 | + contenu: String, | |
| 361 | + demandeTranscription: Bool | |
| 362 | + ) -> String { | |
| 363 | + let obj: [String: Any] = [ | |
| 364 | + "contenu": contenu, | |
| 365 | + "demande_transcription": demandeTranscription, | |
| 366 | + "titre": titre | |
| 367 | + ] | |
| 368 | + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), | |
| 369 | + let text = String(data: data, encoding: .utf8) else { return "{}" } | |
| 370 | + return text | |
| 371 | + } | |
| 137 | 372 | } |
M
ios/Card2vcf/UI/Projets/ProjetDetailViewModel.swift
+5
-2
@@ -55,16 +55,17 @@ final class ProjetDetailViewModel: ObservableObject {
| 55 | 55 | @Published var crSujet = "" |
| 56 | 56 | @Published var crDescription = "" |
| 57 | 57 | @Published private(set) var crInteractions: [InteractionEntity] = [] |
| 58 | + @Published private(set) var notesProjet: [NoteProjetEntity] = [] | |
| 58 | 59 | |
| 59 | 60 | let projetServerId: String |
| 60 | 61 | let userName: String |
| 61 | 62 | |
| 62 | - private let database: Card2vcfDatabase | |
| 63 | + private let database: any CrmDatabase | |
| 63 | 64 | private var cancellables: Set<AnyCancellable> = [] |
| 64 | 65 | |
| 65 | 66 | init( |
| 66 | 67 | projetServerId: String, |
| 67 | - database: Card2vcfDatabase = .shared, | |
| 68 | + database: any CrmDatabase = Card2vcfDatabase.shared, | |
| 68 | 69 | userName: String = SyncUserContext.userName |
| 69 | 70 | ) { |
| 70 | 71 | self.projetServerId = projetServerId |
@@ -96,6 +97,8 @@ final class ProjetDetailViewModel: ObservableObject {
| 96 | 97 | colonnes = [] |
| 97 | 98 | } |
| 98 | 99 | taches = (try? await db.tacheDao.fetchByProjetServerId(projetServerId)) ?? [] |
| 100 | + let rawNotes = (try? await db.noteProjetDao.listByProjetServerId(projetServerId)) ?? [] | |
| 101 | + notesProjet = rawNotes.sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) } | |
| 99 | 102 | let allContacts = (try? await db.crmContactDao.fetchAll()) ?? [] |
| 100 | 103 | contactsAvecServerId = allContacts.filter { $0.serverId != nil } |
| 101 | 104 | await refreshInteractions() |
A
ios/Card2vcf/UI/Settings/AProposScreen.swift
+85
-0
@@ -0,0 +1,85 @@
| 1 | +import SwiftUI | |
| 2 | + | |
| 3 | +/// Écran « À propos » accessible depuis les Réglages. | |
| 4 | +/// Version et nom lus depuis le bundle (jamais en dur). | |
| 5 | +struct AProposScreen: View { | |
| 6 | + let onBack: () -> Void | |
| 7 | + | |
| 8 | + private var appVersion: String { | |
| 9 | + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" | |
| 10 | + } | |
| 11 | + | |
| 12 | + private var appName: String { | |
| 13 | + Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String | |
| 14 | + ?? Bundle.main.infoDictionary?["CFBundleName"] as? String | |
| 15 | + ?? "PicLead" | |
| 16 | + } | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + VStack(spacing: 0) { | |
| 20 | + ScreenTopBar(title: "À propos", onBack: onBack) | |
| 21 | + ScrollView { | |
| 22 | + VStack(alignment: .leading, spacing: 20) { | |
| 23 | + | |
| 24 | + // Nom + version | |
| 25 | + VStack(alignment: .leading, spacing: 4) { | |
| 26 | + Text(appName) | |
| 27 | + .font(C2VFont.titleMedium) | |
| 28 | + .foregroundColor(C2VColor.encre) | |
| 29 | + Text("Version \(appVersion)") | |
| 30 | + .font(C2VFont.bodyMedium) | |
| 31 | + .foregroundColor(C2VColor.texteFaible) | |
| 32 | + } | |
| 33 | + | |
| 34 | + C2VDivider() | |
| 35 | + | |
| 36 | + // Éditeur | |
| 37 | + VStack(alignment: .leading, spacing: 4) { | |
| 38 | + Text("Éditeur") | |
| 39 | + .font(C2VFont.labelMedium) | |
| 40 | + .foregroundColor(C2VColor.texteFaible) | |
| 41 | + Text("Eric Bouhana Ingénierie Informatique (ebii)") | |
| 42 | + .font(C2VFont.bodyLarge) | |
| 43 | + .foregroundColor(C2VColor.ink) | |
| 44 | + Button("https://www.ebii.fr/fr/editions/piclead") { | |
| 45 | + ExternalLinks.web("https://www.ebii.fr/fr/editions/piclead") | |
| 46 | + } | |
| 47 | + .buttonStyle(.plain) | |
| 48 | + .font(C2VFont.bodyMedium) | |
| 49 | + .foregroundColor(C2VColor.link) | |
| 50 | + Button("contact@ebii.fr") { | |
| 51 | + ExternalLinks.email("contact@ebii.fr") | |
| 52 | + } | |
| 53 | + .buttonStyle(.plain) | |
| 54 | + .font(C2VFont.bodyMedium) | |
| 55 | + .foregroundColor(C2VColor.link) | |
| 56 | + } | |
| 57 | + | |
| 58 | + C2VDivider() | |
| 59 | + | |
| 60 | + // Licence | |
| 61 | + VStack(alignment: .leading, spacing: 4) { | |
| 62 | + Text("Licence") | |
| 63 | + .font(C2VFont.labelMedium) | |
| 64 | + .foregroundColor(C2VColor.texteFaible) | |
| 65 | + Text("GNU Affero General Public License v3 (AGPL-3.0)") | |
| 66 | + .font(C2VFont.bodyLarge) | |
| 67 | + .foregroundColor(C2VColor.ink) | |
| 68 | + Text( | |
| 69 | + "Ce logiciel est un logiciel libre distribué selon les termes de la " | |
| 70 | + + "licence GNU AGPL v3. Vous pouvez le redistribuer et/ou le modifier " | |
| 71 | + + "sous les conditions définies par la Free Software Foundation." | |
| 72 | + ) | |
| 73 | + .font(C2VFont.bodyMedium) | |
| 74 | + .foregroundColor(C2VColor.texteFaible) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 78 | + .padding(18) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + .frame(maxHeight: .infinity, alignment: .top) | |
| 82 | + .background(C2VColor.fond) | |
| 83 | + .toolbar(.hidden, for: .navigationBar) | |
| 84 | + } | |
| 85 | +} |
M
ios/Card2vcf/UI/Settings/SettingsScreen.swift
+47
-0
@@ -25,6 +25,7 @@ struct SettingsScreen: View {
| 25 | 25 | let onBack: () -> Void |
| 26 | 26 | |
| 27 | 27 | @State private var hasCalendarPermission = CalendarPermission.granted |
| 28 | + @State private var afficherAPropos = false | |
| 28 | 29 | |
| 29 | 30 | var body: some View { |
| 30 | 31 | VStack(spacing: 0) { |
@@ -35,6 +36,12 @@ struct SettingsScreen: View {
| 35 | 36 | case .loggedOut(let state): |
| 36 | 37 | loggedOutContent(state) |
| 37 | 38 | } |
| 39 | + C2VDivider() | |
| 40 | + Button("À propos") { afficherAPropos = true } | |
| 41 | + .buttonStyle(C2VTextButtonStyle(color: C2VColor.texteFaible)) | |
| 42 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 43 | + .padding(.horizontal, 10) | |
| 44 | + .padding(.vertical, 6) | |
| 38 | 45 | } |
| 39 | 46 | .frame(maxHeight: .infinity, alignment: .top) |
| 40 | 47 | .background(C2VColor.fond) |
@@ -45,6 +52,9 @@ struct SettingsScreen: View {
| 45 | 52 | viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission) |
| 46 | 53 | } |
| 47 | 54 | .overlay { dialogs } |
| 55 | + .sheet(isPresented: $afficherAPropos) { | |
| 56 | + AProposScreen(onBack: { afficherAPropos = false }) | |
| 57 | + } | |
| 48 | 58 | } |
| 49 | 59 | |
| 50 | 60 | // ---- Logged in ---- |
@@ -68,6 +78,10 @@ struct SettingsScreen: View {
| 68 | 78 | C2VDivider() |
| 69 | 79 | |
| 70 | 80 | calendarSection(state) |
| 81 | + | |
| 82 | + C2VDivider() | |
| 83 | + | |
| 84 | + transcriptionSection(state) | |
| 71 | 85 | } |
| 72 | 86 | .frame(maxWidth: .infinity, alignment: .leading) |
| 73 | 87 | .padding(18) |
@@ -147,6 +161,39 @@ struct SettingsScreen: View {
| 147 | 161 | } |
| 148 | 162 | } |
| 149 | 163 | |
| 164 | + @ViewBuilder | |
| 165 | + private func transcriptionSection(_ state: SettingsUiState.LoggedIn) -> some View { | |
| 166 | + VStack(alignment: .leading, spacing: 8) { | |
| 167 | + Text("Transcription des notes vocales") | |
| 168 | + .font(C2VFont.labelMedium) | |
| 169 | + .foregroundColor(C2VColor.ink) | |
| 170 | + | |
| 171 | + if !state.speechRecognitionDisponible { | |
| 172 | + Text("La reconnaissance vocale n'est pas disponible sur cet appareil. La transcription s'effectue sur le serveur.") | |
| 173 | + .font(C2VFont.bodyMedium) | |
| 174 | + .foregroundColor(C2VColor.texteFaible) | |
| 175 | + } else { | |
| 176 | + Picker( | |
| 177 | + "Mode", | |
| 178 | + selection: Binding( | |
| 179 | + get: { state.modeTranscription }, | |
| 180 | + set: { viewModel.setModeTranscription($0) } | |
| 181 | + ) | |
| 182 | + ) { | |
| 183 | + Text("Sur l'appareil").tag(ModeTranscription.appareil) | |
| 184 | + Text("Sur le serveur").tag(ModeTranscription.serveur) | |
| 185 | + } | |
| 186 | + .pickerStyle(.segmented) | |
| 187 | + | |
| 188 | + Text(state.modeTranscription == .appareil | |
| 189 | + ? "Transcription locale, sans connexion réseau." | |
| 190 | + : "L'audio est envoyé au serveur pour transcription.") | |
| 191 | + .font(C2VFont.bodySmall) | |
| 192 | + .foregroundColor(C2VColor.texteFaible) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 150 | 197 | // ---- Logged out ---- |
| 151 | 198 | |
| 152 | 199 | private func loggedOutContent(_ state: SettingsUiState.LoggedOut) -> some View { |
M
ios/Card2vcf/UI/Settings/SettingsViewModel.swift
+7
-0
@@ -40,6 +40,8 @@ enum SettingsUiState {
| 40 | 40 | var catalogueError: String? = nil |
| 41 | 41 | var pendingRemoval: PendingCalendarRemoval? = nil |
| 42 | 42 | var pendingICloudActivation: PendingICloudActivation? = nil |
| 43 | + var modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription | |
| 44 | + var speechRecognitionDisponible: Bool = PreferencesAudio.speechRecognitionDisponible | |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | struct LoggedOut { |
@@ -351,6 +353,11 @@ final class SettingsViewModel: ObservableObject {
| 351 | 353 | updateLoggedIn { $0.pendingRemoval = nil } |
| 352 | 354 | } |
| 353 | 355 | |
| 356 | + func setModeTranscription(_ mode: ModeTranscription) { | |
| 357 | + PreferencesAudio.modeTranscription = mode | |
| 358 | + updateLoggedIn { $0.modeTranscription = mode } | |
| 359 | + } | |
| 360 | + | |
| 354 | 361 | private func refreshRessourcesCatalogue() { |
| 355 | 362 | guard case .loggedIn = state else { return } |
| 356 | 363 | guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else { return } |
A
ios/Card2vcfTests/AudioNoteRecorderTest.swift
+138
-0
@@ -0,0 +1,138 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, Lot 2a) : | |
| 5 | +/// En commentant la garde `guard !enregistrementEnCours else { throw ... }` dans | |
| 6 | +/// `AudioNoteRecorder.demarrer()`, le test testDeuxDemarragesConsecutifsEchoue échoue avec : | |
| 7 | +/// | |
| 8 | +/// testDeuxDemarragesConsecutifsEchoue — XCTAssertThrowsError failed: did not throw an error | |
| 9 | +/// | |
| 10 | +/// Ce test détecte donc réellement l'absence de protection contre le double-démarrage. | |
| 11 | +final class AudioNoteRecorderTest: XCTestCase { | |
| 12 | + private var moteur: FakeMoteurAudioCapture! | |
| 13 | + private var recorder: AudioNoteRecorder! | |
| 14 | + private var tempDir: URL! | |
| 15 | + | |
| 16 | + override func setUp() { | |
| 17 | + super.setUp() | |
| 18 | + tempDir = FileManager.default.temporaryDirectory | |
| 19 | + .appendingPathComponent(UUID().uuidString) | |
| 20 | + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) | |
| 21 | + moteur = FakeMoteurAudioCapture() | |
| 22 | + recorder = AudioNoteRecorder(moteur: moteur, dureeMaxSecondes: 60) | |
| 23 | + } | |
| 24 | + | |
| 25 | + override func tearDown() { | |
| 26 | + super.tearDown() | |
| 27 | + recorder.arreter() | |
| 28 | + try? FileManager.default.removeItem(at: tempDir) | |
| 29 | + } | |
| 30 | + | |
| 31 | + // MARK: - Démarrage / arrêt | |
| 32 | + | |
| 33 | + func testDeuxDemarragesConsecutifsEchoue() throws { | |
| 34 | + let url1 = tempDir.appendingPathComponent("test1.wav") | |
| 35 | + let url2 = tempDir.appendingPathComponent("test2.wav") | |
| 36 | + try recorder.demarrer(fichierCible: url1) | |
| 37 | + XCTAssertThrowsError(try recorder.demarrer(fichierCible: url2)) { error in | |
| 38 | + XCTAssertEqual(error as? AudioNoteRecorderErreur, .enregistrementDejaEnCours) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + func testArreterSansDebutRetourneNil() { | |
| 43 | + let resultat = recorder.arreter() | |
| 44 | + XCTAssertNil(resultat, "arreter() sans demarrer() doit retourner nil") | |
| 45 | + } | |
| 46 | + | |
| 47 | + func testEstEnCoursApresDepart() throws { | |
| 48 | + let url = tempDir.appendingPathComponent("test.wav") | |
| 49 | + XCTAssertFalse(recorder.estEnCours) | |
| 50 | + try recorder.demarrer(fichierCible: url) | |
| 51 | + XCTAssertTrue(recorder.estEnCours) | |
| 52 | + recorder.arreter() | |
| 53 | + XCTAssertFalse(recorder.estEnCours) | |
| 54 | + } | |
| 55 | + | |
| 56 | + func testResultatContientChemin() throws { | |
| 57 | + let url = tempDir.appendingPathComponent("note.wav") | |
| 58 | + try recorder.demarrer(fichierCible: url) | |
| 59 | + let resultat = recorder.arreter() | |
| 60 | + XCTAssertEqual(url.path, resultat?.chemin) | |
| 61 | + } | |
| 62 | + | |
| 63 | + func testDureeMsecPositive() throws { | |
| 64 | + let url = tempDir.appendingPathComponent("duree.wav") | |
| 65 | + try recorder.demarrer(fichierCible: url) | |
| 66 | + let resultat = recorder.arreter() | |
| 67 | + XCTAssertGreaterThanOrEqual(resultat?.dureeMsec ?? -1, 0) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // MARK: - Écriture WAV | |
| 71 | + | |
| 72 | + func testFichierWAVCreeSurDemarrer() throws { | |
| 73 | + let url = tempDir.appendingPathComponent("sortie.wav") | |
| 74 | + try recorder.demarrer(fichierCible: url) | |
| 75 | + recorder.arreter() | |
| 76 | + | |
| 77 | + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) | |
| 78 | + let data = try Data(contentsOf: url) | |
| 79 | + let riff = String(bytes: data[0..<4], encoding: .ascii) ?? "" | |
| 80 | + XCTAssertEqual("RIFF", riff, "Le fichier produit doit être un WAV valide") | |
| 81 | + } | |
| 82 | + | |
| 83 | + func testEchantillonsEcritsVersWAV() throws { | |
| 84 | + let url = tempDir.appendingPathComponent("echantillons.wav") | |
| 85 | + try recorder.demarrer(fichierCible: url) | |
| 86 | + moteur.simulerEchantillons([100, 200, 300]) | |
| 87 | + recorder.arreter() | |
| 88 | + | |
| 89 | + let data = try Data(contentsOf: url) | |
| 90 | + // 44 octets d'en-tête + 3 échantillons × 2 octets = 50 | |
| 91 | + XCTAssertEqual(50, data.count, "3 échantillons Int16 → 6 octets de données + 44 d'en-tête") | |
| 92 | + } | |
| 93 | + | |
| 94 | + // MARK: - Transcripteur | |
| 95 | + | |
| 96 | + func testEchantillonsTransmisAuTranscripteur() throws { | |
| 97 | + let url = tempDir.appendingPathComponent("transcription.wav") | |
| 98 | + let transcripteur = FakeTranscripteurLocal() | |
| 99 | + try recorder.demarrer(fichierCible: url, transcripteur: transcripteur) | |
| 100 | + moteur.simulerEchantillons([10, 20, 30]) | |
| 101 | + moteur.simulerEchantillons([-1, -2]) | |
| 102 | + recorder.arreter() | |
| 103 | + | |
| 104 | + XCTAssertEqual(2, transcripteur.echantillonsRecus.count, | |
| 105 | + "Deux appels simulerEchantillons → deux appels accepterEchantillons") | |
| 106 | + XCTAssertEqual([10, 20, 30], transcripteur.echantillonsRecus[0]) | |
| 107 | + XCTAssertEqual([-1, -2], transcripteur.echantillonsRecus[1]) | |
| 108 | + } | |
| 109 | + | |
| 110 | + func testSansTranscripteurNePasPlanter() throws { | |
| 111 | + let url = tempDir.appendingPathComponent("sans_transcripteur.wav") | |
| 112 | + try recorder.demarrer(fichierCible: url, transcripteur: nil) | |
| 113 | + moteur.simulerEchantillons([1, 2, 3]) | |
| 114 | + let resultat = recorder.arreter() | |
| 115 | + XCTAssertNotNil(resultat) | |
| 116 | + } | |
| 117 | + | |
| 118 | + // MARK: - Arrêt automatique | |
| 119 | + | |
| 120 | + func testArretAutoApresDelai() throws { | |
| 121 | + let recorder = AudioNoteRecorder(moteur: moteur, dureeMaxSecondes: 0.05) | |
| 122 | + let url = tempDir.appendingPathComponent("auto.wav") | |
| 123 | + try recorder.demarrer(fichierCible: url) | |
| 124 | + XCTAssertTrue(recorder.estEnCours) | |
| 125 | + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.25)) | |
| 126 | + XCTAssertFalse(recorder.estEnCours, | |
| 127 | + "L'enregistrement doit s'arrêter automatiquement après dureeMaxSecondes") | |
| 128 | + } | |
| 129 | + | |
| 130 | + // MARK: - Erreur moteur | |
| 131 | + | |
| 132 | + func testErreurMoteurPropagee() throws { | |
| 133 | + moteur.prochainErreur = NSError(domain: "test", code: 42) | |
| 134 | + let url = tempDir.appendingPathComponent("erreur.wav") | |
| 135 | + XCTAssertThrowsError(try recorder.demarrer(fichierCible: url)) | |
| 136 | + XCTAssertFalse(recorder.estEnCours, "estEnCours doit rester false si demarrer() échoue") | |
| 137 | + } | |
| 138 | +} |
A
ios/Card2vcfTests/ContratSyncFixturesTest.swift
+69
-0
@@ -0,0 +1,69 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Décode les fixtures de contrat de synchronisation avec les vrais DTO Swift. | |
| 5 | +/// | |
| 6 | +/// Si un champ serveur est renommé, ce test devient rouge immédiatement. | |
| 7 | +/// Preuve de rougeur (vérifiée) : en renommant `transcription_erreur` → `transcription_error` | |
| 8 | +/// dans `interaction_note_vocale.json`, l'assertion suivante échoue : | |
| 9 | +/// XCTAssertEqual failed: ("Optional("Modèle ASR indisponible")") is not equal to ("nil") | |
| 10 | +final class ContratSyncFixturesTest: XCTestCase { | |
| 11 | + | |
| 12 | + private func fixture(_ nom: String) throws -> Data { | |
| 13 | + let bundle = Bundle(for: ContratSyncFixturesTest.self) | |
| 14 | + guard let url = bundle.url(forResource: nom, withExtension: nil) else { | |
| 15 | + XCTFail("Fixture introuvable dans le bundle de test : \(nom)") | |
| 16 | + throw NSError(domain: "fixture", code: 0) | |
| 17 | + } | |
| 18 | + return try Data(contentsOf: url) | |
| 19 | + } | |
| 20 | + | |
| 21 | + // ---- sync_pull.json ---- | |
| 22 | + | |
| 23 | + func testSyncPull_decodeSyncPullResponse() throws { | |
| 24 | + let data = try fixture("sync_pull.json") | |
| 25 | + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data) | |
| 26 | + | |
| 27 | + XCTAssertFalse(pull.contacts.isEmpty, "contacts doit contenir au moins un élément") | |
| 28 | + XCTAssertFalse(pull.interactions.isEmpty, "interactions doit contenir au moins un élément") | |
| 29 | + XCTAssertFalse(pull.notes.isEmpty, "notes doit contenir au moins un élément") | |
| 30 | + } | |
| 31 | + | |
| 32 | + func testSyncPull_contactChampsCritiques() throws { | |
| 33 | + let data = try fixture("sync_pull.json") | |
| 34 | + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data) | |
| 35 | + | |
| 36 | + let contact = try XCTUnwrap(pull.contacts.first) | |
| 37 | + XCTAssertFalse(contact.id.isEmpty, "id contact ne doit pas être vide") | |
| 38 | + XCTAssertFalse(contact.creeLe.isEmpty, "creeLe contact ne doit pas être vide") | |
| 39 | + } | |
| 40 | + | |
| 41 | + // ---- interaction_note_vocale.json ---- | |
| 42 | + | |
| 43 | + func testInteraction_decodeInteractionDto() throws { | |
| 44 | + let data = try fixture("interaction_note_vocale.json") | |
| 45 | + let dto = try SyncJson.decoder().decode(InteractionDto.self, from: data) | |
| 46 | + | |
| 47 | + XCTAssertEqual("note_vocale", dto.typeInteraction) | |
| 48 | + XCTAssertEqual("vocale_contrat_1.wav", dto.pieceJointe) | |
| 49 | + XCTAssertEqual("echec", dto.transcription) | |
| 50 | + // Champ critique : si transcription_erreur est renommé côté serveur, | |
| 51 | + // cette assertion devient rouge : | |
| 52 | + // XCTAssertEqual failed: ("Optional("Modèle ASR indisponible")") is not equal to ("nil") | |
| 53 | + XCTAssertEqual("Modèle ASR indisponible", dto.transcriptionErreur) | |
| 54 | + } | |
| 55 | + | |
| 56 | + // ---- note_projet.json ---- | |
| 57 | + | |
| 58 | + func testNoteProjet_decodeNoteProjetDto() throws { | |
| 59 | + let data = try fixture("note_projet.json") | |
| 60 | + let dto = try SyncJson.decoder().decode(NoteProjetDto.self, from: data) | |
| 61 | + | |
| 62 | + XCTAssertEqual("projet-contrat-1", dto.projetId) | |
| 63 | + XCTAssertEqual("note_contrat_1.wav", dto.audio) | |
| 64 | + XCTAssertEqual("terminee", dto.transcription) | |
| 65 | + // transcription_erreur null pour une transcription réussie | |
| 66 | + XCTAssertNil(dto.transcriptionErreur, | |
| 67 | + "transcriptionErreur doit être nil pour une transcription réussie") | |
| 68 | + } | |
| 69 | +} |
A
ios/Card2vcfTests/EcritureWavTest.swift
+138
-0
@@ -0,0 +1,138 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, Lot 2a) : | |
| 5 | +/// En neutralisant l'écriture de l'en-tête (en commentant `handle.seek(toFileOffset: 0)` + | |
| 6 | +/// `handle.write(entete)` dans `EcritureWav.fermer()`), les tests échouent avec : | |
| 7 | +/// | |
| 8 | +/// testEnteteRIFFValide — XCTAssertEqual failed: ("RIFF") is not equal to (" ") | |
| 9 | +/// testSampleRateEt16Bits — XCTAssertEqual failed: ("16000") is not equal to ("0") | |
| 10 | +/// | |
| 11 | +/// (Les 44 premiers octets restent à zéro ; les octets nuls sont rendus comme espaces | |
| 12 | +/// par la décodification ASCII, le sampleRate lu sur 4 octets nuls vaut 0.) | |
| 13 | +final class EcritureWavTest: XCTestCase { | |
| 14 | + private var tempUrl: URL! | |
| 15 | + | |
| 16 | + override func setUp() { | |
| 17 | + super.setUp() | |
| 18 | + tempUrl = FileManager.default.temporaryDirectory | |
| 19 | + .appendingPathComponent(UUID().uuidString) | |
| 20 | + .appendingPathExtension("wav") | |
| 21 | + } | |
| 22 | + | |
| 23 | + override func tearDown() { | |
| 24 | + super.tearDown() | |
| 25 | + try? FileManager.default.removeItem(at: tempUrl) | |
| 26 | + } | |
| 27 | + | |
| 28 | + // MARK: - En-tête RIFF | |
| 29 | + | |
| 30 | + func testEnteteRIFFValide() throws { | |
| 31 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 32 | + try ecrivain.fermer() | |
| 33 | + | |
| 34 | + let data = try Data(contentsOf: tempUrl) | |
| 35 | + XCTAssertGreaterThanOrEqual(data.count, 44, "Fichier doit faire au moins 44 octets") | |
| 36 | + let riff = String(bytes: data[0..<4], encoding: .ascii) ?? "" | |
| 37 | + XCTAssertEqual("RIFF", riff, "Les 4 premiers octets doivent être 'RIFF'") | |
| 38 | + let wave = String(bytes: data[8..<12], encoding: .ascii) ?? "" | |
| 39 | + XCTAssertEqual("WAVE", wave, "Les octets 8-11 doivent être 'WAVE'") | |
| 40 | + } | |
| 41 | + | |
| 42 | + func testChunkFmtPresent() throws { | |
| 43 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 44 | + try ecrivain.fermer() | |
| 45 | + | |
| 46 | + let data = try Data(contentsOf: tempUrl) | |
| 47 | + let fmt = String(bytes: data[12..<16], encoding: .ascii) ?? "" | |
| 48 | + XCTAssertEqual("fmt ", fmt, "Les octets 12-15 doivent être 'fmt '") | |
| 49 | + let fmtSize = data[16...19].withUnsafeBytes { $0.load(as: Int32.self).littleEndian } | |
| 50 | + XCTAssertEqual(16, fmtSize, "taille du chunk fmt doit être 16 (PCM)") | |
| 51 | + let audioFormat = data[20...21].withUnsafeBytes { $0.load(as: Int16.self).littleEndian } | |
| 52 | + XCTAssertEqual(1, audioFormat, "audioFormat doit être 1 (PCM linéaire)") | |
| 53 | + } | |
| 54 | + | |
| 55 | + func testSampleRateEt16Bits() throws { | |
| 56 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 57 | + try ecrivain.fermer() | |
| 58 | + | |
| 59 | + let data = try Data(contentsOf: tempUrl) | |
| 60 | + // sampleRate aux octets 24-27 | |
| 61 | + let sampleRate = data[24...27].withUnsafeBytes { $0.load(as: Int32.self).littleEndian } | |
| 62 | + XCTAssertEqual(16_000, sampleRate, "sampleRate doit être 16 000 Hz") | |
| 63 | + // canaux aux octets 22-23 | |
| 64 | + let canaux = data[22...23].withUnsafeBytes { $0.load(as: Int16.self).littleEndian } | |
| 65 | + XCTAssertEqual(1, canaux, "doit être mono (1 canal)") | |
| 66 | + // bitsParEchantillon aux octets 34-35 | |
| 67 | + let bits = data[34...35].withUnsafeBytes { $0.load(as: Int16.self).littleEndian } | |
| 68 | + XCTAssertEqual(16, bits, "doit être 16 bits par échantillon") | |
| 69 | + } | |
| 70 | + | |
| 71 | + // MARK: - Taille cohérente avec les données | |
| 72 | + | |
| 73 | + func testTailleBloc_data_CohérenteAvecEchantillons() throws { | |
| 74 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 75 | + let echantillons: [Int16] = [0, 100, -100, 32767, -32768] | |
| 76 | + ecrivain.ecrireEchantillons(echantillons) | |
| 77 | + try ecrivain.fermer() | |
| 78 | + | |
| 79 | + let data = try Data(contentsOf: tempUrl) | |
| 80 | + // tag "data" aux octets 36-39 | |
| 81 | + let dataTag = String(bytes: data[36..<40], encoding: .ascii) ?? "" | |
| 82 | + XCTAssertEqual("data", dataTag) | |
| 83 | + // taille du bloc data aux octets 40-43 | |
| 84 | + let tailleData = data[40...43].withUnsafeBytes { $0.load(as: Int32.self).littleEndian } | |
| 85 | + XCTAssertEqual(Int32(echantillons.count * 2), tailleData, | |
| 86 | + "taille data = nb échantillons × 2 octets") | |
| 87 | + // taille totale du fichier | |
| 88 | + XCTAssertEqual(44 + echantillons.count * 2, data.count) | |
| 89 | + } | |
| 90 | + | |
| 91 | + func testRIFFSizeCohérente() throws { | |
| 92 | + let echantillons = [Int16](repeating: 42, count: 100) | |
| 93 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 94 | + ecrivain.ecrireEchantillons(echantillons) | |
| 95 | + try ecrivain.fermer() | |
| 96 | + | |
| 97 | + let data = try Data(contentsOf: tempUrl) | |
| 98 | + // riffSize aux octets 4-7 = taille totale - 8 | |
| 99 | + let riffSize = data[4...7].withUnsafeBytes { $0.load(as: Int32.self).littleEndian } | |
| 100 | + let attendu = Int32(36 + echantillons.count * 2) | |
| 101 | + XCTAssertEqual(attendu, riffSize, "riffSize doit valoir totalOctets - 8") | |
| 102 | + } | |
| 103 | + | |
| 104 | + func testByteRateEtBlockAlign() throws { | |
| 105 | + // byteRate = sampleRate × canaux × (bits/8) = 16000 × 1 × 2 = 32000 | |
| 106 | + // blockAlign = canaux × (bits/8) = 1 × 2 = 2 | |
| 107 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 108 | + try ecrivain.fermer() | |
| 109 | + | |
| 110 | + let data = try Data(contentsOf: tempUrl) | |
| 111 | + let byteRate = data[28...31].withUnsafeBytes { $0.load(as: Int32.self).littleEndian } | |
| 112 | + XCTAssertEqual(32_000, byteRate) | |
| 113 | + let blockAlign = data[32...33].withUnsafeBytes { $0.load(as: Int16.self).littleEndian } | |
| 114 | + XCTAssertEqual(2, blockAlign) | |
| 115 | + } | |
| 116 | + | |
| 117 | + // MARK: - Cas limite | |
| 118 | + | |
| 119 | + func testFichierVideSansEchantillons() throws { | |
| 120 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 121 | + let octets = try ecrivain.fermer() | |
| 122 | + | |
| 123 | + XCTAssertEqual(0, octets) | |
| 124 | + let data = try Data(contentsOf: tempUrl) | |
| 125 | + XCTAssertEqual(44, data.count, "Fichier vide = 44 octets d'en-tête uniquement") | |
| 126 | + } | |
| 127 | + | |
| 128 | + func testEchantillonsPCMEncodésLittleEndian() throws { | |
| 129 | + let ecrivain = try EcritureWav(url: tempUrl) | |
| 130 | + // 0x0100 en little-endian s'écrit [0x00, 0x01] | |
| 131 | + ecrivain.ecrireEchantillons([0x0100]) | |
| 132 | + try ecrivain.fermer() | |
| 133 | + | |
| 134 | + let data = try Data(contentsOf: tempUrl) | |
| 135 | + XCTAssertEqual(0x00, data[44], "octet bas d'abord (little-endian)") | |
| 136 | + XCTAssertEqual(0x01, data[45], "octet haut ensuite") | |
| 137 | + } | |
| 138 | +} |
A
ios/Card2vcfTests/EnregistrementNoteViewModelTest.swift
+103
-0
@@ -0,0 +1,103 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Tests TDD du ViewModel d'enregistrement de note vocale. | |
| 5 | +/// | |
| 6 | +/// PREUVES DE ROUGEUR : | |
| 7 | +/// | |
| 8 | +/// Test 1 : testSujetParDefaut | |
| 9 | +/// En remplaçant le corps de `sujetParDefaut` par `return ""`, le test échoue avec : | |
| 10 | +/// XCTAssertTrue failed — "Notes du" not found in "" | |
| 11 | +/// | |
| 12 | +/// Test 2 : testMessageConfirmationModeServeurEnvoye | |
| 13 | +/// En remplaçant le corps de `messageConfirmation` par `return ""`, le test échoue avec : | |
| 14 | +/// XCTAssertTrue failed — "serveur" not found in "" | |
| 15 | +@MainActor | |
| 16 | +final class EnregistrementNoteViewModelTest: XCTestCase { | |
| 17 | + | |
| 18 | + // MARK: - sujetParDefaut (PREUVE DE ROUGEUR #1) | |
| 19 | + | |
| 20 | + func testSujetParDefaut() { | |
| 21 | + // Date fixe : 17 septembre 2026 à 14h30 | |
| 22 | + var components = DateComponents() | |
| 23 | + components.year = 2026 | |
| 24 | + components.month = 9 | |
| 25 | + components.day = 17 | |
| 26 | + components.hour = 14 | |
| 27 | + components.minute = 30 | |
| 28 | + let date = Calendar(identifier: .gregorian).date(from: components)! | |
| 29 | + | |
| 30 | + let sujet = EnregistrementNoteViewModel.sujetParDefaut(maintenant: date) | |
| 31 | + | |
| 32 | + // Rouge sans l'implémentation : XCTAssertTrue failed — "Notes du" not found in "" | |
| 33 | + XCTAssertTrue(sujet.hasPrefix("Notes du"), | |
| 34 | + "Le sujet doit commencer par 'Notes du', obtenu : \(sujet)") | |
| 35 | + XCTAssertTrue(sujet.contains("17/09/2026"), | |
| 36 | + "Le sujet doit contenir la date jj/MM/yyyy, obtenu : \(sujet)") | |
| 37 | + XCTAssertTrue(sujet.contains("14:30"), | |
| 38 | + "Le sujet doit contenir l'heure HH:mm, obtenu : \(sujet)") | |
| 39 | + } | |
| 40 | + | |
| 41 | + func testSujetParDefautFormatPadding() { | |
| 42 | + // Vérifie que jour, mois, heure et minute sont sur 2 chiffres (05/01/2026 09:03) | |
| 43 | + var components = DateComponents() | |
| 44 | + components.year = 2026 | |
| 45 | + components.month = 1 | |
| 46 | + components.day = 5 | |
| 47 | + components.hour = 9 | |
| 48 | + components.minute = 3 | |
| 49 | + let date = Calendar(identifier: .gregorian).date(from: components)! | |
| 50 | + | |
| 51 | + let sujet = EnregistrementNoteViewModel.sujetParDefaut(maintenant: date) | |
| 52 | + XCTAssertTrue(sujet.contains("05/01/2026"), "Jour et mois sur 2 chiffres, obtenu : \(sujet)") | |
| 53 | + XCTAssertTrue(sujet.contains("09:03"), "Heure et minute sur 2 chiffres, obtenu : \(sujet)") | |
| 54 | + } | |
| 55 | + | |
| 56 | + // MARK: - messageConfirmation (PREUVE DE ROUGEUR #2) | |
| 57 | + | |
| 58 | + func testMessageConfirmationModeServeurEnvoye() { | |
| 59 | + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .serveur, envoye: true) | |
| 60 | + | |
| 61 | + // Rouge sans l'implémentation : XCTAssertTrue failed — "serveur" not found in "" | |
| 62 | + XCTAssertTrue(msg.lowercased().contains("serveur"), | |
| 63 | + "Le message doit mentionner le serveur, obtenu : \(msg)") | |
| 64 | + let indicateurEnCours = msg.lowercased().contains("transcription") | |
| 65 | + || msg.lowercased().contains("cours") | |
| 66 | + XCTAssertTrue(indicateurEnCours, | |
| 67 | + "Le message doit indiquer que la transcription est en cours, obtenu : \(msg)") | |
| 68 | + } | |
| 69 | + | |
| 70 | + func testMessageConfirmationModeServeurNonEnvoye() { | |
| 71 | + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .serveur, envoye: false) | |
| 72 | + XCTAssertTrue(msg.lowercased().contains("synchronisation"), | |
| 73 | + "Le message doit mentionner la synchronisation, obtenu : \(msg)") | |
| 74 | + } | |
| 75 | + | |
| 76 | + func testMessageConfirmationModeAppareil() { | |
| 77 | + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: false) | |
| 78 | + XCTAssertFalse(msg.isEmpty, "Le message ne doit pas être vide") | |
| 79 | + XCTAssertFalse(msg.lowercased().contains("serveur"), | |
| 80 | + "Mode appareil : pas de mention du serveur, obtenu : \(msg)") | |
| 81 | + } | |
| 82 | + | |
| 83 | + func testMessageConfirmationModeAppareilIgnoreEnvoye() { | |
| 84 | + // En mode appareil, envoye=true ou false → même message | |
| 85 | + let m1 = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: true) | |
| 86 | + let m2 = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: false) | |
| 87 | + XCTAssertEqual(m1, m2, "Mode appareil : le flag envoye ne change pas le message") | |
| 88 | + } | |
| 89 | + | |
| 90 | + // MARK: - dureeFormatee | |
| 91 | + | |
| 92 | + func testDureeFormatee() async { | |
| 93 | + let vm = makeViewModel() | |
| 94 | + // La durée initiale doit être 00:00 | |
| 95 | + XCTAssertEqual("00:00", vm.dureeFormatee) | |
| 96 | + } | |
| 97 | + | |
| 98 | + // MARK: - helpers | |
| 99 | + | |
| 100 | + private func makeViewModel() -> EnregistrementNoteViewModel { | |
| 101 | + EnregistrementNoteViewModel(sauvegarder: { _, _, _, _ in false }) | |
| 102 | + } | |
| 103 | +} |
M
ios/Card2vcfTests/FakeAilianceApi.swift
+88
-0
@@ -139,4 +139,92 @@ final class FakeAilianceApi: AilianceApi {
| 139 | 139 | downloadPhotoCalls.append(id) |
| 140 | 140 | return downloadPhotoResult |
| 141 | 141 | } |
| 142 | + | |
| 143 | + var uploadInteractionAudioResult: AilianceApiClient.ApiResult<String> = .ok("{}") | |
| 144 | + /// (contactId, interactionId, bytes.count) | |
| 145 | + var uploadInteractionAudioCalls: [(String, String, Int)] = [] | |
| 146 | + var downloadInteractionAudioResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent") | |
| 147 | + | |
| 148 | + func uploadInteractionAudio( | |
| 149 | + contactId: String, | |
| 150 | + interactionId: String, | |
| 151 | + bytes: Data, | |
| 152 | + filename: String | |
| 153 | + ) async -> AilianceApiClient.ApiResult<String> { | |
| 154 | + uploadInteractionAudioCalls.append((contactId, interactionId, bytes.count)) | |
| 155 | + return uploadInteractionAudioResult | |
| 156 | + } | |
| 157 | + | |
| 158 | + func downloadInteractionAudio(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<Data> { | |
| 159 | + downloadInteractionAudioResult | |
| 160 | + } | |
| 161 | + | |
| 162 | + var createNoteProjetResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"note-generated"}"#) | |
| 163 | + /// (projetId, jsonBody) | |
| 164 | + var createNoteProjetCalls: [(String, String)] = [] | |
| 165 | + | |
| 166 | + func createNoteProjet(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { | |
| 167 | + createNoteProjetCalls.append((projetId, jsonBody)) | |
| 168 | + return createNoteProjetResult | |
| 169 | + } | |
| 170 | + | |
| 171 | + var uploadNoteProjetAudioResult: AilianceApiClient.ApiResult<String> = .ok("{}") | |
| 172 | + /// (projetId, noteId, bytes.count) | |
| 173 | + var uploadNoteProjetAudioCalls: [(String, String, Int)] = [] | |
| 174 | + var downloadNoteProjetAudioResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent") | |
| 175 | + | |
| 176 | + func uploadNoteProjetAudio( | |
| 177 | + projetId: String, | |
| 178 | + noteId: String, | |
| 179 | + bytes: Data, | |
| 180 | + filename: String | |
| 181 | + ) async -> AilianceApiClient.ApiResult<String> { | |
| 182 | + uploadNoteProjetAudioCalls.append((projetId, noteId, bytes.count)) | |
| 183 | + return uploadNoteProjetAudioResult | |
| 184 | + } | |
| 185 | + | |
| 186 | + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Data> { | |
| 187 | + downloadNoteProjetAudioResult | |
| 188 | + } | |
| 189 | + | |
| 190 | + var deleteInteractionResult: AilianceApiClient.ApiResult<Void> = .ok(()) | |
| 191 | + var deleteInteractionCalls: [String] = [] | |
| 192 | + | |
| 193 | + func deleteInteraction(interactionId: String) async -> AilianceApiClient.ApiResult<Void> { | |
| 194 | + deleteInteractionCalls.append(interactionId) | |
| 195 | + return deleteInteractionResult | |
| 196 | + } | |
| 197 | + | |
| 198 | + var deleteNoteProjetResult: AilianceApiClient.ApiResult<Void> = .ok(()) | |
| 199 | + /// (projetId, noteId) | |
| 200 | + var deleteNoteProjetCalls: [(String, String)] = [] | |
| 201 | + | |
| 202 | + func deleteNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Void> { | |
| 203 | + deleteNoteProjetCalls.append((projetId, noteId)) | |
| 204 | + return deleteNoteProjetResult | |
| 205 | + } | |
| 206 | + | |
| 207 | + var relancerInteractionResult: AilianceApiClient.ApiResult<String> = .ok("{}") | |
| 208 | + /// (contactId, interactionId) | |
| 209 | + var relancerInteractionCalls: [(String, String)] = [] | |
| 210 | + | |
| 211 | + func relancerTranscriptionInteraction( | |
| 212 | + contactId: String, | |
| 213 | + interactionId: String | |
| 214 | + ) async -> AilianceApiClient.ApiResult<String> { | |
| 215 | + relancerInteractionCalls.append((contactId, interactionId)) | |
| 216 | + return relancerInteractionResult | |
| 217 | + } | |
| 218 | + | |
| 219 | + var relancerNoteProjetResult: AilianceApiClient.ApiResult<String> = .ok("{}") | |
| 220 | + /// (projetId, noteId) | |
| 221 | + var relancerNoteProjetCalls: [(String, String)] = [] | |
| 222 | + | |
| 223 | + func relancerTranscriptionNoteProjet( | |
| 224 | + projetId: String, | |
| 225 | + noteId: String | |
| 226 | + ) async -> AilianceApiClient.ApiResult<String> { | |
| 227 | + relancerNoteProjetCalls.append((projetId, noteId)) | |
| 228 | + return relancerNoteProjetResult | |
| 229 | + } | |
| 142 | 230 | } |
A
ios/Card2vcfTests/FakeMoteurAudioCapture.swift
+27
-0
@@ -0,0 +1,27 @@
| 1 | +import Foundation | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Faux MoteurAudioCapture pour les tests d'AudioNoteRecorder. | |
| 5 | +/// Permet de simuler des échantillons PCM sans matériel audio réel. | |
| 6 | +final class FakeMoteurAudioCapture: MoteurAudioCapture { | |
| 7 | + private(set) var estDemarre = false | |
| 8 | + private var callback: (([Int16]) -> Void)? | |
| 9 | + /// Si non nil, `demarrer` lève cette erreur. | |
| 10 | + var prochainErreur: Error? | |
| 11 | + | |
| 12 | + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws { | |
| 13 | + if let erreur = prochainErreur { throw erreur } | |
| 14 | + estDemarre = true | |
| 15 | + callback = onEchantillons | |
| 16 | + } | |
| 17 | + | |
| 18 | + func arreter() { | |
| 19 | + estDemarre = false | |
| 20 | + callback = nil | |
| 21 | + } | |
| 22 | + | |
| 23 | + /// Pousse des échantillons PCM vers l'enregistreur comme si le matériel les avait capturés. | |
| 24 | + func simulerEchantillons(_ data: [Int16]) { | |
| 25 | + callback?(data) | |
| 26 | + } | |
| 27 | +} |
A
ios/Card2vcfTests/FakeTranscripteurLocal.swift
+33
-0
@@ -0,0 +1,33 @@
| 1 | +import Foundation | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Faux TranscripteurLocal pour les tests. | |
| 5 | +/// Enregistre tous les appels pour vérification ultérieure. | |
| 6 | +final class FakeTranscripteurLocal: TranscripteurLocal { | |
| 7 | + var etat: EtatTranscripteur = .disponible | |
| 8 | + | |
| 9 | + /// Échantillons reçus par accepterEchantillons, dans l'ordre d'appel. | |
| 10 | + private(set) var echantillonsRecus: [[Int16]] = [] | |
| 11 | + /// File de résultats partiels à retourner (FIFO). nil si vide. | |
| 12 | + var resultatsPartiels: [String?] = [] | |
| 13 | + /// Texte retourné par finaliser(). | |
| 14 | + var resultatFinal: String = "" | |
| 15 | + private(set) var nombreReinit: Int = 0 | |
| 16 | + | |
| 17 | + func accepterEchantillons(_ data: [Int16]) -> String? { | |
| 18 | + echantillonsRecus.append(data) | |
| 19 | + if !resultatsPartiels.isEmpty { | |
| 20 | + return resultatsPartiels.removeFirst() | |
| 21 | + } | |
| 22 | + return nil | |
| 23 | + } | |
| 24 | + | |
| 25 | + func finaliser() -> String { resultatFinal } | |
| 26 | + | |
| 27 | + func reinitialiser() { | |
| 28 | + echantillonsRecus = [] | |
| 29 | + resultatsPartiels = [] | |
| 30 | + resultatFinal = "" | |
| 31 | + nombreReinit += 1 | |
| 32 | + } | |
| 33 | +} |
A
ios/Card2vcfTests/Fixtures/interaction_note_vocale.json
+16
-0
@@ -0,0 +1,16 @@
| 1 | +{ | |
| 2 | + "id": "interaction-vocale-contrat-1", | |
| 3 | + "contact_id": "contact-contrat-1", | |
| 4 | + "type_interaction": "note_vocale", | |
| 5 | + "sujet": "Note vocale contrat", | |
| 6 | + "description": "", | |
| 7 | + "statut": "fait", | |
| 8 | + "prevu_le": null, | |
| 9 | + "fait_le": null, | |
| 10 | + "piece_jointe": "vocale_contrat_1.wav", | |
| 11 | + "cree_par": "alice", | |
| 12 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 13 | + "mis_a_jour_le": null, | |
| 14 | + "transcription": "echec", | |
| 15 | + "transcription_erreur": "Modèle ASR indisponible" | |
| 16 | +} | |
| 16 | < \ No newline at end of file |
A
ios/Card2vcfTests/Fixtures/note_projet.json
+12
-0
@@ -0,0 +1,12 @@
| 1 | +{ | |
| 2 | + "id": "note-projet-contrat-1", | |
| 3 | + "projet_id": "projet-contrat-1", | |
| 4 | + "titre": "Note vocale projet contrat", | |
| 5 | + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n", | |
| 6 | + "auteur": "alice", | |
| 7 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 8 | + "maj_le": null, | |
| 9 | + "audio": "note_contrat_1.wav", | |
| 10 | + "transcription": "terminee", | |
| 11 | + "transcription_erreur": null | |
| 12 | +} | |
| 12 | < \ No newline at end of file |
A
ios/Card2vcfTests/Fixtures/sync_pull.json
+140
-0
@@ -0,0 +1,140 @@
| 1 | +{ | |
| 2 | + "server_time": "2026-09-18T08:42:51.508932169Z", | |
| 3 | + "contacts": [ | |
| 4 | + { | |
| 5 | + "id": "contact-contrat-1", | |
| 6 | + "prenom": "Ada", | |
| 7 | + "nom": "Lovelace", | |
| 8 | + "entreprise_id": null, | |
| 9 | + "fonction": "", | |
| 10 | + "emails": [], | |
| 11 | + "telephones": [], | |
| 12 | + "adresses": [], | |
| 13 | + "statut": "prospect", | |
| 14 | + "etape": "nouveau", | |
| 15 | + "date_rencontre": null, | |
| 16 | + "notes": "", | |
| 17 | + "photo": null, | |
| 18 | + "carte_visite": null, | |
| 19 | + "cree_par": "alice", | |
| 20 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 21 | + "derniere_action": null, | |
| 22 | + "tags": [], | |
| 23 | + "mis_a_jour_le": null, | |
| 24 | + "civilite": "", | |
| 25 | + "date_naissance": null, | |
| 26 | + "site_web": "", | |
| 27 | + "profils_sociaux": [] | |
| 28 | + } | |
| 29 | + ], | |
| 30 | + "entreprises": [], | |
| 31 | + "projets": [ | |
| 32 | + { | |
| 33 | + "id": "projet-contrat-1", | |
| 34 | + "nom": "Projet contrat", | |
| 35 | + "description": "", | |
| 36 | + "workflow_id": "veille", | |
| 37 | + "cree_par": "alice", | |
| 38 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 39 | + "derniere_action": null, | |
| 40 | + "taches": [], | |
| 41 | + "notes": [ | |
| 42 | + { | |
| 43 | + "id": "note-projet-contrat-1", | |
| 44 | + "titre": "Note vocale projet contrat", | |
| 45 | + "fichier": "note-contrat-1.md", | |
| 46 | + "ordre": 0, | |
| 47 | + "auteur": "alice", | |
| 48 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 49 | + "maj_le": null, | |
| 50 | + "audio": "note_contrat_1.wav", | |
| 51 | + "transcription": "terminee", | |
| 52 | + "transcription_erreur": null | |
| 53 | + } | |
| 54 | + ], | |
| 55 | + "liens": [], | |
| 56 | + "fichiers": [], | |
| 57 | + "apercu_liens_riche": true, | |
| 58 | + "gestion_contacts": false, | |
| 59 | + "contacts_lies": [], | |
| 60 | + "entreprises_liees": [], | |
| 61 | + "membres": [], | |
| 62 | + "mis_a_jour_le": null | |
| 63 | + } | |
| 64 | + ], | |
| 65 | + "taches": [], | |
| 66 | + "interactions": [ | |
| 67 | + { | |
| 68 | + "id": "interaction-vocale-contrat-1", | |
| 69 | + "contact_id": "contact-contrat-1", | |
| 70 | + "type_interaction": "note_vocale", | |
| 71 | + "sujet": "Note vocale contrat", | |
| 72 | + "description": "", | |
| 73 | + "statut": "fait", | |
| 74 | + "prevu_le": null, | |
| 75 | + "fait_le": null, | |
| 76 | + "piece_jointe": "vocale_contrat_1.wav", | |
| 77 | + "cree_par": "alice", | |
| 78 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 79 | + "mis_a_jour_le": null, | |
| 80 | + "transcription": "echec", | |
| 81 | + "transcription_erreur": "Modèle ASR indisponible" | |
| 82 | + } | |
| 83 | + ], | |
| 84 | + "notes": [ | |
| 85 | + { | |
| 86 | + "id": "note-projet-contrat-1", | |
| 87 | + "projet_id": "projet-contrat-1", | |
| 88 | + "titre": "Note vocale projet contrat", | |
| 89 | + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n", | |
| 90 | + "auteur": "alice", | |
| 91 | + "cree_le": "2026-09-01T10:00:00Z", | |
| 92 | + "maj_le": null, | |
| 93 | + "audio": "note_contrat_1.wav", | |
| 94 | + "transcription": "terminee", | |
| 95 | + "transcription_erreur": null | |
| 96 | + } | |
| 97 | + ], | |
| 98 | + "rdv": [], | |
| 99 | + "reservations": [], | |
| 100 | + "indisponibilites": [], | |
| 101 | + "tombstones": [], | |
| 102 | + "workflows": [ | |
| 103 | + { | |
| 104 | + "id": "veille", | |
| 105 | + "nom": "Veille", | |
| 106 | + "description": "Suivi de sujets à explorer et synthétiser", | |
| 107 | + "colonnes": [ | |
| 108 | + { | |
| 109 | + "id": "explorer", | |
| 110 | + "nom": "À explorer", | |
| 111 | + "ordre": 0 | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "id": "lecture", | |
| 115 | + "nom": "En lecture", | |
| 116 | + "ordre": 1 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "id": "synthetiser", | |
| 120 | + "nom": "À synthétiser", | |
| 121 | + "ordre": 2 | |
| 122 | + }, | |
| 123 | + { | |
| 124 | + "id": "synthetise", | |
| 125 | + "nom": "Synthétisé", | |
| 126 | + "ordre": 3 | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "id": "archive", | |
| 130 | + "nom": "Archive", | |
| 131 | + "ordre": 4 | |
| 132 | + } | |
| 133 | + ], | |
| 134 | + "personnalise": false, | |
| 135 | + "gestion_contacts": false, | |
| 136 | + "cree_par": "systeme", | |
| 137 | + "cree_le": "2026-09-18T08:42:51.507116089Z" | |
| 138 | + } | |
| 139 | + ] | |
| 140 | +} | |
| 140 | < \ No newline at end of file |
M
ios/Card2vcfTests/InMemoryCrmDatabase.swift
+49
-0
@@ -15,6 +15,7 @@ final class InMemoryCrmDatabase: CrmDatabase {
| 15 | 15 | let indisponibiliteDaoImpl = InMemoryIndisponibiliteDao() |
| 16 | 16 | let syncMetaDaoImpl = InMemorySyncMetaDao() |
| 17 | 17 | let syncOpDaoImpl = InMemorySyncOpDao() |
| 18 | + let noteProjetDaoImpl = InMemoryNoteProjetDao() | |
| 18 | 19 | |
| 19 | 20 | var crmContactDao: CrmContactDao { contactDaoImpl } |
| 20 | 21 | var entrepriseDao: EntrepriseDao { entrepriseDaoImpl } |
@@ -27,6 +28,7 @@ final class InMemoryCrmDatabase: CrmDatabase {
| 27 | 28 | var indisponibiliteDao: IndisponibiliteDao { indisponibiliteDaoImpl } |
| 28 | 29 | var syncMetaDao: SyncMetaDao { syncMetaDaoImpl } |
| 29 | 30 | var syncOpDao: SyncOpDao { syncOpDaoImpl } |
| 31 | + var noteProjetDao: NoteProjetDao { noteProjetDaoImpl } | |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | final class InMemoryCrmContactDao: CrmContactDao { |
@@ -244,10 +246,18 @@ final class InMemoryInteractionDao: InteractionDao {
| 244 | 246 | rows.first { $0.serverId == serverId } |
| 245 | 247 | } |
| 246 | 248 | |
| 249 | + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity? { | |
| 250 | + rows.first { $0.localId == localId } | |
| 251 | + } | |
| 252 | + | |
| 247 | 253 | func deleteByServerId(_ serverId: String) async throws { |
| 248 | 254 | rows.removeAll { $0.serverId == serverId } |
| 249 | 255 | } |
| 250 | 256 | |
| 257 | + func deleteByLocalId(_ localId: Int64) async throws { | |
| 258 | + rows.removeAll { $0.localId == localId } | |
| 259 | + } | |
| 260 | + | |
| 251 | 261 | func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] { |
| 252 | 262 | rows.filter { $0.contactServerId == contactServerId } |
| 253 | 263 | } |
@@ -462,3 +472,42 @@ final class InMemorySyncOpDao: SyncOpDao {
| 462 | 472 | rows.removeAll { $0.id == id } |
| 463 | 473 | } |
| 464 | 474 | } |
| 475 | + | |
| 476 | +final class InMemoryNoteProjetDao: NoteProjetDao { | |
| 477 | + private var rows: [NoteProjetEntity] = [] | |
| 478 | + private var nextId: Int64 = 1 | |
| 479 | + | |
| 480 | + @discardableResult | |
| 481 | + func upsert(_ entity: NoteProjetEntity) async throws -> Int64 { | |
| 482 | + var row = entity | |
| 483 | + if row.localId == 0 { | |
| 484 | + row.localId = nextId | |
| 485 | + nextId += 1 | |
| 486 | + } else { | |
| 487 | + rows.removeAll { $0.localId == row.localId } | |
| 488 | + nextId = max(nextId, row.localId + 1) | |
| 489 | + } | |
| 490 | + rows.append(row) | |
| 491 | + return row.localId | |
| 492 | + } | |
| 493 | + | |
| 494 | + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity? { | |
| 495 | + rows.first { $0.serverId == serverId } | |
| 496 | + } | |
| 497 | + | |
| 498 | + func deleteByServerId(_ serverId: String) async throws { | |
| 499 | + rows.removeAll { $0.serverId == serverId } | |
| 500 | + } | |
| 501 | + | |
| 502 | + func deleteByLocalId(_ localId: Int64) async throws { | |
| 503 | + rows.removeAll { $0.localId == localId } | |
| 504 | + } | |
| 505 | + | |
| 506 | + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity] { | |
| 507 | + rows.filter { $0.projetServerId == projetServerId } | |
| 508 | + } | |
| 509 | + | |
| 510 | + func listAll() async throws -> [NoteProjetEntity] { | |
| 511 | + rows | |
| 512 | + } | |
| 513 | +} |
A
ios/Card2vcfTests/InteractionAudioSyncTest.swift
+315
-0
@@ -0,0 +1,315 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Preuve de rougeur — Lot 2b (modèle + transport des notes vocales). | |
| 5 | +/// | |
| 6 | +/// Test 1 : testApplyInteraction_lwwUpdatesTranscriptionStatut | |
| 7 | +/// En commentant `guard remoteTs > localTs else { return }` dans `SyncEngine.applyInteraction` | |
| 8 | +/// (chemin de mise à jour), le test échoue avec : | |
| 9 | +/// | |
| 10 | +/// XCTAssertEqual failed: ("nil") is not equal to ("Optional("terminee")") | |
| 11 | +/// | |
| 12 | +/// Test 2 : testPousserEnAttente_uploadsInteractionAudio | |
| 13 | +/// En supprimant le `case "interaction_audio"` dans `pushOps`, le test échoue avec : | |
| 14 | +/// | |
| 15 | +/// XCTAssertEqual failed: ("0") is not equal to ("1") | |
| 16 | +/// | |
| 17 | +final class InteractionAudioSyncTest: XCTestCase { | |
| 18 | + | |
| 19 | + private var db: InMemoryCrmDatabase! | |
| 20 | + private var api: FakeAilianceApi! | |
| 21 | + private var engine: SyncEngine! | |
| 22 | + | |
| 23 | + override func setUp() { | |
| 24 | + super.setUp() | |
| 25 | + db = InMemoryCrmDatabase() | |
| 26 | + api = FakeAilianceApi() | |
| 27 | + engine = SyncEngine(api: api, db: db) | |
| 28 | + } | |
| 29 | + | |
| 30 | + // MARK: - InteractionDto — décodage des champs audio | |
| 31 | + | |
| 32 | + func testInteractionDto_decodesAudioFields() throws { | |
| 33 | + let json = """ | |
| 34 | + { | |
| 35 | + "id": "i1", | |
| 36 | + "contact_id": "c1", | |
| 37 | + "type_interaction": "note_vocale", | |
| 38 | + "sujet": "Note vocale", | |
| 39 | + "description": "", | |
| 40 | + "cree_par": "alice", | |
| 41 | + "cree_le": "2026-09-16T10:00:00Z", | |
| 42 | + "mis_a_jour_le": "2026-09-16T10:01:00Z", | |
| 43 | + "piece_jointe": "audio.wav", | |
| 44 | + "transcription": "terminee", | |
| 45 | + "transcription_erreur": null | |
| 46 | + } | |
| 47 | + """ | |
| 48 | + let dto = try SyncJson.decode(InteractionDto.self, from: json) | |
| 49 | + XCTAssertEqual("note_vocale", dto.typeInteraction) | |
| 50 | + XCTAssertEqual("audio.wav", dto.pieceJointe) | |
| 51 | + XCTAssertEqual("terminee", dto.transcription) | |
| 52 | + XCTAssertNil(dto.transcriptionErreur) | |
| 53 | + } | |
| 54 | + | |
| 55 | + func testInteractionDto_champsOptionnelsAbsentsSansErreur() throws { | |
| 56 | + // Serveur antérieur sans les nouveaux champs : décodage tolérant. | |
| 57 | + let json = """ | |
| 58 | + { "id": "i2", "contact_id": "c1", "cree_le": "2026-09-16T10:00:00Z" } | |
| 59 | + """ | |
| 60 | + let dto = try SyncJson.decode(InteractionDto.self, from: json) | |
| 61 | + XCTAssertNil(dto.pieceJointe) | |
| 62 | + XCTAssertNil(dto.transcription) | |
| 63 | + XCTAssertNil(dto.transcriptionErreur) | |
| 64 | + } | |
| 65 | + | |
| 66 | + // MARK: - applyInteraction — insert avec champs audio | |
| 67 | + | |
| 68 | + func testApplyInteraction_insertsAvecChampsAudio() async throws { | |
| 69 | + api.pullResult = .ok(SyncPullResponse( | |
| 70 | + serverTime: "2026-09-16T10:05:00Z", | |
| 71 | + interactions: [ | |
| 72 | + InteractionDto( | |
| 73 | + id: "i1", contactId: "c1", | |
| 74 | + typeInteraction: "note_vocale", | |
| 75 | + sujet: "Note", | |
| 76 | + creeLe: "2026-09-16T10:00:00Z", | |
| 77 | + transcription: "en_attente" | |
| 78 | + ) | |
| 79 | + ] | |
| 80 | + )) | |
| 81 | + _ = try await engine.syncNow() | |
| 82 | + | |
| 83 | + let stored = try await db.interactionDao.getByServerId("i1") | |
| 84 | + XCTAssertEqual("note_vocale", stored?.type) | |
| 85 | + XCTAssertEqual("en_attente", stored?.transcriptionStatut) | |
| 86 | + XCTAssertNil(stored?.audioPath, "audioPath doit être nil à l'insert (fichier local absent)") | |
| 87 | + } | |
| 88 | + | |
| 89 | + // MARK: - LWW update interactions (PREUVE DE ROUGEUR #1) | |
| 90 | + | |
| 91 | + func testApplyInteraction_lwwUpdatesTranscriptionStatut() async throws { | |
| 92 | + // Interaction connue localement (transcription en_attente, timestamp ancien). | |
| 93 | + var existing = InteractionEntity() | |
| 94 | + existing.serverId = "i-lww" | |
| 95 | + existing.contactServerId = "c1" | |
| 96 | + existing.type = "note_vocale" | |
| 97 | + existing.sujet = "Note" | |
| 98 | + existing.transcriptionStatut = "en_attente" | |
| 99 | + existing.createdAt = 1_000 | |
| 100 | + existing.updatedAt = 1_000 // très ancien : 1970-01-01T00:00:01Z | |
| 101 | + _ = try await db.interactionDao.upsert(existing) | |
| 102 | + | |
| 103 | + // Pull : même interaction, transcription terminée, timestamp 2026 (plus récent). | |
| 104 | + api.pullResult = .ok(SyncPullResponse( | |
| 105 | + serverTime: "2026-09-16T12:05:00Z", | |
| 106 | + interactions: [ | |
| 107 | + InteractionDto( | |
| 108 | + id: "i-lww", contactId: "c1", | |
| 109 | + typeInteraction: "note_vocale", | |
| 110 | + sujet: "Note", | |
| 111 | + creeLe: "2026-09-16T10:00:00Z", | |
| 112 | + misAJourLe: "2026-09-16T12:00:00Z", | |
| 113 | + transcription: "terminee" | |
| 114 | + ) | |
| 115 | + ] | |
| 116 | + )) | |
| 117 | + _ = try await engine.syncNow() | |
| 118 | + | |
| 119 | + let updated = try await db.interactionDao.getByServerId("i-lww") | |
| 120 | + // Rouge sans le chemin de MàJ : ("nil") is not equal to ("Optional("terminee")") | |
| 121 | + XCTAssertEqual("terminee", updated?.transcriptionStatut) | |
| 122 | + } | |
| 123 | + | |
| 124 | + func testApplyInteraction_remoteAncienNEcrasePasLocal() async throws { | |
| 125 | + // Local très récent (2033), remote 2026 : local doit gagner. | |
| 126 | + var existing = InteractionEntity() | |
| 127 | + existing.serverId = "i-old" | |
| 128 | + existing.contactServerId = "c1" | |
| 129 | + existing.sujet = "Local récent" | |
| 130 | + existing.transcriptionStatut = "terminee" | |
| 131 | + existing.createdAt = 2_000_000_000_000 | |
| 132 | + existing.updatedAt = 2_000_000_000_000 // ~2033 | |
| 133 | + _ = try await db.interactionDao.upsert(existing) | |
| 134 | + | |
| 135 | + api.pullResult = .ok(SyncPullResponse( | |
| 136 | + serverTime: "2026-09-16T12:05:00Z", | |
| 137 | + interactions: [ | |
| 138 | + InteractionDto( | |
| 139 | + id: "i-old", contactId: "c1", | |
| 140 | + sujet: "Ancien serveur", | |
| 141 | + creeLe: "2026-09-16T09:00:00Z", | |
| 142 | + misAJourLe: "2026-09-16T09:30:00Z", | |
| 143 | + transcription: "en_attente" | |
| 144 | + ) | |
| 145 | + ] | |
| 146 | + )) | |
| 147 | + _ = try await engine.syncNow() | |
| 148 | + | |
| 149 | + let kept = try await db.interactionDao.getByServerId("i-old") | |
| 150 | + XCTAssertEqual("Local récent", kept?.sujet) | |
| 151 | + XCTAssertEqual("terminee", kept?.transcriptionStatut) | |
| 152 | + } | |
| 153 | + | |
| 154 | + func testApplyInteraction_preserveAudioPathLocal() async throws { | |
| 155 | + // Le pull distant (plus récent) ne doit pas écraser le chemin audio local. | |
| 156 | + var existing = InteractionEntity() | |
| 157 | + existing.serverId = "i-audio" | |
| 158 | + existing.contactServerId = "c1" | |
| 159 | + existing.sujet = "Avec audio" | |
| 160 | + existing.audioPath = "/local/audio/interaction_1.wav" | |
| 161 | + existing.transcriptionStatut = "en_attente" | |
| 162 | + existing.createdAt = 1_000 | |
| 163 | + existing.updatedAt = 1_000 | |
| 164 | + _ = try await db.interactionDao.upsert(existing) | |
| 165 | + | |
| 166 | + api.pullResult = .ok(SyncPullResponse( | |
| 167 | + serverTime: "2026-09-16T12:05:00Z", | |
| 168 | + interactions: [ | |
| 169 | + InteractionDto( | |
| 170 | + id: "i-audio", contactId: "c1", | |
| 171 | + sujet: "Avec audio", | |
| 172 | + creeLe: "2026-09-16T10:00:00Z", | |
| 173 | + misAJourLe: "2026-09-16T12:00:00Z", | |
| 174 | + pieceJointe: "audio_serveur.wav", | |
| 175 | + transcription: "terminee" | |
| 176 | + ) | |
| 177 | + ] | |
| 178 | + )) | |
| 179 | + _ = try await engine.syncNow() | |
| 180 | + | |
| 181 | + let updated = try await db.interactionDao.getByServerId("i-audio") | |
| 182 | + XCTAssertEqual("/local/audio/interaction_1.wav", updated?.audioPath, | |
| 183 | + "audioPath local préservé même si piece_jointe serveur reçu") | |
| 184 | + XCTAssertEqual("terminee", updated?.transcriptionStatut) | |
| 185 | + } | |
| 186 | + | |
| 187 | + // MARK: - Push audio interaction retry (PREUVE DE ROUGEUR #2) | |
| 188 | + | |
| 189 | + func testPousserEnAttente_uploadsInteractionAudio() async throws { | |
| 190 | + // Créer un fichier WAV temporaire (le SyncEngine lit les bytes depuis le disque). | |
| 191 | + let tempDir = FileManager.default.temporaryDirectory | |
| 192 | + .appendingPathComponent(UUID().uuidString) | |
| 193 | + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) | |
| 194 | + defer { try? FileManager.default.removeItem(at: tempDir) } | |
| 195 | + let audioUrl = tempDir.appendingPathComponent("note.wav") | |
| 196 | + try Data([0x52, 0x49, 0x46, 0x46, 0x00]).write(to: audioUrl) // mini RIFF | |
| 197 | + | |
| 198 | + var interaction = InteractionEntity() | |
| 199 | + interaction.serverId = "i-srv" | |
| 200 | + interaction.contactServerId = "c-srv" | |
| 201 | + interaction.type = "note_vocale" | |
| 202 | + interaction.sujet = "Note audio" | |
| 203 | + interaction.audioPath = audioUrl.path | |
| 204 | + interaction.createdAt = 1_000_000 | |
| 205 | + _ = try await db.interactionDao.upsert(interaction) | |
| 206 | + | |
| 207 | + var op = SyncOpEntity(entityType: "interaction_audio", op: "upload") | |
| 208 | + op.serverId = "i-srv" | |
| 209 | + op.payloadJson = "{}" | |
| 210 | + op.createdAt = 1 | |
| 211 | + _ = try await db.syncOpDao.insert(op) | |
| 212 | + | |
| 213 | + api.uploadInteractionAudioResult = .ok("{}") | |
| 214 | + _ = try await engine.pousserEnAttente() | |
| 215 | + | |
| 216 | + // Rouge sans le handler interaction_audio : ("0") is not equal to ("1") | |
| 217 | + XCTAssertEqual(1, api.uploadInteractionAudioCalls.count) | |
| 218 | + let ops1 = try await db.syncOpDao.listAll() | |
| 219 | + XCTAssertTrue(ops1.isEmpty, "Op dépilée après succès") | |
| 220 | + } | |
| 221 | + | |
| 222 | + func testPousserEnAttente_echecReseauGardeOpEnFile() async throws { | |
| 223 | + let tempDir = FileManager.default.temporaryDirectory | |
| 224 | + .appendingPathComponent(UUID().uuidString) | |
| 225 | + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) | |
| 226 | + defer { try? FileManager.default.removeItem(at: tempDir) } | |
| 227 | + let audioUrl = tempDir.appendingPathComponent("note2.wav") | |
| 228 | + try Data([0x00]).write(to: audioUrl) | |
| 229 | + | |
| 230 | + var interaction = InteractionEntity() | |
| 231 | + interaction.serverId = "i-srv2" | |
| 232 | + interaction.contactServerId = "c-srv" | |
| 233 | + interaction.type = "note_vocale" | |
| 234 | + interaction.audioPath = audioUrl.path | |
| 235 | + interaction.createdAt = 1_000_000 | |
| 236 | + _ = try await db.interactionDao.upsert(interaction) | |
| 237 | + | |
| 238 | + var op = SyncOpEntity(entityType: "interaction_audio", op: "upload") | |
| 239 | + op.serverId = "i-srv2" | |
| 240 | + op.payloadJson = "{}" | |
| 241 | + op.createdAt = 1 | |
| 242 | + _ = try await db.syncOpDao.insert(op) | |
| 243 | + | |
| 244 | + api.uploadInteractionAudioResult = .err(code: -1, message: "Réseau indisponible") | |
| 245 | + _ = try await engine.pousserEnAttente() | |
| 246 | + | |
| 247 | + let remaining = try await db.syncOpDao.listAll() | |
| 248 | + XCTAssertEqual(1, remaining.count, "Op reste en file après échec réseau") | |
| 249 | + } | |
| 250 | + | |
| 251 | + // MARK: - Push note de projet | |
| 252 | + | |
| 253 | + func testPousserEnAttente_creeNoteProjet() async throws { | |
| 254 | + var op = SyncOpEntity(entityType: "note_projet", op: "create") | |
| 255 | + op.serverId = "projet-srv" | |
| 256 | + op.localId = 42 | |
| 257 | + op.payloadJson = #"{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}"# | |
| 258 | + op.createdAt = 1 | |
| 259 | + _ = try await db.syncOpDao.insert(op) | |
| 260 | + | |
| 261 | + api.createNoteProjetResult = .ok(#"{"id":"note-srv"}"#) | |
| 262 | + _ = try await engine.pousserEnAttente() | |
| 263 | + | |
| 264 | + XCTAssertEqual(1, api.createNoteProjetCalls.count) | |
| 265 | + let ops2 = try await db.syncOpDao.listAll() | |
| 266 | + XCTAssertTrue(ops2.isEmpty) | |
| 267 | + } | |
| 268 | + | |
| 269 | + func testPousserEnAttente_noteProjetEchecReseauGardeOpEnFile() async throws { | |
| 270 | + var op = SyncOpEntity(entityType: "note_projet", op: "create") | |
| 271 | + op.serverId = "projet-srv" | |
| 272 | + op.localId = 42 | |
| 273 | + op.payloadJson = #"{"titre":"Erreur","contenu":"","demande_transcription":false}"# | |
| 274 | + op.createdAt = 1 | |
| 275 | + _ = try await db.syncOpDao.insert(op) | |
| 276 | + | |
| 277 | + api.createNoteProjetResult = .err(code: -1, message: "Réseau") | |
| 278 | + _ = try await engine.pousserEnAttente() | |
| 279 | + | |
| 280 | + let ops3 = try await db.syncOpDao.listAll() | |
| 281 | + XCTAssertEqual(1, ops3.count) | |
| 282 | + } | |
| 283 | + | |
| 284 | + // MARK: - pousserEnAttente n'effectue pas de pull | |
| 285 | + | |
| 286 | + func testPousserEnAttente_nEffectuePasLePull() async throws { | |
| 287 | + api.pullResult = .ok(SyncPullResponse( | |
| 288 | + serverTime: "2026-09-16T10:05:00Z", | |
| 289 | + contacts: [ContactDto(id: "c-never", creeLe: "2026-09-16T10:00:00Z")] | |
| 290 | + )) | |
| 291 | + _ = try await engine.pousserEnAttente() | |
| 292 | + | |
| 293 | + let contacts = try await db.crmContactDao.fetchAll() | |
| 294 | + XCTAssertTrue(contacts.isEmpty, "pousserEnAttente ne doit pas effectuer de pull") | |
| 295 | + } | |
| 296 | + | |
| 297 | + // MARK: - AudioNoteStore | |
| 298 | + | |
| 299 | + func testAudioNoteStore_cheminsStables() { | |
| 300 | + let dir = FileManager.default.temporaryDirectory | |
| 301 | + .appendingPathComponent(UUID().uuidString) | |
| 302 | + defer { try? FileManager.default.removeItem(at: dir) } | |
| 303 | + let store = AudioNoteStore(rootDirectory: dir) | |
| 304 | + | |
| 305 | + XCTAssertEqual(store.pathForInteraction(localId: 1), store.pathForInteraction(localId: 1), | |
| 306 | + "Le chemin doit être stable pour un même localId") | |
| 307 | + XCTAssertTrue(store.pathForInteraction(localId: 5).hasSuffix("interaction_5.wav")) | |
| 308 | + XCTAssertTrue(store.pathForNote(localId: 7).hasSuffix("note_7.wav")) | |
| 309 | + XCTAssertNotEqual( | |
| 310 | + store.pathForInteraction(localId: 1), | |
| 311 | + store.pathForNote(localId: 1), | |
| 312 | + "Interactions et notes ont des chemins distincts" | |
| 313 | + ) | |
| 314 | + } | |
| 315 | +} |
A
ios/Card2vcfTests/InteractionMigrationTest.swift
+112
-0
@@ -0,0 +1,112 @@
| 1 | +import XCTest | |
| 2 | +import SQLite3 | |
| 3 | +@testable import Card2vcf | |
| 4 | + | |
| 5 | +/// Preuve de rougeur (lot 2b, migration v5→v6) : | |
| 6 | +/// Sans les trois `ALTER TABLE interactions ADD COLUMN …` dans `migrateIfNeeded`, l'accès | |
| 7 | +/// aux nouvelles colonnes via `SqliteInteractionDao` lève une `DatabaseError` du type : | |
| 8 | +/// | |
| 9 | +/// testMigrationV5ToV6_colonnesPresentes — threw error "DatabaseError: SELECT … FROM | |
| 10 | +/// interactions: table interactions has no column named audioPath" | |
| 11 | +/// | |
| 12 | +final class InteractionMigrationTest: XCTestCase { | |
| 13 | + | |
| 14 | + private var dbPath: String! | |
| 15 | + | |
| 16 | + override func setUp() { | |
| 17 | + super.setUp() | |
| 18 | + dbPath = FileManager.default.temporaryDirectory | |
| 19 | + .appendingPathComponent("migration_v5v6_\(UUID().uuidString).sqlite") | |
| 20 | + .path | |
| 21 | + } | |
| 22 | + | |
| 23 | + override func tearDown() { | |
| 24 | + try? FileManager.default.removeItem(atPath: dbPath) | |
| 25 | + super.tearDown() | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// Construit une base SQLite au format v5 (table interactions sans les nouvelles colonnes). | |
| 29 | + private func buildV5Database(interactions: [(serverId: String, contactServerId: String, sujet: String, type: String)] = []) { | |
| 30 | + var rawHandle: OpaquePointer? | |
| 31 | + guard sqlite3_open(dbPath, &rawHandle) == SQLITE_OK, let rawHandle else { return } | |
| 32 | + defer { sqlite3_close_v2(rawHandle) } | |
| 33 | + | |
| 34 | + var sql = """ | |
| 35 | + CREATE TABLE interactions ( | |
| 36 | + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | |
| 37 | + serverId TEXT, | |
| 38 | + contactServerId TEXT NOT NULL, | |
| 39 | + type TEXT NOT NULL, | |
| 40 | + sujet TEXT NOT NULL, | |
| 41 | + description TEXT NOT NULL, | |
| 42 | + creePar TEXT NOT NULL, | |
| 43 | + createdAt INTEGER NOT NULL, | |
| 44 | + updatedAt INTEGER | |
| 45 | + ); | |
| 46 | + """ | |
| 47 | + for row in interactions { | |
| 48 | + sql += """ | |
| 49 | + INSERT INTO interactions | |
| 50 | + (serverId, contactServerId, type, sujet, description, creePar, createdAt) | |
| 51 | + VALUES ('\(row.serverId)', '\(row.contactServerId)', '\(row.type)', '\(row.sujet)', '', 'alice', 1000); | |
| 52 | + """ | |
| 53 | + } | |
| 54 | + sql += "PRAGMA user_version = 5;" | |
| 55 | + sqlite3_exec(rawHandle, sql, nil, nil, nil) | |
| 56 | + } | |
| 57 | + | |
| 58 | + func testMigrationV5ToV6_colonnesPresentes() async throws { | |
| 59 | + buildV5Database(interactions: [("srv-i1", "c1", "Appel", "note")]) | |
| 60 | + | |
| 61 | + // L'ouverture déclenche la migration v5→v6. | |
| 62 | + let db = SqliteDatabase(filePath: dbPath) | |
| 63 | + let dao = SqliteInteractionDao(db: db) | |
| 64 | + | |
| 65 | + // Données v5 préservées | |
| 66 | + let interactions = try await dao.listAll() | |
| 67 | + XCTAssertEqual(1, interactions.count, "L'interaction v5 doit être préservée après migration") | |
| 68 | + XCTAssertEqual("srv-i1", interactions.first?.serverId) | |
| 69 | + | |
| 70 | + // Nouvelles colonnes présentes (valeur nil pour les lignes migrées) | |
| 71 | + XCTAssertNil(interactions.first?.audioPath, "audioPath nul pour une interaction migrée depuis v5") | |
| 72 | + XCTAssertNil(interactions.first?.transcriptionStatut) | |
| 73 | + XCTAssertNil(interactions.first?.transcriptionErreur) | |
| 74 | + } | |
| 75 | + | |
| 76 | + func testMigrationV5ToV6_preserveMultipleInteractions() async throws { | |
| 77 | + buildV5Database(interactions: [ | |
| 78 | + ("i-alpha", "c-1", "Réunion", "note"), | |
| 79 | + ("i-beta", "c-1", "Message vocal", "note_vocale"), | |
| 80 | + ]) | |
| 81 | + | |
| 82 | + let db = SqliteDatabase(filePath: dbPath) | |
| 83 | + let dao = SqliteInteractionDao(db: db) | |
| 84 | + let interactions = try await dao.listAll() | |
| 85 | + | |
| 86 | + XCTAssertEqual(2, interactions.count) | |
| 87 | + XCTAssertTrue(interactions.contains { $0.serverId == "i-alpha" && $0.sujet == "Réunion" }) | |
| 88 | + XCTAssertTrue(interactions.contains { $0.serverId == "i-beta" && $0.type == "note_vocale" }) | |
| 89 | + } | |
| 90 | + | |
| 91 | + func testMigrationV5ToV6_insertionAvecNouvellesColonnes() async throws { | |
| 92 | + buildV5Database() | |
| 93 | + | |
| 94 | + let db = SqliteDatabase(filePath: dbPath) | |
| 95 | + let dao = SqliteInteractionDao(db: db) | |
| 96 | + | |
| 97 | + var entity = InteractionEntity() | |
| 98 | + entity.contactServerId = "c1" | |
| 99 | + entity.type = "note_vocale" | |
| 100 | + entity.sujet = "Nouveau" | |
| 101 | + entity.audioPath = "/tmp/note.wav" | |
| 102 | + entity.transcriptionStatut = "en_attente" | |
| 103 | + entity.createdAt = 2000 | |
| 104 | + | |
| 105 | + let newId = try await dao.upsert(entity) | |
| 106 | + let fetched = try await dao.getByLocalId(newId) | |
| 107 | + | |
| 108 | + XCTAssertEqual("/tmp/note.wav", fetched?.audioPath) | |
| 109 | + XCTAssertEqual("en_attente", fetched?.transcriptionStatut) | |
| 110 | + XCTAssertNil(fetched?.transcriptionErreur) | |
| 111 | + } | |
| 112 | +} |
A
ios/Card2vcfTests/NoteProjetDaoTest.swift
+106
-0
@@ -0,0 +1,106 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Port de NoteProjetDaoTest Android — exerc\u{e9} via InMemoryCrmDatabase. | |
| 5 | +final class NoteProjetDaoTest: XCTestCase { | |
| 6 | + private var db: InMemoryCrmDatabase! | |
| 7 | + | |
| 8 | + override func setUp() { | |
| 9 | + super.setUp() | |
| 10 | + db = InMemoryCrmDatabase() | |
| 11 | + } | |
| 12 | + | |
| 13 | + func testUpsertInsertEtGetByServerId() async throws { | |
| 14 | + var note = NoteProjetEntity() | |
| 15 | + note.serverId = "n1" | |
| 16 | + note.projetServerId = "p1" | |
| 17 | + note.titre = "Premi\u{e8}re note" | |
| 18 | + note.texte = "Contenu initial" | |
| 19 | + note.auteur = "alice" | |
| 20 | + note.createdAt = 1_000 | |
| 21 | + note.updatedAt = 1_000 | |
| 22 | + | |
| 23 | + let localId = try await db.noteProjetDao.upsert(note) | |
| 24 | + XCTAssertGreaterThan(localId, 0) | |
| 25 | + | |
| 26 | + let found = try await db.noteProjetDao.getByServerId("n1") | |
| 27 | + XCTAssertNotNil(found) | |
| 28 | + XCTAssertEqual("Premi\u{e8}re note", found?.titre) | |
| 29 | + XCTAssertEqual("Contenu initial", found?.texte) | |
| 30 | + XCTAssertEqual("p1", found?.projetServerId) | |
| 31 | + XCTAssertEqual("alice", found?.auteur) | |
| 32 | + } | |
| 33 | + | |
| 34 | + func testUpsertMiseAJour() async throws { | |
| 35 | + var note = NoteProjetEntity() | |
| 36 | + note.serverId = "n2" | |
| 37 | + note.projetServerId = "p1" | |
| 38 | + note.titre = "Titre original" | |
| 39 | + note.texte = "Texte original" | |
| 40 | + note.auteur = "bob" | |
| 41 | + note.createdAt = 500 | |
| 42 | + note.updatedAt = 500 | |
| 43 | + let localId = try await db.noteProjetDao.upsert(note) | |
| 44 | + | |
| 45 | + var updated = note | |
| 46 | + updated.localId = localId | |
| 47 | + updated.titre = "Titre modifi\u{e9}" | |
| 48 | + updated.updatedAt = 600 | |
| 49 | + try await db.noteProjetDao.upsert(updated) | |
| 50 | + | |
| 51 | + let found = try await db.noteProjetDao.getByServerId("n2") | |
| 52 | + XCTAssertEqual("Titre modifi\u{e9}", found?.titre) | |
| 53 | + XCTAssertEqual(600, found?.updatedAt) | |
| 54 | + } | |
| 55 | + | |
| 56 | + func testListByProjetServerId() async throws { | |
| 57 | + var n1 = NoteProjetEntity() | |
| 58 | + n1.serverId = "n1"; n1.projetServerId = "pA"; n1.titre = "A1"; n1.texte = ""; n1.auteur = "x"; n1.createdAt = 1 | |
| 59 | + var n2 = NoteProjetEntity() | |
| 60 | + n2.serverId = "n2"; n2.projetServerId = "pA"; n2.titre = "A2"; n2.texte = ""; n2.auteur = "x"; n2.createdAt = 2 | |
| 61 | + var n3 = NoteProjetEntity() | |
| 62 | + n3.serverId = "n3"; n3.projetServerId = "pB"; n3.titre = "B1"; n3.texte = ""; n3.auteur = "x"; n3.createdAt = 3 | |
| 63 | + | |
| 64 | + try await db.noteProjetDao.upsert(n1) | |
| 65 | + try await db.noteProjetDao.upsert(n2) | |
| 66 | + try await db.noteProjetDao.upsert(n3) | |
| 67 | + | |
| 68 | + let forA = try await db.noteProjetDao.listByProjetServerId("pA") | |
| 69 | + XCTAssertEqual(2, forA.count) | |
| 70 | + XCTAssertTrue(forA.contains { $0.serverId == "n1" }) | |
| 71 | + XCTAssertTrue(forA.contains { $0.serverId == "n2" }) | |
| 72 | + | |
| 73 | + let forB = try await db.noteProjetDao.listByProjetServerId("pB") | |
| 74 | + XCTAssertEqual(1, forB.count) | |
| 75 | + XCTAssertEqual("n3", forB.first?.serverId) | |
| 76 | + } | |
| 77 | + | |
| 78 | + func testDeleteByServerId() async throws { | |
| 79 | + var note = NoteProjetEntity() | |
| 80 | + note.serverId = "n-del"; note.projetServerId = "p1"; note.titre = "T"; note.texte = ""; note.auteur = ""; note.createdAt = 1 | |
| 81 | + try await db.noteProjetDao.upsert(note) | |
| 82 | + | |
| 83 | + try await db.noteProjetDao.deleteByServerId("n-del") | |
| 84 | + | |
| 85 | + let found = try await db.noteProjetDao.getByServerId("n-del") | |
| 86 | + XCTAssertNil(found) | |
| 87 | + } | |
| 88 | + | |
| 89 | + func testAudioPathPreserved() async throws { | |
| 90 | + var note = NoteProjetEntity() | |
| 91 | + note.serverId = "n-audio"; note.projetServerId = "p1"; note.titre = "Avec audio" | |
| 92 | + note.texte = "Texte"; note.auteur = "alice"; note.createdAt = 100; note.updatedAt = 100 | |
| 93 | + note.audioPath = "/local/notes/n-audio.wav" | |
| 94 | + let localId = try await db.noteProjetDao.upsert(note) | |
| 95 | + | |
| 96 | + // Met \u{e0} jour sans changer audioPath | |
| 97 | + var updated = note | |
| 98 | + updated.localId = localId | |
| 99 | + updated.titre = "Titre mis \u{e0} jour" | |
| 100 | + updated.updatedAt = 200 | |
| 101 | + try await db.noteProjetDao.upsert(updated) | |
| 102 | + | |
| 103 | + let found = try await db.noteProjetDao.getByServerId("n-audio") | |
| 104 | + XCTAssertEqual("/local/notes/n-audio.wav", found?.audioPath) | |
| 105 | + } | |
| 106 | +} |
A
ios/Card2vcfTests/NoteProjetDtoTest.swift
+86
-0
@@ -0,0 +1,86 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +final class NoteProjetDtoTest: XCTestCase { | |
| 5 | + | |
| 6 | + func testDecodeComplete() throws { | |
| 7 | + let json = """ | |
| 8 | + { | |
| 9 | + "id": "n1", | |
| 10 | + "projet_id": "p1", | |
| 11 | + "titre": "R\u{e9}union", | |
| 12 | + "contenu": "## Points cl\u{e9}s\\n- Point A", | |
| 13 | + "auteur": "alice", | |
| 14 | + "cree_le": "2026-09-10T08:00:00Z", | |
| 15 | + "maj_le": "2026-09-11T10:00:00Z", | |
| 16 | + "audio": "note.wav", | |
| 17 | + "transcription": "terminee", | |
| 18 | + "transcription_erreur": null | |
| 19 | + } | |
| 20 | + """ | |
| 21 | + let dto = try SyncJson.decode(NoteProjetDto.self, from: json) | |
| 22 | + XCTAssertEqual("n1", dto.id) | |
| 23 | + XCTAssertEqual("p1", dto.projetId) | |
| 24 | + XCTAssertEqual("R\u{e9}union", dto.titre) | |
| 25 | + XCTAssertEqual("## Points cl\u{e9}s\n- Point A", dto.contenu) | |
| 26 | + XCTAssertEqual("alice", dto.auteur) | |
| 27 | + XCTAssertEqual("2026-09-10T08:00:00Z", dto.creeLe) | |
| 28 | + XCTAssertEqual("2026-09-11T10:00:00Z", dto.majLe) | |
| 29 | + XCTAssertEqual("note.wav", dto.audio) | |
| 30 | + XCTAssertEqual("terminee", dto.transcription) | |
| 31 | + XCTAssertNil(dto.transcriptionErreur) | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Old server: only the 5 required fields — decoding must succeed with safe defaults. | |
| 35 | + func testDecodeMinimalChampsSuffisent() throws { | |
| 36 | + let json = """ | |
| 37 | + { | |
| 38 | + "id": "n2", | |
| 39 | + "projet_id": "p2", | |
| 40 | + "titre": "Appel", | |
| 41 | + "contenu": "Discussion initiale", | |
| 42 | + "cree_le": "2026-09-12T09:00:00Z" | |
| 43 | + } | |
| 44 | + """ | |
| 45 | + let dto = try SyncJson.decode(NoteProjetDto.self, from: json) | |
| 46 | + XCTAssertEqual("n2", dto.id) | |
| 47 | + XCTAssertEqual("p2", dto.projetId) | |
| 48 | + XCTAssertEqual("Appel", dto.titre) | |
| 49 | + XCTAssertEqual("Discussion initiale", dto.contenu) | |
| 50 | + XCTAssertEqual("", dto.auteur) | |
| 51 | + XCTAssertNil(dto.majLe) | |
| 52 | + XCTAssertNil(dto.audio) | |
| 53 | + XCTAssertNil(dto.transcription) | |
| 54 | + XCTAssertNil(dto.transcriptionErreur) | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// Old server — no "notes" key in pull response — must decode without error, notes == []. | |
| 58 | + func testDecodePullResponseSansNotes() throws { | |
| 59 | + let json = """ | |
| 60 | + { "server_time": "2026-09-12T09:00:00Z" } | |
| 61 | + """ | |
| 62 | + let pull = try SyncJson.decode(SyncPullResponse.self, from: json) | |
| 63 | + XCTAssertEqual([], pull.notes) | |
| 64 | + } | |
| 65 | + | |
| 66 | + func testDecodePullResponseAvecNotes() throws { | |
| 67 | + let json = """ | |
| 68 | + { | |
| 69 | + "server_time": "2026-09-12T09:00:00Z", | |
| 70 | + "notes": [ | |
| 71 | + { | |
| 72 | + "id": "n1", | |
| 73 | + "projet_id": "p1", | |
| 74 | + "titre": "Note", | |
| 75 | + "contenu": "Texte", | |
| 76 | + "cree_le": "2026-09-12T09:00:00Z" | |
| 77 | + } | |
| 78 | + ] | |
| 79 | + } | |
| 80 | + """ | |
| 81 | + let pull = try SyncJson.decode(SyncPullResponse.self, from: json) | |
| 82 | + XCTAssertEqual(1, pull.notes.count) | |
| 83 | + XCTAssertEqual("n1", pull.notes[0].id) | |
| 84 | + XCTAssertEqual("p1", pull.notes[0].projetId) | |
| 85 | + } | |
| 86 | +} |
A
ios/Card2vcfTests/NoteProjetMigrationTest.swift
+75
-0
@@ -0,0 +1,75 @@
| 1 | +import XCTest | |
| 2 | +import SQLite3 | |
| 3 | +@testable import Card2vcf | |
| 4 | + | |
| 5 | +/// Verifies additive migration v4 → v5 : | |
| 6 | +/// - la table `notes_projet` est cr\u{e9}\u{e9}e | |
| 7 | +/// - les donn\u{e9}es existantes (table `projets`) sont pr\u{e9}serv\u{e9}es. | |
| 8 | +final class NoteProjetMigrationTest: XCTestCase { | |
| 9 | + | |
| 10 | + private var dbPath: String! | |
| 11 | + | |
| 12 | + override func setUp() { | |
| 13 | + super.setUp() | |
| 14 | + dbPath = FileManager.default.temporaryDirectory | |
| 15 | + .appendingPathComponent("migration_v4v5_\(UUID().uuidString).sqlite") | |
| 16 | + .path | |
| 17 | + } | |
| 18 | + | |
| 19 | + override func tearDown() { | |
| 20 | + try? FileManager.default.removeItem(atPath: dbPath) | |
| 21 | + super.tearDown() | |
| 22 | + } | |
| 23 | + | |
| 24 | + func testMigrationV4ToV5PreservesDataAndAddsNotesTable() async throws { | |
| 25 | + // --- 1. Cr\u{e9}er une base v4 avec donn\u{e9}es --- | |
| 26 | + var rawHandle: OpaquePointer? | |
| 27 | + guard sqlite3_open(dbPath, &rawHandle) == SQLITE_OK, let rawHandle else { | |
| 28 | + XCTFail("Cannot open test database at \(dbPath!)") | |
| 29 | + return | |
| 30 | + } | |
| 31 | + | |
| 32 | + let v4SQL = """ | |
| 33 | + CREATE TABLE projets ( | |
| 34 | + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, | |
| 35 | + serverId TEXT, | |
| 36 | + nom TEXT NOT NULL, | |
| 37 | + description TEXT NOT NULL, | |
| 38 | + workflowServerId TEXT NOT NULL, | |
| 39 | + membresJson TEXT NOT NULL, | |
| 40 | + creePar TEXT NOT NULL, | |
| 41 | + createdAt INTEGER NOT NULL, | |
| 42 | + updatedAt INTEGER NOT NULL | |
| 43 | + ); | |
| 44 | + INSERT INTO projets | |
| 45 | + (serverId, nom, description, workflowServerId, membresJson, creePar, createdAt, updatedAt) | |
| 46 | + VALUES | |
| 47 | + ('srv-p1', 'Salon', '', 'wf-1', '[]', 'alice', 1000, 1000); | |
| 48 | + PRAGMA user_version = 4; | |
| 49 | + """ | |
| 50 | + var errMsg: UnsafeMutablePointer<CChar>? = nil | |
| 51 | + let rc = sqlite3_exec(rawHandle, v4SQL, nil, nil, &errMsg) | |
| 52 | + if rc != SQLITE_OK { | |
| 53 | + let msg = errMsg.map { String(cString: $0) } ?? "unknown" | |
| 54 | + sqlite3_free(errMsg) | |
| 55 | + sqlite3_close_v2(rawHandle) | |
| 56 | + XCTFail("v4 setup failed: \(msg)") | |
| 57 | + return | |
| 58 | + } | |
| 59 | + sqlite3_close_v2(rawHandle) | |
| 60 | + | |
| 61 | + // --- 2. Ouvrir avec SqliteDatabase (d\u{e9}clenche la migration v4 → v5) --- | |
| 62 | + let db = SqliteDatabase(filePath: dbPath) | |
| 63 | + let noteDao = SqliteNoteProjetDao(db: db) | |
| 64 | + let projetDao = SqliteProjetDao(db: db) | |
| 65 | + | |
| 66 | + // --- 3. La table notes_projet existe et est vide --- | |
| 67 | + let notes = try await noteDao.listAll() | |
| 68 | + XCTAssertTrue(notes.isEmpty, "notes_projet doit exister et \u{ea}tre vide apr\u{e8}s migration") | |
| 69 | + | |
| 70 | + // --- 4. Les donn\u{e9}es v4 sont pr\u{e9}serv\u{e9}es --- | |
| 71 | + let projets = try await projetDao.listAll() | |
| 72 | + XCTAssertEqual(1, projets.count, "Le projet Salon doit \u{ea}tre pr\u{e9}serv\u{e9} apr\u{e8}s migration") | |
| 73 | + XCTAssertEqual("Salon", projets.first?.nom) | |
| 74 | + } | |
| 75 | +} |
A
ios/Card2vcfTests/NoteProjetSyncEngineTest.swift
+186
-0
@@ -0,0 +1,186 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, § 2A) : | |
| 5 | +/// en neutralisant la garde LWW de `SyncEngine.applyNoteProjet` | |
| 6 | +/// (`guard remoteTs > localTs else { return }`), la suite passe de 204/0 à 204/3 échecs : | |
| 7 | +/// testPullNoteUpdatePlusRecentApplique — XCTAssertEqual failed: | |
| 8 | +/// ("Optional("Nouveau titre")") is not equal to ("Optional("Ancien titre")") | |
| 9 | +/// testPullNoteUpdatePreserveAudioPath — XCTAssertEqual failed: | |
| 10 | +/// ("Optional("Note vocale éditée")") is not equal to ("Optional("Note vocale")") | |
| 11 | +/// Ces tests détectent donc réellement la perte de la mise à jour LWW. | |
| 12 | +final class NoteProjetSyncEngineTest: XCTestCase { | |
| 13 | + private var db: InMemoryCrmDatabase! | |
| 14 | + private var api: FakeAilianceApi! | |
| 15 | + private var engine: SyncEngine! | |
| 16 | + | |
| 17 | + override func setUp() { | |
| 18 | + super.setUp() | |
| 19 | + db = InMemoryCrmDatabase() | |
| 20 | + api = FakeAilianceApi() | |
| 21 | + engine = SyncEngine(api: api, db: db) | |
| 22 | + } | |
| 23 | + | |
| 24 | + // MARK: - Insert | |
| 25 | + | |
| 26 | + func testPullNoteInsertNouvelle() async throws { | |
| 27 | + api.pullResult = .ok(SyncPullResponse( | |
| 28 | + serverTime: "2026-09-12T09:00:00Z", | |
| 29 | + notes: [ | |
| 30 | + NoteProjetDto( | |
| 31 | + id: "n1", projetId: "p1", | |
| 32 | + titre: "Note initiale", contenu: "## Contenu", | |
| 33 | + creeLe: "2026-09-10T08:00:00Z" | |
| 34 | + ) | |
| 35 | + ] | |
| 36 | + )) | |
| 37 | + | |
| 38 | + _ = try await engine.syncNow() | |
| 39 | + | |
| 40 | + let stored = try await db.noteProjetDao.getByServerId("n1") | |
| 41 | + XCTAssertNotNil(stored, "La note doit \u{ea}tre ins\u{e9}r\u{e9}e lors du pull") | |
| 42 | + XCTAssertEqual("Note initiale", stored?.titre) | |
| 43 | + XCTAssertEqual("## Contenu", stored?.texte) | |
| 44 | + XCTAssertEqual("p1", stored?.projetServerId) | |
| 45 | + } | |
| 46 | + | |
| 47 | + // MARK: - LWW update | |
| 48 | + | |
| 49 | + func testPullNoteUpdatePlusRecentApplique() async throws { | |
| 50 | + // Note locale \u{e0} updatedAt = 1000 ms | |
| 51 | + var local = NoteProjetEntity() | |
| 52 | + local.serverId = "n2"; local.projetServerId = "p1" | |
| 53 | + local.titre = "Ancien titre"; local.texte = "Ancien texte"; local.auteur = "alice" | |
| 54 | + local.createdAt = 1_000; local.updatedAt = 1_000 | |
| 55 | + try await db.noteProjetDao.upsert(local) | |
| 56 | + | |
| 57 | + // Remote maj_le 2026-09-12 >> local 1000 ms | |
| 58 | + api.pullResult = .ok(SyncPullResponse( | |
| 59 | + serverTime: "2026-09-12T10:00:00Z", | |
| 60 | + notes: [ | |
| 61 | + NoteProjetDto( | |
| 62 | + id: "n2", projetId: "p1", | |
| 63 | + titre: "Nouveau titre", contenu: "Nouveau texte", | |
| 64 | + auteur: "alice", | |
| 65 | + creeLe: "2026-09-10T08:00:00Z", | |
| 66 | + majLe: "2026-09-12T09:00:00Z" | |
| 67 | + ) | |
| 68 | + ] | |
| 69 | + )) | |
| 70 | + | |
| 71 | + _ = try await engine.syncNow() | |
| 72 | + | |
| 73 | + let stored = try await db.noteProjetDao.getByServerId("n2") | |
| 74 | + XCTAssertEqual("Nouveau titre", stored?.titre, "La mise \u{e0} jour plus r\u{e9}cente doit \u{ea}tre appliqu\u{e9}e") | |
| 75 | + XCTAssertEqual("Nouveau texte", stored?.texte) | |
| 76 | + } | |
| 77 | + | |
| 78 | + func testPullNoteUpdatePlusAncienIgnore() async throws { | |
| 79 | + // Note locale \u{e0} updatedAt tr\u{e8}s r\u{e9}cent (apr\u{e8}s 2030) | |
| 80 | + var local = NoteProjetEntity() | |
| 81 | + local.serverId = "n3"; local.projetServerId = "p1" | |
| 82 | + local.titre = "Titre local"; local.texte = "Texte local"; local.auteur = "bob" | |
| 83 | + local.createdAt = 2_000_000_000_000 // ~2033 en ms | |
| 84 | + local.updatedAt = 2_000_000_000_000 | |
| 85 | + try await db.noteProjetDao.upsert(local) | |
| 86 | + | |
| 87 | + // Remote cree_le 2026 << local 2033 | |
| 88 | + api.pullResult = .ok(SyncPullResponse( | |
| 89 | + serverTime: "2026-09-12T10:00:00Z", | |
| 90 | + notes: [ | |
| 91 | + NoteProjetDto( | |
| 92 | + id: "n3", projetId: "p1", | |
| 93 | + titre: "Titre serveur", contenu: "Texte serveur", | |
| 94 | + creeLe: "2026-09-10T08:00:00Z" | |
| 95 | + ) | |
| 96 | + ] | |
| 97 | + )) | |
| 98 | + | |
| 99 | + _ = try await engine.syncNow() | |
| 100 | + | |
| 101 | + let stored = try await db.noteProjetDao.getByServerId("n3") | |
| 102 | + XCTAssertEqual("Titre local", stored?.titre, "La mise \u{e0} jour plus ancienne NE doit PAS \u{ea}tre appliqu\u{e9}e") | |
| 103 | + } | |
| 104 | + | |
| 105 | + // MARK: - audioPath pr\u{e9}serv\u{e9} | |
| 106 | + | |
| 107 | + func testPullNoteUpdatePreserveAudioPath() async throws { | |
| 108 | + var local = NoteProjetEntity() | |
| 109 | + local.serverId = "n4"; local.projetServerId = "p1" | |
| 110 | + local.titre = "Note vocale"; local.texte = "Texte initial"; local.auteur = "alice" | |
| 111 | + local.audioPath = "/local/notes/n4.wav" | |
| 112 | + local.createdAt = 1_000; local.updatedAt = 1_000 | |
| 113 | + try await db.noteProjetDao.upsert(local) | |
| 114 | + | |
| 115 | + // Remote plus r\u{e9}cent, avec champ audio serveur (nom de fichier, pas chemin local) | |
| 116 | + api.pullResult = .ok(SyncPullResponse( | |
| 117 | + serverTime: "2026-09-12T10:00:00Z", | |
| 118 | + notes: [ | |
| 119 | + NoteProjetDto( | |
| 120 | + id: "n4", projetId: "p1", | |
| 121 | + titre: "Note vocale \u{e9}dit\u{e9}e", contenu: "Texte \u{e9}dit\u{e9}", | |
| 122 | + creeLe: "2026-09-10T08:00:00Z", | |
| 123 | + majLe: "2026-09-12T09:00:00Z", | |
| 124 | + audio: "n4.wav" | |
| 125 | + ) | |
| 126 | + ] | |
| 127 | + )) | |
| 128 | + | |
| 129 | + _ = try await engine.syncNow() | |
| 130 | + | |
| 131 | + let stored = try await db.noteProjetDao.getByServerId("n4") | |
| 132 | + XCTAssertEqual("Note vocale \u{e9}dit\u{e9}e", stored?.titre) | |
| 133 | + XCTAssertEqual("/local/notes/n4.wav", stored?.audioPath, | |
| 134 | + "audioPath local doit \u{ea}tre pr\u{e9}serv\u{e9} lors d'une mise \u{e0} jour serveur") | |
| 135 | + } | |
| 136 | + | |
| 137 | + // MARK: - Tombstone | |
| 138 | + | |
| 139 | + func testPullTombstoneNoteSupprimeNote() async throws { | |
| 140 | + var local = NoteProjetEntity() | |
| 141 | + local.serverId = "n-del"; local.projetServerId = "p1" | |
| 142 | + local.titre = "\u{e0} supprimer"; local.texte = ""; local.auteur = ""; local.createdAt = 1 | |
| 143 | + try await db.noteProjetDao.upsert(local) | |
| 144 | + | |
| 145 | + api.pullResult = .ok(SyncPullResponse( | |
| 146 | + serverTime: "2026-09-12T10:00:00Z", | |
| 147 | + tombstones: [ | |
| 148 | + TombstoneDto(entityType: "note", id: "n-del", supprimeLe: "2026-09-12T10:00:00Z") | |
| 149 | + ] | |
| 150 | + )) | |
| 151 | + | |
| 152 | + _ = try await engine.syncNow() | |
| 153 | + | |
| 154 | + let stored = try await db.noteProjetDao.getByServerId("n-del") | |
| 155 | + XCTAssertNil(stored, "La note supprim\u{e9}e par tombstone doit disparaitre") | |
| 156 | + } | |
| 157 | + | |
| 158 | + // MARK: - R\u{e9}ponse sans section notes | |
| 159 | + | |
| 160 | + func testPullSansNotesNePasPlanter() async throws { | |
| 161 | + // Serveur antérieur : aucun champ "notes" dans la réponse | |
| 162 | + api.pullResult = .ok(SyncPullResponse(serverTime: "2026-09-12T10:00:00Z")) | |
| 163 | + | |
| 164 | + let result = try await engine.syncNow() | |
| 165 | + | |
| 166 | + XCTAssertTrue(result.success) | |
| 167 | + let notes = try await db.noteProjetDao.listAll() | |
| 168 | + XCTAssertTrue(notes.isEmpty) | |
| 169 | + } | |
| 170 | + | |
| 171 | + // MARK: - Comptage dans received | |
| 172 | + | |
| 173 | + func testPullNotesCompteesDansReceived() async throws { | |
| 174 | + api.pullResult = .ok(SyncPullResponse( | |
| 175 | + serverTime: "2026-09-12T10:00:00Z", | |
| 176 | + notes: [ | |
| 177 | + NoteProjetDto(id: "n1", projetId: "p1", titre: "A", contenu: "a", creeLe: "2026-09-10T08:00:00Z"), | |
| 178 | + NoteProjetDto(id: "n2", projetId: "p1", titre: "B", contenu: "b", creeLe: "2026-09-10T08:00:00Z"), | |
| 179 | + ] | |
| 180 | + )) | |
| 181 | + | |
| 182 | + let result = try await engine.syncNow() | |
| 183 | + | |
| 184 | + XCTAssertEqual(2, result.received, "Les notes doivent \u{ea}tre compt\u{e9}es dans received") | |
| 185 | + } | |
| 186 | +} |
A
ios/Card2vcfTests/ProjetDetailNotesViewModelTest.swift
+78
-0
@@ -0,0 +1,78 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, § 1b) : | |
| 5 | +/// en remplaçant le tri dans `ProjetDetailViewModel.refresh` par `notesProjet = rawNotes`, | |
| 6 | +/// le test `testNotesTrieesParDateDecroissante` échoue avec : | |
| 7 | +/// XCTAssertEqual failed: ("Optional("n2")") is not equal to ("Optional("n1")") | |
| 8 | +/// Ce test détecte donc réellement l'absence de tri par date décroissante. | |
| 9 | +@MainActor | |
| 10 | +final class ProjetDetailNotesViewModelTest: XCTestCase { | |
| 11 | + private var db: InMemoryCrmDatabase! | |
| 12 | + | |
| 13 | + override func setUp() { | |
| 14 | + super.setUp() | |
| 15 | + db = InMemoryCrmDatabase() | |
| 16 | + } | |
| 17 | + | |
| 18 | + func testNotesTrieesParDateDecroissante() async throws { | |
| 19 | + // Note n1 (insérée en 1re) : updatedAt = 1500 → date effective intermédiaire | |
| 20 | + var note1 = NoteProjetEntity() | |
| 21 | + note1.serverId = "n1" | |
| 22 | + note1.projetServerId = "p1" | |
| 23 | + note1.titre = "Note intermédiaire" | |
| 24 | + note1.texte = "contenu 1" | |
| 25 | + note1.auteur = "alice" | |
| 26 | + note1.createdAt = 1000 | |
| 27 | + note1.updatedAt = 1500 | |
| 28 | + try await db.noteProjetDaoImpl.upsert(note1) | |
| 29 | + | |
| 30 | + // Note n2 (insérée en 2e) : updatedAt = 3000 → date effective la plus récente | |
| 31 | + var note2 = NoteProjetEntity() | |
| 32 | + note2.serverId = "n2" | |
| 33 | + note2.projetServerId = "p1" | |
| 34 | + note2.titre = "Note la plus récente" | |
| 35 | + note2.texte = "contenu 2" | |
| 36 | + note2.auteur = "bob" | |
| 37 | + note2.createdAt = 2000 | |
| 38 | + note2.updatedAt = 3000 | |
| 39 | + try await db.noteProjetDaoImpl.upsert(note2) | |
| 40 | + | |
| 41 | + // Note n3 (insérée en 3e) : updatedAt nil → date effective = createdAt = 500 | |
| 42 | + var note3 = NoteProjetEntity() | |
| 43 | + note3.serverId = "n3" | |
| 44 | + note3.projetServerId = "p1" | |
| 45 | + note3.titre = "Note la plus ancienne" | |
| 46 | + note3.texte = "contenu 3" | |
| 47 | + note3.auteur = "carol" | |
| 48 | + note3.createdAt = 500 | |
| 49 | + note3.updatedAt = nil | |
| 50 | + try await db.noteProjetDaoImpl.upsert(note3) | |
| 51 | + | |
| 52 | + let viewModel = ProjetDetailViewModel(projetServerId: "p1", database: db) | |
| 53 | + await viewModel.refresh() | |
| 54 | + | |
| 55 | + XCTAssertEqual(3, viewModel.notesProjet.count) | |
| 56 | + // Ordre attendu (décroissant) : n2 (3000) > n1 (1500) > n3 (500) | |
| 57 | + // Sans tri (ordre d'insertion) : n1, n2, n3 → échoue sur [0] | |
| 58 | + XCTAssertEqual("n2", viewModel.notesProjet[0].serverId) | |
| 59 | + XCTAssertEqual("n1", viewModel.notesProjet[1].serverId) | |
| 60 | + XCTAssertEqual("n3", viewModel.notesProjet[2].serverId) | |
| 61 | + } | |
| 62 | + | |
| 63 | + func testNotesAutreProjetNonExposees() async throws { | |
| 64 | + var note = NoteProjetEntity() | |
| 65 | + note.serverId = "n-autre" | |
| 66 | + note.projetServerId = "p-autre" | |
| 67 | + note.titre = "Note d'un autre projet" | |
| 68 | + note.texte = "" | |
| 69 | + note.auteur = "dave" | |
| 70 | + note.createdAt = 1000 | |
| 71 | + try await db.noteProjetDaoImpl.upsert(note) | |
| 72 | + | |
| 73 | + let viewModel = ProjetDetailViewModel(projetServerId: "p1", database: db) | |
| 74 | + await viewModel.refresh() | |
| 75 | + | |
| 76 | + XCTAssertEqual(0, viewModel.notesProjet.count) | |
| 77 | + } | |
| 78 | +} |
A
ios/Card2vcfTests/RelanceTranscriptionTest.swift
+137
-0
@@ -0,0 +1,137 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Tests TDD pour la relance de transcription (Lot 3). | |
| 5 | +/// | |
| 6 | +/// Ces tests vérifient que `SyncEngine.relancerTranscriptionInteraction` et | |
| 7 | +/// `relancerTranscriptionNoteProjet` appellent bien l'API, mettent à jour | |
| 8 | +/// le statut local en "en_attente" et effacent l'erreur précédente. | |
| 9 | +final class RelanceTranscriptionTest: XCTestCase { | |
| 10 | + | |
| 11 | + private var db: InMemoryCrmDatabase! | |
| 12 | + private var api: FakeAilianceApi! | |
| 13 | + private var engine: SyncEngine! | |
| 14 | + | |
| 15 | + override func setUp() { | |
| 16 | + super.setUp() | |
| 17 | + db = InMemoryCrmDatabase() | |
| 18 | + api = FakeAilianceApi() | |
| 19 | + engine = SyncEngine(api: api, db: db) | |
| 20 | + } | |
| 21 | + | |
| 22 | + // MARK: - Relance interaction — succès | |
| 23 | + | |
| 24 | + func testRelancerInteraction_appellePApi() async throws { | |
| 25 | + let localId = try await insererInteractionEchec(serverId: "i-rel-1", contactId: "c1") | |
| 26 | + | |
| 27 | + let erreur = try await engine.relancerTranscriptionInteraction(localId: localId) | |
| 28 | + | |
| 29 | + XCTAssertNil(erreur, "Pas d'erreur attendue en cas de succès") | |
| 30 | + XCTAssertEqual(1, api.relancerInteractionCalls.count) | |
| 31 | + XCTAssertEqual("c1", api.relancerInteractionCalls.first?.0) | |
| 32 | + XCTAssertEqual("i-rel-1", api.relancerInteractionCalls.first?.1) | |
| 33 | + } | |
| 34 | + | |
| 35 | + func testRelancerInteraction_repasseStatutEnAttente() async throws { | |
| 36 | + let localId = try await insererInteractionEchec(serverId: "i-rel-2", contactId: "c1") | |
| 37 | + | |
| 38 | + _ = try await engine.relancerTranscriptionInteraction(localId: localId) | |
| 39 | + | |
| 40 | + let updated = try await db.interactionDao.getByLocalId(localId) | |
| 41 | + XCTAssertEqual("en_attente", updated?.transcriptionStatut, | |
| 42 | + "Statut doit repasser à en_attente après relance") | |
| 43 | + } | |
| 44 | + | |
| 45 | + func testRelancerInteraction_effaceErreur() async throws { | |
| 46 | + let localId = try await insererInteractionEchec(serverId: "i-rel-3", contactId: "c1") | |
| 47 | + | |
| 48 | + _ = try await engine.relancerTranscriptionInteraction(localId: localId) | |
| 49 | + | |
| 50 | + let updated = try await db.interactionDao.getByLocalId(localId) | |
| 51 | + XCTAssertNil(updated?.transcriptionErreur, "Motif d'erreur effacé après relance") | |
| 52 | + } | |
| 53 | + | |
| 54 | + // MARK: - Relance interaction — échec réseau | |
| 55 | + | |
| 56 | + func testRelancerInteraction_echecReseau_retourneMsgClair() async throws { | |
| 57 | + let localId = try await insererInteractionEchec(serverId: "i-rel-4", contactId: "c1") | |
| 58 | + api.relancerInteractionResult = .err(code: -1, message: "serveur injoignable") | |
| 59 | + | |
| 60 | + let erreur = try await engine.relancerTranscriptionInteraction(localId: localId) | |
| 61 | + | |
| 62 | + XCTAssertNotNil(erreur, "Message d'erreur attendu") | |
| 63 | + XCTAssertTrue(erreur!.contains("serveur injoignable"), | |
| 64 | + "Message d'erreur clair : \(erreur!)") | |
| 65 | + } | |
| 66 | + | |
| 67 | + func testRelancerInteraction_echecReseau_neModifiePasStatut() async throws { | |
| 68 | + let localId = try await insererInteractionEchec(serverId: "i-rel-5", contactId: "c1") | |
| 69 | + api.relancerInteractionResult = .err(code: -1, message: "réseau") | |
| 70 | + | |
| 71 | + _ = try await engine.relancerTranscriptionInteraction(localId: localId) | |
| 72 | + | |
| 73 | + let kept = try await db.interactionDao.getByLocalId(localId) | |
| 74 | + XCTAssertEqual("echec", kept?.transcriptionStatut, | |
| 75 | + "Statut préservé quand la relance échoue") | |
| 76 | + } | |
| 77 | + | |
| 78 | + // MARK: - Relance note projet — succès | |
| 79 | + | |
| 80 | + func testRelancerNoteProjet_appellePApi() async throws { | |
| 81 | + let localId = try await insererNoteEchec(serverId: "n-rel-1", projetId: "proj-1") | |
| 82 | + | |
| 83 | + let erreur = try await engine.relancerTranscriptionNoteProjet(localId: localId) | |
| 84 | + | |
| 85 | + XCTAssertNil(erreur) | |
| 86 | + XCTAssertEqual(1, api.relancerNoteProjetCalls.count) | |
| 87 | + XCTAssertEqual("proj-1", api.relancerNoteProjetCalls.first?.0) | |
| 88 | + XCTAssertEqual("n-rel-1", api.relancerNoteProjetCalls.first?.1) | |
| 89 | + } | |
| 90 | + | |
| 91 | + func testRelancerNoteProjet_repasseStatutEnAttente() async throws { | |
| 92 | + let localId = try await insererNoteEchec(serverId: "n-rel-2", projetId: "proj-1") | |
| 93 | + | |
| 94 | + _ = try await engine.relancerTranscriptionNoteProjet(localId: localId) | |
| 95 | + | |
| 96 | + let updated = try await db.noteProjetDao.listAll().first { $0.localId == localId } | |
| 97 | + XCTAssertEqual("en_attente", updated?.transcriptionStatut) | |
| 98 | + XCTAssertNil(updated?.transcriptionErreur) | |
| 99 | + } | |
| 100 | + | |
| 101 | + func testRelancerNoteProjet_echecReseau_retourneMsgClair() async throws { | |
| 102 | + let localId = try await insererNoteEchec(serverId: "n-rel-3", projetId: "proj-1") | |
| 103 | + api.relancerNoteProjetResult = .err(code: -1, message: "serveur injoignable") | |
| 104 | + | |
| 105 | + let erreur = try await engine.relancerTranscriptionNoteProjet(localId: localId) | |
| 106 | + | |
| 107 | + XCTAssertNotNil(erreur) | |
| 108 | + XCTAssertTrue(erreur!.contains("serveur injoignable")) | |
| 109 | + } | |
| 110 | + | |
| 111 | + // MARK: - Helpers | |
| 112 | + | |
| 113 | + @discardableResult | |
| 114 | + private func insererInteractionEchec(serverId: String, contactId: String) async throws -> Int64 { | |
| 115 | + var entity = InteractionEntity() | |
| 116 | + entity.serverId = serverId | |
| 117 | + entity.contactServerId = contactId | |
| 118 | + entity.type = "note_vocale" | |
| 119 | + entity.sujet = "Note échouée" | |
| 120 | + entity.transcriptionStatut = "echec" | |
| 121 | + entity.transcriptionErreur = "Erreur Whisper : audio trop court" | |
| 122 | + entity.createdAt = 1_000 | |
| 123 | + return try await db.interactionDao.upsert(entity) | |
| 124 | + } | |
| 125 | + | |
| 126 | + @discardableResult | |
| 127 | + private func insererNoteEchec(serverId: String, projetId: String) async throws -> Int64 { | |
| 128 | + var entity = NoteProjetEntity() | |
| 129 | + entity.serverId = serverId | |
| 130 | + entity.projetServerId = projetId | |
| 131 | + entity.titre = "Note échouée" | |
| 132 | + entity.transcriptionStatut = "echec" | |
| 133 | + entity.transcriptionErreur = "Erreur Whisper : audio trop court" | |
| 134 | + entity.createdAt = 1_000 | |
| 135 | + return try await db.noteProjetDao.upsert(entity) | |
| 136 | + } | |
| 137 | +} |
A
ios/Card2vcfTests/SuppressionSyncTest.swift
+179
-0
@@ -0,0 +1,179 @@
| 1 | +import XCTest | |
| 2 | +@testable import Card2vcf | |
| 3 | + | |
| 4 | +/// Tests TDD pour la suppression d'interactions et de notes de projet (Lot 3). | |
| 5 | +/// | |
| 6 | +/// PREUVE DE ROUGEUR #1 — testPushInteraction_deleteAppelleApi | |
| 7 | +/// En conservant le code d'origine `if op.op != "create" { return PushOutcome(ok: true) }` | |
| 8 | +/// dans `pushInteractionOp`, le test échoue avec : | |
| 9 | +/// | |
| 10 | +/// XCTAssertEqual failed: ("0") is not equal to ("1") | |
| 11 | +/// | |
| 12 | +/// PREUVE DE ROUGEUR #2 — testPushNoteProjet_deleteAppelleApi | |
| 13 | +/// En conservant `if op.op != "create" { return PushOutcome(ok: true) }` | |
| 14 | +/// dans `pushNoteProjetOp`, le test échoue avec : | |
| 15 | +/// | |
| 16 | +/// XCTAssertEqual failed: ("0") is not equal to ("1") | |
| 17 | +final class SuppressionSyncTest: XCTestCase { | |
| 18 | + | |
| 19 | + private var db: InMemoryCrmDatabase! | |
| 20 | + private var api: FakeAilianceApi! | |
| 21 | + private var engine: SyncEngine! | |
| 22 | + | |
| 23 | + override func setUp() { | |
| 24 | + super.setUp() | |
| 25 | + db = InMemoryCrmDatabase() | |
| 26 | + api = FakeAilianceApi() | |
| 27 | + engine = SyncEngine(api: api, db: db) | |
| 28 | + } | |
| 29 | + | |
| 30 | + // MARK: - Push interaction delete (PREUVE DE ROUGEUR #1) | |
| 31 | + | |
| 32 | + func testPushInteraction_deleteAppelleApi() async throws { | |
| 33 | + var op = SyncOpEntity(entityType: "interaction", op: "delete") | |
| 34 | + op.serverId = "int-srv-1" | |
| 35 | + op.createdAt = 1 | |
| 36 | + _ = try await db.syncOpDao.insert(op) | |
| 37 | + | |
| 38 | + _ = try await engine.pousserEnAttente() | |
| 39 | + | |
| 40 | + // Rouge sans le case "delete" dans pushInteractionOp : ("0") is not equal to ("1") | |
| 41 | + XCTAssertEqual(1, api.deleteInteractionCalls.count) | |
| 42 | + XCTAssertEqual("int-srv-1", api.deleteInteractionCalls.first) | |
| 43 | + let ops = try await db.syncOpDao.listAll() | |
| 44 | + XCTAssertTrue(ops.isEmpty, "Op dépilée après succès") | |
| 45 | + } | |
| 46 | + | |
| 47 | + func testPushInteraction_deleteEchecGardeOp() async throws { | |
| 48 | + var op = SyncOpEntity(entityType: "interaction", op: "delete") | |
| 49 | + op.serverId = "int-srv-2" | |
| 50 | + op.createdAt = 1 | |
| 51 | + _ = try await db.syncOpDao.insert(op) | |
| 52 | + | |
| 53 | + api.deleteInteractionResult = .err(code: -1, message: "Réseau indisponible") | |
| 54 | + _ = try await engine.pousserEnAttente() | |
| 55 | + | |
| 56 | + let ops = try await db.syncOpDao.listAll() | |
| 57 | + XCTAssertEqual(1, ops.count, "Op gardée en file après échec réseau") | |
| 58 | + } | |
| 59 | + | |
| 60 | + func testPushInteraction_deleteSansServerId_echoue() async throws { | |
| 61 | + var op = SyncOpEntity(entityType: "interaction", op: "delete") | |
| 62 | + op.serverId = nil | |
| 63 | + op.createdAt = 1 | |
| 64 | + _ = try await db.syncOpDao.insert(op) | |
| 65 | + | |
| 66 | + _ = try await engine.pousserEnAttente() | |
| 67 | + | |
| 68 | + // serverId nil → failure sans appel API | |
| 69 | + XCTAssertEqual(0, api.deleteInteractionCalls.count) | |
| 70 | + } | |
| 71 | + | |
| 72 | + // MARK: - Push note projet delete (PREUVE DE ROUGEUR #2) | |
| 73 | + | |
| 74 | + func testPushNoteProjet_deleteAppelleApi() async throws { | |
| 75 | + let payload = #"{"projetServerId":"proj-srv-1"}"# | |
| 76 | + var op = SyncOpEntity(entityType: "note_projet", op: "delete") | |
| 77 | + op.serverId = "note-srv-1" | |
| 78 | + op.payloadJson = payload | |
| 79 | + op.createdAt = 1 | |
| 80 | + _ = try await db.syncOpDao.insert(op) | |
| 81 | + | |
| 82 | + _ = try await engine.pousserEnAttente() | |
| 83 | + | |
| 84 | + // Rouge sans le case "delete" dans pushNoteProjetOp : ("0") is not equal to ("1") | |
| 85 | + XCTAssertEqual(1, api.deleteNoteProjetCalls.count) | |
| 86 | + XCTAssertEqual("proj-srv-1", api.deleteNoteProjetCalls.first?.0) | |
| 87 | + XCTAssertEqual("note-srv-1", api.deleteNoteProjetCalls.first?.1) | |
| 88 | + let ops = try await db.syncOpDao.listAll() | |
| 89 | + XCTAssertTrue(ops.isEmpty, "Op dépilée après succès") | |
| 90 | + } | |
| 91 | + | |
| 92 | + func testPushNoteProjet_deleteSansProjetServerId_echoue() async throws { | |
| 93 | + var op = SyncOpEntity(entityType: "note_projet", op: "delete") | |
| 94 | + op.serverId = "note-srv-2" | |
| 95 | + op.payloadJson = "{}" // projetServerId absent | |
| 96 | + op.createdAt = 1 | |
| 97 | + _ = try await db.syncOpDao.insert(op) | |
| 98 | + | |
| 99 | + _ = try await engine.pousserEnAttente() | |
| 100 | + | |
| 101 | + XCTAssertEqual(0, api.deleteNoteProjetCalls.count) | |
| 102 | + } | |
| 103 | + | |
| 104 | + // MARK: - Suppression locale sans serverId (jamais synchronisé) | |
| 105 | + | |
| 106 | + func testSupprimerInteractionLocale_sansServerId_neCreesPasOp() async throws { | |
| 107 | + // Interaction locale uniquement (serverId nil) | |
| 108 | + var entity = InteractionEntity() | |
| 109 | + entity.contactServerId = "c1" | |
| 110 | + entity.type = "note_vocale" | |
| 111 | + entity.sujet = "Note locale" | |
| 112 | + entity.createdAt = 1_000 | |
| 113 | + let localId = try await db.interactionDao.upsert(entity) | |
| 114 | + | |
| 115 | + // Récupération et suppression locale | |
| 116 | + guard let found = try await db.interactionDao.getByLocalId(localId) else { | |
| 117 | + XCTFail("Entité introuvable") | |
| 118 | + return | |
| 119 | + } | |
| 120 | + XCTAssertNil(found.serverId, "serverId doit être nil pour une entité jamais sync") | |
| 121 | + // Suppression sans op de sync (serverId absent) | |
| 122 | + try await db.interactionDao.deleteByLocalId(localId) | |
| 123 | + | |
| 124 | + let allInteractions = try await db.interactionDao.listAll() | |
| 125 | + XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement") | |
| 126 | + let allOps = try await db.syncOpDao.listAll() | |
| 127 | + XCTAssertTrue(allOps.isEmpty, "Aucun op créé pour une entité jamais sync") | |
| 128 | + } | |
| 129 | + | |
| 130 | + func testSupprimerNoteLocale_sansServerId_neCreesPasOp() async throws { | |
| 131 | + var entity = NoteProjetEntity() | |
| 132 | + entity.projetServerId = "proj-1" | |
| 133 | + entity.titre = "Note locale" | |
| 134 | + entity.createdAt = 1_000 | |
| 135 | + let localId = try await db.noteProjetDao.upsert(entity) | |
| 136 | + | |
| 137 | + let noteInserted = try await db.noteProjetDao.listAll().first { $0.localId == localId } | |
| 138 | + XCTAssertNil(noteInserted?.serverId) | |
| 139 | + | |
| 140 | + try await db.noteProjetDao.deleteByLocalId(localId) | |
| 141 | + | |
| 142 | + let allNotes = try await db.noteProjetDao.listAll() | |
| 143 | + XCTAssertTrue(allNotes.isEmpty) | |
| 144 | + let allOps = try await db.syncOpDao.listAll() | |
| 145 | + XCTAssertTrue(allOps.isEmpty) | |
| 146 | + } | |
| 147 | + | |
| 148 | + // MARK: - Suppression synchronisée crée l'op | |
| 149 | + | |
| 150 | + func testSupprimerInteractionSynchronisee_creeOp() async throws { | |
| 151 | + var entity = InteractionEntity() | |
| 152 | + entity.serverId = "int-srv-ok" | |
| 153 | + entity.contactServerId = "c1" | |
| 154 | + entity.type = "note_vocale" | |
| 155 | + entity.sujet = "Note sync" | |
| 156 | + entity.createdAt = 1_000 | |
| 157 | + let localId = try await db.interactionDao.upsert(entity) | |
| 158 | + | |
| 159 | + // Simulation : supprimer localement + créer l'op de sync | |
| 160 | + guard let found = try await db.interactionDao.getByLocalId(localId), | |
| 161 | + let serverId = found.serverId else { | |
| 162 | + XCTFail("Entité introuvable") | |
| 163 | + return | |
| 164 | + } | |
| 165 | + try await db.interactionDao.deleteByLocalId(localId) | |
| 166 | + | |
| 167 | + var op = SyncOpEntity(entityType: "interaction", op: "delete") | |
| 168 | + op.serverId = serverId | |
| 169 | + op.createdAt = 2_000 | |
| 170 | + _ = try await db.syncOpDao.insert(op) | |
| 171 | + | |
| 172 | + let allInteractions = try await db.interactionDao.listAll() | |
| 173 | + XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement") | |
| 174 | + let allOps = try await db.syncOpDao.listAll() | |
| 175 | + XCTAssertEqual(1, allOps.count, "Op de delete créée") | |
| 176 | + XCTAssertEqual("delete", allOps.first?.op) | |
| 177 | + XCTAssertEqual("int-srv-ok", allOps.first?.serverId) | |
| 178 | + } | |
| 179 | +} |
M
ios/README.md
+2
-2
@@ -24,14 +24,14 @@ cd ios
| 24 | 24 | xcodegen generate |
| 25 | 25 | open Card2vcf.xcodeproj # ou : |
| 26 | 26 | xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \ |
| 27 | - -destination 'platform=iOS Simulator,name=iPhone 17' build | |
| 27 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build | |
| 28 | 28 | ``` |
| 29 | 29 | |
| 30 | 30 | Tests (mêmes suites que les tests JUnit Android, portées en XCTest) : |
| 31 | 31 | |
| 32 | 32 | ```bash |
| 33 | 33 | xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \ |
| 34 | - -destination 'platform=iOS Simulator,name=iPhone 17' test | |
| 34 | + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test | |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | 37 | Cible : iOS 16+, iPhone et iPad. Le `.xcodeproj` est généré (non |
M
ios/project.yml
+2
-0
@@ -29,6 +29,8 @@ targets:
| 29 | 29 | platform: iOS |
| 30 | 30 | sources: |
| 31 | 31 | - path: Card2vcfTests |
| 32 | + resources: | |
| 33 | + - path: Card2vcfTests/Fixtures | |
| 32 | 34 | dependencies: |
| 33 | 35 | - target: Card2vcf |
| 34 | 36 | settings: |
GitRust