-
Notifications
You must be signed in to change notification settings - Fork 0
test(lecture): couvrir la projection d'état du contrôleur Media3 #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
334 changes: 334 additions & 0 deletions
334
app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| 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.Assert.fail | ||
| 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 | ||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| /** | ||
| * 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<PlaybackService>? = 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 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() | ||
|
|
||
| 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) | ||
| attendre("la file posée") { controleur.state.value.current != null } | ||
|
|
||
| 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) | ||
| attendre("la file posée") { controleur.state.value.current != null } | ||
|
|
||
| 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) | ||
| 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) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // 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() | ||
| attendre("l'aléatoire actif") { controleur.state.value.shuffleEnabled } | ||
|
|
||
| controleur.play(listOf(song(1), song(2)), startIndex = 0) | ||
| attendre("la file posée") { controleur.state.value.current != null } | ||
|
|
||
| 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))) | ||
| attendre("la file posée") { controleur.state.value.current != null } | ||
|
|
||
| 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) | ||
|
|
||
| var precedent = RepeatMode.Off | ||
| val parcours = List(3) { | ||
| controleur.cycleRepeatMode() | ||
| 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) | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // 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. | ||
| // | ||
| // 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) | ||
| attendre("le tampon avant le premier son") { controleur.state.value.isBuffering } | ||
|
|
||
| assertFalse(controleur.state.value.isPlaying) | ||
| assertNull(controleur.state.value.failure) | ||
| } | ||
|
|
||
| @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) | ||
| attendre("la panne du lecteur", avancerHorloge = true) { | ||
| controleur.state.value.failure != null | ||
| } | ||
|
|
||
| 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) | ||
| attendre("la file posée") { controleur.state.value.current != null } | ||
|
|
||
| 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() | ||
| attendre("la liaison au service") { it.state.value.isConnected } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Attend qu'une condition se réalise, en écoulant les messages entre deux | ||
| * essais. | ||
| * | ||
| * 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. | ||
| * | ||
| * @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 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") | ||
| } | ||
|
|
||
| /** | ||
| * Écoule les messages de toutes les boucles encore vivantes. | ||
| * | ||
| * Toutes, et pas seulement la principale : ExoPlayer tient sa machine à | ||
| * états sur un `HandlerThread` à lui. | ||
| */ | ||
| 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 TIMEOUT_S = 15L | ||
| const val PAUSE_MS = 5L | ||
| const val TICK_MS = 200L | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.