diff --git a/README.md b/README.md index 4508b3a..b59485a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `HttpCatalogApiTest` | paging params, flattened details, track ordering | | `CatalogRepositoryTest` | token plumbing, retry after a refused token | | `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out | +| `CatalogSearchTest` | debounce pinned both ways, blank queries, failures, sign-out | | `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI | | `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved | | `RemoteAlbumDetailScreenTest` | Play and Shuffle are distinct, empty album, track tap | @@ -159,7 +160,7 @@ into `DragState` and tested there instead. - [x] Album / artist browsing (Navigation Compose + bottom bar) - [x] Local playlists (Room): create, rename, delete, add / remove tracks - [x] Drag-to-reorder inside a playlist -- [x] Search across songs, albums and artists +- [x] Search across songs, albums and artists (device and server, separately) - [x] Compose UI tests (Robolectric, no device) - [x] Sign in to a WaveFlow server (session, refresh, sign-out) - [x] Browse the server catalogue (albums, artists, paginated) @@ -175,6 +176,17 @@ and browses its catalogue — albums, artists, and what each contains. A remote album plays like a local one: tap a track, or use Play / Shuffle. Nothing of the local library is sent anywhere. +Search stays split in two. The magnifier in the top bar searches the device; the +Server tab has its own field. RFC-003 forbids guessing that a local track and a +server track are the same recording, so a single merged result list would show +the same album twice with nothing to explain why — it would pose on screen the +question the protocol has not answered. When a device search finds nothing and a +server is connected, an explicit *Chercher sur le serveur* button hands the +query over; results never drift in on their own. + +Unlike the local search, which filters in memory, each keystroke here would hit +the network, so the query is debounced and the previous request is dropped. + The two sources stay separate by design: the tab is its own section rather than a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / `RemoteSong` are distinct types from their local counterparts. Their ids are diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index e2c6ba1..18a405c 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -400,10 +400,17 @@ private fun WaveFlowRoot() { .collectAsStateWithLifecycle() val remoteArtists by catalogViewModel.artists .collectAsStateWithLifecycle() + val remoteSearch by catalogViewModel.search + .collectAsStateWithLifecycle() ServerCatalogScreen( albums = remoteAlbums, artists = remoteArtists, + search = remoteSearch, + onSearchQueryChange = catalogViewModel::onSearchQueryChange, + // Un résultat de recherche se lit seul : la file + // est ce morceau, faute de contexte d'album. + onSongClick = { playerViewModel.playRemoteFrom(listOf(it), it) }, onAlbumClick = { navController.navigate(Routes.serverAlbumDetail(it.id)) }, @@ -543,6 +550,17 @@ private fun WaveFlowRoot() { }, bottomPadding = listBottomPadding, onSongLongClick = { songToAdd = it }, + // Proposé seulement quand un serveur est connecté : + // sinon le bouton mènerait à un écran de connexion, + // que l'utilisateur n'a pas demandé. + onSearchOnServer = serverState.connected?.let { + { + val terme = searchQuery + closeSearch() + catalogViewModel.onSearchQueryChange(terme) + navController.switchTab(TopLevelDestination.Server) + } + }, ) } } diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt index d89fe18..a121ea5 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults /** * Lecture du catalogue d'un serveur WaveFlow. @@ -36,6 +37,19 @@ interface CatalogApi { /** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */ suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail + /** + * `GET /api/v2/search`, sur les trois types à la fois. + * + * La pertinence est celle de l'index du serveur : rien n'est reclassé ici. + */ + suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults + /** * `POST /api/v2/tracks/{id}/stream-ticket`. * diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt index 1920e23..08d1635 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults import app.waveflow.model.ServerSession /** @@ -29,6 +30,9 @@ class CatalogRepository( suspend fun artist(artistId: String): RemoteArtistDetail = authorized { url, token -> api.artist(url, token, artistId) } + suspend fun search(query: String, limit: Int = CATALOG_PAGE_SIZE): RemoteSearchResults = + authorized { url, token -> api.search(url, token, query, offset = 0, limit = limit) } + /** * URL de diffusion d'une piste, valable une heure côté serveur. * diff --git a/app/src/main/java/app/waveflow/data/remote/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt index 64f09dc..425a532 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -124,6 +124,14 @@ internal data class AlbumDetailResponse( get() = AlbumResponse(id, title, artist, artistId, year, artworkHash) } +/** `/api/v2/search` : les trois types dans une seule réponse. */ +@Serializable +internal data class SearchResponse( + val artists: List = emptyList(), + val albums: List = emptyList(), + val songs: List = emptyList(), +) + /** `{"url": "/api/v2/stream/", "expires_at": }` — l'URL est relative. */ @Serializable internal data class StreamTicketResponse( diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index 5439172..4f3712a 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -4,6 +4,7 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults import kotlinx.serialization.SerializationException /** Catalogue distant, par-dessus [ServerHttp]. */ @@ -71,6 +72,26 @@ class HttpCatalogApi( ) } + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults = http.get( + serverUrl = serverUrl, + path = SEARCH, + query = pageQuery(offset, limit) + ("q" to query), + accessToken = accessToken, + ).decode().let { response -> + val artwork = artwork(serverUrl) + RemoteSearchResults( + songs = response.songs.map { it.toModel(artwork) }, + albums = response.albums.map { it.toModel(artwork) }, + artists = response.artists.map { it.toModel(artwork) }, + ) + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, @@ -103,6 +124,7 @@ class HttpCatalogApi( const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" const val TRACKS = "api/v2/tracks" + const val SEARCH = "api/v2/search" /** Sans numéro de piste, on retombe sur le titre plutôt que sur rien. */ val BY_TRACK_THEN_TITLE = compareBy( diff --git a/app/src/main/java/app/waveflow/model/RemoteCatalog.kt b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt index 26ca2e5..213f6a8 100644 --- a/app/src/main/java/app/waveflow/model/RemoteCatalog.kt +++ b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt @@ -53,3 +53,19 @@ data class RemoteArtistDetail( val artist: RemoteArtist, val albums: List, ) + +/** + * Résultats d'une recherche sur le serveur. + * + * Le serveur les rend groupés par type et déjà classés : contrairement à la + * recherche locale, rien n'est trié ici — c'est son index qui décide de la + * pertinence. + */ +data class RemoteSearchResults( + val songs: List = emptyList(), + val albums: List = emptyList(), + val artists: List = emptyList(), +) { + val isEmpty: Boolean + get() = songs.isEmpty() && albums.isEmpty() && artists.isEmpty() +} diff --git a/app/src/main/java/app/waveflow/ui/search/SearchField.kt b/app/src/main/java/app/waveflow/ui/search/SearchField.kt index ae63be0..562fc81 100644 --- a/app/src/main/java/app/waveflow/ui/search/SearchField.kt +++ b/app/src/main/java/app/waveflow/ui/search/SearchField.kt @@ -26,12 +26,18 @@ fun SearchField( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, + /** + * `false` pour un champ permanent, qui fait déjà partie de l'écran. + * Prendre le focus n'a de sens que pour une barre qu'on vient d'ouvrir — + * sinon le clavier monte à chaque visite de l'onglet. + */ + autoFocus: Boolean = true, ) { val focusRequester = remember { FocusRequester() } // Le champ s'ouvre prêt à recevoir la frappe : sans ça, ouvrir la // recherche demanderait un second appui pour faire monter le clavier. - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } TextField( value = query, diff --git a/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt b/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt index fa7c5f6..7e254a9 100644 --- a/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt +++ b/app/src/main/java/app/waveflow/ui/search/SearchScreen.kt @@ -20,6 +20,8 @@ import app.waveflow.model.Artist import app.waveflow.model.SearchResults import app.waveflow.model.Song import app.waveflow.ui.albumCountLabel +import androidx.compose.material3.Button +import androidx.compose.material3.Text import app.waveflow.ui.components.CenteredMessage import app.waveflow.ui.components.MediaRow import app.waveflow.ui.components.SongRow @@ -43,6 +45,13 @@ fun SearchScreen( modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, onSongLongClick: (Song) -> Unit = {}, + /** + * Proposé quand la recherche locale ne trouve rien et qu'un serveur est + * connecté. Un bouton explicite plutôt que des résultats distants qui + * s'ajouteraient d'eux-mêmes : les deux sources restent séparées, et + * l'utilisateur sait toujours où il cherche. + */ + onSearchOnServer: (() -> Unit)? = null, ) { if (query.isBlank() || results.isEmpty) { Box(modifier = modifier.fillMaxSize()) { @@ -50,9 +59,14 @@ fun SearchScreen( message = if (query.isBlank()) { "Cherchez un titre, un album ou un artiste." } else { - "Aucun résultat pour « ${query.trim()} »." + "Aucun résultat pour « ${query.trim()} » sur cet appareil." }, modifier = Modifier.align(Alignment.Center), + action = onSearchOnServer + ?.takeIf { query.isNotBlank() } + ?.let { chercher -> + { Button(onClick = chercher) { Text("Chercher sur le serveur") } } + }, ) } return diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt index b949e91..f231a08 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt @@ -2,6 +2,7 @@ package app.waveflow.ui.server.catalog import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults /** * Une liste paginée en cours de chargement. @@ -32,6 +33,25 @@ data class DetailState( val errorMessage: String? = null, ) +/** + * Recherche sur le serveur. + * + * Distincte de la recherche locale, qui filtre en mémoire : celle-ci part sur + * le réseau, peut échouer, et met du temps. L'écran doit pouvoir le montrer. + */ +data class RemoteSearchState( + val query: String = "", + val results: RemoteSearchResults = RemoteSearchResults(), + val isSearching: Boolean = false, + val errorMessage: String? = null, +) { + val isActive: Boolean get() = query.isNotBlank() + + /** Vrai quand la recherche a abouti sans rien trouver. */ + val foundNothing: Boolean + get() = isActive && !isSearching && errorMessage == null && results.isEmpty +} + typealias AlbumDetailState = DetailState typealias ArtistDetailState = DetailState diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt index 209d403..7933ef3 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -12,10 +12,17 @@ import app.waveflow.data.remote.CatalogRepository import app.waveflow.data.remote.ServerException import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteSearchResults import app.waveflow.model.ServerSession import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.Job +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -30,6 +37,7 @@ import kotlinx.coroutines.launch * Les pages ne sont pas conservées à la déconnexion : elles appartiennent à un * compte, et l'écran suivant pourrait être celui d'un autre. */ +@OptIn(ExperimentalCoroutinesApi::class) class CatalogViewModel( private val catalogRepository: CatalogRepository, session: StateFlow, @@ -47,6 +55,9 @@ class CatalogViewModel( private val _artistDetail = MutableStateFlow(ArtistDetailState()) val artistDetail: StateFlow = _artistDetail.asStateFlow() + private val _search = MutableStateFlow(RemoteSearchState()) + val search: StateFlow = _search.asStateFlow() + /** Une seule page en vol par liste : deux requêtes doubleraient le contenu. */ private var albumsJob: Job? = null private var artistsJob: Job? = null @@ -67,6 +78,63 @@ class CatalogViewModel( .distinctUntilChanged() .onEach { connected -> if (connected) loadFirstPages() else clear() } .launchIn(viewModelScope) + + // Contrairement à la recherche locale, qui filtre en mémoire à chaque + // frappe, celle-ci part sur le réseau : sans délai de grâce, taper + // « écho » lancerait quatre requêtes dont trois inutiles. + _search + .map { it.query.trim() } + .distinctUntilChanged() + .flatMapLatest { query -> searchFlow(query) } + .onEach { outcome -> _search.update { outcome(it) } } + .launchIn(viewModelScope) + } + + fun onSearchQueryChange(query: String) { + _search.update { it.copy(query = query) } + } + + fun clearSearch() { + _search.value = RemoteSearchState() + } + + /** + * Une recherche, sous forme de mises à jour successives de l'état. + * + * Renvoyer des transformations plutôt que des états complets évite d'écraser + * la requête que l'utilisateur continue de taper pendant l'appel. + * + * Le délai de grâce est **dans** ce flux, et non avant `flatMapLatest` : + * placé avant, il retarderait aussi l'arrivée de la nouvelle requête, et la + * précédente continuerait de courir pendant ce temps — jusqu'à rendre des + * résultats périmés sous une requête déjà changée. Ici, une frappe annule + * immédiatement l'attente comme l'appel en cours. + * + * Une requête vide n'attend pas : effacer le champ doit vider l'écran tout + * de suite. + */ + private fun searchFlow(query: String): Flow<(RemoteSearchState) -> RemoteSearchState> = flow { + if (query.isBlank()) { + emit { it.copy(results = RemoteSearchResults(), isSearching = false, errorMessage = null) } + return@flow + } + + delay(SEARCH_DEBOUNCE_MS) + emit { it.copy(isSearching = true, errorMessage = null) } + try { + val results = catalogRepository.search(query) + emit { it.copy(results = results, isSearching = false) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + emit { + it.copy( + results = RemoteSearchResults(), + isSearching = false, + errorMessage = error.toMessage(), + ) + } + } } /** Charge la page suivante d'albums, si elle a lieu d'être. */ @@ -168,6 +236,7 @@ class CatalogViewModel( _artists.value = PagedList() _albumDetail.value = AlbumDetailState() _artistDetail.value = ArtistDetailState() + _search.value = RemoteSearchState() } private fun Exception.toMessage(): String = when (this) { @@ -185,6 +254,9 @@ class CatalogViewModel( companion object { private const val TAG = "CatalogViewModel" + /** De quoi laisser finir un mot avant d'interroger le serveur. */ + private const val SEARCH_DEBOUNCE_MS = 300L + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt index 3389449..2bda27b 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -1,28 +1,38 @@ package app.waveflow.ui.server.catalog +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Tab import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteSong import app.waveflow.model.orUnknownArtist import app.waveflow.ui.albumCountLabel +import app.waveflow.ui.components.CenteredMessage import app.waveflow.ui.components.MediaRow +import app.waveflow.ui.search.SearchField private enum class CatalogTab(val label: String) { Albums("Albums"), @@ -40,6 +50,9 @@ private enum class CatalogTab(val label: String) { fun ServerCatalogScreen( albums: PagedList, artists: PagedList, + search: RemoteSearchState, + onSearchQueryChange: (String) -> Unit, + onSongClick: (RemoteSong) -> Unit, onAlbumClick: (RemoteAlbum) -> Unit, onArtistClick: (RemoteArtist) -> Unit, onLoadMoreAlbums: () -> Unit, @@ -58,6 +71,29 @@ fun ServerCatalogScreen( val artistsListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } Column(modifier = modifier.fillMaxSize()) { + // Le champ vit dans la section Serveur : aucune ambiguïté sur ce qu'il + // cherche, contrairement à la loupe de la barre du haut qui, elle, + // reste locale. + SearchField( + query = search.query, + onQueryChange = onSearchQueryChange, + autoFocus = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + + if (search.isActive) { + RemoteSearchResultsList( + state = search, + onSongClick = onSongClick, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + bottomPadding = bottomPadding, + ) + return@Column + } + PrimaryTabRow(selectedTabIndex = tab.ordinal) { CatalogTab.entries.forEach { entry -> Tab( @@ -159,3 +195,82 @@ private fun ArtistsTab( } } + +/** + * Résultats d'une recherche sur le serveur. + * + * Les trois types partagent une seule liste, comme la recherche locale : un + * album qui n'apparaît qu'après vingt titres reste atteignable en continuant de + * faire défiler. + */ +@Composable +private fun RemoteSearchResultsList( + state: RemoteSearchState, + onSongClick: (RemoteSong) -> Unit, + onAlbumClick: (RemoteAlbum) -> Unit, + onArtistClick: (RemoteArtist) -> Unit, + bottomPadding: Dp, +) { + when { + state.errorMessage != null -> Box(modifier = Modifier.fillMaxSize()) { + CenteredMessage( + message = state.errorMessage, + modifier = Modifier.align(Alignment.Center), + ) + } + + // L'indicateur n'apparaît qu'à défaut de résultats : pendant une frappe + // suivante, les précédents restent lisibles plutôt que de clignoter. + state.isSearching && state.results.isEmpty -> Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.foundNothing -> Box(modifier = Modifier.fillMaxSize()) { + CenteredMessage( + message = "Aucun résultat sur le serveur pour « ${state.query.trim()} ».", + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> { + val listState = rememberLazyListState() + + // Une nouvelle requête donne une autre liste : rester au milieu de + // l'ancienne ferait manquer les premiers résultats, ceux que le + // serveur juge les plus pertinents. + LaunchedEffect(state.query.trim()) { listState.scrollToItem(0) } + + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(state.results.songs, key = { "song-${it.id}" }) { song -> + MediaRow( + artworkUri = song.artworkUri, + title = song.title, + subtitle = song.artist.orUnknownArtist(), + onClick = { onSongClick(song) }, + ) + } + items(state.results.albums, key = { "album-${it.id}" }) { album -> + MediaRow( + artworkUri = album.artworkUri, + title = album.title, + subtitle = album.artist.orUnknownArtist(), + onClick = { onAlbumClick(album) }, + ) + } + items(state.results.artists, key = { "artist-${it.id}" }) { artist -> + MediaRow( + artworkUri = artist.artworkUri, + title = artist.name, + subtitle = artist.albumCount?.let(::albumCountLabel).orEmpty(), + onClick = { onArtistClick(artist) }, + artworkShape = CircleShape, + ) + } + } + } + } +} diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 05f7ba6..1de247d 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -9,6 +9,8 @@ import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteAlbumDetail import app.waveflow.model.RemoteArtist import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.RemoteSearchResults +import app.waveflow.model.RemoteSong import app.waveflow.model.ServerSession import kotlinx.coroutines.CompletableDeferred @@ -151,6 +153,17 @@ class FakeCatalogApi( ) } + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults { + record(serverUrl, accessToken, offset to limit) + return RemoteSearchResults() + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, @@ -195,6 +208,10 @@ class PagingCatalogApi( private val gate: CompletableDeferred? = null, /** Même rôle que [gate], pour les détails. */ private val detailGate: CompletableDeferred? = null, + /** Même rôle, pour la recherche. */ + private val searchGate: CompletableDeferred? = null, + private val searchFailure: Throwable? = null, + private val searchResults: List = emptyList(), ) : CatalogApi { var albumCalls = 0 @@ -258,6 +275,26 @@ class PagingCatalogApi( ) } + /** Requêtes reçues, dans l'ordre : c'est l'anti-rebond qu'on éprouve. */ + val searchQueries = mutableListOf() + + override suspend fun search( + serverUrl: String, + accessToken: String, + query: String, + offset: Int, + limit: Int, + ): RemoteSearchResults { + searchQueries += query + searchGate?.await() + searchFailure?.let { throw it } + return RemoteSearchResults( + songs = searchResults + .filter { it.title.contains(query, ignoreCase = true) } + .page(offset, limit), + ) + } + override suspend fun streamTicket( serverUrl: String, accessToken: String, diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt new file mode 100644 index 0000000..d179109 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogSearchTest.kt @@ -0,0 +1,269 @@ +package app.waveflow.ui.server.catalog + +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.testing.MainDispatcherRule +import app.waveflow.testing.PagingCatalogApi +import app.waveflow.testing.remoteSong +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La recherche sur le serveur. + * + * Contrairement à la recherche locale, qui filtre en mémoire, chaque frappe + * pourrait partir sur le réseau : c'est surtout l'anti-rebond qu'on vérifie ici. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CatalogSearchTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val connected = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private val catalogue = listOf( + remoteSong(id = "a", title = "Écho lointain"), + remoteSong(id = "b", title = "Résonance"), + ) + + private suspend fun viewModel(catalog: PagingCatalogApi): CatalogViewModel { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = connected), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return CatalogViewModel( + CatalogRepository(catalog, sessions), + MutableStateFlow(connected), + ) + } + + @Test + fun `une requete interroge le serveur et expose ses resultats`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + val state = viewModel.search.value + assertEquals(listOf("Écho"), catalog.searchQueries) + assertEquals(listOf("Écho lointain"), state.results.songs.map { it.title }) + assertFalse(state.isSearching) + } + + @Test + fun `taper vite ne lance qu'une requete`() = + runTest(mainDispatcherRule.dispatcher) { + // Le cas qui compte : sur le réseau, une requête par frappe est un + // gâchis que la recherche locale, en mémoire, ne connaît pas. + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + "Écho".forEachIndexed { index, _ -> + viewModel.onSearchQueryChange("Écho".take(index + 1)) + advanceTimeBy(50) + } + advanceUntilIdle() + + assertEquals(listOf("Écho"), catalog.searchQueries) + } + + @Test + fun `la requete attend la fin de la frappe, sans plus`() = + runTest(mainDispatcherRule.dispatcher) { + // Épingle le délai dans les deux sens. `advanceUntilIdle` avance le + // temps virtuel sans limite : un anti-rebond démesuré y passerait + // inaperçu, seule une avance mesurée le révèle. + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(299) + runCurrent() + assertTrue("rien tant que la frappe peut continuer", catalog.searchQueries.isEmpty()) + + // La milliseconde qui suit : le délai est épinglé à sa valeur, pas + // à un intervalle où n'importe quelle constante passerait. + advanceTimeBy(1) + runCurrent() + assertEquals(listOf("Écho"), catalog.searchQueries) + } + + @Test + fun `une requete differente relance la recherche`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + viewModel.onSearchQueryChange("Réso") + advanceUntilIdle() + + assertEquals(listOf("Écho", "Réso"), catalog.searchQueries) + } + + @Test + fun `vider la requete n'interroge pas le serveur et efface les resultats`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + viewModel.onSearchQueryChange("") + advanceUntilIdle() + + assertEquals("une requête vide ne vaut pas un appel", 1, catalog.searchQueries.size) + assertTrue(viewModel.search.value.results.isEmpty) + assertFalse(viewModel.search.value.isActive) + } + + @Test + fun `les espaces seuls ne valent pas une requete`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(searchResults = catalogue) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange(" ") + advanceUntilIdle() + + assertTrue(catalog.searchQueries.isEmpty()) + } + + @Test + fun `la frappe qui continue pendant l'appel n'est pas ecrasee`() = + runTest(mainDispatcherRule.dispatcher) { + // L'état porte à la fois la requête et les résultats : les remplacer + // ensemble ferait reculer le curseur de l'utilisateur. + val portail = CompletableDeferred() + val catalog = PagingCatalogApi(searchResults = catalogue, searchGate = portail) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(400) + runCurrent() + + viewModel.onSearchQueryChange("Écho lo") + portail.complete(Unit) + advanceUntilIdle() + + assertEquals("Écho lo", viewModel.search.value.query) + } + + @Test + fun `une frappe annule la recherche en cours, resultats compris`() = + runTest(mainDispatcherRule.dispatcher) { + // Le délai est sous `flatMapLatest`, pas avant : placé avant, il + // retarderait la nouvelle requête et la précédente continuerait de + // courir, jusqu'à rendre ses résultats sous une requête changée. + val portail = CompletableDeferred() + val catalog = PagingCatalogApi(searchResults = catalogue, searchGate = portail) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceTimeBy(400) + runCurrent() + assertEquals("la première est partie", listOf("Écho"), catalog.searchQueries) + + // Elle est toujours en vol quand la suivante arrive, et se + // débloque juste après. + viewModel.onSearchQueryChange("Réso") + portail.complete(Unit) + + // Moins que le délai de grâce : la seconde recherche n'est pas + // encore partie. C'est l'instant qui départage les deux montages — + // le délai placé avant `flatMapLatest` laisserait la première + // rendre ici ses résultats, sous une requête déjà changée. Les deux + // convergent ensuite, ce que la fin du test ne prouverait donc pas. + advanceTimeBy(100) + runCurrent() + assertTrue( + "aucun résultat périmé sous la nouvelle requête", + viewModel.search.value.results.isEmpty, + ) + + advanceUntilIdle() + val state = viewModel.search.value + assertEquals("Réso", state.query) + assertEquals( + "les résultats sont ceux de la requête affichée", + listOf("Résonance"), + state.results.songs.map { it.title }, + ) + } + + @Test + fun `un echec devient un message et non des resultats vides`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi( + searchResults = catalogue, + searchFailure = ServerException.Unreachable("coupure"), + ) + val viewModel = viewModel(catalog) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + val state = viewModel.search.value + assertEquals("Serveur injoignable.", state.errorMessage) + assertFalse("un échec n'est pas une absence de résultat", state.foundNothing) + } + + @Test + fun `la deconnexion efface la recherche`() = + runTest(mainDispatcherRule.dispatcher) { + val session = MutableStateFlow(connected) + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = connected), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + val viewModel = CatalogViewModel( + CatalogRepository(PagingCatalogApi(searchResults = catalogue), sessions), + session, + ) + + viewModel.onSearchQueryChange("Écho") + advanceUntilIdle() + + session.value = ServerSession.Disconnected + advanceUntilIdle() + + assertEquals("", viewModel.search.value.query) + assertTrue(viewModel.search.value.results.isEmpty) + assertNull(viewModel.search.value.errorMessage) + } +}