mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Fix suggestions mixing content from different libraries and update recommendation logic (#644)
## Description This PR aims to fix #638 where the "Suggestions" row was mixing up content from different libraries that shared the same library type. For example you were in the Anime library, you'd see regular TV shows showing up, as the TV Shows and Anime libraries would both be "Show" libraries. As @damontecres mentioned in the issue discussion, the `/Items/Suggestions` endpoint Wholphin is using just returns random items from the same library type, with no special logic or recommendation algorithm. I also looked at how the official WebUI handles this. It basically just mixes "Resume," "Next Up," and "Latest Items" together. That felt a bit redundant since we usually have dedicated rows for those things anyway; I wanted to try something that helps discover new content. I replaced the broken endpoint with a few custom `GetItemsRequest` calls. These work correctly with the `ParentId` filter, so we only get results from the library we are actually looking at. Additionally, I added in some logic that takes into account what the user has been watching to tailor the suggestions a little bit. The new logic uses a 40/30/30 mix (this can of course be tweaked as needed) - Tailored (40%): Grabs your recently watched items from this library, checks their genres, and returns items from the same genre. - Random (30%): Random unwatched stuff from this library. - New (30%): Recently added items. This is implemented using only `GetItemsRequest` API calls using filters, rather than any specialized endpoints like the `/Items/Suggestions` endpoint. This means the feature should be predictable and stable. If Jellyfin ever adds an actual recommendation algorithm and endpoint to call it, I imagine we would want to switch to that in the future though. I also added some failsafe logic to make sure there will be diversity in the "recently watched" items that get pulled, in case a user might have recently binged a TV show. We wouldn't want every single recommendation to be based off of that one show. To fix this, I changed the query to fetch the last 20 history items instead of just the top 3. Then, we filter that list client-side to find unique Series IDs. If the user watched 10 episodes of One Piece in a row, the logic collapses them into a single "One Piece" entry and moves on to the next distinct show they watched. This guarantees we get variety in the source material for recommendations. This is a work in progress right now, mainly due to performance. For my server it takes about 10-15 seconds to load the Suggestions row when I tested. This is unacceptable in my opinion, especially if we ever intend to add a Suggestions row like this to the home page like Plex does. I have some ideas on how to improve performance, but I'm open to suggestions. ### Related issues Resolves #638 ### Screenshots Before: <img width="1953" height="1162" alt="suggestions1" src="https://github.com/user-attachments/assets/6e301c67-1a46-46b5-8184-3adb9e52b330" /> After: <img width="1937" height="1108" alt="suggestions3" src="https://github.com/user-attachments/assets/b9563865-4055-40b6-b452-f94c26c8b6e9" /> ### AI/LLM usage I used Claude Code to help write the Kotlin Coroutines so the API calls run at the same time. I also used it to write the tests in `TestSuggestionsLogic.kt`. --------- Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
parent
d9d5ab02e8
commit
aeaecc0f59
17 changed files with 1809 additions and 76 deletions
|
|
@ -0,0 +1,261 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionServiceTest {
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>()
|
||||
private val mockCache = mockk<SuggestionsCache>()
|
||||
private val mockWorkManager = mockk<WorkManager>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
io.mockk.unmockkObject(GetItemsRequestHandler)
|
||||
}
|
||||
|
||||
private fun createService() =
|
||||
SuggestionService(
|
||||
api = mockApi,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
)
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> =
|
||||
mockk {
|
||||
every { content } returns
|
||||
mockk {
|
||||
every { this@mockk.items } returns items
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser =
|
||||
JellyfinUser(
|
||||
id = id,
|
||||
name = "TestUser",
|
||||
serverId = UUID.randomUUID(),
|
||||
accessToken = "token",
|
||||
)
|
||||
|
||||
private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state }
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
||||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_active_work_states_to_Loading() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Loading, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_finished_work_states_to_Empty() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passes_correct_arguments_to_cache() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val otherLibraryId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
otherLibraryId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
val service = createService()
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
libraryId,
|
||||
BaseItemKind.SERIES,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
val dto =
|
||||
mockk<BaseItemDto>(relaxed = true) {
|
||||
every { id } returns cachedId
|
||||
every { type } returns BaseItemKind.MOVIE
|
||||
}
|
||||
io.mockk.mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto))
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertTrue(result is SuggestionsResource.Success)
|
||||
val items = (result as SuggestionsResource.Success).items
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(cachedId, items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_emitsEmpty_whenApiFails() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
io.mockk.mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error")
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.io.path.createTempDirectory
|
||||
|
||||
class SuggestionsCacheTest {
|
||||
private val tempDir = createTempDirectory("suggestions-cache-test").toFile()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun testCacheWithTempDir(): SuggestionsCache {
|
||||
val mockContext = mockk<Context>(relaxed = true)
|
||||
every { mockContext.cacheDir } returns tempDir
|
||||
return SuggestionsCache(mockContext)
|
||||
}
|
||||
|
||||
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
|
||||
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putThenGet_returnsCachedSuggestions() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
|
||||
val loaded = cache.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_readsFromDisk_whenMemoryAbsent() =
|
||||
runTest {
|
||||
val cache1 = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
cache1.save()
|
||||
|
||||
// Create a fresh instance which won't have the memory entry
|
||||
val cache2 = testCacheWithTempDir()
|
||||
// memoryCache should be empty
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
// After read, memory cache should contain the entry
|
||||
assertTrue(memoryCacheOf(cache2).isNotEmpty())
|
||||
}
|
||||
|
||||
// LRU behavior is not enforced in production; keep tests focused on public behavior.
|
||||
@Test
|
||||
fun memoryCache_respectsLruLimit() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
|
||||
// Insert MAX + 2 entries and ensure size never exceeds limit
|
||||
val limit = 8 // keep in sync with implementation
|
||||
val libIds = mutableListOf<UUID>()
|
||||
for (i in 0 until (limit + 2)) {
|
||||
val libId = UUID.randomUUID()
|
||||
libIds.add(libId)
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
}
|
||||
|
||||
// memoryCache should be bounded to the limit
|
||||
val mem = memoryCacheOf(cache)
|
||||
assertTrue(mem.size <= limit)
|
||||
// The oldest (first inserted) should be evicted from memory cache
|
||||
val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}"
|
||||
assertFalse(mem.containsKey(firstKey))
|
||||
}
|
||||
|
||||
// Library isolation tests - verify different parentIds/itemKinds don't mix
|
||||
|
||||
@Test
|
||||
fun differentParentIds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val movieLibraryId = UUID.randomUUID()
|
||||
val tvLibraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = List(2) { UUID.randomUUID() }
|
||||
val tvIds = List(3) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE)
|
||||
val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedTv)
|
||||
assertEquals(2, loadedMovies!!.ids.size)
|
||||
assertEquals(3, loadedTv!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(tvIds[0], loadedTv.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentItemKinds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = listOf(UUID.randomUUID())
|
||||
val seriesIds = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedSeries)
|
||||
assertEquals(1, loadedMovies!!.ids.size)
|
||||
assertEquals(2, loadedSeries!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(seriesIds[0], loadedSeries.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rapidLibrarySwitching_maintainsIsolation() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
val lib3 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
val ids3 = listOf(UUID.randomUUID())
|
||||
|
||||
// Simulate rapid switching: put -> get -> put -> get pattern
|
||||
cache.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib2, BaseItemKind.MOVIE, ids2)
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Switch back to lib1 - should still have correct data
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib3, BaseItemKind.MOVIE, ids3)
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Verify all libraries still have correct data after rapid switching
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryIsolation_persistsToDisk() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
|
||||
// Write with first cache instance
|
||||
val cache1 = testCacheWithTempDir()
|
||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||
cache1.save()
|
||||
|
||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||
val cache2 = testCacheWithTempDir()
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE)
|
||||
val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loaded1)
|
||||
assertNotNull(loaded2)
|
||||
assertEquals(ids1[0], loaded1!!.ids[0])
|
||||
assertEquals(ids2[0], loaded2!!.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentUsers_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val user1 = UUID.randomUUID()
|
||||
val user2 = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val user1Ids = listOf(UUID.randomUUID())
|
||||
val user2Ids = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids)
|
||||
cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids)
|
||||
|
||||
val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedUser1)
|
||||
assertNotNull(loadedUser2)
|
||||
assertEquals(1, loadedUser1!!.ids.size)
|
||||
assertEquals(2, loadedUser2!!.ids.size)
|
||||
assertEquals(user1Ids[0], loadedUser1.ids[0])
|
||||
assertEquals(user2Ids[0], loadedUser2.ids[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionsSchedulerServiceTest {
|
||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||
every { mockServerRepository.current } returns currentLiveData
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun createService() =
|
||||
SuggestionsSchedulerService(
|
||||
context = mockActivity,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
).also { it.dispatcher = testDispatcher }
|
||||
|
||||
@Test
|
||||
fun schedules_periodic_work_when_user_present() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancels_work_when_user_null() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
currentLiveData.value = null
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
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.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class SuggestionsWorkerTest {
|
||||
private val testUserId = UUID.randomUUID()
|
||||
private val testServerId = UUID.randomUUID()
|
||||
private val mockWorkerParams = mockk<WorkerParameters>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockPreferences = mockk<DataStore<AppPreferences>>(relaxed = true)
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
||||
private val mockUserViewsApi = mockk<UserViewsApi>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { mockApi.userViewsApi } returns mockUserViewsApi
|
||||
every { mockApi.baseUrl } returns "http://localhost"
|
||||
every { mockApi.accessToken } returns "test-token"
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkObject(GetItemsRequestHandler)
|
||||
|
||||
private fun createWorker(
|
||||
userId: UUID? = testUserId,
|
||||
serverId: UUID? = testServerId,
|
||||
): SuggestionsWorker {
|
||||
val inputData =
|
||||
Data
|
||||
.Builder()
|
||||
.apply {
|
||||
userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) }
|
||||
serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) }
|
||||
}.build()
|
||||
every { mockWorkerParams.inputData } returns inputData
|
||||
return SuggestionsWorker(
|
||||
context = mockk(relaxed = true),
|
||||
workerParams = mockWorkerParams,
|
||||
serverRepository = mockServerRepository,
|
||||
preferences = mockPreferences,
|
||||
api = mockApi,
|
||||
cache = mockCache,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockPrefs() =
|
||||
mockk<AppPreferences>(relaxed = true) {
|
||||
every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 }
|
||||
}
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
|
||||
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
|
||||
every { content } returns mockk { every { this@mockk.items } returns items }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_failure_on_invalid_input() =
|
||||
runTest {
|
||||
listOf(
|
||||
createWorker(userId = null),
|
||||
createWorker(serverId = null),
|
||||
).forEach { worker ->
|
||||
assertEquals(ListenableWorker.Result.failure(), worker.doWork())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restores_session_when_api_not_configured() =
|
||||
runTest {
|
||||
every { mockApi.baseUrl } returns null
|
||||
every { mockApi.accessToken } returns null
|
||||
val mockUser = mockk<CurrentUser>()
|
||||
var restored = false
|
||||
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
|
||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||
restored = true
|
||||
mockUser
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun caches_suggestions_for_supported_types() =
|
||||
runTest {
|
||||
listOf(
|
||||
CollectionType.MOVIES to BaseItemKind.MOVIE,
|
||||
CollectionType.TVSHOWS to BaseItemKind.SERIES,
|
||||
).forEach { (collectionType, itemKind) ->
|
||||
val viewId = UUID.randomUUID()
|
||||
val view =
|
||||
mockk<BaseItemDto>(relaxed = true) {
|
||||
every { id } returns viewId
|
||||
every { this@mockk.collectionType } returns collectionType
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
|
||||
mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true)))
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||
coVerify { mockCache.save() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_retry_on_network_error() =
|
||||
runTest {
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error")
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
/**
|
||||
* Tests for the suggestions deduplication and genre collection logic used in
|
||||
* RecommendedMovie and RecommendedTvShow ViewModels.
|
||||
*/
|
||||
class TestSuggestionsDeduplication {
|
||||
@Test
|
||||
fun `deduplication by seriesId groups episodes of same series`() {
|
||||
val seriesId = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = seriesId, name = "Episode 1"),
|
||||
episode(seriesId = seriesId, name = "Episode 2"),
|
||||
episode(seriesId = seriesId, name = "Episode 3"),
|
||||
movie(name = "Some Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// Should have 2 items: one series entry and one movie
|
||||
assertEquals(2, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplication preserves order - most recent first`() {
|
||||
val series1 = UUID.randomUUID()
|
||||
val series2 = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = series1, name = "S1 Episode 1"), // Most recent
|
||||
episode(seriesId = series1, name = "S1 Episode 2"),
|
||||
episode(seriesId = series2, name = "S2 Episode 1"),
|
||||
movie(name = "Old Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
// First item should be from series1 (most recent)
|
||||
assertEquals(series1, deduplicated[0].seriesId)
|
||||
assertEquals(series2, deduplicated[1].seriesId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movies use their own id for deduplication`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// All movies should be kept since they have unique IDs
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `take 3 limits seed items`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
movie(name = "Movie 4"),
|
||||
movie(name = "Movie 5"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsGenreCollection {
|
||||
@Test
|
||||
fun `collects genres from multiple seed items`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie", genres = listOf(genre1)),
|
||||
movie(name = "Comedy Movie", genres = listOf(genre2)),
|
||||
movie(name = "Drama Movie", genres = listOf(genre3)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(3, allGenreIds.size)
|
||||
assertTrue(allGenreIds.contains(genre1.id))
|
||||
assertTrue(allGenreIds.contains(genre2.id))
|
||||
assertTrue(allGenreIds.contains(genre3.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplicates shared genres across seed items`() {
|
||||
val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie 1", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Movie 2", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
// Should only have 2 unique genres
|
||||
assertEquals(2, allGenreIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles items with no genres`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie with genre", genres = listOf(genre1)),
|
||||
movie(name = "Movie without genre", genres = null),
|
||||
movie(name = "Movie with empty genres", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(1, allGenreIds.size)
|
||||
assertEquals(genre1.id, allGenreIds[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty list when no seed items have genres`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1", genres = null),
|
||||
movie(name = "Movie 2", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertTrue(allGenreIds.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsExcludeIds {
|
||||
@Test
|
||||
fun `excludeIds contains all seed item ids`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val excludeIds = seedItems.map { it.id }
|
||||
|
||||
assertEquals(3, excludeIds.size)
|
||||
seedItems.forEach { item ->
|
||||
assertTrue(excludeIds.contains(item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class TestSuggestionsCombineAndDeduplicate(
|
||||
private val contextual: List<BaseItemDto>,
|
||||
private val random: List<BaseItemDto>,
|
||||
private val fresh: List<BaseItemDto>,
|
||||
private val expectedUniqueCount: Int,
|
||||
private val description: String,
|
||||
) {
|
||||
@Test
|
||||
fun `combine and deduplicate works correctly`() {
|
||||
val combined =
|
||||
(contextual + random + fresh)
|
||||
.distinctBy { it.id }
|
||||
|
||||
assertEquals(description, expectedUniqueCount, combined.size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{4}")
|
||||
fun data(): Collection<Array<Any>> {
|
||||
val movie1 = movie(name = "Movie 1")
|
||||
val movie2 = movie(name = "Movie 2")
|
||||
val movie3 = movie(name = "Movie 3")
|
||||
val movie4 = movie(name = "Movie 4")
|
||||
|
||||
return listOf(
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
listOf(movie4),
|
||||
4,
|
||||
"no duplicates - all 4 unique",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie1, movie3),
|
||||
listOf(movie2, movie4),
|
||||
4,
|
||||
"with duplicates - deduplicates to 4",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
1,
|
||||
"all same - deduplicates to 1",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
3,
|
||||
"empty contextual - still combines others",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
0,
|
||||
"all empty - returns empty",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to create test data
|
||||
|
||||
private fun movie(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String = "Test Movie",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.MOVIE,
|
||||
name = name,
|
||||
seriesId = null,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
||||
private fun episode(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
seriesId: UUID,
|
||||
name: String = "Test Episode",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.EPISODE,
|
||||
name = name,
|
||||
seriesId = seriesId,
|
||||
genreItems = genres,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue