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:
Damontecres 2026-06-19 11:06:35 -04:00 • committed by GitHub
parent 2afac3a1a5
commit 8e0798ee16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2313 additions and 63 deletions

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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,
)

View file

@ -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
}

View file

@ -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])
}
}
}

View file

@ -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,
)

View file

@ -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))

View file

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