Skip to content
Open
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
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

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<Song>,
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<Song>, playlist: Playlist): PlaylistViewModel {
val viewModel = mockk<PlaylistViewModel>(relaxed = true)
every { viewModel.uiState } returns MutableStateFlow(
PlaylistUiState(currentPlaylistDetails = playlist, currentPlaylistSongs = songs)
)
return viewModel
}

private fun mockPlayerViewModel(): PlayerViewModel {
val viewModel = mockk<PlayerViewModel>(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
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -337,7 +333,7 @@ fun SongPickerSelectionPane(
Column(
modifier = modifier.fillMaxSize()
) {
SongPickerSearchField(
SearchFilterTextField(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it }
)
Expand Down Expand Up @@ -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(
Expand Down
Loading