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
6 changes: 6 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ android {
}

testOptions {
unitTests {
isIncludeAndroidResources = true
}
managedDevices {
localDevices {
create("pixel6Api34Atd") {
Expand Down Expand Up @@ -286,6 +289,9 @@ android {
testImplementation(libs.kotest)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutinesTest)
testImplementation(libs.robolectric)
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
androidTestImplementation(libs.androidx.core.ktx)
Expand Down
33 changes: 33 additions & 0 deletions android/app/src/test/java/com/simplecityapps/creationFunctions.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.simplecityapps

import com.simplecityapps.shuttle.model.Genre
import com.simplecityapps.shuttle.model.MediaProviderType
import com.simplecityapps.shuttle.model.Playlist
import com.simplecityapps.shuttle.model.Song
import com.simplecityapps.shuttle.sorting.PlaylistSongSortOrder
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate

Expand Down Expand Up @@ -48,3 +51,33 @@ fun createSong(
sampleRate = null,
channelCount = null,
)

fun createGenre(
name: String = "Rock",
songCount: Int = 10,
duration: Int = 600,
mediaProviders: List<MediaProviderType> = listOf(MediaProviderType.Shuttle),
) = Genre(
name = name,
songCount = songCount,
duration = duration,
mediaProviders = mediaProviders,
)

fun createPlaylist(
id: Long = 1,
name: String = "My Playlist",
songCount: Int = 5,
duration: Int = 300,
sortOrder: PlaylistSongSortOrder = PlaylistSongSortOrder.Position,
mediaProvider: MediaProviderType = MediaProviderType.Shuttle,
externalId: String? = null,
) = Playlist(
id = id,
name = name,
songCount = songCount,
duration = duration,
sortOrder = sortOrder,
mediaProvider = mediaProvider,
externalId = externalId,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.simplecityapps.shuttle.ui.screens.library.genres

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.simplecityapps.shuttle.model.Genre
import com.simplecityapps.shuttle.model.Playlist
import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
import com.simplecityapps.shuttle.ui.theme.AppTheme
import kotlinx.collections.immutable.toImmutableList

/**
* Test robot for [GenreList] and [GenreListItem] Compose characterisation tests.
*
* View state tests render via [GenreList]. Context menu tests render via
* [GenreListItem] directly, because the [FastScroller] overlay in GenreList
* causes DropdownMenu popups to be immediately dismissed under Robolectric.
* This detail is encapsulated here — tests don't need to know about it.
*/
class GenreListRobot(private val rule: ComposeContentTestRule) {

// -- Callback captures --

var lastSelectedGenre: Genre? = null; private set
var lastPlayedGenre: Genre? = null; private set
var lastAddedToQueue: Genre? = null; private set
var lastPlayNext: Genre? = null; private set
var lastExcluded: Genre? = null; private set
var lastEditTags: Genre? = null; private set
var lastAddToPlaylist: Pair<Playlist, PlaylistData>? = null; private set
var lastCreatePlaylistDialog: Genre? = null; private set

private fun callbacks() = Callbacks(
onSelectGenre = { lastSelectedGenre = it },
onPlayGenre = { lastPlayedGenre = it },
onAddToQueue = { lastAddedToQueue = it },
onPlayNext = { lastPlayNext = it },
onExclude = { lastExcluded = it },
onEditTags = { lastEditTags = it },
onAddToPlaylist = { playlist, data -> lastAddToPlaylist = playlist to data },
onShowCreatePlaylistDialog = { lastCreatePlaylistDialog = it },
)

private fun resetCallbacks() {
lastSelectedGenre = null
lastPlayedGenre = null
lastAddedToQueue = null
lastPlayNext = null
lastExcluded = null
lastEditTags = null
lastAddToPlaylist = null
lastCreatePlaylistDialog = null
}

// -- Content setup --

/** Render the full [GenreList] composable (for view state tests). */
fun setContent(
viewState: GenreListViewModel.ViewState,
playlists: List<Playlist> = emptyList(),
) {
resetCallbacks()
val cb = callbacks()
rule.setContent {
AppTheme {
GenreList(
viewState = viewState,
playlists = playlists.toImmutableList(),
onSelectGenre = cb.onSelectGenre,
onPlayGenre = cb.onPlayGenre,
onAddToQueue = cb.onAddToQueue,
onPlayNext = cb.onPlayNext,
onExclude = cb.onExclude,
onEditTags = cb.onEditTags,
onAddToPlaylist = cb.onAddToPlaylist,
onShowCreatePlaylistDialog = cb.onShowCreatePlaylistDialog,
)
}
}
}

/** Render a single [GenreListItem] (for context menu tests). */
fun setItemContent(
genre: Genre,
playlists: List<Playlist> = emptyList(),
) {
resetCallbacks()
val cb = callbacks()
rule.setContent {
AppTheme {
GenreListItem(
genre = genre,
playlists = playlists.toImmutableList(),
onSelectGenre = cb.onSelectGenre,
onPlayGenre = cb.onPlayGenre,
onAddToQueue = cb.onAddToQueue,
onPlayNext = cb.onPlayNext,
onExclude = cb.onExclude,
onEditTags = cb.onEditTags,
onAddToPlaylist = cb.onAddToPlaylist,
onShowCreatePlaylistDialog = cb.onShowCreatePlaylistDialog,
)
}
}
}

// -- Assertions --

fun assertTextDisplayed(text: String) {
rule.onNodeWithText(text).assertIsDisplayed()
}

fun assertTextNotDisplayed(text: String) {
rule.onNodeWithText(text).assertDoesNotExist()
}

// -- Interactions --

fun clickText(text: String) {
rule.onNodeWithText(text).performClick()
}

fun openContextMenu() {
rule.onNodeWithContentDescription("Genre menu").performClick()
}

fun clickMenuItem(text: String) {
rule.onNodeWithText(text).performClick()
}

private data class Callbacks(
val onSelectGenre: (Genre) -> Unit,
val onPlayGenre: (Genre) -> Unit,
val onAddToQueue: (Genre) -> Unit,
val onPlayNext: (Genre) -> Unit,
val onExclude: (Genre) -> Unit,
val onEditTags: (Genre) -> Unit,
val onAddToPlaylist: (Playlist, PlaylistData) -> Unit,
val onShowCreatePlaylistDialog: (Genre) -> Unit,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.simplecityapps.shuttle.ui.screens.library.genres

import com.simplecityapps.createGenre
import com.simplecityapps.mediaprovider.Progress
import com.simplecityapps.shuttle.model.Genre

fun readyGenreList(
genres: List<Genre> = listOf(createGenre()),
) = GenreListViewModel.ViewState.Ready(genres)

fun scanningGenreList(progress: Progress? = null) =
GenreListViewModel.ViewState.Scanning(progress)

fun emptyGenreList() = readyGenreList(genres = emptyList())

val loadingGenreList = GenreListViewModel.ViewState.Loading
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.simplecityapps.shuttle.ui.screens.library.genres

import androidx.compose.ui.test.junit4.createComposeRule
import com.simplecityapps.createGenre
import com.simplecityapps.mediaprovider.Progress
import com.simplecityapps.shuttle.model.MediaProviderType
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class GenreListTest {

@get:Rule
val composeTestRule = createComposeRule()

private val robot = GenreListRobot(composeTestRule)

// region View state rendering

@Test
fun `loading state shows loading indicator`() {
robot.setContent(loadingGenreList)
robot.assertTextDisplayed("Loading…")
}

@Test
fun `scanning state shows scan progress message`() {
robot.setContent(scanningGenreList(Progress(10, 100)))
robot.assertTextDisplayed("Scanning your library")
}

@Test
fun `scanning state with null progress shows scan message`() {
robot.setContent(scanningGenreList())
robot.assertTextDisplayed("Scanning your library")
}

@Test
fun `ready state with empty genres shows empty message`() {
robot.setContent(emptyGenreList())
robot.assertTextDisplayed("No genres")
}

@Test
fun `ready state shows genre name`() {
robot.setContent(readyGenreList(genres = listOf(createGenre(name = "Electronic"))))
robot.assertTextDisplayed("Electronic")
}

@Test
fun `ready state shows song count for single song`() {
robot.setContent(readyGenreList(genres = listOf(createGenre(name = "Ambient", songCount = 1))))
robot.assertTextDisplayed("1 song")
}

@Test
fun `ready state shows song count for multiple songs`() {
robot.setContent(readyGenreList(genres = listOf(createGenre(name = "Rock", songCount = 245))))
robot.assertTextDisplayed("245 songs")
}

@Test
fun `ready state shows multiple genres`() {
robot.setContent(
readyGenreList(
genres = listOf(
createGenre(name = "Rock"),
createGenre(name = "Jazz"),
createGenre(name = "Blues"),
)
)
)
robot.assertTextDisplayed("Rock")
robot.assertTextDisplayed("Jazz")
robot.assertTextDisplayed("Blues")
}

// endregion

// region Callbacks

@Test
fun `clicking genre invokes onSelectGenre`() {
val genre = createGenre(name = "Rock")
robot.setContent(readyGenreList(genres = listOf(genre)))
robot.clickText("Rock")
robot.lastSelectedGenre shouldBe genre
}

// endregion

// region Context menu (rendered via GenreListItem — see GenreListRobot doc)

@Test
fun `context menu shows standard items for local genre`() {
robot.setItemContent(genre = createGenre(mediaProviders = listOf(MediaProviderType.Shuttle)))
robot.openContextMenu()

robot.assertTextDisplayed("Play")
robot.assertTextDisplayed("Add to Queue")
robot.assertTextDisplayed("Add to Playlist")
robot.assertTextDisplayed("Play Next")
robot.assertTextDisplayed("Exclude")
robot.assertTextDisplayed("Edit Tags")
}

@Test
fun `context menu hides Edit Tags when any provider does not support it`() {
robot.setItemContent(
genre = createGenre(mediaProviders = listOf(MediaProviderType.Shuttle, MediaProviderType.Jellyfin)),
)
robot.openContextMenu()
robot.assertTextNotDisplayed("Edit Tags")
}

@Test
fun `context menu hides Edit Tags for remote-only provider`() {
robot.setItemContent(genre = createGenre(mediaProviders = listOf(MediaProviderType.Plex)))
robot.openContextMenu()
robot.assertTextNotDisplayed("Edit Tags")
}

@Test
fun `context menu invokes onPlayGenre`() {
val genre = createGenre(name = "Jazz")
robot.setItemContent(genre = genre)
robot.openContextMenu()
robot.clickMenuItem("Play")
robot.lastPlayedGenre shouldBe genre
}

@Test
fun `context menu invokes onAddToQueue`() {
val genre = createGenre(name = "Blues")
robot.setItemContent(genre = genre)
robot.openContextMenu()
robot.clickMenuItem("Add to Queue")
robot.lastAddedToQueue shouldBe genre
}

@Test
fun `context menu invokes onPlayNext`() {
val genre = createGenre(name = "Pop")
robot.setItemContent(genre = genre)
robot.openContextMenu()
robot.clickMenuItem("Play Next")
robot.lastPlayNext shouldBe genre
}

@Test
fun `context menu invokes onExclude`() {
val genre = createGenre(name = "Country")
robot.setItemContent(genre = genre)
robot.openContextMenu()
robot.clickMenuItem("Exclude")
robot.lastExcluded shouldBe genre
}

@Test
fun `context menu invokes onEditTags for editable provider`() {
val genre = createGenre(name = "Reggae", mediaProviders = listOf(MediaProviderType.Shuttle))
robot.setItemContent(genre = genre)
robot.openContextMenu()
robot.clickMenuItem("Edit Tags")
robot.lastEditTags shouldBe genre
}

// endregion
}
Loading
Loading