From c772d6fe758c97f7b291fbd8f3788a33ec482a2f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 18 Aug 2026 21:36:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?test(lecture):=20couvrir=20la=20projection?= =?UTF-8?q?=20d'=C3=A9tat=20du=20contr=C3=B4leur=20Media3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Media3PlaybackController` était la plus grande zone du dépôt qu'aucun test n'atteignait. Neutraliser `player.playerError` ou `player.playbackState` dans `syncFrom` ne faisait tomber personne — vérifié par retrait, deux fois, dans deux sessions différentes. C'est pourtant cette projection qui décide de ce que l'écran affiche, et la PR #23 a déjà montré ce que coûte une zone non tenue. Rien n'est simulé ici : un `PlaybackService` est créé comme Android le ferait, un vrai `MediaController` s'y lie, et l'état observé est celui que `syncFrom` projette depuis le lecteur. Le seul artifice est la liaison au service, que Robolectric n'établit pas seule : on lui fournit le `Binder` que le service rend lui-même. Le contrôleur, lui, emprunte son chemin habituel — `SessionToken` déduit du `ComponentName`, connexion asynchrone comprise. Deux détails d'ordonnancement portent tout le reste. Les messages en attente suffisent à observer la file posée et le tampon en cours ; il faut en revanche avancer l'horloge de toutes les boucles pour que la machine à états du lecteur aille jusqu'à renoncer, faute de quoi l'erreur ne remonte jamais. Et `getAllLoopers` ramasse les boucles des tests précédents, dont les fils s'arrêtent : d'où le filtre. Chacun des neuf comportements couverts a été éprouvé par retrait, séparément. Chaque retrait fait tomber exactement un test, et le bon. `media3-test-utils`, envisagé au départ, ne sert finalement à rien : la vraie chaîne suffit, et un lecteur factice aurait couvert moins. --- .../playback/Media3PlaybackControllerTest.kt | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt diff --git a/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt new file mode 100644 index 0000000..9c9e37a --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt @@ -0,0 +1,297 @@ +package app.waveflow.playback + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import androidx.media3.session.MediaSessionService +import androidx.test.core.app.ApplicationProvider +import app.waveflow.testing.remoteSong +import app.waveflow.testing.song +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import org.robolectric.shadows.ShadowLooper +import java.time.Duration + +/** + * Le contrôleur, éprouvé sur la vraie chaîne Media3. + * + * Rien ici n'est simulé : un [PlaybackService] est créé comme Android le ferait, + * un vrai `MediaController` s'y lie, et l'état observé est celui que + * `syncFrom` projette depuis le lecteur. C'était jusqu'ici la plus grande zone + * du dépôt qu'aucun test n'atteignait — neutraliser une ligne de la projection + * ne faisait tomber personne. + * + * Le seul artifice est la liaison au service : Robolectric ne démarre pas de + * vrai service sur `bindService`, on lui fournit donc le `Binder` que le + * service rend lui-même. Le contrôleur, lui, emprunte son chemin habituel — + * `SessionToken` déduit du `ComponentName`, connexion asynchrone comprise. + */ +@RunWith(RobolectricTestRunner::class) +class Media3PlaybackControllerTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + + private var service: ServiceController? = null + private var controller: Media3PlaybackController? = null + + @After + fun tearDown() { + controller?.release() + service?.destroy() + } + + // ------------------------------------------------------------------ + // Connexion + // ------------------------------------------------------------------ + + @Test + fun `tant que la liaison n'est pas etablie l'etat reste vierge`() { + val controleur = Media3PlaybackController(app).also { controller = it } + + assertFalse(controleur.state.value.isConnected) + assertNull(controleur.state.value.current) + } + + @Test + fun `les commandes sont ignorees avant la connexion`() { + // Les gardes `controller ?: return` ne sont pas décoratives : l'UI peut + // appeler une commande pendant que la liaison s'établit encore. + val controleur = Media3PlaybackController(app).also { controller = it } + + controleur.play(listOf(song(1)), startIndex = 0) + controleur.playPause() + controleur.skipNext() + controleur.toggleShuffle() + controleur.cycleRepeatMode() + + assertEquals(PlaybackState(), controleur.state.value) + } + + @Test + fun `la connexion publie l'etat du lecteur`() { + val controleur = controleurConnecte() + + assertTrue(controleur.state.value.isConnected) + } + + // ------------------------------------------------------------------ + // Ce que le lecteur a en main + // ------------------------------------------------------------------ + + @Test + fun `le morceau courant decrit la piste locale demandee`() { + val controleur = controleurConnecte() + + controleur.play(listOf(song(1), song(2), song(3)), startIndex = 1) + reposer() + + val courant = controleur.state.value.current + assertNotNull(courant) + // Le préfixe est la seule chose qui distingue un identifiant MediaStore + // d'un UUID de serveur une fois dans la file. + assertEquals("local:2", courant!!.mediaId) + assertEquals(2L, courant.localSongId) + assertEquals(TrackSource.Local, courant.source) + assertEquals("Titre 2", courant.title) + assertEquals("Artiste 2", courant.artist) + assertEquals("Album 2", courant.album) + } + + @Test + fun `le morceau courant decrit la piste distante demandee`() { + val controleur = controleurConnecte() + + controleur.playRemote(listOf(remoteSong("uuid-1")), startIndex = 0) + reposer() + + val courant = controleur.state.value.current + assertNotNull(courant) + assertEquals("remote:uuid-1", courant!!.mediaId) + // Aucun identifiant MediaStore à souligner : la piste n'est pas d'ici. + assertNull(courant.localSongId) + assertEquals(TrackSource.Remote, courant.source) + } + + @Test + fun `une duree inconnue vaut zero et non la sentinelle de Media3`() { + // `C.TIME_UNSET` vaut Long.MIN_VALUE + 1 : laissé tel quel, il + // traverserait jusqu'à la barre de progression. + val controleur = controleurConnecte() + + controleur.play(listOf(song(1)), startIndex = 0) + reposer() + + assertEquals(0L, controleur.state.value.durationMs) + } + + // ------------------------------------------------------------------ + // Aléatoire et répétition + // ------------------------------------------------------------------ + + @Test + fun `demarrer une lecture ordonnee eteint l'aleatoire laisse par la precedente`() { + // Sans ça, un aléatoire encore actif ferait démarrer la file ailleurs + // que sur le morceau demandé — le bouton Lecture paraîtrait sans effet. + val controleur = controleurConnecte() + + controleur.toggleShuffle() + reposer() + assertTrue("L'aléatoire n'a pas été activé", controleur.state.value.shuffleEnabled) + + controleur.play(listOf(song(1), song(2)), startIndex = 0) + reposer() + + assertFalse(controleur.state.value.shuffleEnabled) + assertEquals("local:1", controleur.state.value.current?.mediaId) + } + + @Test + fun `la lecture aleatoire s'annonce dans l'etat`() { + val controleur = controleurConnecte() + + controleur.playShuffled(listOf(song(1), song(2), song(3))) + reposer() + + assertTrue(controleur.state.value.shuffleEnabled) + } + + @Test + fun `le mode de repetition tourne sur trois positions puis reboucle`() { + val controleur = controleurConnecte() + assertEquals(RepeatMode.Off, controleur.state.value.repeatMode) + + val parcours = List(3) { + controleur.cycleRepeatMode() + reposer() + controleur.state.value.repeatMode + } + + assertEquals(listOf(RepeatMode.All, RepeatMode.One, RepeatMode.Off), parcours) + } + + // ------------------------------------------------------------------ + // Attente et panne + // ------------------------------------------------------------------ + + @Test + fun `l'attente avant le premier son se voit`() { + // Pour une piste distante, cette attente couvre l'obtention du ticket, + // qui précède toute requête de diffusion. C'est là que se joue + // l'essentiel du délai ressenti. + val controleur = controleurConnecte() + + controleur.playRemote(listOf(remoteSong("uuid-1")), startIndex = 0) + reposer() + + assertTrue(controleur.state.value.isBuffering) + assertFalse(controleur.state.value.isPlaying) + } + + @Test + fun `un morceau que le lecteur n'ouvre pas remonte comme illisible`() { + // Le `content://` de la fixture ne résout vers aucun fichier : c'est la + // piste qui est en cause, pas la liaison, et l'écran doit le dire ainsi. + val controleur = controleurConnecte() + + controleur.play(listOf(song(1)), startIndex = 0) + reposerJusquALaPanne() + + assertEquals(PlaybackFailure.Unplayable, controleur.state.value.failure) + assertFalse(controleur.state.value.isBuffering) + } + + // ------------------------------------------------------------------ + // Fin de vie + // ------------------------------------------------------------------ + + @Test + fun `relacher le controleur remet l'etat a zero`() { + val controleur = controleurConnecte() + controleur.play(listOf(song(1)), startIndex = 0) + reposer() + assertNotNull(controleur.state.value.current) + + controleur.release() + + assertEquals(PlaybackState(), controleur.state.value) + } + + // ------------------------------------------------------------------ + // Échafaudage + // ------------------------------------------------------------------ + + /** Le service tel qu'Android le crée, et un contrôleur qui s'y est lié. */ + private fun controleurConnecte(): Media3PlaybackController { + val demarre = Robolectric.buildService(PlaybackService::class.java).create() + service = demarre + + val composant = ComponentName(app, PlaybackService::class.java) + val intent = Intent(MediaSessionService.SERVICE_INTERFACE).setComponent(composant) + shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( + intent, + composant, + demarre.get().onBind(intent), + ) + + return Media3PlaybackController(app).also { + controller = it + it.connect() + reposer() + assertTrue("La liaison au service n'a pas abouti", it.state.value.isConnected) + } + } + + /** + * Écoule les messages en attente sans avancer l'horloge. + * + * Le lecteur atteint ainsi son état de départ — file posée, tampon en + * cours — sans que sa machine à états aille jusqu'à renoncer. + */ + private fun reposer(tours: Int = 30) = repeat(tours) { + bouclesVivantes().forEach { boucle -> runCatching { shadowOf(boucle).idle() } } + Thread.sleep(IDLE_PAUSE_MS) + } + + /** + * Avance l'horloge de toutes les boucles jusqu'à ce que le lecteur renonce. + * + * Le chargement échoue sur un fil bien réel, mais la machine à états du + * lecteur vit sur une boucle que Robolectric fige : sans avancer son + * horloge, l'erreur ne remonterait jamais. + */ + private fun reposerJusquALaPanne(controleur: Media3PlaybackController? = controller) { + repeat(TOURS_MAX) { + bouclesVivantes().forEach { boucle -> + runCatching { shadowOf(boucle).idleFor(Duration.ofMillis(TICK_MS)) } + } + Thread.sleep(IDLE_PAUSE_MS) + if (controleur?.state?.value?.failure != null) return + } + } + + /** + * Les boucles de messages qu'on peut encore faire tourner. + * + * `getAllLoopers` ramasse aussi celles des tests précédents : leurs fils + * s'arrêtent, et les solliciter lève « Looper is quitting ». Le filtre + * écarte le gros du lot, le `runCatching` couvre la course qui reste. + */ + private fun bouclesVivantes() = + ShadowLooper.getAllLoopers().filter { it.thread.isAlive } + + private companion object { + const val IDLE_PAUSE_MS = 5L + const val TICK_MS = 200L + const val TOURS_MAX = 200 + } +} From 43ceb0656819af0f4c6ec1ff1af297ba2010966b Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 18 Aug 2026 21:48:49 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(lecture):=20rendre=20la=20suite=20du?= =?UTF-8?q?=20contr=C3=B4leur=20discriminante=20et=20stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois retours de revue, tous fondés. **La durée était satisfiable par un état vide.** `durationMs` vaut zéro dans un `PlaybackState()` neuf : le test passait donc aussi bien si la file n'avait jamais été posée — la fixture satisfaisait l'assertion toute seule. Il vérifie maintenant d'abord la piste chargée. Le retrait le confirme : neutraliser `current` le fait désormais tomber, ce qui n'était pas le cas avant. **L'attente n'est plus un nombre de tours fixe.** Trente tours étaient un pari sur la vitesse de la machine : trop court le test devient instable, trop long on paie l'attente à chaque exécution. `attendre` écoule les messages, teste la condition, et renonce sur échéance avec le libellé de ce qu'on attendait. Les messages postés pendant la pause sont écoulés avant qu'on interrogé l'état. **Toutes les commandes passent la garde.** `playRemote`, `playShuffled`, `playRemoteShuffled`, `skipPrevious` et `seekTo` manquaient à l'appel. Remplacer la garde de `seekTo` ou celle de `playRemoteShuffled` par `!!` fait maintenant tomber le test — vérifié. La matrice de retrait a été repassée en entier après restructuration : chaque neutralisation fait toujours tomber les bons tests, et eux seuls. --- .../playback/Media3PlaybackControllerTest.kt | 131 +++++++++++------- 1 file changed, 84 insertions(+), 47 deletions(-) diff --git a/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt index 9c9e37a..3292c9d 100644 --- a/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt +++ b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt @@ -13,6 +13,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -21,6 +22,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ServiceController import org.robolectric.shadows.ShadowLooper import java.time.Duration +import java.util.concurrent.TimeUnit /** * Le contrôleur, éprouvé sur la vraie chaîne Media3. @@ -64,13 +66,20 @@ class Media3PlaybackControllerTest { @Test fun `les commandes sont ignorees avant la connexion`() { - // Les gardes `controller ?: return` ne sont pas décoratives : l'UI peut - // appeler une commande pendant que la liaison s'établit encore. + // Les gardes sur `controller` ne sont pas décoratives : l'UI peut + // appeler n'importe quelle commande pendant que la liaison s'établit + // encore. Elles y passent toutes, faute de quoi celle qu'on oublierait + // de garder ne se signalerait qu'à l'usage. val controleur = Media3PlaybackController(app).also { controller = it } controleur.play(listOf(song(1)), startIndex = 0) + controleur.playRemote(listOf(remoteSong("uuid-1")), startIndex = 0) + controleur.playShuffled(listOf(song(1), song(2))) + controleur.playRemoteShuffled(listOf(remoteSong("uuid-1"), remoteSong("uuid-2"))) controleur.playPause() controleur.skipNext() + controleur.skipPrevious() + controleur.seekTo(1_000L) controleur.toggleShuffle() controleur.cycleRepeatMode() @@ -93,7 +102,7 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.play(listOf(song(1), song(2), song(3)), startIndex = 1) - reposer() + attendre("la file posée") { controleur.state.value.current != null } val courant = controleur.state.value.current assertNotNull(courant) @@ -112,7 +121,7 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.playRemote(listOf(remoteSong("uuid-1")), startIndex = 0) - reposer() + attendre("la file posée") { controleur.state.value.current != null } val courant = controleur.state.value.current assertNotNull(courant) @@ -129,8 +138,12 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.play(listOf(song(1)), startIndex = 0) - reposer() + attendre("la file posée") { controleur.state.value.current != null } + // Zéro est aussi la valeur d'un état neuf : sans cette première + // assertion, le test passerait alors même que la file n'aurait jamais + // été posée. C'est la piste chargée qui rend la seconde probante. + assertEquals("local:1", controleur.state.value.current?.mediaId) assertEquals(0L, controleur.state.value.durationMs) } @@ -145,11 +158,10 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.toggleShuffle() - reposer() - assertTrue("L'aléatoire n'a pas été activé", controleur.state.value.shuffleEnabled) + attendre("l'aléatoire actif") { controleur.state.value.shuffleEnabled } controleur.play(listOf(song(1), song(2)), startIndex = 0) - reposer() + attendre("la file posée") { controleur.state.value.current != null } assertFalse(controleur.state.value.shuffleEnabled) assertEquals("local:1", controleur.state.value.current?.mediaId) @@ -160,7 +172,7 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.playShuffled(listOf(song(1), song(2), song(3))) - reposer() + attendre("la file posée") { controleur.state.value.current != null } assertTrue(controleur.state.value.shuffleEnabled) } @@ -170,10 +182,13 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() assertEquals(RepeatMode.Off, controleur.state.value.repeatMode) + var precedent = RepeatMode.Off val parcours = List(3) { controleur.cycleRepeatMode() - reposer() - controleur.state.value.repeatMode + attendre("le mode de répétition change") { + controleur.state.value.repeatMode != precedent + } + controleur.state.value.repeatMode.also { mode -> precedent = mode } } assertEquals(listOf(RepeatMode.All, RepeatMode.One, RepeatMode.Off), parcours) @@ -188,13 +203,18 @@ class Media3PlaybackControllerTest { // Pour une piste distante, cette attente couvre l'obtention du ticket, // qui précède toute requête de diffusion. C'est là que se joue // l'essentiel du délai ressenti. + // + // Le tampon est ici la condition d'attente et non une assertion : il + // n'apparaît pas au même instant que la file, et l'exiger d'un coup + // rendrait le test instable. Une projection muette fait donc échouer + // sur le délai, avec le libellé pour le dire. val controleur = controleurConnecte() controleur.playRemote(listOf(remoteSong("uuid-1")), startIndex = 0) - reposer() + attendre("le tampon avant le premier son") { controleur.state.value.isBuffering } - assertTrue(controleur.state.value.isBuffering) assertFalse(controleur.state.value.isPlaying) + assertNull(controleur.state.value.failure) } @Test @@ -204,7 +224,9 @@ class Media3PlaybackControllerTest { val controleur = controleurConnecte() controleur.play(listOf(song(1)), startIndex = 0) - reposerJusquALaPanne() + attendre("la panne du lecteur", avancerHorloge = true) { + controleur.state.value.failure != null + } assertEquals(PlaybackFailure.Unplayable, controleur.state.value.failure) assertFalse(controleur.state.value.isBuffering) @@ -218,8 +240,7 @@ class Media3PlaybackControllerTest { fun `relacher le controleur remet l'etat a zero`() { val controleur = controleurConnecte() controleur.play(listOf(song(1)), startIndex = 0) - reposer() - assertNotNull(controleur.state.value.current) + attendre("la file posée") { controleur.state.value.current != null } controleur.release() @@ -246,52 +267,68 @@ class Media3PlaybackControllerTest { return Media3PlaybackController(app).also { controller = it it.connect() - reposer() - assertTrue("La liaison au service n'a pas abouti", it.state.value.isConnected) + attendre("la liaison au service") { it.state.value.isConnected } } } /** - * Écoule les messages en attente sans avancer l'horloge. + * Attend qu'une condition se réalise, en écoulant les messages entre deux + * essais. * - * Le lecteur atteint ainsi son état de départ — file posée, tampon en - * cours — sans que sa machine à états aille jusqu'à renoncer. - */ - private fun reposer(tours: Int = 30) = repeat(tours) { - bouclesVivantes().forEach { boucle -> runCatching { shadowOf(boucle).idle() } } - Thread.sleep(IDLE_PAUSE_MS) - } - - /** - * Avance l'horloge de toutes les boucles jusqu'à ce que le lecteur renonce. + * Un nombre de tours fixe serait un pari sur la vitesse de la machine : + * trop court il rend le test instable, trop long il fait payer l'attente à + * chaque exécution. La condition dit quand s'arrêter, l'échéance quand + * renoncer — et le libellé dit ce qu'on attendait. * - * Le chargement échoue sur un fil bien réel, mais la machine à états du - * lecteur vit sur une boucle que Robolectric fige : sans avancer son - * horloge, l'erreur ne remonterait jamais. + * @param avancerHorloge nécessaire pour que la machine à états du lecteur + * aille jusqu'à renoncer : son chargement échoue sur un fil bien réel, + * mais elle vit sur une boucle que Robolectric fige. Sans avancer le + * temps, l'erreur ne remonte jamais. */ - private fun reposerJusquALaPanne(controleur: Media3PlaybackController? = controller) { - repeat(TOURS_MAX) { - bouclesVivantes().forEach { boucle -> - runCatching { shadowOf(boucle).idleFor(Duration.ofMillis(TICK_MS)) } - } - Thread.sleep(IDLE_PAUSE_MS) - if (controleur?.state?.value?.failure != null) return + private fun attendre( + quoi: String, + avancerHorloge: Boolean = false, + condition: () -> Boolean, + ) { + val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (true) { + // Écouler d'abord : les messages postés pendant la pause doivent + // être traités avant qu'on interroge l'état. + ecouler(avancerHorloge) + if (condition()) return + if (System.nanoTime() >= echeance) break + Thread.sleep(PAUSE_MS) } + fail("Délai dépassé en attendant : $quoi") } /** - * Les boucles de messages qu'on peut encore faire tourner. + * Écoule les messages de toutes les boucles encore vivantes. * - * `getAllLoopers` ramasse aussi celles des tests précédents : leurs fils - * s'arrêtent, et les solliciter lève « Looper is quitting ». Le filtre - * écarte le gros du lot, le `runCatching` couvre la course qui reste. + * Toutes, et pas seulement la principale : ExoPlayer tient sa machine à + * états sur un `HandlerThread` à lui. */ - private fun bouclesVivantes() = - ShadowLooper.getAllLoopers().filter { it.thread.isAlive } + private fun ecouler(avancerHorloge: Boolean) { + ShadowLooper.getAllLoopers() + .filter { it.thread.isAlive } + .forEach { boucle -> + // `getAllLoopers` ramasse aussi les boucles des tests + // précédents, dont les fils s'arrêtent — « Looper is quitting ». + // Le filtre écarte le gros du lot, ce garde-fou couvre la + // course qui reste. + runCatching { + if (avancerHorloge) { + shadowOf(boucle).idleFor(Duration.ofMillis(TICK_MS)) + } else { + shadowOf(boucle).idle() + } + } + } + } private companion object { - const val IDLE_PAUSE_MS = 5L + const val TIMEOUT_S = 15L + const val PAUSE_MS = 5L const val TICK_MS = 200L - const val TOURS_MAX = 200 } }