From 573b7a37bbbbbc60ab34bd9fd4e1dceea219a20f Mon Sep 17 00:00:00 2001 From: Tim Malseed Date: Sat, 28 Mar 2026 17:40:46 +1100 Subject: [PATCH] Add Compose UI characterisation tests with robot/scenario pattern Robolectric-based Compose tests for SongList and GenreList that verify all observable UI behaviour, enabling safe rearchitecting of the underlying Compose/ViewModel code. - Add Robolectric 4.14.1 and Compose UI test dependencies - Add robolectric.properties (sdk=34, graphics=NATIVE, plain Application) - Add SongListTest (19 tests) and GenreListTest (17 tests) - Add screen robots (SongListRobot, GenreListRobot) encapsulating selectors - Add scenario factories for readable ViewState construction - Add createGenre() and createPlaylist() test model factories - Remove 3 redundant mock-verify-only tests from SongListViewModelTest (covered by UI characterisation tests) --- android/app/build.gradle.kts | 6 + .../com/simplecityapps/creationFunctions.kt | 33 +++ .../screens/library/genres/GenreListRobot.kt | 140 +++++++++++++ .../library/genres/GenreListScenarios.kt | 16 ++ .../screens/library/genres/GenreListTest.kt | 172 ++++++++++++++++ .../ui/screens/library/songs/SongListRobot.kt | 112 +++++++++++ .../library/songs/SongListScenarios.kt | 19 ++ .../ui/screens/library/songs/SongListTest.kt | 190 ++++++++++++++++++ .../library/songs/SongListViewModelTest.kt | 51 ++--- .../src/test/resources/robolectric.properties | 4 + gradle/libs.versions.toml | 2 + 11 files changed, 716 insertions(+), 29 deletions(-) create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListRobot.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListScenarios.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListTest.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListRobot.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListScenarios.kt create mode 100644 android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListTest.kt create mode 100644 android/app/src/test/resources/robolectric.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d02d7f07a..177512f8f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -95,6 +95,9 @@ android { } testOptions { + unitTests { + isIncludeAndroidResources = true + } managedDevices { localDevices { create("pixel6Api34Atd") { @@ -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) diff --git a/android/app/src/test/java/com/simplecityapps/creationFunctions.kt b/android/app/src/test/java/com/simplecityapps/creationFunctions.kt index cc51e1358..9c33c7f23 100644 --- a/android/app/src/test/java/com/simplecityapps/creationFunctions.kt +++ b/android/app/src/test/java/com/simplecityapps/creationFunctions.kt @@ -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 @@ -48,3 +51,33 @@ fun createSong( sampleRate = null, channelCount = null, ) + +fun createGenre( + name: String = "Rock", + songCount: Int = 10, + duration: Int = 600, + mediaProviders: List = 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, +) diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListRobot.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListRobot.kt new file mode 100644 index 000000000..f2f38c0d6 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListRobot.kt @@ -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? = 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 = 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 = 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, + ) +} diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListScenarios.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListScenarios.kt new file mode 100644 index 000000000..c1d805aa3 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListScenarios.kt @@ -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 = 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 diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListTest.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListTest.kt new file mode 100644 index 000000000..1dfce1567 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListTest.kt @@ -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 +} diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListRobot.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListRobot.kt new file mode 100644 index 000000000..4ea9fb2e3 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListRobot.kt @@ -0,0 +1,112 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import com.simplecityapps.shuttle.model.Playlist +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData +import com.simplecityapps.shuttle.ui.theme.AppTheme +import kotlinx.collections.immutable.toImmutableList + +/** + * Test robot for [SongList] Compose characterisation tests. + * + * Encapsulates selectors and interaction mechanics so tests express + * *what* they verify, not *how* to find nodes. When the Compose + * implementation changes (test tags, content descriptions, layout + * structure), update this robot — not every test. + */ +class SongListRobot(private val rule: ComposeContentTestRule) { + + // -- Callback captures (populated by setContent) -- + + var lastSongClicked: Song? = null; private set + var lastSongLongClicked: Song? = null; private set + var lastAddedToQueue: Song? = null; private set + var lastPlayNext: Song? = null; private set + var lastSongInfo: Song? = null; private set + var lastExcluded: Song? = null; private set + var lastEditTags: Song? = null; private set + var lastDeleted: Song? = null; private set + var lastAddToPlaylist: Pair? = null; private set + var lastCreatePlaylistDialog: Song? = null; private set + var shuffleClicked = false; private set + + // -- Content setup -- + + fun setContent( + viewState: SongListViewModel.ViewState, + playlists: List = emptyList(), + ) { + resetCallbacks() + rule.setContent { + AppTheme { + SongList( + viewState = viewState, + playlists = playlists.toImmutableList(), + onSongClick = { lastSongClicked = it }, + onSongLongClick = { lastSongLongClicked = it }, + onAddToQueue = { lastAddedToQueue = it }, + onAddToPlaylist = { playlist, data -> lastAddToPlaylist = playlist to data }, + onShowCreatePlaylistDialog = { lastCreatePlaylistDialog = it }, + onPlayNext = { lastPlayNext = it }, + onSongInfo = { lastSongInfo = it }, + onExclude = { lastExcluded = it }, + onEditTags = { lastEditTags = it }, + onDelete = { lastDeleted = it }, + onShuffle = { shuffleClicked = true }, + ) + } + } + } + + private fun resetCallbacks() { + lastSongClicked = null + lastSongLongClicked = null + lastAddedToQueue = null + lastPlayNext = null + lastSongInfo = null + lastExcluded = null + lastEditTags = null + lastDeleted = null + lastAddToPlaylist = null + lastCreatePlaylistDialog = null + shuffleClicked = false + } + + // -- Assertions -- + + fun assertTextDisplayed(text: String) { + rule.onNodeWithText(text).assertIsDisplayed() + } + + fun assertTextNotDisplayed(text: String) { + rule.onNodeWithText(text).assertDoesNotExist() + } + + fun assertSubtextDisplayed(text: String) { + rule.onNode(hasText(text, substring = true)).assertIsDisplayed() + } + + fun assertSelectionMarkDisplayed() { + rule.onNodeWithContentDescription("Selection mark").assertIsDisplayed() + } + + fun assertSelectionMarkNotDisplayed() { + rule.onNodeWithContentDescription("Selection mark").assertDoesNotExist() + } + + // -- Interactions -- + + fun clickText(text: String) { + rule.onNodeWithText(text).performClick() + } + + fun openContextMenu() { + rule.onNodeWithContentDescription("Song context menu").performClick() + } + + fun clickMenuItem(text: String) { + rule.onNodeWithText(text).performClick() + } +} diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListScenarios.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListScenarios.kt new file mode 100644 index 000000000..afdc7bd8d --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListScenarios.kt @@ -0,0 +1,19 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import com.simplecityapps.createSong +import com.simplecityapps.mediaprovider.Progress +import com.simplecityapps.shuttle.model.Song +import com.simplecityapps.shuttle.sorting.SongSortOrder + +fun readySongList( + songs: List = listOf(createSong()), + selectedSongs: Set = emptySet(), + sortOrder: SongSortOrder = SongSortOrder.Default, +) = SongListViewModel.ViewState.Ready(songs, selectedSongs, sortOrder) + +fun scanningSongList(progress: Progress? = null) = + SongListViewModel.ViewState.Scanning(progress) + +fun emptySongList() = readySongList(songs = emptyList()) + +val loadingSongList = SongListViewModel.ViewState.Loading diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListTest.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListTest.kt new file mode 100644 index 000000000..b4831a505 --- /dev/null +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListTest.kt @@ -0,0 +1,190 @@ +package com.simplecityapps.shuttle.ui.screens.library.songs + +import androidx.compose.ui.test.junit4.createComposeRule +import com.simplecityapps.createSong +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 SongListTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val robot = SongListRobot(composeTestRule) + + // region View state rendering + + @Test + fun `loading state shows loading indicator`() { + robot.setContent(loadingSongList) + robot.assertTextDisplayed("Loading…") + } + + @Test + fun `scanning state shows scan progress message`() { + robot.setContent(scanningSongList(Progress(50, 200))) + robot.assertTextDisplayed("Scanning your library") + } + + @Test + fun `scanning state with null progress shows scan message`() { + robot.setContent(scanningSongList()) + robot.assertTextDisplayed("Scanning your library") + } + + @Test + fun `ready state with empty songs shows empty message`() { + robot.setContent(emptySongList()) + robot.assertTextDisplayed("No songs") + } + + @Test + fun `ready state shows song name`() { + robot.setContent(readySongList(songs = listOf(createSong(name = "My Great Song")))) + robot.assertTextDisplayed("My Great Song") + } + + @Test + fun `ready state shows artist and album as subtitle`() { + robot.setContent( + readySongList( + songs = listOf(createSong(name = "Track 1", albumArtist = "The Artist", album = "The Album")) + ) + ) + robot.assertSubtextDisplayed("The Artist") + robot.assertSubtextDisplayed("The Album") + } + + @Test + fun `ready state shows multiple songs`() { + robot.setContent( + readySongList( + songs = listOf( + createSong(id = 1, name = "First Song"), + createSong(id = 2, name = "Second Song"), + createSong(id = 3, name = "Third Song"), + ) + ) + ) + robot.assertTextDisplayed("First Song") + robot.assertTextDisplayed("Second Song") + robot.assertTextDisplayed("Third Song") + } + + @Test + fun `ready state shows shuffle button`() { + robot.setContent(readySongList()) + robot.assertTextDisplayed("Shuffle") + } + + @Test + fun `selected song shows selection mark`() { + val song = createSong(id = 1) + robot.setContent(readySongList(songs = listOf(song), selectedSongs = setOf(song))) + robot.assertSelectionMarkDisplayed() + } + + @Test + fun `unselected song does not show selection mark`() { + robot.setContent(readySongList(songs = listOf(createSong(id = 1)))) + robot.assertSelectionMarkNotDisplayed() + } + + // endregion + + // region Callbacks + + @Test + fun `shuffle button invokes onShuffle`() { + robot.setContent(readySongList()) + robot.clickText("Shuffle") + robot.shuffleClicked shouldBe true + } + + // endregion + + // region Context menu + + @Test + fun `context menu shows standard items for local song`() { + robot.setContent(readySongList(songs = listOf(createSong(mediaProvider = MediaProviderType.Shuttle)))) + robot.openContextMenu() + + robot.assertTextDisplayed("Add to Queue") + robot.assertTextDisplayed("Add to Playlist") + robot.assertTextDisplayed("Play Next") + robot.assertTextDisplayed("Song Info") + robot.assertTextDisplayed("Exclude") + robot.assertTextDisplayed("Edit Tags") + robot.assertTextDisplayed("Delete") + } + + @Test + fun `context menu hides Edit Tags for non-tag-editing provider`() { + robot.setContent(readySongList(songs = listOf(createSong(mediaProvider = MediaProviderType.Jellyfin)))) + robot.openContextMenu() + robot.assertTextNotDisplayed("Edit Tags") + } + + @Test + fun `context menu invokes onAddToQueue`() { + val song = createSong(name = "Queue Me") + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Add to Queue") + robot.lastAddedToQueue shouldBe song + } + + @Test + fun `context menu invokes onPlayNext`() { + val song = createSong(name = "Next Song") + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Play Next") + robot.lastPlayNext shouldBe song + } + + @Test + fun `context menu invokes onSongInfo`() { + val song = createSong(name = "Info Song") + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Song Info") + robot.lastSongInfo shouldBe song + } + + @Test + fun `context menu invokes onExclude`() { + val song = createSong(name = "Exclude Me") + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Exclude") + robot.lastExcluded shouldBe song + } + + @Test + fun `context menu invokes onEditTags for Shuttle provider`() { + val song = createSong(name = "Tag Me", mediaProvider = MediaProviderType.Shuttle) + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Edit Tags") + robot.lastEditTags shouldBe song + } + + @Test + fun `context menu invokes onDelete for deletable song`() { + val song = createSong(name = "Delete Me", mediaProvider = MediaProviderType.Shuttle) + robot.setContent(readySongList(songs = listOf(song))) + robot.openContextMenu() + robot.clickMenuItem("Delete") + robot.lastDeleted shouldBe song + } + + // endregion +} diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt index 9c6a5519e..6c799514e 100644 --- a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt @@ -225,19 +225,6 @@ class SongListViewModelTest { .shouldBe(emptyList()) } - @Test - fun `adds song to queue`() = runTest { - mockSongs(listOf(SONG)) - coEvery { mockPlaybackManager.addToQueue(allAny()) } just Runs - viewModel = createViewModel() - advanceUntilIdle() - - viewModel.addToQueue(SONG) {} - advanceUntilIdle() - - coVerify(exactly = 1) { mockPlaybackManager.addToQueue(listOf(SONG)) } - } - @Test fun `adds selected songs to queue`() = runTest { val songs = listOf(SONG1, SONG2) @@ -254,19 +241,6 @@ class SongListViewModelTest { viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() } - @Test - fun `adds a song to play next`() = runTest { - mockSongs(listOf(SONG)) - coEvery { mockPlaybackManager.playNext(allAny()) } just Runs - viewModel = createViewModel() - advanceUntilIdle() - - viewModel.playNext(SONG) {} - advanceUntilIdle() - - coVerify(exactly = 1) { mockPlaybackManager.playNext(listOf(SONG)) } - } - @Test fun `shuffles songs`() = runTest { mockSongs(listOf(SONG)) @@ -352,10 +326,29 @@ class SongListViewModelTest { viewModel.setSortOrder(SongSortOrder.ArtistGroupKey) advanceUntilIdle() - coVerify(exactly = 1) { - spiedSortPreferenceManager.sortOrderSongList = SongSortOrder.ArtistGroupKey - } viewModel.selectedSortOrder.value.shouldBe(SongSortOrder.ArtistGroupKey) + io.mockk.verify { spiedSortPreferenceManager.sortOrderSongList = SongSortOrder.ArtistGroupKey } + } + + @Test + fun `reports play failure via completion callback`() = runTest { + val playError = Error("playback failed") + mockSongs(listOf(SONG)) + mockSongImportStateAsImportComplete() + coEvery { mockQueueManager.setQueue(allAny()) } returns true + coEvery { mockPlaybackManager.load(seekPosition = null, completion = any()) } answers { + (arg(1) as (Result) -> Unit).invoke(Result.failure(playError)) + } + viewModel = createViewModel() + advanceUntilIdle() + + var receivedError: Throwable? = null + viewModel.onSongClick(SONG) { result -> + result.onFailure { receivedError = it } + } + advanceUntilIdle() + + receivedError.shouldBe(playError) } fun createViewModel(): SongListViewModel = SongListViewModel( diff --git a/android/app/src/test/resources/robolectric.properties b/android/app/src/test/resources/robolectric.properties new file mode 100644 index 000000000..89f3f77d8 --- /dev/null +++ b/android/app/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +sdk=34 +qualifiers=w411dp-h891dp +graphics=NATIVE +application=android.app.Application diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19a6f9d15..16731d513 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ recyclerview = "1.4.0" recyclerview-fastscroll = "2.0.1" review = "2.0.2" room-compiler = "2.8.3" +robolectric = "4.14.1" runner = "1.7.0" security-crypto = "1.1.0" semver4j = "3.1.0" @@ -174,6 +175,7 @@ okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp paramsen-noise = { module = "com.github.timusus:noise", version.ref = "noise" } philjay-mpAndroidChart = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpandroidchart" } relex-circleindicator = { module = "me.relex:circleindicator", version.ref = "circleindicator" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } retrofit2-converterMoshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converter-moshi" } retrofit2-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converter-moshi" } square-phrase = { module = "com.github.square:phrase", version.ref = "phrase" }