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() + } + } +}