diff --git a/README.md b/README.md index f2a6545..6651e81 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `PlayerViewModelTest` | contextual play queue, current-song resolution, controller release | | `PlaylistsViewModelTest` | flow failures, write failures, resolution order, atomic creation, reorder rollback and staleness | | `DragStateTest` | drag arithmetic: target rank, visual offset, bounds, `moved` | +| `PlaylistDetailScreenTest` | reorder accessibility actions, order restored after a failed write | | `SearchTest` | matching by title / album / artist, accent and case folding, prefix ranking | | `SearchViewModelTest` | query → results, clearing, following the library | | `GroupingTest` | album / artist derivation, sorting, missing tags | @@ -126,9 +127,10 @@ in-memory SQLite for Room, so the DAO is exercised without a device. Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`. -No Compose UI test runs yet, so anything that only exists as composable state — -the drag gesture itself, its accessibility actions — is covered through the -plain-Kotlin logic it delegates to, not through the UI. +Compose tests run on the JVM too: `createComposeRule()` works under Robolectric, +so a screen can be composed and driven without a device. What still cannot be +covered that way is the pointer gesture itself — its arithmetic is extracted +into `DragState` and tested there instead. ## Roadmap @@ -138,7 +140,7 @@ plain-Kotlin logic it delegates to, not through the UI. - [x] Local playlists (Room): create, rename, delete, add / remove tracks - [x] Drag-to-reorder inside a playlist - [x] Search across songs, albums and artists -- [ ] Compose UI tests (Robolectric, no device) +- [x] Compose UI tests (Robolectric, no device) - [ ] WaveFlow server as a remote source: browse and stream its catalogue - [ ] Server user-data sync (playlists, favorites, ratings) — see below - [ ] Android Auto (Media3 `MediaLibraryService`) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff8d77d..449879e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,10 +80,15 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.junit) + + // Tests Compose sur la JVM : `createComposeRule()` tourne sous Robolectric, + // sans émulateur. Les mêmes artefacts servaient en `androidTest`, dont le + // source set n'a jamais existé — ils sont ici pour la première fois utiles. + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + + // L'activité vide qui héberge le contenu sous test vient de ce manifest ; + // les tests unitaires fusionnent celui de la variante debug. debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) } \ No newline at end of file diff --git a/app/src/test/java/app/waveflow/ui/playlists/PlaylistDetailScreenTest.kt b/app/src/test/java/app/waveflow/ui/playlists/PlaylistDetailScreenTest.kt new file mode 100644 index 0000000..c953144 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/playlists/PlaylistDetailScreenTest.kt @@ -0,0 +1,189 @@ +package app.waveflow.ui.playlists + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import app.waveflow.model.Playlist +import app.waveflow.model.Song +import app.waveflow.testing.song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Ce que le glisser-déposer ne peut pas prouver ailleurs. + * + * `DragStateTest` couvre l'arithmétique du geste, mais deux comportements + * n'existent que dans la composition : les actions d'accessibilité, seul accès + * au déplacement pour TalkBack et le clavier, et la restauration de l'ordre + * affiché quand l'écriture échoue. + */ +@RunWith(RobolectricTestRunner::class) +// L'écran par défaut de Robolectric est trop court : l'en-tête et sa pochette +// le remplissent, et la `LazyColumn` ne compose alors qu'une partie des lignes +// — celles qui manquent sont absentes de l'arbre, pas seulement invisibles. +@Config(qualifiers = "w411dp-h2000dp-xhdpi") +class PlaylistDetailScreenTest { + + @get:Rule + val compose = createComposeRule() + + private val playlist = Playlist(id = 1L, name = "Ma playlist") + + private val songs = listOf( + song(id = 1L, title = "Alpha"), + song(id = 2L, title = "Bravo"), + song(id = 3L, title = "Charlie"), + ) + + /** Ordres transmis à `onReorder`, et le rappel de restauration du dernier. */ + private val ordersSent = mutableListOf>() + private var lastFailureCallback: (() -> Unit)? = null + + private fun afficher(songs: List = this.songs) { + compose.setContent { + PlaylistDetailScreen( + playlist = playlist, + songs = songs, + nowPlayingId = null, + onSongClick = {}, + onRemoveSong = {}, + onReorder = { order, onFailure -> + ordersSent += order + lastFailureCallback = onFailure + }, + onPlay = {}, + onShuffle = {}, + ) + } + } + + @Test + fun `descendre une ligne change l'ordre affiche et transmet le nouvel ordre`() { + afficher() + + compose.deplacer("Alpha", "Descendre") + + assertEquals(listOf("Bravo", "Alpha", "Charlie"), compose.titresAffiches()) + assertEquals( + listOf(listOf(2L, 1L, 3L)), + ordersSent.map { order -> order.map { it.id } }, + ) + } + + @Test + fun `monter une ligne change l'ordre affiche et transmet le nouvel ordre`() { + afficher() + + compose.deplacer("Charlie", "Monter") + + assertEquals(listOf("Alpha", "Charlie", "Bravo"), compose.titresAffiches()) + assertEquals( + listOf(listOf(1L, 3L, 2L)), + ordersSent.map { order -> order.map { it.id } }, + ) + } + + @Test + fun `les bords de la liste n'offrent que le deplacement possible`() { + afficher() + + assertEquals(listOf("Descendre"), compose.actionsDe("Alpha")) + assertEquals(listOf("Monter", "Descendre"), compose.actionsDe("Bravo")) + assertEquals(listOf("Monter"), compose.actionsDe("Charlie")) + } + + @Test + fun `un morceau seul n'offre aucun deplacement`() { + afficher(songs = songs.take(1)) + + assertEquals(emptyList(), compose.actionsDe("Alpha")) + } + + @Test + fun `une ecriture qui echoue ramene l'ordre affiche a celui de la base`() { + afficher() + + compose.deplacer("Alpha", "Descendre") + assertEquals(listOf("Bravo", "Alpha", "Charlie"), compose.titresAffiches()) + + // Room n'émet rien quand l'écriture échoue : sans ce rappel, l'écran + // resterait sur un ordre que la base ignore. + val restaurer = requireNotNull(lastFailureCallback) + compose.runOnUiThread(restaurer) + compose.waitForIdle() + + assertEquals(listOf("Alpha", "Bravo", "Charlie"), compose.titresAffiches()) + } + + @Test + fun `deux deplacements de suite s'enchainent depuis l'ordre courant`() { + afficher() + + compose.deplacer("Alpha", "Descendre") + compose.deplacer("Alpha", "Descendre") + + assertEquals(listOf("Bravo", "Charlie", "Alpha"), compose.titresAffiches()) + assertEquals( + listOf(listOf(2L, 1L, 3L), listOf(2L, 3L, 1L)), + ordersSent.map { order -> order.map { it.id } }, + ) + } + + // --- Lecture de l'arbre de sémantique ----------------------------------- + // + // Les titres et les actions sont relus depuis l'arbre plutôt que par des + // marqueurs posés dans l'écran : le test s'appuie sur ce que le système + // d'accessibilité voit réellement, ce qui est justement l'objet du test. + + private val titresConnus get() = songs.map { it.title }.toSet() + + private fun ComposeContentTestRule.titresAffiches(): List = + onRoot().fetchSemanticsNode().collecter { node -> + node.textes().filter { it in titresConnus } + } + + private fun ComposeContentTestRule.actionsDe(titre: String): List { + val ligne = onRoot().fetchSemanticsNode() + .collecter { node -> if (titre in node.textes()) listOf(node) else emptyList() } + .lastOrNull() + ?: error("ligne « $titre » absente de l'arbre") + + return ligne.config + .getOrNull(SemanticsActions.CustomActions) + .orEmpty() + .map { it.label } + } + + /** Déclenche l'action nommée [action] portée par la ligne [titre]. */ + private fun ComposeContentTestRule.deplacer(titre: String, action: String) { + val ligne = onRoot().fetchSemanticsNode() + .collecter { node -> if (titre in node.textes()) listOf(node) else emptyList() } + .lastOrNull() + ?: error("ligne « $titre » absente de l'arbre") + + val cible = ligne.config + .getOrNull(SemanticsActions.CustomActions) + .orEmpty() + .firstOrNull { it.label == action } + ?: error("action « $action » absente de la ligne « $titre »") + + assertTrue("l'action « $action » a échoué", runOnUiThread { cible.action() }) + waitForIdle() + } + + private fun SemanticsNode.textes(): List = + config.getOrNull(SemanticsProperties.Text).orEmpty().map { it.text } + + /** Parcourt l'arbre dans l'ordre d'affichage en concaténant [extraire]. */ + private fun SemanticsNode.collecter(extraire: (SemanticsNode) -> List): List = + extraire(this) + children.flatMap { it.collecter(extraire) } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index deeb497..f182d35 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,6 @@ agp = "9.2.1" coreKtx = "1.10.1" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" lifecycle = "2.9.4" activityCompose = "1.8.0" kotlin = "2.2.10" @@ -21,8 +19,6 @@ androidxTestCore = "1.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }