From 8e0798ee16eb3257f1644cc384d5c99e56f44184 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 19 Jun 2026 11:06:35 -0400 Subject: [PATCH] Unit tests for PlaybackViewModel & several services (#1592) ## Description This PR contains only tests, there are no user facing changes. Adds tests for playback covering: - Playing next, previous, or within the queue - Media segments (intro/credit) - Playing intros before media Adds basic unit tests for track selection. This uses a new "test track" builder which can model various files and build them as represented (differently) by the server, ExoPlayer, and MPV. In future, this will allow for testing of more complex files. Also add unit tests for services covering things like: - Fetching extras - Deleting media - Server/user management & auth ### Related issues Will help with testing/verifying some issues like #1475, #1548 ### Testing Ran the unit tests! ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Playlist.kt | 2 + .../wholphin/services/PlayerFactory.kt | 6 + .../wholphin/ui/playback/PlaybackViewModel.kt | 26 +- .../wholphin/services/ExtrasServiceTest.kt | 187 +++++ .../services/MediaManagementServiceTest.kt | 190 +++++ .../wholphin/services/ServerRepositoryTest.kt | 485 +++++++++++ .../StreamChoiceServiceTest.kt} | 5 +- .../test/TestSelectionTrackExamples.kt | 114 +++ .../wholphin/test/TestSuggestionsLogic.kt | 30 - .../wholphin/test/TestTrackSelection.kt | 36 +- .../damontecres/wholphin/test/TestTracks.kt | 476 +++++++++++ .../damontecres/wholphin/test/TestUtils.kt | 59 ++ .../github/damontecres/wholphin/ui/Utils.kt | 4 + .../ui/playback/PlaybackViewModelTests.kt | 756 ++++++++++++++++++ 14 files changed, 2313 insertions(+), 63 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt rename app/src/test/java/com/github/damontecres/wholphin/{test/TestStreamChoiceService.kt => services/StreamChoiceServiceTest.kt} (99%) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 68771f6a..9f10f58c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -13,6 +13,8 @@ data class Playlist( ) { companion object { const val MAX_SIZE = 100 + + fun fromMedia(list: List) = Playlist(list.map { PlaylistItem.Media(it) }) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 6cdeeb34..0517497c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences @@ -211,6 +212,11 @@ class PlayerFactory ), ) } + + fun createMediaSession(player: Player) = + MediaSession + .Builder(context, player) + .build() } val Player.isReleased: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index fbc694d5..6a531119 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -192,14 +192,18 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow + // Exposed for testing + val initJob: Job + init { - viewModelScope.launchIO { - addCloseable { - screensaverService.keepScreenOn(false) - disconnectPlayer() + initJob = + viewModelScope.launchIO { + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } + init() } - init() - } } private fun disconnectPlayer() { @@ -269,10 +273,7 @@ class PlaybackViewModel player, preferences.appPreferences.playbackPreferences, ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() + mediaSession = playerFactory.createMediaSession(sessionPlayer) } /** @@ -1054,7 +1055,8 @@ class PlaybackViewModel } // Variables for tracking segment state - private var segmentJob: Job? = null + // Exposed for testing + internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf() private val outroShownSegments = mutableSetOf() @@ -1269,7 +1271,7 @@ class PlaybackViewModel } } } else { - Timber.w("Attempting to play next, but there are no more items") + Timber.w("Attempting to play previous, but there is none") return@launchDefault } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt new file mode 100644 index 00000000..5988d5d6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt @@ -0,0 +1,187 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.res.Resources +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ImageType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ExtrasServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk() + private val mockApiClient = mockk() + private val mockImageUrlService = mockk() + + private val mockUserLibraryApi = mockk() + private val mockResources = mockk() + + private val extrasService = ExtrasService(mockApiClient, mockContext, mockImageUrlService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApiClient.userLibraryApi } returns mockUserLibraryApi + every { mockContext.resources } returns mockResources + every { mockResources.getQuantityString(R.plurals.interviews, any()) } returns "Interviews" + every { mockResources.getQuantityString(R.plurals.clips, any()) } returns "Clips" + every { mockResources.getQuantityString(R.plurals.interviews, any(), any()) } returns "Interviews" + every { mockResources.getQuantityString(R.plurals.clips, any(), any()) } returns "Clips" + + every { mockResources.getQuantityString(R.plurals.items, any(), any()) } returns "Items" + + every { mockImageUrlService.getItemImageUrl(any(), ImageType.PRIMARY, any(), any(), any()) } returns + "https://localhost/image.jpg" + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + @Test + fun `Test grouping extras`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(1, extras.size) + val extra = extras.first() + Assert.assertTrue(extra is ExtrasItem.Group) + extra as ExtrasItem.Group + val extrasIds = extra.items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extra.type, ExtraType.INTERVIEW) + } + + @Test + fun `Test grouping extras with multiple types`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Group) + } + val extrasIds = extras.flatMap { (it as ExtrasItem.Group).items.map { it.id } }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test extras not grouped`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ), + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Single) + } + val extrasIds = extras.map { (it as ExtrasItem.Single).item.id }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test mixed grouping`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + Assert.assertTrue(extras[0] is ExtrasItem.Single) + Assert.assertTrue(extras[1] is ExtrasItem.Group) + + val extrasIds = listOf((extras[0] as ExtrasItem.Single).item.id) + (extras[1] as ExtrasItem.Group).items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt new file mode 100644 index 00000000..e88c2b81 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.InterfacePreferences +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.operations.LibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.UserPolicy +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class MediaManagementServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk() + private val mockApiClient = mockk() + private val mockServerRepository = mockk() + private val mockUserPreferencesService = mockk() + + private val mockLibraryApi = mockk() + + private val mediaManagementService = + MediaManagementService(mockContext, mockApiClient, mockServerRepository, mockUserPreferencesService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { mockApiClient.libraryApi } returns mockLibraryApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private val movie = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = true, + ), + ) + private val recording = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.RECORDING, + canDelete = true, + ), + ) + + private val movieCannotDelete = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = false, + ), + ) + + private val userWithLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk { + every { enableLiveTvManagement } returns true + }, + ) + + private val userWithoutLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk { + every { enableLiveTvManagement } returns false + }, + ) + + private val prefsEnabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = true + }.build() + }.build() + + private val prefsDisabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = false + }.build() + }.build() + + @Test + fun `Test canDelete`() { + Assert.assertTrue(mediaManagementService.canDelete(movie, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(movie, prefsDisabled)) + Assert.assertFalse(mediaManagementService.canDelete(movieCannotDelete, prefsEnabled)) + } + + @Test + fun `Test canDelete recording`() { + every { mockServerRepository.currentUserDto } returns userWithLiveTv + Assert.assertTrue(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @Test + fun `Test canDelete recording no permission`() { + every { mockServerRepository.currentUserDto } returns userWithoutLiveTv + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test successful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } returns successResponse(Unit) + + val deletedItems = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Success) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(1, deletedItems.size) + Assert.assertEquals(deletedItems[0].item.id, movie.id) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test unsuccessful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } throws InvalidStatusException(403) + + val deletedItems = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Error) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(0, deletedItems.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt new file mode 100644 index 00000000..06402b88 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -0,0 +1,485 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ServerRepositoryTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + val temporaryFolder: TemporaryFolder = + TemporaryFolder + .builder() + .assureDeletion() + .build() + + private val mockContext = mockk(relaxed = true) + private val mockJellyfin = mockk() + private val mockJellyfinServerDao = mockk() + private val mockApiClient = mockk() + private val dataStore = + DataStoreFactory.create( + serializer = AppPreferencesSerializer(), + produceFile = { temporaryFolder.newFile("test_datastore.pb") }, + scope = CoroutineScope(testDispatcher), + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + + private val mockUserApi = mockk() + private val mockSystemApi = mockk() + private val mockSharedPreferences = mockk(relaxed = true) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences + every { mockApiClient.userApi } returns mockUserApi + every { mockApiClient.systemApi } returns mockSystemApi + coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) + coEvery { mockSystemApi.getPublicSystemInfo() } returns + Response( + PublicSystemInfo( + id = serverId.toServerString(), + serverName = "test server", + version = "10.11.11", + ), + 200, + emptyMap(), + ) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") + every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") + every { mockApiClient.update(any(), any(), any(), any()) } just Runs + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + private fun create() = + ServerRepository( + mockContext, + mockJellyfin, + mockJellyfinServerDao, + mockApiClient, + dataStore, + testDispatcher, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private fun setUpCurrentUser( + serverRepository: ServerRepository, + currentUser: JellyfinUser = user, + ) { + every { mockApiClient.baseUrl } returns server.url + every { mockApiClient.accessToken } returns user.accessToken + Assert.assertNull(serverRepository.currentUser) + (serverRepository.current as MutableStateFlow).update { + CurrentUser(server, currentUser) + } + Assert.assertNotNull(serverRepository.currentUser) + } + + @Test + fun `Test overriding state`() { + val serverRepository = create() + Assert.assertNull(serverRepository.currentServer) + Assert.assertNull(serverRepository.currentUser) + setUpCurrentUser(serverRepository, currentUser = user) + Assert.assertEquals(serverId, serverRepository.currentServer?.id) + Assert.assertEquals(userId, serverRepository.currentUser?.id) + } + + @Test + fun `Test changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.changeUser(server, user) + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + } + + @Test + fun `Test restoreUser via changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test restoreUser via shortcut`() = + runTest { + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + setUpCurrentUser(serverRepository) + + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + + // TODO fix this, need to check userDto too +// Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test remove user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different user than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val otherUser = + JellyfinUser( + rowId = 2, + id = UUID.randomUUID(), + name = "other", + serverId = serverId, + accessToken = "other token", + ) + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = otherUser) + Assert.assertEquals(otherUser, serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 0) { mockApiClient.update(accessToken = null) } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + Assert.assertEquals(otherUser, serverRepository.currentUser) + } + + @Test + fun `Test remove current user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeUser(user) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = nonBlankString(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different server than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + val toRemove = JellyfinServer(UUID.randomUUID(), null, "http://jellyfin.local", null) + serverRepository.removeServer(toRemove) + + Assert.assertEquals(server, serverRepository.currentServer) + verify(exactly = 0) { + mockApiClient.update( + accessToken = any(), + baseUrl = any(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(toRemove.id) } + } + + @Test + fun `Test remove current server`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeServer(server) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = null, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + } + + @Test + fun `Test changeUser via auth`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, null) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Newly created user, so rowId should be 0 + Assert.assertEquals(0, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via auth for existing`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Existing user, so rowId should not change + Assert.assertEquals(user.rowId, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + // PIN should be removed when re-authing existing user + Assert.assertNull(savedUser.captured.pin) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via invalid auth`() { + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = null, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = null, + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = null, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt similarity index 99% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt rename to app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index dbf04447..e16bc4e5 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository @@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceService import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID @@ -22,7 +21,7 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class TestStreamChoiceServiceBasic( +class StreamChoiceServiceTest( val input: TestInput, ) { @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt new file mode 100644 index 00000000..e1bf9fc8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.TrackSelectionParameters +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +class TestSelectionTrackExamples { + private fun runTest( + builder: TestTracks.Builder, + playerBackend: PlayerBackend, + audioIndex: Int, + subtitleIndex: Int, + onResult: (TrackSelectionResult) -> Unit, + ) { + val testTacks = + when (playerBackend) { + PlayerBackend.EXO_PLAYER -> builder.buildForExoPlayer() + PlayerBackend.MPV -> builder.buildForMpv() + else -> throw IllegalArgumentException("Unsupported backend: $playerBackend") + } + + // These checks help check for invalid inputs + val audioTrack = testTacks.tracks.filter { it.index == audioIndex } + Assert.assertEquals(1, audioTrack.size) + Assert.assertEquals(MediaStreamType.AUDIO, audioTrack.first().type) + val subtitleTrack = testTacks.tracks.filter { it.index == subtitleIndex } + Assert.assertEquals(1, subtitleTrack.size) + Assert.assertEquals(MediaStreamType.SUBTITLE, subtitleTrack.first().type) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTacks.toTracks(), + playerBackend = playerBackend, + supportsDirectPlay = true, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = testTacks.toMediaSourceInfo(), + ).also(onResult) + } + + @Test + fun `test VAS Exo`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.EXO_PLAYER, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test VAS MPV`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.MPV, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS Exo`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("0:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS MPV`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt index 5027aee8..90b45182 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.test import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -264,32 +263,3 @@ class TestSuggestionsCombineAndDeduplicate( } } } - -// Helper functions to create test data - -private fun movie( - id: UUID = UUID.randomUUID(), - name: String = "Test Movie", - genres: List? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.MOVIE, - name = name, - seriesId = null, - genreItems = genres, - ) - -private fun episode( - id: UUID = UUID.randomUUID(), - seriesId: UUID, - name: String = "Test Episode", - genres: List? = null, -): BaseItemDto = - BaseItemDto( - id = id, - type = BaseItemKind.EPISODE, - name = name, - seriesId = seriesId, - genreItems = genres, - ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index cad496ca..956ddcd4 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -235,24 +235,6 @@ class TestTrackSelection { return Tracks(groups) } - private fun TrackSelectionParameters.getAudioOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_AUDIO) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) - } - } - return null - } - - private fun TrackSelectionParameters.getSubtitleOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_TEXT) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) - } - } - return null - } - @Test fun `test MPV embedded`() { val resource = javaClass.classLoader?.getResource("embedded_subs.json") @@ -589,3 +571,21 @@ class TestTrackSelection { } } } + +internal fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} + +internal fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt new file mode 100644 index 00000000..ba8d0d53 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -0,0 +1,476 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaProtocol +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaSourceType +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +/** + * Represents a list of video, audio or, subtitle tracks in a file when direct playing + * + * @see Builder + */ +class TestTracks private constructor( + val tracks: List = emptyList(), +) { + /** + * Convert to media3 [Tracks] + */ + fun toTracks(): Tracks { + val groups = + tracks + .map { + val mimeType = + when (it.type) { + MediaStreamType.VIDEO -> "video/sample" + MediaStreamType.AUDIO -> "audio/default" + MediaStreamType.SUBTITLE -> "text/sample" + else -> throw UnsupportedOperationException("${it.type}") + } + Format + .Builder() + .setId(it.id) + .setSampleMimeType(mimeType) + .build() + }.map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + fun toMediaSourceInfo(): MediaSourceInfo { + val streams = + tracks.mapIndexed { index, track -> + MediaStream( + index = index, + type = track.type, + isExternal = track.external, + codec = null, + language = null, + isInterlaced = false, + isDefault = false, + isForced = false, + isHearingImpaired = false, + isTextSubtitleStream = false, + supportsExternalStream = false, + ) + } + val source = + MediaSourceInfo( + mediaStreams = streams, + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = false, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = false, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) + return source + } + + /** + * Builder for adding tracks + */ + class Builder { + private val tracks = mutableListOf() + + fun addVideo(): Builder { + tracks.add(TestTrackBuilder(MediaStreamType.VIDEO)) + return this + } + + fun addAudio(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.AUDIO)) + } + return this + } + + fun addSubtitle(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, false)) + } + return this + } + + fun addExternalSubtitle(count: Int = 1): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, true)) + } + return this + } + + /** + * Create the [TestTracks] as if being playing by ExoPlayer + */ + fun buildForExoPlayer(): TestTracks { + // Format is: [:][e:], "e:" indicates external subtitle + val hasExternal = tracks.firstOrNull { it.external } != null + val testTracks = + buildList { + var externalSubCount = 0 + val t = + tracks + .mapIndexed { index, track -> + if (track.external) { + externalSubCount++ + TestTrack( + "$externalSubCount:e:$index", + index, + track.type, + track.external, + ) + } else if (hasExternal) { + val idx = index + 1 - externalSubCount + // If there's a sidecar file, the actual file + TestTrack("0:$idx", index, track.type, track.external) + } else { + val idx = index + 1 + TestTrack("$idx", index, track.type, track.external) + } + } + addAll(t) + } + return TestTracks(testTracks) + } + + /** + * Create the [TestTracks] as if being playing by MPV + */ + fun buildForMpv(): TestTracks { + val embeddedCount = tracks.count { !it.external } + val embeddedSubtitleCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } + val externalSubCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } + var videoCount = 0 + var audioCount = 0 + var subtitleCount = 0 + val testTracks = + tracks.mapIndexed { index, track -> + val id = + if (track.external) { + // MPV places external subtitles last + val idx = embeddedCount + index + subtitleCount++ + val count = embeddedSubtitleCount + subtitleCount + "$idx:e:$count" + } else { + val idx = index - externalSubCount + when (track.type) { + MediaStreamType.AUDIO -> { + audioCount++ + "$idx:$audioCount" + } + + MediaStreamType.VIDEO -> { + videoCount++ + "$idx:$videoCount" + } + + MediaStreamType.SUBTITLE -> { + subtitleCount++ + val count = subtitleCount - externalSubCount + "$idx:$count" + } + + else -> { + throw IllegalArgumentException("Unsupported type " + track.type) + } + } + } + TestTrack(id, index, track.type, track.external) + } + return TestTracks(testTracks) + } + } + + companion object { + fun fromMediaSourceInfo(source: MediaSourceInfo): TestTracks.Builder = + TestTracks.Builder().apply { + source.mediaStreams.orEmpty().forEach { + when (it.type) { + MediaStreamType.AUDIO -> addAudio() + MediaStreamType.VIDEO -> addVideo() + MediaStreamType.SUBTITLE -> if (it.isExternal) addExternalSubtitle() else addSubtitle() + else -> throw IllegalArgumentException("Unsupported type ${it.type}") + } + } + } + } +} + +data class TestTrack( + val id: String, + val index: Int, + val type: MediaStreamType, + val external: Boolean, +) + +class TestTrackBuilder( + val type: MediaStreamType, + val external: Boolean = false, +) + +fun assertIdType( + expectedId: String, + expectedType: MediaStreamType, + track: TestTrack, +) { + Assert.assertEquals(expectedId, track.id) + Assert.assertEquals(expectedType, track.type) +} + +/** + * Varies [TestTracks.Builder] examples + * + * They are named by the order of the tracks, V=video, A=audio, S=subtitle, E=external subtitle + */ +object TrackExamples { + val builderVAASSS = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(3) + + val builderAASSSV = + TestTracks + .Builder() + .addAudio(2) + .addSubtitle(3) + .addVideo() + + val builderAAVSSS = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addSubtitle(3) + + val builderVASASS = + TestTracks + .Builder() + .addVideo() + .addAudio(1) + .addSubtitle(1) + .addAudio(1) + .addSubtitle(2) + + val builderEVAASSS = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(2) + .addSubtitle(3) +} + +class TestTracksTests { + @Test + fun testMpv() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForMpv() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("9:e:5", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("1:1", testTracks[2].id) + Assert.assertEquals("2:2", testTracks[3].id) + Assert.assertEquals("3:3", testTracks[4].id) + Assert.assertEquals("4:4", testTracks[5].id) + Assert.assertEquals("5:1", testTracks[6].id) + Assert.assertEquals("6:2", testTracks[7].id) + Assert.assertEquals("7:3", testTracks[8].id) + Assert.assertEquals("8:4", testTracks[9].id) + } + + @Test + fun `test ExoPlayer with external`() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("1:e:0", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("0:2", testTracks[2].id) + Assert.assertEquals("0:3", testTracks[3].id) + Assert.assertEquals("0:4", testTracks[4].id) + Assert.assertEquals("0:5", testTracks[5].id) + Assert.assertEquals("0:6", testTracks[6].id) + Assert.assertEquals("0:7", testTracks[7].id) + Assert.assertEquals("0:8", testTracks[8].id) + Assert.assertEquals("0:9", testTracks[9].id) + } + + @Test + fun `test ExoPlayer without external`() { + val testTracks = + TestTracks + .Builder() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(9, testTracks.size) + Assert.assertEquals("1", testTracks[0].id) + Assert.assertEquals("2", testTracks[1].id) + Assert.assertEquals("3", testTracks[2].id) + Assert.assertEquals("4", testTracks[3].id) + Assert.assertEquals("5", testTracks[4].id) + Assert.assertEquals("6", testTracks[5].id) + Assert.assertEquals("7", testTracks[6].id) + Assert.assertEquals("8", testTracks[7].id) + Assert.assertEquals("9", testTracks[8].id) + } + + @Test + fun `test original-VAS`() { + TrackExamples.builderVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + Assert.assertEquals("1", exo[0].id) + Assert.assertEquals("2", exo[1].id) + Assert.assertEquals("3", exo[2].id) + Assert.assertEquals("4", exo[3].id) + Assert.assertEquals("5", exo[4].id) + Assert.assertEquals("6", exo[5].id) + } + + TrackExamples.builderVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + Assert.assertEquals("0:1", mpv[0].id) + Assert.assertEquals("1:1", mpv[1].id) + Assert.assertEquals("2:2", mpv[2].id) + Assert.assertEquals("3:1", mpv[3].id) + Assert.assertEquals("4:2", mpv[4].id) + Assert.assertEquals("5:3", mpv[5].id) + } + } + + @Test + fun `test VAS with external`() { + TrackExamples.builderEVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(7, exo.size) + Assert.assertTrue(exo[0].external) + assertIdType("1:e:0", MediaStreamType.SUBTITLE, exo[0]) + assertIdType("0:1", MediaStreamType.VIDEO, exo[1]) + assertIdType("0:2", MediaStreamType.AUDIO, exo[2]) + assertIdType("0:3", MediaStreamType.AUDIO, exo[3]) + assertIdType("0:4", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("0:5", MediaStreamType.SUBTITLE, exo[5]) + assertIdType("0:6", MediaStreamType.SUBTITLE, exo[6]) + } + + TrackExamples.builderEVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(7, mpv.size) + Assert.assertTrue(mpv[0].external) + Assert.assertEquals("6:e:4", mpv[0].id) + Assert.assertEquals("0:1", mpv[1].id) + Assert.assertEquals("1:1", mpv[2].id) + Assert.assertEquals("2:2", mpv[3].id) + Assert.assertEquals("3:1", mpv[4].id) + Assert.assertEquals("4:2", mpv[5].id) + Assert.assertEquals("5:3", mpv[6].id) + } + } + + @Test + fun `test ASV`() { + TrackExamples.builderAASSSV.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.VIDEO, exo[5]) + } + + TrackExamples.builderAASSSV.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:3", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:1", MediaStreamType.VIDEO, mpv[5]) + } + } + + @Test + fun `test AVS`() { + TrackExamples.builderAAVSSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderAAVSSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:1", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `test VASASS`() { + TrackExamples.builderVASASS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.VIDEO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderVASASS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.VIDEO, mpv[0]) + assertIdType("1:1", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt new file mode 100644 index 00000000..a1bcb2f1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import io.mockk.MockKMatcherScope +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.NameGuidPair + +fun MockKMatcherScope.nonBlankString() = match { it.isNotNullOrBlank() } + +/** + * Create a simple [BaseItemDto] movie + */ +fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + name = name, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] tv episode + */ +fun episode( + id: UUID = UUID.randomUUID(), + seriesId: UUID, + name: String = "Test Episode", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] playlist + */ +fun playlist( + id: UUID = UUID.randomUUID(), + name: String = "Test Playlist", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.PLAYLIST, + name = name, + seriesId = null, + genreItems = genres, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt index d0bc8532..2fc00bce 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult @OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.performClickEnter() = @@ -14,3 +16,5 @@ fun SemanticsNodeInteraction.performClickEnter() = } fun successResponse(content: T) = Response(content, 200, emptyMap()) + +fun successQueryResult(items: List) = successResponse(BaseItemDtoQueryResult(items, items.size, 0)) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt new file mode 100644 index 00000000..ad4d7a43 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -0,0 +1,756 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ShowNextUpWhen +import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlayerCreation +import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.MediaInfoApi +import org.jellyfin.sdk.api.operations.MediaSegmentsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult +import org.jellyfin.sdk.model.api.MediaSegmentType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlaybackInfoResponse +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@RunWith(RobolectricTestRunner::class) +class PlaybackViewModelTests { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk(relaxed = true) + private val mockApi = mockk(relaxed = true) + private val mockNavigationManager = mockk(relaxed = true) + private val mockPlaylistCreator = mockk(relaxed = true) + private val mockItemPlaybackDao = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockItemPlaybackRepository = mockk(relaxed = true) + private val mockPlayerFactory = mockk() + private val mockDatePlayedService = mockk(relaxed = true) + private val mockDeviceInfo = mockk(relaxed = true) + private val mockDeviceProfileService = mockk(relaxed = true) + private val mockRefreshRateService = mockk(relaxed = true) + private val mockStreamChoiceService = mockk(relaxed = true) + private val mockUserPreferencesService = mockk(relaxed = true) + private val mockImageUrlService = mockk(relaxed = true) + private val mockScreensaverService = mockk(relaxed = true) + private val mockMusicService = mockk(relaxed = true) + + private val mockUserLibraryApi = mockk() + private val mockMediaInfoApi = mockk() + private val mockVideosApi = mockk() + private val mockMediaSegmentsApi = mockk() + private val mockPlayer = mockk(relaxed = true) + + fun create(destination: Destination): PlaybackViewModel = + PlaybackViewModel( + context = mockContext, + api = mockApi, + navigationManager = mockNavigationManager, + playlistCreator = mockPlaylistCreator, + itemPlaybackDao = mockItemPlaybackDao, + serverRepository = mockServerRepository, + itemPlaybackRepository = mockItemPlaybackRepository, + playerFactory = mockPlayerFactory, + datePlayedService = mockDatePlayedService, + deviceInfo = mockDeviceInfo, + deviceProfileService = mockDeviceProfileService, + refreshRateService = mockRefreshRateService, + streamChoiceService = mockStreamChoiceService, + userPreferencesService = mockUserPreferencesService, + imageUrlService = mockImageUrlService, + screensaverService = mockScreensaverService, + musicService = mockMusicService, + destination = destination, + ) + + private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { + val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) + val prefs = UserPreferences(appPrefs) + coEvery { mockUserPreferencesService.getCurrent() } returns prefs + } + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = + JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addSubtitle() + .buildForExoPlayer() + .toMediaSourceInfo() + + private val playSessionId = "playsessionid12345" + private val videoStreamUrl = "http://localhost:8096/video/stream" + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + every { mockApi.mediaInfoApi } returns mockMediaInfoApi + every { mockApi.videosApi } returns mockVideosApi + every { mockApi.mediaSegmentsApi } returns mockMediaSegmentsApi + + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(mediaSource), + playSessionId = playSessionId, + ), + ) + + every { + mockVideosApi.getVideoStreamUrl( + itemId = any(), + mediaSourceId = mediaSource.id, + static = true, + tag = mediaSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + coEvery { mockServerRepository.currentUser } returns user + coEvery { mockServerRepository.currentUserDto } returns userDto + + coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null + coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns + PlayerCreation(mockPlayer) + every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) + + coEvery { + mockStreamChoiceService.chooseAudioStream( + source = mediaSource, + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + + coEvery { + mockStreamChoiceService.chooseSubtitleStream( + source = mediaSource, + any(), + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + // Test data + val playlist = playlist() + val movie = movie(name = "Test Movie 1") + val movie2 = movie(name = "Test Movie 2") + val movie3 = movie(name = "Test Movie 3") + val intro = movie(name = "Test Intro") + val playlistItems = + Playlist.fromMedia(listOf(BaseItem(movie), BaseItem(movie2), BaseItem(movie3))) + val introSegmentMovie2 = + MediaSegmentDto( + id = UUID.randomUUID(), + itemId = movie2.id, + type = MediaSegmentType.INTRO, + startTicks = 5.seconds.inWholeTicks, + endTicks = 10.seconds.inWholeTicks, + ) + val segments = + MediaSegmentDtoQueryResult( + items = listOf(introSegmentMovie2), + totalRecordCount = 1, + startIndex = 0, + ) + // END test data + + /** + * Create the [PlaybackViewModel] and wait for its init job to complete + * + * Throws an exception if the job throws one + */ + @OptIn(InternalCoroutinesApi::class) + private suspend fun TestScope.createViewModel(destination: Destination): PlaybackViewModel { + val viewModel = create(destination) + testScheduler.advanceUntilIdle() + viewModel.initJob.join() + viewModel.initJob + .getCancellationException() + .cause + ?.let { throw it } + return viewModel + } + + @Test + fun `Play intro first`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + // Should be playing the intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + + @Test + fun `Play two intros first`() = + runTest(testDispatcher) { + val movie = movie() + val intro = movie() + val intro2 = movie() + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro, intro2)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + // Should be playing the first intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + val playlist = state.playlist.items + Assert.assertEquals(2, playlist.size) + Assert.assertEquals(intro.id, playlist[0].id) + Assert.assertEquals(intro2.id, playlist[1].id) + } + + @Test + fun `Don't play intro`() = + runTest(testDispatcher) { + val movie = movie() + setupPreferences { + cinemaMode = false + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + coVerify(exactly = 0) { mockUserLibraryApi.getIntros(any(), any()) } + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + viewModel.state.value.also { state -> + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + } + + private fun setupForPlaylist() { + coEvery { mockUserLibraryApi.getItem(playlist.id, any()) } returns + successResponse(playlist) + coEvery { + mockPlaylistCreator.createFrom( + item = playlist, + startIndex = any(), + sortAndDirection = any(), + shuffled = any(), + recursive = any(), + filter = any(), + ) + } returns PlaylistCreationResult.Success(playlistItems) + } + + @Test + fun `Play next up`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItem = slot() + val mediaItem2 = slot() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any()) + mockPlayer.setMediaItem(capture(mediaItem2), any()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Play previous`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + } + + viewModel.playPrevious() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItems = mutableListOf() + verify(exactly = 4) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any()) + } + Assert.assertEquals(4, mediaItems.size) + Assert.assertEquals(movie.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[1].mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItems[2].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[3].mediaId) + } + + @OptIn(InternalCoroutinesApi::class) + @Test + fun `Show next up at end of playback`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(movie2, state.nextUp?.data) + } + } + + @Test + fun `Show next up never`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + } + } + + @Test + fun playItemInPlaylist() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.playItemInPlaylist(BaseItem(movie3)) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + val mediaItem = slot() + val mediaItem2 = slot() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any()) + mockPlayer.setMediaItem(capture(mediaItem2), any()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Media segments auto skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1500.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and clicked`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + + viewModel.updateSegment(introSegmentMovie2.id, false) + every { mockPlayer.currentPosition } returns introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1 + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and dismissed`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + + viewModel.updateSegment(introSegmentMovie2.id, true) + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + + // Never skipped + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ignore`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.IGNORE + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment not at current position`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 2.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } +}