Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,18 @@ 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 |
| `DurationFormatTest` | `m:ss` / `h:mm:ss` formatting |

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

Expand All @@ -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`)
Expand Down
13 changes: 9 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<List<Song>>()
private var lastFailureCallback: (() -> Unit)? = null

private fun afficher(songs: List<Song> = 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<String>(), 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<String> =
onRoot().fetchSemanticsNode().collecter { node ->
node.textes().filter { it in titresConnus }
}

private fun ComposeContentTestRule.actionsDe(titre: String): List<String> {
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<String> =
config.getOrNull(SemanticsProperties.Text).orEmpty().map { it.text }

/** Parcourt l'arbre dans l'ordre d'affichage en concaténant [extraire]. */
private fun <T> SemanticsNode.collecter(extraire: (SemanticsNode) -> List<T>): List<T> =
extraire(this) + children.flatMap { it.collecter(extraire) }
}
4 changes: 0 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down