v1.1.0
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings
-----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQOwlHRfQqaoMZESsY18BqmXMIMwtJzU8cAEB2FWHNv5x2j/ZsGi8ES7VNW+rYtgV/B KF9ctdqeDoocL8r8da+AY= -----END SSH SIGNATURE----- Merge upstream v1.1.0 while preserving Wolfskin customizations
This commit is contained in:
commit
670e95f2fa
159 changed files with 10810 additions and 2864 deletions
|
|
@ -1,14 +1,12 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.github.damontecres.wholphin.MainActivityViewModel
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
|
|
@ -19,8 +17,10 @@ import com.github.damontecres.wholphin.services.IntentService
|
|||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.test.currentUser
|
||||
import com.github.damontecres.wholphin.test.server
|
||||
import com.github.damontecres.wholphin.test.user
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
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
|
||||
|
|
@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
|
@ -58,7 +58,6 @@ class TestMainActivityViewModel {
|
|||
private val backdropService: BackdropService = mockk(relaxed = true)
|
||||
private val appUpgradeHandler: AppUpgradeHandler = mockk()
|
||||
private val intentService: IntentService = mockk()
|
||||
private val serverDao: JellyfinServerDao = mockk()
|
||||
|
||||
val viewModel =
|
||||
MainActivityViewModel(
|
||||
|
|
@ -74,6 +73,7 @@ class TestMainActivityViewModel {
|
|||
)
|
||||
|
||||
private val serverId = UUID.randomUUID()
|
||||
private val server = server(serverId)
|
||||
private val userId = UUID.randomUUID()
|
||||
private val currentUser = currentUser(serverId, userId)
|
||||
|
||||
|
|
@ -88,13 +88,6 @@ class TestMainActivityViewModel {
|
|||
WholphinDispatchers.configure(testDispatcher)
|
||||
every { appUpgradeHandler.needUpgrade() } returns false
|
||||
every { appUpgradeHandler.copySubfont(any()) } returns Unit
|
||||
every { serverRepository.serverDao } returns serverDao
|
||||
coEvery { serverDao.getServer(serverId) } returns
|
||||
JellyfinServerUsers(
|
||||
currentUser.server,
|
||||
// User list is unused
|
||||
emptyList(),
|
||||
)
|
||||
mockkStatic(Toast::class)
|
||||
val mockToast = mockk<Toast>()
|
||||
every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast
|
||||
|
|
@ -121,22 +114,26 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.restoreLastSession() } returns
|
||||
// RestoredSession.Success(currentUser)
|
||||
|
||||
viewModel.appStart(null)
|
||||
advanceUntilIdle()
|
||||
viewModel.handleAppStart()
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.AppContent,
|
||||
)
|
||||
destination as SetupDestination.AppContent
|
||||
assertEquals(currentUser, destination.current)
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -144,27 +141,27 @@ class TestMainActivityViewModel {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = false
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.getMostRecentServer() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.appStart(null)
|
||||
advanceUntilIdle()
|
||||
viewModel.handleAppStart()
|
||||
|
||||
verify(exactly = 1) { serverDao.getServer(serverId) }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||
val args = mutableListOf<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||
assertEquals(2, args.size)
|
||||
Assert.assertEquals(1, args.size)
|
||||
args[0].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
|
||||
}
|
||||
args[1].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
assertEquals(currentUser.server, destination.server)
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,27 +170,30 @@ class TestMainActivityViewModel {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
every { serverRepository.current } returns
|
||||
MutableStateFlow<CurrentUser?>(
|
||||
protectedCurrentUser,
|
||||
)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.appStart(null)
|
||||
advanceUntilIdle()
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
verify { serverDao.getServer(serverId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
val args = mutableListOf<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||
assertEquals(2, args.size)
|
||||
Assert.assertEquals(1, args.size)
|
||||
args[0].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
|
||||
}
|
||||
args[1].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
assertEquals(currentUser.server, destination.server)
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,22 +202,26 @@ class TestMainActivityViewModel {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
|
||||
viewModel.appStart(null)
|
||||
advanceUntilIdle()
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
assertEquals(currentUser.server, destination.server)
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,45 +230,49 @@ class TestMainActivityViewModel {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSession(null, null) } returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||
|
||||
viewModel.appStart(null)
|
||||
advanceUntilIdle()
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is noop`() =
|
||||
fun `Test intent result is noop, hot reload`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertFalse(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
|
||||
destination as SetupDestination.AppContent
|
||||
assertEquals(currentUser, destination.current)
|
||||
}
|
||||
// val slot = slot<SetupDestination>()
|
||||
// verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
// slot.captured.let { destination ->
|
||||
// Assert.assertTrue(
|
||||
// "destination is ${destination::class}",
|
||||
// destination is SetupDestination.AppContent,
|
||||
// )
|
||||
// destination as SetupDestination.AppContent
|
||||
// Assert.assertEquals(currentUser, destination.current)
|
||||
// }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -272,22 +280,22 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
assertEquals(currentUser.server, destination.server)
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,20 +304,20 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(null)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,11 +326,9 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
|
|
@ -331,21 +337,27 @@ class TestMainActivityViewModel {
|
|||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
verify { navigationManager.reloadHome() }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.AppContent,
|
||||
)
|
||||
destination as SetupDestination.AppContent
|
||||
assertEquals(currentUser, destination.current)
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
// Note: navigation manager internally handles Home destination, so it won't be in this list
|
||||
assertEquals(1, backstack.size)
|
||||
Assert.assertEquals(1, backstack.size)
|
||||
backstack[0].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is Destination.Favorites,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,11 +366,9 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
|
|
@ -367,20 +377,26 @@ class TestMainActivityViewModel {
|
|||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
verify(exactly = 0) { navigationManager.reloadHome() }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.AppContent,
|
||||
)
|
||||
destination as SetupDestination.AppContent
|
||||
assertEquals(currentUser, destination.current)
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
assertEquals(1, backstack.size)
|
||||
Assert.assertEquals(1, backstack.size)
|
||||
backstack[0].let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is Destination.Favorites,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -389,11 +405,9 @@ class TestMainActivityViewModel {
|
|||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(null)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
|
|
@ -402,14 +416,17 @@ class TestMainActivityViewModel {
|
|||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
viewModel.appStart(Intent())
|
||||
advanceUntilIdle()
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
verify(exactly = 0) { navigationManager.reloadHome() }
|
||||
slot.captured.let { destination ->
|
||||
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class BaseItemSubtitleTest {
|
||||
private fun album(
|
||||
albumArtist: String? = null,
|
||||
artists: List<String>? = null,
|
||||
productionYear: Int? = null,
|
||||
) = BaseItem(
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = BaseItemKind.MUSIC_ALBUM,
|
||||
name = "Test Album",
|
||||
albumArtist = albumArtist,
|
||||
artists = artists,
|
||||
productionYear = productionYear,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun albumPrefersAlbumArtist() {
|
||||
val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021)
|
||||
assertEquals("Album Artist - 2021", item.subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumFallsBackToArtists() {
|
||||
val item = album(artists = listOf("A", "B"), productionYear = 2021)
|
||||
assertEquals("A, B - 2021", item.subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumWithoutYear() {
|
||||
assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumWithoutArtist() {
|
||||
assertEquals("2021", album(productionYear = 2021).subtitle)
|
||||
assertNull(album(artists = emptyList()).subtitle)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.test.TestTracks
|
||||
import com.github.damontecres.wholphin.test.assertNotNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelativeMediaStreamTest {
|
||||
@Test
|
||||
fun `Test getRelativeIndex`() {
|
||||
val source =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addSubtitle(2)
|
||||
.addAudio(2)
|
||||
.addVideo()
|
||||
.buildMediaSourceInfo()
|
||||
val streams = source.mediaStreams!!
|
||||
RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex ->
|
||||
assertNotNull(relativeIndex)
|
||||
assertEquals(0, relativeIndex)
|
||||
}
|
||||
RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex ->
|
||||
assertNotNull(relativeIndex)
|
||||
assertEquals(1, relativeIndex)
|
||||
}
|
||||
RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex ->
|
||||
assertNotNull(relativeIndex)
|
||||
assertEquals(0, relativeIndex)
|
||||
}
|
||||
RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex ->
|
||||
assertNotNull(relativeIndex)
|
||||
assertEquals(1, relativeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test getRelativeIndex for stream`() {
|
||||
val source =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addSubtitle(2)
|
||||
.addAudio(2)
|
||||
.addVideo()
|
||||
.buildMediaSourceInfo()
|
||||
val streams = source.mediaStreams!!
|
||||
RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex ->
|
||||
assertNotNull(relativeIndex)
|
||||
assertEquals(1, relativeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test asRelative basic`() {
|
||||
val source =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addSubtitle(2)
|
||||
.addAudio(2)
|
||||
.addVideo()
|
||||
.buildMediaSourceInfo()
|
||||
val streams = source.mediaStreams!!
|
||||
assertEquals(5, streams.size)
|
||||
|
||||
val relative = streams.asRelative()
|
||||
// Subtitle
|
||||
assertEquals(0, relative[0].relativeIndex)
|
||||
assertEquals(0, relative[0].stream.index)
|
||||
assertEquals(1, relative[1].relativeIndex)
|
||||
assertEquals(1, relative[1].stream.index)
|
||||
|
||||
// Audio
|
||||
assertEquals(0, relative[2].relativeIndex)
|
||||
assertEquals(2, relative[2].stream.index)
|
||||
assertEquals(1, relative[3].relativeIndex)
|
||||
assertEquals(3, relative[3].stream.index)
|
||||
|
||||
// Video
|
||||
assertEquals(0, relative[4].relativeIndex)
|
||||
assertEquals(4, relative[4].stream.index)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test asRelative with external`() {
|
||||
val source =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addExternalSubtitle(2)
|
||||
.addSubtitle(2)
|
||||
.addAudio(2)
|
||||
.buildMediaSourceInfo()
|
||||
val streams = source.mediaStreams!!
|
||||
assertEquals(6, streams.size)
|
||||
|
||||
val relative = streams.asRelative()
|
||||
// External Subtitle
|
||||
assertEquals(0, relative[0].relativeIndex)
|
||||
assertEquals(0, relative[0].stream.index)
|
||||
assertEquals(1, relative[1].relativeIndex)
|
||||
assertEquals(1, relative[1].stream.index)
|
||||
|
||||
// Subtitle
|
||||
assertEquals(0, relative[2].relativeIndex)
|
||||
assertEquals(2, relative[2].stream.index)
|
||||
assertEquals(1, relative[3].relativeIndex)
|
||||
assertEquals(3, relative[3].stream.index)
|
||||
|
||||
// Audio
|
||||
assertEquals(0, relative[4].relativeIndex)
|
||||
assertEquals(4, relative[4].stream.index)
|
||||
assertEquals(1, relative[5].relativeIndex)
|
||||
assertEquals(5, relative[5].stream.index)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test asRelative with external mixed`() {
|
||||
val source =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addExternalSubtitle(1)
|
||||
.addSubtitle(1)
|
||||
.addAudio(1)
|
||||
.addExternalSubtitle(1)
|
||||
.addAudio(1)
|
||||
.addSubtitle(1)
|
||||
.buildMediaSourceInfo()
|
||||
val streams = source.mediaStreams!!
|
||||
assertEquals(6, streams.size)
|
||||
|
||||
val relative = streams.asRelative()
|
||||
// External Subtitle
|
||||
assertEquals(0, relative[0].relativeIndex)
|
||||
assertEquals(0, relative[0].stream.index)
|
||||
assertEquals(1, relative[3].relativeIndex)
|
||||
assertEquals(3, relative[3].stream.index)
|
||||
|
||||
// Subtitle
|
||||
assertEquals(0, relative[1].relativeIndex)
|
||||
assertEquals(1, relative[1].stream.index)
|
||||
assertEquals(1, relative[5].relativeIndex)
|
||||
assertEquals(5, relative[5].stream.index)
|
||||
|
||||
// Audio
|
||||
assertEquals(0, relative[2].relativeIndex)
|
||||
assertEquals(2, relative[2].stream.index)
|
||||
assertEquals(1, relative[4].relativeIndex)
|
||||
assertEquals(4, relative[4].stream.index)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.operations.ImageApi
|
||||
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.ImageType
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ImageUrlServiceTest {
|
||||
private val mockApiClient = mockk<ApiClient>()
|
||||
private val imageUrlService = ImageUrlService(mockApiClient)
|
||||
|
||||
private val itemId = UUID.randomUUID()
|
||||
private val seriesId = UUID.randomUUID()
|
||||
private val seasonId = UUID.randomUUID()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { mockApiClient.baseUrl } returns "http://localhost"
|
||||
every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() }
|
||||
every { mockApiClient.imageApi } returns ImageApi(mockApiClient)
|
||||
}
|
||||
|
||||
private fun episode(
|
||||
imageTags: Map<ImageType, String> = emptyMap(),
|
||||
parentLogoItemId: UUID? = null,
|
||||
parentThumbItemId: UUID? = null,
|
||||
parentBackdropItemId: UUID? = null,
|
||||
) = BaseItemDto(
|
||||
id = itemId,
|
||||
type = BaseItemKind.EPISODE,
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
imageTags = imageTags,
|
||||
seriesPrimaryImageTag = "seriesPrimary",
|
||||
parentLogoItemId = parentLogoItemId,
|
||||
parentLogoImageTag = parentLogoItemId?.let { "parentLogo" },
|
||||
parentThumbItemId = parentThumbItemId,
|
||||
parentThumbImageTag = parentThumbItemId?.let { "parentThumb" },
|
||||
parentBackdropItemId = parentBackdropItemId,
|
||||
parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") },
|
||||
)
|
||||
|
||||
private fun movie(
|
||||
imageTags: Map<ImageType, String> = emptyMap(),
|
||||
backdropImageTags: List<String>? = null,
|
||||
) = BaseItemDto(
|
||||
id = itemId,
|
||||
type = BaseItemKind.MOVIE,
|
||||
imageTags = imageTags,
|
||||
backdropImageTags = backdropImageTags,
|
||||
)
|
||||
|
||||
private fun assertImage(
|
||||
url: String?,
|
||||
expectedId: UUID,
|
||||
expectedType: ImageType,
|
||||
expectedTag: String?,
|
||||
) {
|
||||
Assert.assertNotNull(url)
|
||||
val path = url!!.substringBefore('?')
|
||||
Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID())
|
||||
Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/"))
|
||||
val tag =
|
||||
url
|
||||
.substringAfter('?', "")
|
||||
.split('&')
|
||||
.firstOrNull { it.startsWith("tag=") }
|
||||
?.substringAfter("tag=")
|
||||
Assert.assertEquals(expectedTag, tag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Own images use own tags`() {
|
||||
val item =
|
||||
BaseItem(
|
||||
movie(
|
||||
imageTags =
|
||||
mapOf(
|
||||
ImageType.PRIMARY to "primary",
|
||||
ImageType.LOGO to "logo",
|
||||
ImageType.THUMB to "thumb",
|
||||
ImageType.BANNER to "banner",
|
||||
ImageType.ART to "art",
|
||||
),
|
||||
backdropImageTags = listOf("backdrop0", "backdrop1"),
|
||||
),
|
||||
)
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Missing tags are omitted`() {
|
||||
val item = BaseItem(movie())
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null)
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null)
|
||||
Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Thumb falls back to own backdrop`() {
|
||||
val item = BaseItem(movie(backdropImageTags = listOf("backdrop0")))
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Episode primary uses series tag`() {
|
||||
val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true)
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null)
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false),
|
||||
itemId,
|
||||
ImageType.PRIMARY,
|
||||
"primary",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Season without primary uses series tag`() {
|
||||
val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null))
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Episode logo uses parent logo tag only if from series`() {
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO),
|
||||
seriesId,
|
||||
ImageType.LOGO,
|
||||
"parentLogo",
|
||||
)
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO),
|
||||
seriesId,
|
||||
ImageType.LOGO,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Episode backdrop uses parent backdrop tag only if from series`() {
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP),
|
||||
seriesId,
|
||||
ImageType.BACKDROP,
|
||||
"parentBackdrop0",
|
||||
)
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP),
|
||||
seriesId,
|
||||
ImageType.BACKDROP,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Episode thumb uses parent tags`() {
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(
|
||||
BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true),
|
||||
ImageType.THUMB,
|
||||
),
|
||||
seriesId,
|
||||
ImageType.THUMB,
|
||||
"parentThumb",
|
||||
)
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(
|
||||
BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true),
|
||||
ImageType.THUMB,
|
||||
),
|
||||
seriesId,
|
||||
ImageType.BACKDROP,
|
||||
"parentBackdrop0",
|
||||
)
|
||||
assertImage(
|
||||
imageUrlService.getItemImageUrl(
|
||||
BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true),
|
||||
ImageType.THUMB,
|
||||
),
|
||||
itemId,
|
||||
ImageType.PRIMARY,
|
||||
"primary",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Season without thumb uses parent thumb tag`() {
|
||||
val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON))
|
||||
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb")
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services
|
|||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
|
|
@ -20,7 +20,6 @@ import io.mockk.coEvery
|
|||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -42,7 +41,6 @@ import org.robolectric.annotation.Config
|
|||
class IntentServiceTest {
|
||||
private val api: ApiClient = mockk()
|
||||
private val serverRepository: ServerRepository = mockk()
|
||||
private val serverDao: JellyfinServerDao = mockk()
|
||||
private val userPreferencesService: UserPreferencesService = mockk()
|
||||
private val userLibraryApi: UserLibraryApi = mockk()
|
||||
|
||||
|
|
@ -64,7 +62,6 @@ class IntentServiceTest {
|
|||
intentService = IntentService(api, serverRepository, userPreferencesService)
|
||||
every { api.userLibraryApi } returns userLibraryApi
|
||||
coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId))
|
||||
every { serverRepository.serverDao } returns serverDao
|
||||
}
|
||||
|
||||
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
|
||||
|
|
@ -82,11 +79,11 @@ class IntentServiceTest {
|
|||
private fun setupAutoSignInUnprotected() {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -98,7 +95,7 @@ class IntentServiceTest {
|
|||
val result = intentService.prepare(intent)
|
||||
assertNull(result)
|
||||
|
||||
coVerify { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify { serverRepository.restoreLastSession() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -106,17 +103,17 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = false
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
|
||||
|
||||
val intent = Intent()
|
||||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -124,17 +121,17 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(protectedCurrentUser.server)
|
||||
|
||||
val intent = Intent()
|
||||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -142,17 +139,17 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = serverId.toServerString()
|
||||
currentUserId = userId.toServerString()
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(protectedCurrentUser.server)
|
||||
|
||||
val intent = Intent()
|
||||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -160,17 +157,16 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||
// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
|
||||
val intent = Intent()
|
||||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -178,12 +174,11 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
|
||||
val intent =
|
||||
Intent().apply {
|
||||
|
|
@ -193,7 +188,7 @@ class IntentServiceTest {
|
|||
val result = intentService.prepare(intent)
|
||||
assertNull(result)
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -201,12 +196,11 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
|
||||
// coEvery { serverRepository.restoreLastSession() } returns
|
||||
// RestoredSession.ServerOnly(protectedCurrentUser.server)
|
||||
|
||||
val intent =
|
||||
Intent().apply {
|
||||
|
|
@ -216,7 +210,7 @@ class IntentServiceTest {
|
|||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -224,12 +218,9 @@ class IntentServiceTest {
|
|||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
currentServerId = ""
|
||||
currentUserId = ""
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null
|
||||
coEvery { serverRepository.restoreSession(serverId, userId) } returns null
|
||||
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
|
||||
|
||||
val intent =
|
||||
Intent().apply {
|
||||
|
|
@ -239,7 +230,7 @@ class IntentServiceTest {
|
|||
val result = intentService.prepare(intent)
|
||||
assertTrue(result is IntentResult.Error)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -296,12 +287,11 @@ class IntentServiceTest {
|
|||
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
coEvery {
|
||||
serverRepository.restoreSession(
|
||||
serverRepository.tryChangeUser(
|
||||
newServerId,
|
||||
newUserId,
|
||||
)
|
||||
} returns newCurrentUser
|
||||
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
|
||||
|
||||
val intent =
|
||||
Intent().apply {
|
||||
|
|
@ -316,9 +306,8 @@ class IntentServiceTest {
|
|||
assertEquals(0, destinations.size)
|
||||
assertTrue(result.addHomeToBackStack)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
verify { serverDao.getUser(newServerId, newUserId) }
|
||||
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
|
||||
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
|
||||
}
|
||||
|
||||
|
|
@ -333,12 +322,11 @@ class IntentServiceTest {
|
|||
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
coEvery {
|
||||
serverRepository.restoreSession(
|
||||
serverRepository.tryChangeUser(
|
||||
newServerId,
|
||||
newUserId,
|
||||
)
|
||||
} returns newCurrentUser
|
||||
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
|
||||
|
||||
val intent =
|
||||
Intent().apply {
|
||||
|
|
@ -358,9 +346,8 @@ class IntentServiceTest {
|
|||
|
||||
assertEquals(itemId, first.itemId)
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||
verify { serverDao.getUser(newServerId, newUserId) }
|
||||
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
|
||||
coVerify { userLibraryApi.getItem(itemId) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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.MostRecentServer
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||
|
|
@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
|||
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.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
|
|
@ -21,6 +24,7 @@ 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.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
|
@ -29,7 +33,6 @@ 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
|
||||
|
|
@ -48,6 +51,8 @@ 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.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -80,14 +85,13 @@ class ServerRepositoryTest {
|
|||
|
||||
private val mockUserApi = mockk<UserApi>()
|
||||
private val mockSystemApi = mockk<SystemApi>()
|
||||
private val mockSharedPreferences = mockk<SharedPreferences>(relaxed = true)
|
||||
private val mockMostRecentServerProvider = mockk<MostRecentServerProvider>()
|
||||
|
||||
@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)
|
||||
|
|
@ -102,9 +106,15 @@ class ServerRepositoryTest {
|
|||
emptyMap(),
|
||||
)
|
||||
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
|
||||
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
|
||||
JellyfinServerUsers(server, listOf(user))
|
||||
coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs
|
||||
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
|
||||
coEvery { mockMostRecentServerProvider.save(any()) } just Runs
|
||||
coEvery { mockMostRecentServerProvider.clear() } just Runs
|
||||
coEvery { mockMostRecentServerProvider.clearUser() } just Runs
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
|
@ -119,8 +129,8 @@ class ServerRepositoryTest {
|
|||
mockJellyfin,
|
||||
mockJellyfinServerDao,
|
||||
mockApiClient,
|
||||
dataStore,
|
||||
testDispatcher,
|
||||
mockMostRecentServerProvider,
|
||||
)
|
||||
|
||||
private val serverId = UUID.randomUUID()
|
||||
|
|
@ -194,24 +204,24 @@ class ServerRepositoryTest {
|
|||
Assert.assertEquals(user, serverRepository.currentUser)
|
||||
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
|
||||
|
||||
val appPreferences = dataStore.data.first()
|
||||
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
|
||||
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
|
||||
|
||||
verify(exactly = 1) { mockSharedPreferences.edit() }
|
||||
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
|
||||
verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test restoreUser via changeUser`() =
|
||||
runTest {
|
||||
val user = user.copy(pin = null)
|
||||
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)
|
||||
val result = serverRepository.tryChangeUser(server.id, user.id)
|
||||
assertTrue(result != null)
|
||||
assertEquals(serverId, result?.server?.id)
|
||||
assertEquals(user.id, result?.user?.id)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockApiClient.update(
|
||||
|
|
@ -226,34 +236,6 @@ class ServerRepositoryTest {
|
|||
Assert.assertEquals(serverUserConfig, 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 {
|
||||
|
|
@ -308,6 +290,7 @@ class ServerRepositoryTest {
|
|||
)
|
||||
}
|
||||
verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) }
|
||||
coVerify { mockMostRecentServerProvider.clearUser() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -351,6 +334,7 @@ class ServerRepositoryTest {
|
|||
)
|
||||
}
|
||||
verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) }
|
||||
coVerify { mockMostRecentServerProvider.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -387,11 +371,7 @@ class ServerRepositoryTest {
|
|||
Assert.assertEquals(user, serverRepository.currentUser)
|
||||
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
|
||||
|
||||
val appPreferences = dataStore.data.first()
|
||||
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
|
||||
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
|
||||
|
||||
verify(exactly = 1) { mockSharedPreferences.edit() }
|
||||
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -430,11 +410,7 @@ class ServerRepositoryTest {
|
|||
Assert.assertEquals(user, serverRepository.currentUser)
|
||||
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
|
||||
|
||||
val appPreferences = dataStore.data.first()
|
||||
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
|
||||
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
|
||||
|
||||
verify(exactly = 1) { mockSharedPreferences.edit() }
|
||||
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -484,4 +460,99 @@ class ServerRepositoryTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test restoreLastSession, success`() =
|
||||
runTest {
|
||||
val user = user.copy(pin = null)
|
||||
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
|
||||
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
|
||||
JellyfinServerUsers(server, listOf(user))
|
||||
every { mockMostRecentServerProvider.get() } returns
|
||||
MostRecentServer.from(CurrentUser(server, user))
|
||||
|
||||
val result = create().restoreLastSession()
|
||||
assertTrue(result is RestoredSession.Success)
|
||||
val current = (result as RestoredSession.Success).currentUser
|
||||
assertEquals(server, current.server)
|
||||
assertEquals(user, current.user)
|
||||
|
||||
verify(exactly = 1) {
|
||||
mockApiClient.update(
|
||||
baseUrl = server.url,
|
||||
accessToken = user.accessToken,
|
||||
clientInfo = any(),
|
||||
deviceInfo = any(),
|
||||
)
|
||||
}
|
||||
coVerify { mockMostRecentServerProvider.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test restoreLastSession, user protected`() =
|
||||
runTest {
|
||||
every { mockMostRecentServerProvider.get() } returns
|
||||
MostRecentServer.ServerAndUser(serverId, server.url, userId, "token")
|
||||
|
||||
val result = create().restoreLastSession()
|
||||
assertTrue(result is RestoredSession.ServerOnly)
|
||||
val current = (result as RestoredSession.ServerOnly)
|
||||
assertEquals(server, current.server)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test restoreLastSession, server only`() =
|
||||
runTest {
|
||||
every { mockMostRecentServerProvider.get() } returns
|
||||
MostRecentServer.Server(server.id, server.url)
|
||||
|
||||
val result = create().restoreLastSession()
|
||||
assertTrue(result is RestoredSession.ServerOnly)
|
||||
val current = (result as RestoredSession.ServerOnly)
|
||||
assertEquals(server, current.server)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test restoreLastSession, nothing to restore`() =
|
||||
runTest {
|
||||
every { mockMostRecentServerProvider.get() } returns MostRecentServer.None
|
||||
|
||||
val result = create().restoreLastSession()
|
||||
assertTrue(result is RestoredSession.None)
|
||||
}
|
||||
}
|
||||
|
||||
class TestMostRecentServerProvider : MostRecentServerProvider {
|
||||
var mostRecentServer: MostRecentServer = MostRecentServer.None
|
||||
|
||||
override fun get(): MostRecentServer = mostRecentServer
|
||||
|
||||
override suspend fun save(current: CurrentUser) {
|
||||
mostRecentServer =
|
||||
if (current.user.accessToken.isNotNullOrBlank()) {
|
||||
MostRecentServer.ServerAndUser(
|
||||
current.server.id,
|
||||
current.server.url,
|
||||
current.user.id,
|
||||
current.user.accessToken,
|
||||
)
|
||||
} else {
|
||||
MostRecentServer.Server(
|
||||
current.server.id,
|
||||
current.server.url,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
mostRecentServer = MostRecentServer.None
|
||||
}
|
||||
|
||||
override suspend fun clearUser() {
|
||||
when (val s = mostRecentServer) {
|
||||
MostRecentServer.None -> Unit
|
||||
is MostRecentServer.Server -> Unit
|
||||
is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced(
|
|||
),
|
||||
streamAudioLang = "eng",
|
||||
),
|
||||
TestInput(
|
||||
0,
|
||||
SubtitlePlaybackMode.NONE,
|
||||
userSubtitleLang = null,
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", forced = true),
|
||||
subtitle(1, "spa", forced = false),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
plc = plc(subtitlesOnlyForced = true),
|
||||
),
|
||||
TestInput(
|
||||
0,
|
||||
SubtitlePlaybackMode.DEFAULT,
|
||||
userSubtitleLang = null,
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", forced = true),
|
||||
subtitle(1, "spa", forced = true),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
plc = plc(subtitlesOnlyForced = true),
|
||||
),
|
||||
TestInput(
|
||||
0,
|
||||
SubtitlePlaybackMode.DEFAULT,
|
||||
userSubtitleLang = "eng",
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", forced = true),
|
||||
subtitle(1, "spa", forced = false),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
plc = plc(subtitlesOnlyForced = true),
|
||||
),
|
||||
TestInput(
|
||||
null,
|
||||
SubtitlePlaybackMode.NONE,
|
||||
userSubtitleLang = null,
|
||||
subtitles =
|
||||
listOf(
|
||||
subtitle(0, "eng", forced = false),
|
||||
subtitle(1, "spa", forced = false),
|
||||
),
|
||||
streamAudioLang = "eng",
|
||||
plc = plc(subtitlesOnlyForced = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -885,7 +935,7 @@ data class TestInput(
|
|||
val streamAudioLang: String? = "eng",
|
||||
val subtitles: List<MediaStream>,
|
||||
val itemPlayback: ItemPlayback? = null,
|
||||
val plc: PlaybackLanguageChoice? = null,
|
||||
val plc: List<SeriesTrackChoice> = emptyList(),
|
||||
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
|
||||
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
|
||||
|
|
@ -895,7 +945,7 @@ data class TestInput(
|
|||
|
||||
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
|
||||
|
||||
private fun serverRepo(
|
||||
fun mockServerRepo(
|
||||
audioLang: String?,
|
||||
subtitleMode: SubtitlePlaybackMode?,
|
||||
subtitleLang: String?,
|
||||
|
|
@ -918,15 +968,15 @@ private fun serverRepo(
|
|||
private fun runTest(input: TestInput) {
|
||||
val service =
|
||||
StreamChoiceService(
|
||||
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
||||
mockk<PlaybackLanguageChoiceDao>(),
|
||||
mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
||||
mockk<SeriesTrackChoiceDao>(),
|
||||
)
|
||||
val result =
|
||||
service.chooseSubtitleStream(
|
||||
audioStreamLang = input.streamAudioLang,
|
||||
candidates = input.subtitles,
|
||||
itemPlayback = input.itemPlayback,
|
||||
playbackLanguageChoice = input.plc,
|
||||
stc = input.plc,
|
||||
prefs =
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
|
|
@ -937,7 +987,7 @@ private fun runTest(input: TestInput) {
|
|||
),
|
||||
),
|
||||
)
|
||||
Assert.assertEquals(input.expectedIndex, result?.index)
|
||||
Assert.assertEquals(input.expectedIndex, result.stream?.index)
|
||||
}
|
||||
|
||||
fun subtitle(
|
||||
|
|
@ -945,6 +995,8 @@ fun subtitle(
|
|||
lang: String?,
|
||||
default: Boolean = false,
|
||||
forced: Boolean = false,
|
||||
sdh: Boolean = false,
|
||||
external: Boolean = false,
|
||||
title: String? = null,
|
||||
): MediaStream =
|
||||
MediaStream(
|
||||
|
|
@ -952,6 +1004,26 @@ fun subtitle(
|
|||
language = lang,
|
||||
isDefault = default,
|
||||
isForced = forced,
|
||||
isHearingImpaired = sdh,
|
||||
isInterlaced = false,
|
||||
index = index,
|
||||
isExternal = external,
|
||||
isTextSubtitleStream = true,
|
||||
supportsExternalStream = true,
|
||||
title = title,
|
||||
)
|
||||
|
||||
fun audio(
|
||||
index: Int,
|
||||
lang: String?,
|
||||
default: Boolean = false,
|
||||
title: String? = null,
|
||||
): MediaStream =
|
||||
MediaStream(
|
||||
type = MediaStreamType.AUDIO,
|
||||
language = lang,
|
||||
isDefault = default,
|
||||
isForced = false,
|
||||
isHearingImpaired = false,
|
||||
isInterlaced = false,
|
||||
index = index,
|
||||
|
|
@ -978,11 +1050,63 @@ private fun plc(
|
|||
audioLang: String? = null,
|
||||
subtitleLang: String? = null,
|
||||
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
|
||||
): PlaybackLanguageChoice =
|
||||
PlaybackLanguageChoice(
|
||||
userId = 1,
|
||||
seriesId = UUID.randomUUID(),
|
||||
audioLanguage = audioLang,
|
||||
subtitleLanguage = subtitleLang,
|
||||
subtitlesDisabled = subtitlesDisabled,
|
||||
)
|
||||
subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
|
||||
): List<SeriesTrackChoice> =
|
||||
buildList {
|
||||
audioLang
|
||||
?.let {
|
||||
SeriesTrackChoice(
|
||||
userId = 1,
|
||||
language = audioLang,
|
||||
parentId = UUID.randomUUID(),
|
||||
type = SeriesTrackChoiceType.AUDIO,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
trackFlags = 0,
|
||||
codec = null,
|
||||
relativeTrackIndex = null,
|
||||
title = null,
|
||||
audioChannels = null,
|
||||
)
|
||||
}?.let(::add)
|
||||
|
||||
if (subtitleLang != null) {
|
||||
SeriesTrackChoice(
|
||||
userId = 1,
|
||||
language = subtitleLang,
|
||||
parentId = UUID.randomUUID(),
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
trackFlags = 0,
|
||||
codec = null,
|
||||
relativeTrackIndex = null,
|
||||
title = null,
|
||||
audioChannels = null,
|
||||
).let(::add)
|
||||
} else if (subtitlesDisabled == true) {
|
||||
SeriesTrackChoice(
|
||||
userId = 1,
|
||||
language = null,
|
||||
parentId = UUID.randomUUID(),
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
trackFlags = 0,
|
||||
codec = null,
|
||||
relativeTrackIndex = null,
|
||||
title = null,
|
||||
audioChannels = null,
|
||||
).let(::add)
|
||||
} else if (subtitlesOnlyForced == true) {
|
||||
SeriesTrackChoice(
|
||||
userId = 1,
|
||||
language = null,
|
||||
parentId = UUID.randomUUID(),
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ONLY_FORCED,
|
||||
trackFlags = 0,
|
||||
codec = null,
|
||||
relativeTrackIndex = null,
|
||||
title = null,
|
||||
audioChannels = null,
|
||||
).let(::add)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import androidx.work.Data
|
|||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.test.currentUser
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
|
@ -113,16 +115,16 @@ class SuggestionsWorkerTest {
|
|||
val mockUser = mockk<CurrentUser>()
|
||||
var restored = false
|
||||
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
|
||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||
coEvery { mockServerRepository.restoreLastSession() } coAnswers {
|
||||
restored = true
|
||||
mockUser
|
||||
RestoredSession.Success(currentUser())
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
|
||||
coVerify { mockServerRepository.restoreLastSession() }
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
class TestHdrFormat {
|
||||
private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences =
|
||||
AppPreferences.getDefaultInstance().updatePlaybackOverrides(block)
|
||||
|
||||
@Test
|
||||
fun `Test all auto returns empty sets`() {
|
||||
val prefs = appPreferences { }
|
||||
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true))
|
||||
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test HDR10 override`() {
|
||||
val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.HDR10.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test Dolby Vision Profile 5 override`() {
|
||||
val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.DOVI.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() {
|
||||
val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
|
||||
Assert.assertEquals(
|
||||
emptySet<String>(),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false),
|
||||
)
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test Dolby Vision Profile 8 override`() {
|
||||
val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false),
|
||||
)
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test multiple formats set to the same mode are unioned`() {
|
||||
val prefs =
|
||||
appPreferences {
|
||||
hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
|
||||
hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE
|
||||
doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE
|
||||
}
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
Assert.assertEquals(
|
||||
setOf(WholphinVideoRangeType.DOVI.serialName),
|
||||
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.KeyValueEntry
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
|
|
@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.KClass
|
||||
|
|
@ -61,8 +62,8 @@ class TestHomeRowSamples {
|
|||
),
|
||||
HomeRowConfig.GetItems(
|
||||
name = "Episodes by date created",
|
||||
getItems =
|
||||
GetItemsRequest(
|
||||
filter =
|
||||
HomeGetItemsFilter(
|
||||
parentId = UUID.randomUUID(),
|
||||
recursive = true,
|
||||
isFavorite = true,
|
||||
|
|
@ -77,6 +78,12 @@ class TestHomeRowSamples {
|
|||
HomeRowConfig.TvPrograms(),
|
||||
HomeRowConfig.TvChannels(),
|
||||
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
|
||||
HomeRowConfig.CustomEndpoint(
|
||||
endpoint = "/path/to/items",
|
||||
title = "Items",
|
||||
query = listOf(KeyValueEntry("key", "value")),
|
||||
headers = listOf(KeyValueEntry("X-Header", "1234")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +107,7 @@ class TestHomeRowSamples {
|
|||
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.Studios -> foundTypes.add(it::class)
|
||||
is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class)
|
||||
}
|
||||
}
|
||||
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
|
||||
|
|
@ -126,12 +134,10 @@ class TestHomeRowSamples {
|
|||
context = mockk(),
|
||||
api = mockk(),
|
||||
serverRepository = mockk(),
|
||||
userPreferencesService = mockk(),
|
||||
navDrawerService = mockk(),
|
||||
latestNextUpService = mockk(),
|
||||
imageUrlService = mockk(),
|
||||
suggestionService = mockk(),
|
||||
displayPreferencesService = mockk(),
|
||||
serverPluginApi = mockk(),
|
||||
serverDao = mockk(),
|
||||
)
|
||||
|
||||
val str = """{
|
||||
|
|
@ -149,7 +155,7 @@ class TestHomeRowSamples {
|
|||
]
|
||||
}"""
|
||||
|
||||
val jsonElement = service.jsonParser.parseToJsonElement(str)
|
||||
val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str)
|
||||
val settings = service.decode(jsonElement)
|
||||
Assert.assertEquals(1, settings.rows.size)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,534 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.model.ActivationFlag
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceReason
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.audio
|
||||
import com.github.damontecres.wholphin.services.mockServerRepo
|
||||
import com.github.damontecres.wholphin.services.subtitle
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class TestSeriesTrackChoice {
|
||||
private val mockServerRepository =
|
||||
mockServerRepo(
|
||||
audioLang = "eng",
|
||||
subtitleLang = "eng",
|
||||
subtitleMode = SubtitlePlaybackMode.DEFAULT,
|
||||
)
|
||||
private val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = false)
|
||||
|
||||
private val streamChoiceService =
|
||||
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
|
||||
|
||||
private val seriesId = UUID.randomUUID()
|
||||
private val season1Id = UUID.randomUUID()
|
||||
private val season2Id = UUID.randomUUID()
|
||||
private val season3Id = UUID.randomUUID()
|
||||
|
||||
private val user =
|
||||
JellyfinUser(
|
||||
1,
|
||||
UUID.randomUUID(),
|
||||
null,
|
||||
UUID.randomUUID(),
|
||||
null,
|
||||
)
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
every { mockServerRepository.currentUser } returns user
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test StreamChoiceService call correct dao function`() =
|
||||
runTest {
|
||||
val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = true)
|
||||
val streamChoiceService =
|
||||
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
|
||||
|
||||
val seriesId = UUID.randomUUID()
|
||||
val seasonId = UUID.randomUUID()
|
||||
|
||||
// Has both series & season
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = BaseItemKind.EPISODE,
|
||||
seriesId = seriesId,
|
||||
parentId = seasonId,
|
||||
).let { item ->
|
||||
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
|
||||
coVerify(exactly = 1) {
|
||||
mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
|
||||
}
|
||||
}
|
||||
|
||||
// Has only series
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = BaseItemKind.EPISODE,
|
||||
seriesId = seriesId,
|
||||
parentId = null,
|
||||
).let { item ->
|
||||
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
|
||||
coVerify(exactly = 1) {
|
||||
mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE)
|
||||
}
|
||||
}
|
||||
|
||||
// Has only season
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = BaseItemKind.EPISODE,
|
||||
seriesId = null,
|
||||
parentId = seasonId,
|
||||
).let { item ->
|
||||
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
|
||||
coVerify(exactly = 1) {
|
||||
mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test series disabled`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(result.stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test series only forced`() =
|
||||
runTest {
|
||||
val mockServerRepository =
|
||||
mockServerRepo(
|
||||
audioLang = "eng",
|
||||
subtitleLang = "eng",
|
||||
subtitleMode = SubtitlePlaybackMode.NONE,
|
||||
)
|
||||
val streamChoiceService =
|
||||
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
|
||||
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs =
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
JellyfinUserPreferences(),
|
||||
),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", forced = true),
|
||||
subtitle(1, "eng", forced = false),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ONLY_FORCED,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(0, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Series)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test series disabled with season`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
),
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(result.stream)
|
||||
assertTrue(result.reason is StreamChoiceReason.Series)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test item playback overrides STC for subtitles`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = user.rowId,
|
||||
itemId = UUID.randomUUID(),
|
||||
subtitleIndex = 1,
|
||||
),
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
),
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.DISABLED,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Item)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test item playback overrides STC for audio`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseAudioStream(
|
||||
itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = user.rowId,
|
||||
itemId = UUID.randomUUID(),
|
||||
audioIndex = 1,
|
||||
),
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
audio(0, "eng", default = true),
|
||||
audio(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.AUDIO,
|
||||
language = "eng",
|
||||
),
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.AUDIO,
|
||||
language = "eng",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Item)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test season STC doesn't apply but series STC does - subtitle`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = user.rowId,
|
||||
itemId = UUID.randomUUID(),
|
||||
audioIndex = 1,
|
||||
),
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ONLY_FORCED,
|
||||
),
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
language = "spa",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Series)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test season STC doesn't apply but series STC does - audio`() =
|
||||
runTest {
|
||||
val result =
|
||||
streamChoiceService.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
audio(0, "eng", default = true),
|
||||
audio(1, "spa"),
|
||||
),
|
||||
stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.AUDIO,
|
||||
activation = ActivationFlag.ONLY_FORCED,
|
||||
),
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = seriesId,
|
||||
parentType = TrackChoiceParentType.SERIES,
|
||||
type = SeriesTrackChoiceType.AUDIO,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
language = "spa",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Series)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test STC has forced flag`() =
|
||||
runTest {
|
||||
val stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
trackFlags = listOf(TrackFlag.FORCED).calculateFlag(),
|
||||
),
|
||||
)
|
||||
|
||||
// Choose forced track
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true, forced = true),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(0, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Season)
|
||||
}
|
||||
|
||||
// No forced track to choose from, so nothing should be chosen
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = false, forced = false),
|
||||
subtitle(1, "spa"),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNull(result.stream)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test prefer SDH`() =
|
||||
runTest {
|
||||
val stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
trackFlags = listOf(TrackFlag.SDH).calculateFlag(),
|
||||
),
|
||||
)
|
||||
|
||||
// Prefer SDH over default and non-SDH
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true, forced = true),
|
||||
subtitle(1, "eng", default = false, forced = false, sdh = false),
|
||||
subtitle(2, "eng", default = false, forced = false, sdh = true),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(2, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Season)
|
||||
}
|
||||
|
||||
// No SDH, so prefer regular over forced
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true, forced = true),
|
||||
subtitle(1, "eng", default = false, forced = false, sdh = false),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Season)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test prefer default track with no other flags`() =
|
||||
runTest {
|
||||
val stc =
|
||||
listOf(
|
||||
SeriesTrackChoice(
|
||||
userId = user.rowId,
|
||||
parentId = season1Id,
|
||||
parentType = TrackChoiceParentType.SEASON,
|
||||
type = SeriesTrackChoiceType.SUBTITLE,
|
||||
activation = ActivationFlag.ACTIVATED,
|
||||
trackFlags = 0,
|
||||
),
|
||||
)
|
||||
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = true, forced = false),
|
||||
subtitle(1, "eng", default = false, forced = false),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(0, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Season)
|
||||
}
|
||||
|
||||
// Test out-of-order tracks
|
||||
streamChoiceService
|
||||
.chooseSubtitleStream(
|
||||
audioStreamLang = "eng",
|
||||
itemPlayback = null,
|
||||
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
candidates =
|
||||
listOf(
|
||||
subtitle(0, "eng", default = false, forced = false),
|
||||
subtitle(1, "eng", default = true, forced = false),
|
||||
),
|
||||
stc = stc,
|
||||
).let { result ->
|
||||
assertNotNull(result.stream)
|
||||
assertEquals(1, result.stream!!.index)
|
||||
assertTrue(result.reason is StreamChoiceReason.Season)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag
|
||||
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.EnumSet
|
||||
|
||||
class TestTrackFlag {
|
||||
@Test
|
||||
fun basicTest() {
|
||||
val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag()
|
||||
assertTrue(TrackFlag.DEFAULT.within(flag))
|
||||
assertTrue(TrackFlag.EXTERNAL.within(flag))
|
||||
assertFalse(TrackFlag.FORCED.within(flag))
|
||||
assertFalse(TrackFlag.SDH.within(flag))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun none() {
|
||||
val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag()
|
||||
assertFalse(TrackFlag.DEFAULT.within(flag))
|
||||
assertFalse(TrackFlag.EXTERNAL.within(flag))
|
||||
assertFalse(TrackFlag.FORCED.within(flag))
|
||||
assertFalse(TrackFlag.SDH.within(flag))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun all() {
|
||||
val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag()
|
||||
TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkFlags() {
|
||||
TrackFlag.entries.forEach {
|
||||
assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0)
|
||||
}
|
||||
val distinctFlags = TrackFlag.entries.map { it.flag }.distinct()
|
||||
assertEquals(TrackFlag.entries.size, distinctFlags.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.junit.Assert.assertNotNull
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
|
|
@ -149,3 +148,16 @@ inline fun <reified T : Any> assertIs(obj: Any?) {
|
|||
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the object is not null
|
||||
*
|
||||
* Includes a [contract] to smart-cast the object to T
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun assertNotNull(obj: Any?) {
|
||||
contract {
|
||||
returns() implies (obj != null)
|
||||
}
|
||||
org.junit.Assert.assertNotNull(obj)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,18 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
|||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.Migrations
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.services.TestMostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
|
||||
|
|
@ -201,6 +204,10 @@ object TestModule {
|
|||
fun seerrApi(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
): SeerrApi = mockk()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider()
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
@ -260,6 +267,10 @@ object TestDatabaseModule {
|
|||
@Singleton
|
||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.services.DeletedItem
|
|||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
|
|
@ -34,6 +35,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.junit.After
|
||||
|
|
@ -57,6 +59,7 @@ class CollectionFolderViewModelTest {
|
|||
private val mockUserLibraryApi = mockk<UserLibraryApi>()
|
||||
|
||||
private val artistsRequest = slot<GetArtistsRequest>()
|
||||
private val albumArtistsRequest = slot<GetAlbumArtistsRequest>()
|
||||
private val itemsRequest = slot<GetItemsRequest>()
|
||||
|
||||
private val libraryId = UUID.randomUUID()
|
||||
|
|
@ -83,11 +86,14 @@ class CollectionFolderViewModelTest {
|
|||
// against the relaxed ApiClient
|
||||
mockkObject(GetItemsRequestHandler)
|
||||
mockkObject(GetArtistsHandler)
|
||||
mockkObject(GetAlbumArtistsHandler)
|
||||
mockkObject(GetPersonsHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns
|
||||
successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT)
|
||||
coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns
|
||||
successQueryResult(totalRecordCount = ARTIST_COUNT)
|
||||
coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns
|
||||
successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT)
|
||||
coEvery { GetPersonsHandler.execute(mockApi, any()) } returns
|
||||
successQueryResult(totalRecordCount = PERSON_COUNT)
|
||||
}
|
||||
|
|
@ -97,33 +103,36 @@ class CollectionFolderViewModelTest {
|
|||
WholphinDispatchers.reset()
|
||||
unmockkObject(GetItemsRequestHandler)
|
||||
unmockkObject(GetArtistsHandler)
|
||||
unmockkObject(GetAlbumArtistsHandler)
|
||||
unmockkObject(GetPersonsHandler)
|
||||
}
|
||||
|
||||
private fun createViewModel(filter: GetItemsFilter) =
|
||||
CollectionFolderViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
api = mockApi,
|
||||
context = mockk(relaxed = true),
|
||||
serverRepository = mockServerRepository,
|
||||
libraryDisplayInfoDao = mockk(relaxed = true),
|
||||
favoriteWatchManager = mockk(relaxed = true),
|
||||
backdropService = mockk(relaxed = true),
|
||||
navigationManager = mockk(relaxed = true),
|
||||
themeSongPlayer = mockk(relaxed = true),
|
||||
userPreferencesService = mockk(relaxed = true),
|
||||
mediaManagementService = mockMediaManagementService,
|
||||
musicService = mockk(relaxed = true),
|
||||
streamChoiceService = mockk(relaxed = true),
|
||||
serverReportService = mockk(relaxed = true),
|
||||
filterOptionCache = mockk(relaxed = true),
|
||||
itemId = libraryId.toString(),
|
||||
initialSortAndDirection = null,
|
||||
recursive = true,
|
||||
collectionFilter = CollectionFolderFilter(filter = filter),
|
||||
useSeriesForPrimary = false,
|
||||
defaultViewOptions = ViewOptionsSquare,
|
||||
)
|
||||
private fun createViewModel(
|
||||
filter: GetItemsFilter,
|
||||
viewOptions: ViewOptions = ViewOptionsSquare,
|
||||
) = CollectionFolderViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
api = mockApi,
|
||||
context = mockk(relaxed = true),
|
||||
serverRepository = mockServerRepository,
|
||||
libraryDisplayInfoDao = mockk(relaxed = true),
|
||||
favoriteWatchManager = mockk(relaxed = true),
|
||||
backdropService = mockk(relaxed = true),
|
||||
navigationManager = mockk(relaxed = true),
|
||||
themeSongPlayer = mockk(relaxed = true),
|
||||
userPreferencesService = mockk(relaxed = true),
|
||||
mediaManagementService = mockMediaManagementService,
|
||||
musicService = mockk(relaxed = true),
|
||||
streamChoiceService = mockk(relaxed = true),
|
||||
serverReportService = mockk(relaxed = true),
|
||||
filterOptionCache = mockk(relaxed = true),
|
||||
itemId = libraryId.toString(),
|
||||
initialSortAndDirection = null,
|
||||
recursive = true,
|
||||
collectionFilter = CollectionFolderFilter(filter = filter),
|
||||
useSeriesForPrimary = false,
|
||||
defaultViewOptions = viewOptions,
|
||||
)
|
||||
|
||||
/** Counting via /Items would return every song and album, overshooting the artist grid */
|
||||
@Test
|
||||
|
|
@ -143,6 +152,37 @@ class CollectionFolderViewModelTest {
|
|||
coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() =
|
||||
runTest(testDispatcher) {
|
||||
val viewModel =
|
||||
createViewModel(
|
||||
GetItemsFilter(override = GetItemsFilterOverride.ARTIST),
|
||||
ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val position = viewModel.positionOfLetter('K')
|
||||
|
||||
assertEquals(ALBUM_ARTIST_COUNT, position)
|
||||
assertEquals("K", albumArtistsRequest.captured.nameLessThan)
|
||||
assertEquals(libraryId, albumArtistsRequest.captured.parentId)
|
||||
coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the artist source reloads from the album artists endpoint`() =
|
||||
runTest(testDispatcher) {
|
||||
val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST))
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) }
|
||||
|
||||
viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists
|
||||
* like "The Beatles" even though the grid sorts them under "B".
|
||||
|
|
@ -197,6 +237,7 @@ class CollectionFolderViewModelTest {
|
|||
companion object {
|
||||
private const val LIBRARY_ITEM_COUNT = 7315
|
||||
private const val ARTIST_COUNT = 730
|
||||
private const val ALBUM_ARTIST_COUNT = 215
|
||||
private const val PERSON_COUNT = 12
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceReason
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceResult
|
||||
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.preferences.ExternalPlayerApp
|
||||
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
|
||||
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.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
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.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||
import org.jellyfin.sdk.api.operations.SubtitleApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.VideosApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.extensions.inWholeTicks
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [30])
|
||||
class PlayExternalViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val context = mockk<Context>()
|
||||
private val api = mockk<ApiClient>()
|
||||
private val userLibraryApi = mockk<UserLibraryApi>()
|
||||
private val videosApi = mockk<VideosApi>()
|
||||
private val subtitleApi = mockk<SubtitleApi>()
|
||||
private val playStateApi = mockk<PlayStateApi>(relaxed = true)
|
||||
private val serverRepository = mockk<ServerRepository>()
|
||||
private val itemPlaybackDao = mockk<ItemPlaybackDao>()
|
||||
private val playlistCreator = mockk<PlaylistCreator>()
|
||||
private val streamChoiceService = mockk<StreamChoiceService>()
|
||||
private val navigationManager = mockk<NavigationManager>(relaxed = true)
|
||||
private val userPreferencesService = mockk<UserPreferencesService>()
|
||||
|
||||
private val movie = movie()
|
||||
private val subtitleUrl = "http://localhost:8096/subtitles/0.srt"
|
||||
private val videoUrl = "http://localhost:8096/video/stream"
|
||||
private val mediaSource =
|
||||
TestTracks
|
||||
.Builder()
|
||||
.addExternalSubtitle()
|
||||
.buildMediaSourceInfo()
|
||||
.let { source ->
|
||||
source.copy(
|
||||
id = "source-id",
|
||||
runTimeTicks = 90.minutes.inWholeTicks,
|
||||
mediaStreams =
|
||||
source.mediaStreams!!.map {
|
||||
it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English")
|
||||
},
|
||||
)
|
||||
}
|
||||
private val subtitle = mediaSource.mediaStreams!!.single()
|
||||
private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Before
|
||||
fun setUp() {
|
||||
WholphinDispatchers.configure(testDispatcher)
|
||||
mockkStatic(::getExternalPlayers)
|
||||
every { getExternalPlayers(context) } returns
|
||||
listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString()))
|
||||
every { api.userLibraryApi } returns userLibraryApi
|
||||
every { api.videosApi } returns videosApi
|
||||
every { api.subtitleApi } returns subtitleApi
|
||||
every { api.playStateApi } returns playStateApi
|
||||
every { serverRepository.currentUser } returns null
|
||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
||||
StreamChoiceResult(subtitle, StreamChoiceReason.Unknown)
|
||||
coEvery { streamChoiceService.getSeriesTrackChoices(any<BaseItemDto>(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList()
|
||||
every {
|
||||
videosApi.getVideoStreamUrl(
|
||||
itemId = movie.id,
|
||||
mediaSourceId = mediaSource.id,
|
||||
static = true,
|
||||
)
|
||||
} returns videoUrl
|
||||
every {
|
||||
subtitleApi.getSubtitleUrl(
|
||||
routeItemId = any(),
|
||||
routeMediaSourceId = mediaSource.id!!,
|
||||
routeIndex = subtitle.index,
|
||||
routeFormat = "srt",
|
||||
)
|
||||
} returns subtitleUrl
|
||||
setupPlayer("")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::getExternalPlayers)
|
||||
WholphinDispatchers.reset()
|
||||
}
|
||||
|
||||
private fun setupPlayer(playerId: String) {
|
||||
val preferences =
|
||||
AppPreferences.getDefaultInstance().updatePlaybackPreferences {
|
||||
externalPlayer = playerId
|
||||
}
|
||||
coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null)
|
||||
}
|
||||
|
||||
private fun createIntent(destination: Destination): Intent {
|
||||
val viewModel =
|
||||
PlayExternalViewModel(
|
||||
SavedStateHandle(),
|
||||
context,
|
||||
api,
|
||||
serverRepository,
|
||||
itemPlaybackDao,
|
||||
playlistCreator,
|
||||
streamChoiceService,
|
||||
navigationManager,
|
||||
userPreferencesService,
|
||||
)
|
||||
viewModel.init(destination)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(LoadingState.Success, viewModel.state.value.loading)
|
||||
return viewModel.state.value.intent
|
||||
}
|
||||
|
||||
private fun assertStartFromBeginning(intent: Intent) {
|
||||
assertEquals(true, intent.extras!!.get("from_start"))
|
||||
assertEquals(0, intent.extras!!.get("position"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Explicit VLC starts from beginning at zero position`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPlayer(vlc.flattenToString())
|
||||
val intent = createIntent(Destination.Playback(movie.id, 0L))
|
||||
assertEquals(vlc, intent.component)
|
||||
assertStartFromBeginning(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Implicit player starts VLC from beginning at zero position`() =
|
||||
runTest(testDispatcher) {
|
||||
val intent = createIntent(Destination.Playback(movie.id, 0L))
|
||||
assertNull(intent.component)
|
||||
assertStartFromBeginning(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Unavailable selected player falls back to implicit start from beginning`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPlayer("missing.player/missing.player.Activity")
|
||||
val intent = createIntent(Destination.Playback(movie.id, 0L))
|
||||
assertNull(intent.component)
|
||||
assertStartFromBeginning(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Explicit VLC preserves positive resume position`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPlayer(vlc.flattenToString())
|
||||
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
|
||||
assertEquals(vlc, intent.component)
|
||||
assertEquals(false, intent.extras!!.get("from_start"))
|
||||
assertEquals(42_000, intent.extras!!.get("position"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Implicit player preserves positive resume position`() =
|
||||
runTest(testDispatcher) {
|
||||
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
|
||||
assertNull(intent.component)
|
||||
assertEquals(false, intent.extras!!.get("from_start"))
|
||||
assertEquals(42_000, intent.extras!!.get("position"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Playback list starts from beginning`() =
|
||||
runTest(testDispatcher) {
|
||||
val playlist = playlist()
|
||||
coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist)
|
||||
coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns
|
||||
PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie))))
|
||||
|
||||
val intent = createIntent(Destination.PlaybackList(playlist.id))
|
||||
assertStartFromBeginning(intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VLC starts from beginning without subtitles or duration`() =
|
||||
runTest(testDispatcher) {
|
||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns
|
||||
mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null)
|
||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null
|
||||
|
||||
val intent = createIntent(Destination.Playback(movie.id, 0L))
|
||||
assertStartFromBeginning(intent)
|
||||
assertFalse(intent.hasExtra("subtitles_location"))
|
||||
assertFalse(intent.hasExtra("extra_duration"))
|
||||
assertFalse(intent.hasExtra("subs.enable"))
|
||||
assertFalse(intent.hasExtra("forcedsrt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Existing player extras and playback-start reporting remain unchanged`() =
|
||||
runTest(testDispatcher) {
|
||||
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
|
||||
val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}"
|
||||
val subtitleUri = Uri.parse(subtitleUrl)
|
||||
assertEquals(Intent.ACTION_VIEW, intent.action)
|
||||
assertEquals(Uri.parse(videoUrl), intent.data)
|
||||
assertEquals("video/*", intent.type)
|
||||
assertEquals(title, intent.getStringExtra("title"))
|
||||
assertEquals(42_000, intent.extras!!.get("position"))
|
||||
assertTrue(intent.getBooleanExtra("return_result", false))
|
||||
assertTrue(intent.getBooleanExtra("secure_uri", false))
|
||||
assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs"))
|
||||
assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name"))
|
||||
assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable"))
|
||||
assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location"))
|
||||
assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration"))
|
||||
assertEquals(42_000, intent.extras!!.get("startfrom"))
|
||||
assertFalse(intent.getBooleanExtra("forceresume", true))
|
||||
assertEquals(title, intent.getStringExtra("forcename"))
|
||||
assertEquals(subtitleUri, intent.getParcelableExtra<Uri>("forcedsrt"))
|
||||
coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) }
|
||||
coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ 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.data.model.PlaylistItem
|
||||
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
|
||||
import com.github.damontecres.wholphin.data.model.ServerUserConfig
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
|
|
@ -29,6 +30,8 @@ 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.StreamChoiceReason
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceResult
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.test.TestTracks
|
||||
|
|
@ -64,6 +67,7 @@ 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.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
|
|
@ -201,7 +205,12 @@ class PlaybackViewModelTests {
|
|||
|
||||
coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null
|
||||
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||
coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null
|
||||
coEvery {
|
||||
mockStreamChoiceService.getSeriesTrackChoices(
|
||||
any<BaseItemDto>(),
|
||||
any<SeriesTrackChoiceType>(),
|
||||
)
|
||||
} returns emptyList()
|
||||
coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns
|
||||
PlayerCreation(mockPlayer)
|
||||
every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true)
|
||||
|
|
@ -214,18 +223,26 @@ class PlaybackViewModelTests {
|
|||
any(),
|
||||
any(),
|
||||
)
|
||||
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }
|
||||
} returns
|
||||
StreamChoiceResult(
|
||||
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
|
||||
StreamChoiceReason.Unknown,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
mockStreamChoiceService.chooseSubtitleStream(
|
||||
item = any(),
|
||||
source = mediaSource,
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
audioStream = any(),
|
||||
itemPlayback = any(),
|
||||
stc = any(),
|
||||
prefs = any(),
|
||||
)
|
||||
} returns
|
||||
StreamChoiceResult(
|
||||
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE },
|
||||
StreamChoiceReason.Unknown,
|
||||
)
|
||||
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
|
@ -275,6 +292,49 @@ class PlaybackViewModelTests {
|
|||
return viewModel
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Direct play includes the opened live stream ID`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences { cinemaMode = false }
|
||||
val liveStreamId = "opened-live-stream"
|
||||
val liveSource = mediaSource.copy(liveStreamId = liveStreamId)
|
||||
coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||
coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns
|
||||
successResponse(
|
||||
PlaybackInfoResponse(
|
||||
mediaSources = listOf(liveSource),
|
||||
playSessionId = playSessionId,
|
||||
),
|
||||
)
|
||||
every {
|
||||
mockVideosApi.getVideoStreamUrl(
|
||||
itemId = movie.id,
|
||||
mediaSourceId = liveSource.id,
|
||||
liveStreamId = liveSource.liveStreamId,
|
||||
static = true,
|
||||
tag = liveSource.eTag,
|
||||
playSessionId = playSessionId,
|
||||
)
|
||||
} returns videoStreamUrl
|
||||
|
||||
createViewModel(Destination.Playback(movie.id, 0L))
|
||||
|
||||
val mediaItem = slot<MediaItem>()
|
||||
verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) }
|
||||
val playbackUri = mediaItem.captured.localConfiguration?.uri
|
||||
Assert.assertEquals(videoStreamUrl, playbackUri.toString())
|
||||
verify(exactly = 1) {
|
||||
mockVideosApi.getVideoStreamUrl(
|
||||
itemId = movie.id,
|
||||
mediaSourceId = liveSource.id,
|
||||
liveStreamId = liveStreamId,
|
||||
static = true,
|
||||
tag = liveSource.eTag,
|
||||
playSessionId = playSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Play intro first`() =
|
||||
runTest(testDispatcher) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import androidx.media3.common.DrmInitData
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DolbyVisionProfile7ConversionTest {
|
||||
@Test
|
||||
fun recognisesProfile7InBothSampleEntryForms() {
|
||||
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06")))
|
||||
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotMistakeOtherProfilesOrCodecsForProfile7() {
|
||||
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06")))
|
||||
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06")))
|
||||
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null)))
|
||||
assertFalse(
|
||||
DolbyVisionProfile7Conversion.isProfile7(
|
||||
Format
|
||||
.Builder()
|
||||
.setSampleMimeType(MimeTypes.VIDEO_H265)
|
||||
.setCodecs("dvhe.07.06")
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewritesOnlyTheProfileOfTheCodecString() {
|
||||
assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06"))
|
||||
assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() {
|
||||
val format = dolbyVision("dvhe.07.06")
|
||||
|
||||
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
|
||||
|
||||
assertEquals("dvhe.08.06", advertised.codecs)
|
||||
assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType)
|
||||
assertEquals(format.width, advertised.width)
|
||||
assertEquals(format.height, advertised.height)
|
||||
assertEquals(format.initializationData, advertised.initializationData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesTracksWhichAreNotProfile7AsTheSameInstance() {
|
||||
val profile8 = dolbyVision("dvhe.08.06")
|
||||
val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build()
|
||||
|
||||
assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8))
|
||||
assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnEncryptedProfile7TrackAlone() {
|
||||
// Encrypted samples take the secure path, which the bitstream filter does not touch
|
||||
val encrypted =
|
||||
dolbyVision("dvhe.07.06")
|
||||
.buildUpon()
|
||||
.setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1))))
|
||||
.build()
|
||||
|
||||
assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun dolbyVision(codecs: String?): Format =
|
||||
Format
|
||||
.Builder()
|
||||
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||
.setCodecs(codecs)
|
||||
.setWidth(3840)
|
||||
.setHeight(2160)
|
||||
.setInitializationData(listOf(byteArrayOf(0x01, 0x02)))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
@file:OptIn(markerClass = [UnstableApi::class])
|
||||
|
||||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.extractor.Extractor
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.ExtractorsFactory
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The factory has to reach the video track's format through three layers of media3 forwarding,
|
||||
* rewrite it there and nowhere else, and let sample data through untouched.
|
||||
*/
|
||||
class DolbyVisionProfile7ExtractorsFactoryTest {
|
||||
private val videoTrack = mockk<TrackOutput>(relaxed = true)
|
||||
private val audioTrack = mockk<TrackOutput>(relaxed = true)
|
||||
private val output =
|
||||
mockk<ExtractorOutput>(relaxed = true) {
|
||||
every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack
|
||||
every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack
|
||||
}
|
||||
private val extractor = mockk<Extractor>(relaxed = true)
|
||||
private val delegate =
|
||||
mockk<ExtractorsFactory> {
|
||||
every { createExtractors() } returns arrayOf(extractor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisesTheVideoTrackAsProfile8() {
|
||||
val video = initialisedVideoTrack()
|
||||
|
||||
video.format(dolbyVision("dvhe.07.06"))
|
||||
|
||||
val published = slot<Format>()
|
||||
verify { videoTrack.format(capture(published)) }
|
||||
assertEquals("dvhe.08.06", published.captured.codecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesOtherVideoFormatsAsTheyAre() {
|
||||
val video = initialisedVideoTrack()
|
||||
val hevc =
|
||||
Format
|
||||
.Builder()
|
||||
.setSampleMimeType(MimeTypes.VIDEO_H265)
|
||||
.setCodecs("hvc1.2.4.L153")
|
||||
.build()
|
||||
|
||||
video.format(hevc)
|
||||
|
||||
verify { videoTrack.format(hevc) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesTheAudioTrackUnwrapped() {
|
||||
val wrapped = wrappedOutput()
|
||||
|
||||
assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesSampleDataStraightThrough() {
|
||||
val video = initialisedVideoTrack()
|
||||
val data = ParsableByteArray(byteArrayOf(1, 2, 3))
|
||||
|
||||
video.sampleData(data, 3)
|
||||
video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null)
|
||||
|
||||
verify { videoTrack.sampleData(data, 3) }
|
||||
verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrapsEveryExtractorOfTheDelegate() {
|
||||
val another = mockk<Extractor>(relaxed = true)
|
||||
every { delegate.createExtractors() } returns arrayOf(extractor, another)
|
||||
|
||||
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
|
||||
|
||||
assertEquals(2, extractors.size)
|
||||
extractors[1].init(output)
|
||||
verify { another.init(any()) }
|
||||
}
|
||||
|
||||
private fun wrappedOutput(): ExtractorOutput {
|
||||
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
|
||||
extractors.single().init(output)
|
||||
val wrapped = slot<ExtractorOutput>()
|
||||
verify { extractor.init(capture(wrapped)) }
|
||||
return wrapped.captured
|
||||
}
|
||||
|
||||
private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO)
|
||||
|
||||
private companion object {
|
||||
const val VIDEO_ID = 1
|
||||
const val AUDIO_ID = 2
|
||||
|
||||
fun dolbyVision(codecs: String): Format =
|
||||
Format
|
||||
.Builder()
|
||||
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||
.setCodecs(codecs)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under
|
||||
* test is everything around it: which access units are touched at all, that the enhancement layer
|
||||
* goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as
|
||||
* it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer.
|
||||
*/
|
||||
class DolbyVisionProfile7FilterTest {
|
||||
@Test
|
||||
fun dropsTheEnhancementLayerAndRewritesTheRpu() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7))
|
||||
val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU))
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() {
|
||||
// A dual layer remux whose enhancement layer lives in Matroska block additions, which the
|
||||
// extractor discards: the RPU still needs its rewrite for the profile 8 decoder
|
||||
val filter = filterWith(FakeRewriter(profile = 7))
|
||||
val target = directBuffer(annexB(VCL, RPU))
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7))
|
||||
val accessUnit = annexB(VCL, EL, RPU)
|
||||
val prefix = byteArrayOf(0x7A, 0x7B)
|
||||
val suffix = byteArrayOf(0x7C)
|
||||
val target = directBuffer(prefix + accessUnit + suffix)
|
||||
|
||||
val size = filter.filter(target, prefix.size, accessUnit.size)
|
||||
|
||||
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size))
|
||||
assertEquals(prefix.toList(), target.bytes(0, prefix.size))
|
||||
assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotMoveThePositionOrLimitOfTheBuffer() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7))
|
||||
val target = directBuffer(annexB(VCL, EL, RPU))
|
||||
target.position(3)
|
||||
target.limit(target.capacity() - 1)
|
||||
|
||||
filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(3, target.position())
|
||||
assertEquals(target.capacity() - 1, target.limit())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnAccessUnitWithoutAnRpuAlone() {
|
||||
val rewriter = FakeRewriter(profile = 7)
|
||||
val filter = filterWith(rewriter)
|
||||
val accessUnit = annexB(VPS, VCL, EL)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, size)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||
assertEquals(0, rewriter.conversions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnRpuOfAnotherProfileAlone() {
|
||||
val rewriter = FakeRewriter(profile = 8)
|
||||
val filter = filterWith(rewriter)
|
||||
val accessUnit = annexB(VCL, RPU)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, size)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||
assertEquals(0, rewriter.conversions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnAccessUnitAloneWhenTheRewriteThrows() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7, throws = true))
|
||||
val accessUnit = annexB(VCL, EL, RPU)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, size)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() {
|
||||
// No enhancement layer to make room, and an RPU which comes out longer than it went in
|
||||
val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 }))
|
||||
val accessUnit = annexB(VCL, RPU)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, size)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() {
|
||||
val longerRpu = RPU + byteArrayOf(0x55, 0x56)
|
||||
val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu))
|
||||
val target = directBuffer(annexB(VCL, EL, RPU))
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1))
|
||||
val accessUnit = annexB(VCL, EL, RPU)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, size)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesDataWhichIsNotAnnexBAlone() {
|
||||
val rewriter = FakeRewriter(profile = 7)
|
||||
val filter = filterWith(rewriter)
|
||||
val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C)
|
||||
val target = directBuffer(data)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(data.size, size)
|
||||
assertEquals(data.toList(), target.bytes(0, size))
|
||||
assertEquals(0, rewriter.conversions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun walksThreeByteStartCodesToo() {
|
||||
val filter = filterWith(FakeRewriter(profile = 7))
|
||||
val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val size = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givesUpForGoodWhenTheNativeLibraryIsMissing() {
|
||||
var attempts = 0
|
||||
val filter =
|
||||
DolbyVisionProfile7Filter {
|
||||
attempts++
|
||||
throw UnsatisfiedLinkError("no libdovi_android for this ABI")
|
||||
}
|
||||
val accessUnit = annexB(VCL, EL, RPU)
|
||||
val target = directBuffer(accessUnit)
|
||||
|
||||
val first = filter.filter(target, 0, target.capacity())
|
||||
val second = filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(accessUnit.size, first)
|
||||
assertEquals(accessUnit.size, second)
|
||||
assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size))
|
||||
assertEquals("the library should be tried once, not on every access unit", 1, attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() {
|
||||
val rewriter = FakeRewriter(profile = 7)
|
||||
val filter = filterWith(rewriter)
|
||||
val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL))
|
||||
|
||||
filter.filter(target, 0, target.capacity())
|
||||
|
||||
assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the
|
||||
* Annex B start codes, and swaps its payload for [converted], moving whatever follows.
|
||||
*/
|
||||
private class FakeRewriter(
|
||||
private val profile: Int,
|
||||
private val converted: ByteArray = CONVERTED_RPU,
|
||||
private val throws: Boolean = false,
|
||||
private val reportedSize: Int? = null,
|
||||
) : RpuRewriter {
|
||||
var conversions = 0
|
||||
var lastFrame: List<Byte> = emptyList()
|
||||
|
||||
override fun profileOf(
|
||||
frame: ByteBuffer,
|
||||
size: Int,
|
||||
): Int {
|
||||
assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray())
|
||||
return profile
|
||||
}
|
||||
|
||||
override fun convertToProfile8(
|
||||
frame: ByteBuffer,
|
||||
size: Int,
|
||||
): Int {
|
||||
conversions++
|
||||
lastFrame = frame.bytes(0, size)
|
||||
if (throws) {
|
||||
throw IllegalArgumentException("Unable to parse Dolby Vision RPU")
|
||||
}
|
||||
val bytes = frame.bytes(0, size).toByteArray()
|
||||
val rpuStart = indexOfNal(bytes, RPU)
|
||||
val rpuEnd = nextStartCode(bytes, rpuStart + 1)
|
||||
val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size)
|
||||
check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" }
|
||||
rewritten.forEachIndexed { index, byte -> frame.put(index, byte) }
|
||||
return reportedSize ?: rewritten.size
|
||||
}
|
||||
|
||||
private fun indexOfNal(
|
||||
bytes: ByteArray,
|
||||
nal: ByteArray,
|
||||
): Int {
|
||||
for (i in 0..bytes.size - nal.size) {
|
||||
if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
throw AssertionError("RPU not found in the frame handed to the rewriter")
|
||||
}
|
||||
|
||||
private fun nextStartCode(
|
||||
bytes: ByteArray,
|
||||
from: Int,
|
||||
): Int {
|
||||
for (i in from..bytes.size - 3) {
|
||||
if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) {
|
||||
return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i
|
||||
}
|
||||
}
|
||||
return bytes.size
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val START_CODE = byteArrayOf(0, 0, 0, 1)
|
||||
private val SHORT_START_CODE = byteArrayOf(0, 0, 1)
|
||||
|
||||
/** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */
|
||||
private fun nal(
|
||||
type: Int,
|
||||
vararg payload: Byte,
|
||||
): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload
|
||||
|
||||
private val VPS = nal(32, 0x0C, 0x01, 0x7F)
|
||||
private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44)
|
||||
private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E)
|
||||
private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07)
|
||||
private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08)
|
||||
|
||||
private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal }
|
||||
|
||||
private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter }
|
||||
|
||||
/** Like the decoder input buffer on a device: direct, with no backing array. */
|
||||
private fun directBuffer(data: ByteArray): ByteBuffer =
|
||||
ByteBuffer.allocateDirect(data.size).also { buffer ->
|
||||
data.forEachIndexed { index, byte -> buffer.put(index, byte) }
|
||||
assertFalse(buffer.hasArray())
|
||||
}
|
||||
|
||||
private fun ByteBuffer.bytes(
|
||||
from: Int,
|
||||
count: Int,
|
||||
): List<Byte> = (from until from + count).map { get(it) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package com.github.damontecres.wholphin.util.profile
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own
|
||||
* codec-capability detection when building the unsupported video range type list.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [30])
|
||||
class TestDeviceProfileUtils {
|
||||
private fun unsupportedRangeTypes(
|
||||
profile: DeviceProfile,
|
||||
codec: String,
|
||||
): Set<String> =
|
||||
profile.codecProfiles
|
||||
.filter { it.type == CodecType.VIDEO && it.codec == codec }
|
||||
.firstNotNullOf { codecProfile ->
|
||||
codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value
|
||||
}.split("|")
|
||||
.toSet()
|
||||
|
||||
private fun buildProfile(
|
||||
mediaTest: MediaCodecCapabilitiesTest,
|
||||
jellyfinTenEleven: Boolean = false,
|
||||
decodeAv1: Boolean = false,
|
||||
forceEnabledHdr: Set<String> = emptySet(),
|
||||
forceDisabledHdr: Set<String> = emptySet(),
|
||||
): DeviceProfile =
|
||||
createDeviceProfile(
|
||||
mediaTest = mediaTest,
|
||||
maxBitrate = 100_000_000,
|
||||
isAC3Enabled = true,
|
||||
downMixAudio = false,
|
||||
assDirectPlay = true,
|
||||
pgsDirectPlay = true,
|
||||
decodeAv1 = decodeAv1,
|
||||
jellyfinTenEleven = jellyfinTenEleven,
|
||||
preferAc3ForSurround = false,
|
||||
forceEnabledHdr = forceEnabledHdr,
|
||||
forceDisabledHdr = forceDisabledHdr,
|
||||
maxResolution = 0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Test HEVC Profile 7 unsupported when device does not support it`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsHevcDolbyVisionEL() } returns false
|
||||
every { mediaTest.supportsHevcDolbyVision() } returns true
|
||||
every { mediaTest.supportsHevcHDR10Plus() } returns true
|
||||
|
||||
val profile = buildProfile(mediaTest, jellyfinTenEleven = true)
|
||||
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test force enable overrides HEVC Profile 7 device support check`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsHevcDolbyVisionEL() } returns false
|
||||
every { mediaTest.supportsHevcDolbyVision() } returns true
|
||||
every { mediaTest.supportsHevcHDR10Plus() } returns true
|
||||
|
||||
val profile =
|
||||
buildProfile(
|
||||
mediaTest,
|
||||
jellyfinTenEleven = true,
|
||||
forceEnabledHdr =
|
||||
setOf(
|
||||
WholphinVideoRangeType.DOVI_WITH_EL.serialName,
|
||||
WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName,
|
||||
),
|
||||
)
|
||||
Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test force disable overrides HEVC Profile 7 device support check`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsHevcDolbyVisionEL() } returns true
|
||||
every { mediaTest.supportsHevcDolbyVision() } returns true
|
||||
every { mediaTest.supportsHevcHDR10Plus() } returns true
|
||||
|
||||
val profile =
|
||||
buildProfile(
|
||||
mediaTest,
|
||||
jellyfinTenEleven = true,
|
||||
forceDisabledHdr =
|
||||
setOf(
|
||||
WholphinVideoRangeType.DOVI_WITH_EL.serialName,
|
||||
WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName,
|
||||
),
|
||||
)
|
||||
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test AV1 Profile 5 unsupported when device does not support it`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsAV1DolbyVision() } returns false
|
||||
|
||||
val profile = buildProfile(mediaTest, decodeAv1 = false)
|
||||
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test force enable overrides AV1 Profile 5 device support check`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsAV1DolbyVision() } returns false
|
||||
|
||||
val profile =
|
||||
buildProfile(
|
||||
mediaTest,
|
||||
decodeAv1 = false,
|
||||
forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName),
|
||||
)
|
||||
Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test force disable overrides AV1 Profile 5 device support check`() {
|
||||
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
|
||||
every { mediaTest.supportsAV1DolbyVision() } returns true
|
||||
|
||||
val profile =
|
||||
buildProfile(
|
||||
mediaTest,
|
||||
decodeAv1 = false,
|
||||
forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName),
|
||||
)
|
||||
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue