diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f2583065f..1aeb8770cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -370,7 +370,9 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.truth) - androidTestImplementation(libs.mockk) + // Android-specific artifact: plain io.mockk:mockk can't mock classes on ART + // (needs a JVM instrumentation agent that isn't available on-device). + androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.worktesting) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) diff --git a/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt b/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt new file mode 100644 index 0000000000..b145d98d58 --- /dev/null +++ b/app/src/androidTest/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreenSearchTest.kt @@ -0,0 +1,234 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Playlist +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import com.theveloper.pixelplay.presentation.viewmodel.PlaylistUiState +import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState +import com.theveloper.pixelplay.ui.theme.PixelPlayTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented Compose coverage for the in-playlist search filter added to + * [PlaylistDetailScreen]. Exercises the real composable (no ViewModel is + * introduced for this feature; state lives in the composable itself) with + * relaxed mocks for [PlayerViewModel]/[PlaylistViewModel], following the + * same pattern already used for concrete-class mocking in this project's + * instrumented tests (see SyncWorkerTest). + */ +@RunWith(AndroidJUnit4::class) +class PlaylistDetailScreenSearchTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val songBohemianRhapsody = buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen") + private val songYesterday = buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + private val songUnderPressure = buildSong(id = "song-3", title = "Under Pressure", artist = "Queen") + private val songImagine = buildSong(id = "song-4", title = "Imagine", artist = "John Lennon") + + private val fakeSongs = listOf(songBohemianRhapsody, songYesterday, songUnderPressure, songImagine) + private val fakePlaylist = Playlist( + id = "playlist-1", + name = "Road Trip", + songIds = fakeSongs.map { it.id } + ) + + @Test + fun searchField_typingQuery_filtersVisibleSongs() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + + composeTestRule.onNodeWithText("Bohemian Rhapsody").assertExists() + composeTestRule.onNodeWithText("Under Pressure").assertExists() + composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist() + composeTestRule.onNodeWithText("Imagine").assertDoesNotExist() + } + + @Test + fun searchField_typingNonMatchingQuery_showsEmptyStateWithQuery() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("zzz") + + val expectedEmptyState = composeTestRule.activity.getString(R.string.search_no_results_for_query, "zzz") + composeTestRule.onNodeWithText(expectedEmptyState).assertExists() + } + + @Test + fun searchField_clearingQuery_restoresFullListAndActionsRow() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + // "Play it"/"Shuffle" are drawn by TightWrapText directly on a Canvas (no semantics + // text node), so the icon's content description is what's actually queryable here. + val playCd = composeTestRule.activity.getString(R.string.common_play) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist() + + composeTestRule.onNode(hasSetTextAction()).performTextClearance() + + composeTestRule.onNodeWithText("Yesterday").assertExists() + composeTestRule.onNodeWithContentDescription(playCd).assertExists() + } + + @Test + fun actionsRow_hiddenWhileSearchQueryIsNotBlank() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + val playCd = composeTestRule.activity.getString(R.string.common_play) + val shuffleCd = composeTestRule.activity.getString(R.string.common_shuffle) + + composeTestRule.onNodeWithContentDescription(playCd).assertExists() + composeTestRule.onNodeWithContentDescription(shuffleCd).assertExists() + + composeTestRule.onNode(hasSetTextAction()).performTextInput("xyz") + + composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(shuffleCd).assertDoesNotExist() + } + + @Test + fun reorderMode_disabledAutomaticallyWhenSearchStartsAndStaysDisabledAfterClearing() { + setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist) + val reorderSongsCd = composeTestRule.activity.getString(R.string.playlist_cd_reorder_songs) + val reorderLabel = composeTestRule.activity.getString(R.string.playlist_action_reorder_songs) + + // Only the toggle button itself exposes this content description before reorder mode is on. + assertReorderCdCount(reorderSongsCd, expectedCount = 1) + + composeTestRule.onNodeWithText(reorderLabel).performClick() + composeTestRule.waitForIdle() + // Toggle button + one drag handle per visible song. + assertReorderCdCount(reorderSongsCd, expectedCount = 1 + fakeSongs.size) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("queen") + composeTestRule.waitForIdle() + // Actions row (and its toggle button) is hidden entirely while filtering. + assertReorderCdCount(reorderSongsCd, expectedCount = 0) + + composeTestRule.onNode(hasSetTextAction()).performTextClearance() + composeTestRule.waitForIdle() + // Reorder mode was force-disabled by the LaunchedEffect, not just hidden: only the + // toggle button reappears, the drag handles do not come back on their own. + assertReorderCdCount(reorderSongsCd, expectedCount = 1) + } + + @Test + fun clickingFilteredSong_playsFullPlaylistFromClickedSong() { + val playerViewModel = mockPlayerViewModel() + val playlistViewModel = mockPlaylistViewModel(fakeSongs, fakePlaylist) + setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel) + + // Filter down to a single song that isn't the first in the playlist. + composeTestRule.onNode(hasSetTextAction()).performTextInput("yesterday") + composeTestRule.onNodeWithText("Yesterday").performClick() + + verify(exactly = 1) { + playerViewModel.playSongs(fakeSongs, songYesterday, fakePlaylist.name, fakePlaylist.id) + } + } + + @Test + fun emptyPlaylist_doesNotShowSearchField() { + setPlaylistDetailContent(songs = emptyList(), playlist = fakePlaylist) + + val searchLabel = composeTestRule.activity.getString(R.string.song_picker_search_label) + composeTestRule.onNodeWithText(searchLabel).assertDoesNotExist() + } + + private fun assertReorderCdCount(contentDescription: String, expectedCount: Int) { + val actualCount = composeTestRule + .onAllNodesWithContentDescription(contentDescription) + .fetchSemanticsNodes() + .size + assert(actualCount == expectedCount) { + "Expected $expectedCount node(s) with content description \"$contentDescription\", found $actualCount" + } + } + + private fun setPlaylistDetailContent( + songs: List, + playlist: Playlist, + playerViewModel: PlayerViewModel = mockPlayerViewModel(), + playlistViewModel: PlaylistViewModel = mockPlaylistViewModel(songs, playlist) + ) { + setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel) + } + + private fun setPlaylistDetailContent( + playerViewModel: PlayerViewModel, + playlistViewModel: PlaylistViewModel + ) { + composeTestRule.setContent { + PixelPlayTheme { + PlaylistDetailScreen( + playlistId = fakePlaylist.id, + onBackClick = {}, + onDeletePlayListClick = {}, + playerViewModel = playerViewModel, + playlistViewModel = playlistViewModel, + navController = rememberNavController() + ) + } + } + } + + private fun mockPlaylistViewModel(songs: List, playlist: Playlist): PlaylistViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.uiState } returns MutableStateFlow( + PlaylistUiState(currentPlaylistDetails = playlist, currentPlaylistSongs = songs) + ) + return viewModel + } + + private fun mockPlayerViewModel(): PlayerViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.stablePlayerState } returns MutableStateFlow(StablePlayerState()) + every { viewModel.selectedSongForInfo } returns MutableStateFlow(null) + every { viewModel.favoriteSongIds } returns MutableStateFlow(emptySet()) + every { viewModel.navBarCompactMode } returns MutableStateFlow(false) + every { viewModel.isSortingSheetVisible } returns MutableStateFlow(false) + return viewModel + } + + private fun buildSong( + id: String, + title: String, + artist: String, + album: String = "Album" + ): Song = Song( + id = id, + title = title, + artist = artist, + artistId = 1L, + album = album, + albumId = 1L, + path = "/tmp/$id.mp3", + contentUriString = "content://pixelplay/song/$id", + albumArtUriString = null, + duration = 180_000L, + mimeType = "audio/mpeg", + bitrate = 320_000, + sampleRate = 44_100 + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt new file mode 100644 index 0000000000..0ee069a5a1 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt @@ -0,0 +1,63 @@ +package com.theveloper.pixelplay.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.theveloper.pixelplay.R + +/** + * Compact, reusable filter/search input field shared across song lists + * (song picker, playlist detail, etc.). Callers own the query state. + */ +@Composable +fun SearchFilterTextField( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = stringResource(R.string.song_picker_search_label) +) { + OutlinedTextField( + value = searchQuery, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedTrailingIconColor = Color.Transparent, + focusedSupportingTextColor = Color.Transparent, + ), + onValueChange = onSearchQueryChange, + label = { Text(label) }, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = CircleShape, + singleLine = true, + leadingIcon = { + Icon(Icons.Rounded.Search, contentDescription = null) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Filled.Clear, null) + } + } + } + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt index 2c30d0f802..2d9c7a6fba 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt @@ -28,12 +28,10 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.FilterChip @@ -48,12 +46,10 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeExtendedFloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import com.theveloper.pixelplay.data.model.StorageFilter import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -337,7 +333,7 @@ fun SongPickerSelectionPane( Column( modifier = modifier.fillMaxSize() ) { - SongPickerSearchField( + SearchFilterTextField( searchQuery = searchQuery, onSearchQueryChange = { searchQuery = it } ) @@ -430,44 +426,6 @@ fun SongPickerSelectionPane( } } -@Composable -private fun SongPickerSearchField( - searchQuery: String, - onSearchQueryChange: (String) -> Unit, - modifier: Modifier = Modifier -) { - OutlinedTextField( - value = searchQuery, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - unfocusedTrailingIconColor = Color.Transparent, - focusedSupportingTextColor = Color.Transparent, - ), - onValueChange = onSearchQueryChange, - label = { Text(stringResource(R.string.song_picker_search_label)) }, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = CircleShape, - singleLine = true, - leadingIcon = { - Icon(Icons.Rounded.Search, contentDescription = null) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChange("") }) { - Icon(Icons.Filled.Clear, null) - } - } - } - ) -} - @OptIn(UnstableApi::class) @Composable fun SongPickerPagingList( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index a2b8c0c670..9783bf6c7f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -103,6 +104,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -138,8 +140,10 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet +import com.theveloper.pixelplay.presentation.components.SearchFilterTextField import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.PlaylistShapeType +import com.theveloper.pixelplay.utils.filterByQuery import kotlinx.coroutines.launch @androidx.annotation.OptIn(UnstableApi::class) @@ -200,6 +204,14 @@ fun PlaylistDetailScreen( var showPlaylistOptionsSheet by remember { mutableStateOf(false) } var showEditPlaylistDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var searchQuery by remember(playlistId) { mutableStateOf("") } + + LaunchedEffect(searchQuery.isNotBlank()) { + if (searchQuery.isNotBlank()) { + isReorderModeEnabled = false + isRemoveModeEnabled = false + } + } val m3uExportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") @@ -224,6 +236,9 @@ fun PlaylistDetailScreen( val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) var showPlaylistBottomSheet by remember { mutableStateOf(false) } var localReorderableSongs by remember(songsInPlaylist) { mutableStateOf(songsInPlaylist) } + val displayedSongs = remember(localReorderableSongs, searchQuery) { + localReorderableSongs.filterByQuery(searchQuery) + } val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -354,6 +369,7 @@ fun PlaylistDetailScreen( ) { val actionButtonsHeight = 42.dp val playbackControlBottomPadding = if (isFolderPlaylist) 8.dp else 6.dp + if (searchQuery.isBlank()) { Row( modifier = Modifier .fillMaxWidth() @@ -657,6 +673,14 @@ fun PlaylistDetailScreen( } } } + } + + if (localReorderableSongs.isNotEmpty()) { + SearchFilterTextField( + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it } + ) + } if (localReorderableSongs.isEmpty()) { Box(Modifier @@ -674,6 +698,20 @@ fun PlaylistDetailScreen( Text(emptyMessage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } + } else if (displayedSongs.isEmpty()) { + Box(Modifier + .fillMaxSize() + .weight(1f), Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Rounded.Search, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.search_no_results_for_query, searchQuery), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + } + } } else { Box( modifier = Modifier @@ -710,7 +748,7 @@ fun PlaylistDetailScreen( } ) { itemsIndexed( - localReorderableSongs, + displayedSongs, key = { _, item -> item.id }, contentType = { _, _ -> "playlist_song" }) { _, song -> ReorderableItem( diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt b/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt index 53209450a9..37b325d89c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/Extensions.kt @@ -6,11 +6,26 @@ import java.nio.charset.Charset import java.text.Normalizer private val WINDOWS_1252: Charset = Charset.forName("windows-1252") +private val COMBINING_DIACRITICAL_MARKS = Regex("\\p{Mn}+") fun Color.toHexString(): String { return String.format("#%08X", this.toArgb()) } +/** + * Strips diacritics (accents, tildes, etc.) so accent-insensitive matching can be done + * with a plain [String.contains]. Decomposes to NFD (splitting each accented character + * into its base letter + combining mark, e.g. "é" -> "e" + U+0301) then drops every + * combining mark (Unicode category Mn). Case is untouched — combine with `ignoreCase` + * at the call site if needed. + * + * Example: "Qué ganas".foldDiacritics() == "Que ganas" + */ +fun String.foldDiacritics(): String { + val decomposed = Normalizer.normalize(this, Normalizer.Form.NFD) + return COMBINING_DIACRITICAL_MARKS.replace(decomposed, "") +} + /** * Attempts to fix incorrectly encoded metadata strings that frequently appear when * tags are saved using Windows-1252/ISO-8859-1 but are later read as UTF-8. This results diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt new file mode 100644 index 0000000000..99a286fb54 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/SongFilterUtils.kt @@ -0,0 +1,23 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.model.Song + +/** + * True when [query] is found in this song's title or artist, case- and accent-insensitively + * (e.g. "que gan" matches "Qué ganas..."). Mirrors the field scope used by the global song + * search (title + artist). A blank query always matches, so callers can filter unconditionally. + */ +fun Song.matchesTitleOrArtist(query: String): Boolean { + if (query.isBlank()) return true + val foldedQuery = query.foldDiacritics() + return title.foldDiacritics().contains(foldedQuery, ignoreCase = true) || + artist.foldDiacritics().contains(foldedQuery, ignoreCase = true) +} + +/** + * Filters this list to songs matching [query] by title or artist, preserving order. + * A blank query returns this list unchanged (same reference), so callers relying on + * identity for `remember`/diffing keys don't do unnecessary work. + */ +fun List.filterByQuery(query: String): List = + if (query.isBlank()) this else filter { it.matchesTitleOrArtist(query) } diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt new file mode 100644 index 0000000000..ace182e297 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/ExtensionsTest.kt @@ -0,0 +1,45 @@ +package com.theveloper.pixelplay.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExtensionsTest { + + @Test + fun foldDiacritics_stripsSpanishAccentsAndTilde() { + assertEquals("Que ganas de bailar", "Qué ganas de bailar".foldDiacritics()) + assertEquals("Manana", "Mañana".foldDiacritics()) + assertEquals("Cancion", "Canción".foldDiacritics()) + } + + @Test + fun foldDiacritics_stripsAccentsAcrossOtherLatinLanguages() { + assertEquals("Deja vu", "Déjà vu".foldDiacritics()) + assertEquals("uber", "über".foldDiacritics()) + assertEquals("naive", "naïve".foldDiacritics()) + } + + @Test + fun foldDiacritics_stringWithoutDiacritics_isUnchanged() { + assertEquals("Bohemian Rhapsody", "Bohemian Rhapsody".foldDiacritics()) + } + + @Test + fun foldDiacritics_emptyString_returnsEmptyString() { + assertEquals("", "".foldDiacritics()) + } + + @Test + fun foldDiacritics_preservesCase() { + // Folding removes marks only; it does not lowercase — callers combine with + // ignoreCase separately (see SongFilterUtils.matchesTitleOrArtist). + assertEquals("QUE", "QUÉ".foldDiacritics()) + assertEquals("que", "qué".foldDiacritics()) + } + + @Test + fun foldDiacritics_nonLatinScript_isUnaffected() { + // No combining marks to strip; the string round-trips unchanged. + assertEquals("こんにちは", "こんにちは".foldDiacritics()) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt new file mode 100644 index 0000000000..9ee736ae21 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/SongFilterUtilsTest.kt @@ -0,0 +1,149 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.model.Song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SongFilterUtilsTest { + + @Test + fun matchesTitleOrArtist_blankQuery_alwaysMatches() { + val song = buildSong(title = "Anything", artist = "Anyone") + + assertTrue(song.matchesTitleOrArtist("")) + assertTrue(song.matchesTitleOrArtist(" ")) + } + + @Test + fun matchesTitleOrArtist_matchesTitleCaseInsensitively() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertTrue(song.matchesTitleOrArtist("rhapsody")) + assertTrue(song.matchesTitleOrArtist("BOHEMIAN")) + } + + @Test + fun matchesTitleOrArtist_matchesArtistCaseInsensitively() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertTrue(song.matchesTitleOrArtist("que")) + } + + @Test + fun matchesTitleOrArtist_noMatch_returnsFalse() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen") + + assertFalse(song.matchesTitleOrArtist("metallica")) + } + + @Test + fun matchesTitleOrArtist_ignoresAlbumField() { + val song = buildSong(title = "Bohemian Rhapsody", artist = "Queen", album = "A Night at the Opera") + + assertFalse(song.matchesTitleOrArtist("opera")) + } + + @Test + fun matchesTitleOrArtist_matchesAccentedTitleWithUnaccentedQuery() { + val song = buildSong(title = "Qué ganas de bailar", artist = "Some Artist") + + assertTrue(song.matchesTitleOrArtist("que gan")) + } + + @Test + fun matchesTitleOrArtist_matchesUnaccentedTitleWithAccentedQuery() { + val song = buildSong(title = "Cancion sin nombre", artist = "Some Artist") + + assertTrue(song.matchesTitleOrArtist("canción")) + } + + @Test + fun matchesTitleOrArtist_matchesAccentedArtist() { + val song = buildSong(title = "Some Title", artist = "Café Tacvba") + + assertTrue(song.matchesTitleOrArtist("cafe tacvba")) + } + + @Test + fun filterByQuery_blankQuery_returnsOriginalListUnfiltered() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("") + + assertSame(songs, result) + } + + @Test + fun filterByQuery_returnsOnlyMatchingSongsPreservingOrder() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles"), + buildSong(id = "song-3", title = "Under Pressure", artist = "Queen"), + buildSong(id = "song-4", title = "Imagine", artist = "John Lennon") + ) + + val result = songs.filterByQuery("queen") + + assertEquals(listOf(songs[0], songs[2]), result) + } + + @Test + fun filterByQuery_noMatches_returnsEmptyList() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("metallica") + + assertTrue(result.isEmpty()) + } + + @Test + fun filterByQuery_emptyInputList_returnsEmptyList() { + val result = emptyList().filterByQuery("queen") + + assertTrue(result.isEmpty()) + } + + @Test + fun filterByQuery_matchesAcrossTitleAndArtistMixed() { + val songs = listOf( + buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen"), + buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles") + ) + + val result = songs.filterByQuery("rhapsody") + val resultByArtist = songs.filterByQuery("beatles") + + assertEquals(listOf(songs[0]), result) + assertEquals(listOf(songs[1]), resultByArtist) + } + + private fun buildSong( + title: String, + artist: String, + album: String = "Album", + id: String = "song-1" + ): Song = Song( + id = id, + title = title, + artist = artist, + artistId = 1L, + album = album, + albumId = 1L, + path = "/tmp/song-1.mp3", + contentUriString = "content://pixelplay/song/1", + albumArtUriString = null, + duration = 180_000L, + mimeType = "audio/mpeg", + bitrate = 320_000, + sampleRate = 44_100 + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6196203923..d42caf9b62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -233,6 +233,7 @@ junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", v junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junitJupiter" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomRuntime" }