mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-06 10:50:58 +01:00
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
This commit is contained in:
parent
2afac3a1a5
commit
8e0798ee16
14 changed files with 2313 additions and 63 deletions
|
|
@ -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<Context>()
|
||||
private val mockApiClient = mockk<ApiClient>()
|
||||
private val mockImageUrlService = mockk<ImageUrlService>()
|
||||
|
||||
private val mockUserLibraryApi = mockk<UserLibraryApi>()
|
||||
private val mockResources = mockk<Resources>()
|
||||
|
||||
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<Int>()) } returns "Interviews"
|
||||
every { mockResources.getQuantityString(R.plurals.clips, any(), any<Int>()) } returns "Clips"
|
||||
|
||||
every { mockResources.getQuantityString(R.plurals.items, any(), any<Int>()) } returns "Items"
|
||||
|
||||
every { mockImageUrlService.getItemImageUrl(any<BaseItem>(), 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Context>()
|
||||
private val mockApiClient = mockk<ApiClient>()
|
||||
private val mockServerRepository = mockk<ServerRepository>()
|
||||
private val mockUserPreferencesService = mockk<UserPreferencesService>()
|
||||
|
||||
private val mockLibraryApi = mockk<LibraryApi>()
|
||||
|
||||
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<UserPolicy> {
|
||||
every { enableLiveTvManagement } returns true
|
||||
},
|
||||
)
|
||||
|
||||
private val userWithoutLiveTv =
|
||||
UserDto(
|
||||
id = UUID.randomUUID(),
|
||||
hasPassword = true,
|
||||
hasConfiguredPassword = true,
|
||||
hasConfiguredEasyPassword = false,
|
||||
policy =
|
||||
mockk<UserPolicy> {
|
||||
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<DeletedItem>()
|
||||
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<DeletedItem>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Context>(relaxed = true)
|
||||
private val mockJellyfin = mockk<Jellyfin>()
|
||||
private val mockJellyfinServerDao = mockk<JellyfinServerDao>()
|
||||
private val mockApiClient = mockk<ApiClient>()
|
||||
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<UserApi>()
|
||||
private val mockSystemApi = mockk<SystemApi>()
|
||||
private val mockSharedPreferences = mockk<SharedPreferences>(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<JellyfinUser>()
|
||||
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<JellyfinUser>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NameGuidPair>? = 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<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.EPISODE,
|
||||
name = name,
|
||||
seriesId = seriesId,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TestTrack> = 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<TestTrackBuilder>()
|
||||
|
||||
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: [<file index>:][e:]<track index>, "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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> { it.isNotNullOrBlank() }
|
||||
|
||||
/**
|
||||
* Create a simple [BaseItemDto] movie
|
||||
*/
|
||||
fun movie(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String = "Test Movie",
|
||||
genres: List<NameGuidPair>? = 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<NameGuidPair>? = 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<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.PLAYLIST,
|
||||
name = name,
|
||||
seriesId = null,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
|
@ -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 <T> successResponse(content: T) = Response(content, 200, emptyMap())
|
||||
|
||||
fun successQueryResult(items: List<BaseItemDto>) = successResponse(BaseItemDtoQueryResult(items, items.size, 0))
|
||||
|
|
|
|||
|
|
@ -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<Context>(relaxed = true)
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockNavigationManager = mockk<NavigationManager>(relaxed = true)
|
||||
private val mockPlaylistCreator = mockk<PlaylistCreator>(relaxed = true)
|
||||
private val mockItemPlaybackDao = mockk<ItemPlaybackDao>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockItemPlaybackRepository = mockk<ItemPlaybackRepository>(relaxed = true)
|
||||
private val mockPlayerFactory = mockk<PlayerFactory>()
|
||||
private val mockDatePlayedService = mockk<DatePlayedService>(relaxed = true)
|
||||
private val mockDeviceInfo = mockk<DeviceInfo>(relaxed = true)
|
||||
private val mockDeviceProfileService = mockk<DeviceProfileService>(relaxed = true)
|
||||
private val mockRefreshRateService = mockk<RefreshRateService>(relaxed = true)
|
||||
private val mockStreamChoiceService = mockk<StreamChoiceService>(relaxed = true)
|
||||
private val mockUserPreferencesService = mockk<UserPreferencesService>(relaxed = true)
|
||||
private val mockImageUrlService = mockk<ImageUrlService>(relaxed = true)
|
||||
private val mockScreensaverService = mockk<ScreensaverService>(relaxed = true)
|
||||
private val mockMusicService = mockk<MusicService>(relaxed = true)
|
||||
|
||||
private val mockUserLibraryApi = mockk<UserLibraryApi>()
|
||||
private val mockMediaInfoApi = mockk<MediaInfoApi>()
|
||||
private val mockVideosApi = mockk<VideosApi>()
|
||||
private val mockMediaSegmentsApi = mockk<MediaSegmentsApi>()
|
||||
private val mockPlayer = mockk<Player>(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<MediaItem>()
|
||||
verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) }
|
||||
// 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<MediaItem>()
|
||||
verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) }
|
||||
// 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<MediaItem>()
|
||||
verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) }
|
||||
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<MediaItem>()
|
||||
val mediaItem2 = slot<MediaItem>()
|
||||
verifyOrder {
|
||||
mockPlayer.setMediaItem(capture(mediaItem), any<Long>())
|
||||
mockPlayer.setMediaItem(capture(mediaItem2), any<Long>())
|
||||
}
|
||||
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<MediaItem>()
|
||||
verify(exactly = 4) {
|
||||
mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any<Long>())
|
||||
}
|
||||
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<MediaItem>()
|
||||
val mediaItem2 = slot<MediaItem>()
|
||||
verifyOrder {
|
||||
mockPlayer.setMediaItem(capture(mediaItem), any<Long>())
|
||||
mockPlayer.setMediaItem(capture(mediaItem2), any<Long>())
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue