Show PWA source ratings on every surface through one shared rating row
All checks were successful
review/code-claude Claude code review: pass, no blocking findings
All checks were successful
review/code-claude Claude code review: pass, no blocking findings
This commit is contained in:
parent
853a3989b2
commit
2ccce98dc1
26 changed files with 944 additions and 568 deletions
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -104,44 +102,59 @@ class SourceRatingsTest {
|
|||
imdbVotes = 1,
|
||||
)
|
||||
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
|
||||
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId })
|
||||
assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge })
|
||||
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
|
||||
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId })
|
||||
assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge })
|
||||
}
|
||||
|
||||
private val jellyfinDetails =
|
||||
QuickDetailsData(
|
||||
basic = AnnotatedString("2020"),
|
||||
communityRating = AnnotatedString("7.8 jellyfin"),
|
||||
criticRating = AnnotatedString("59% jellyfin"),
|
||||
@Test
|
||||
fun everyRtMarkHasItsOwnBadge() {
|
||||
val badges =
|
||||
listOf(
|
||||
RtMark.FRESH to RatingBadge.RT_FRESH,
|
||||
RtMark.ROTTEN to RatingBadge.RT_ROTTEN,
|
||||
RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT,
|
||||
RtMark.SPILLED to RatingBadge.RT_SPILLED,
|
||||
RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN,
|
||||
)
|
||||
badges.forEach { (mark, badge) ->
|
||||
assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge)
|
||||
}
|
||||
}
|
||||
|
||||
private val movie =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = 96,
|
||||
rtCriticsLabel = "Certified Fresh",
|
||||
rtAudienceScore = 59,
|
||||
rtAudienceLabel = "Spilled",
|
||||
imdbScore = 8.8,
|
||||
imdbVotes = 10,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() {
|
||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null))
|
||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings()))
|
||||
fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() {
|
||||
// Rotten Tomatoes (critic rating setting) before IMDb (community rating setting)
|
||||
assertEquals(
|
||||
listOf("96%", "59%", "8.8"),
|
||||
movie.visibleEntries(community = true, critic = true).map { it.text },
|
||||
)
|
||||
assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text })
|
||||
assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text })
|
||||
assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() {
|
||||
val details =
|
||||
jellyfinDetails.withSourceRatings(
|
||||
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10),
|
||||
)
|
||||
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
|
||||
assertTrue(details.communityRating!!.text.endsWith("8.8"))
|
||||
assertTrue(details.criticRating!!.text.endsWith("96%"))
|
||||
assertEquals(jellyfinDetails.basic, details.basic)
|
||||
// PWA order: Rotten Tomatoes before IMDb
|
||||
assertTrue(details.criticRatingFirst)
|
||||
assertFalse(jellyfinDetails.criticRatingFirst)
|
||||
fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() {
|
||||
val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true)
|
||||
assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
|
||||
assertEquals(listOf("7.6"), entries.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() {
|
||||
// Seerr has only RT: the Jellyfin star is not shown next to it
|
||||
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh"))
|
||||
assertNull(details.communityRating)
|
||||
assertTrue(details.criticRating!!.text.endsWith("50%"))
|
||||
fun noEntriesWithoutRatings() {
|
||||
assertTrue(SourceRatings().isEmpty)
|
||||
assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
|
||||
assertFalse(movie.isEmpty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.MoviesApi
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.TvApi
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked
|
||||
*/
|
||||
class SeerrRatingsItemsTest {
|
||||
private val moviesApi = mockk<MoviesApi>()
|
||||
private val tvApi = mockk<TvApi>()
|
||||
private val seerrApi =
|
||||
mockk<SeerrApiClient> {
|
||||
every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi
|
||||
every { tvApi } returns this@SeerrRatingsItemsTest.tvApi
|
||||
}
|
||||
private val active = MutableStateFlow(true)
|
||||
private val seerrService =
|
||||
mockk<SeerrService> {
|
||||
every { api } returns seerrApi
|
||||
every { this@mockk.active } returns this@SeerrRatingsItemsTest.active
|
||||
}
|
||||
private val userLibraryApi = mockk<UserLibraryApi>()
|
||||
private val jellyfin =
|
||||
mockk<ApiClient> {
|
||||
every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi
|
||||
}
|
||||
private val service = SeerrRatingsService(seerrService, jellyfin)
|
||||
|
||||
private val seriesId = UUID.randomUUID()
|
||||
private val seriesTmdb = 1396
|
||||
|
||||
private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh"))
|
||||
private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh")
|
||||
|
||||
private fun item(
|
||||
type: BaseItemKind,
|
||||
providerIds: Map<String, String>? = null,
|
||||
seriesId: UUID? = null,
|
||||
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId))
|
||||
|
||||
private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) {
|
||||
coEvery { userLibraryApi.getItem(seriesId) } returns
|
||||
successResponse(
|
||||
BaseItemDto(
|
||||
id = seriesId,
|
||||
type = BaseItemKind.SERIES,
|
||||
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
RatingsTarget(SeerrItemType.MOVIE, 11),
|
||||
service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))),
|
||||
)
|
||||
assertEquals(
|
||||
RatingsTarget(SeerrItemType.TV, 22),
|
||||
service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))),
|
||||
)
|
||||
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() =
|
||||
runTest {
|
||||
jellyfinHasSeries()
|
||||
// The Tmdb id on an episode or season identifies the episode or season, never the series
|
||||
val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId)
|
||||
val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId)
|
||||
|
||||
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
|
||||
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() =
|
||||
runTest {
|
||||
val none =
|
||||
listOf(
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.AUDIO,
|
||||
BaseItemKind.PHOTO,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
BaseItemKind.PROGRAM,
|
||||
BaseItemKind.PLAYLIST,
|
||||
BaseItemKind.VIDEO,
|
||||
)
|
||||
none.forEach {
|
||||
val item = item(it, mapOf("Tmdb" to "5"), seriesId)
|
||||
assertNull(it.name, service.resolveTarget(item))
|
||||
assertNull(it.name, service.forItem(item).first())
|
||||
assertNull(it.name, service.cachedFor(item))
|
||||
}
|
||||
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
|
||||
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||
coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeWithoutASeriesHasNoRatings() =
|
||||
runTest {
|
||||
assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() =
|
||||
runTest {
|
||||
jellyfinHasSeries()
|
||||
val first = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
val second = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
|
||||
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first))
|
||||
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second))
|
||||
|
||||
// every other episode of the series is known without a request
|
||||
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeMovieWithoutProviderIdsLooksItUp() =
|
||||
runTest {
|
||||
val movie = item(BaseItemKind.MOVIE, null)
|
||||
coEvery { userLibraryApi.getItem(movie.id) } returns
|
||||
successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77")))
|
||||
|
||||
assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() =
|
||||
runTest {
|
||||
jellyfinHasSeries(tmdb = null)
|
||||
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
|
||||
assertNull(service.resolveTarget(episode))
|
||||
assertNull(service.forItem(episode).first())
|
||||
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedLookupIsRetriedLater() =
|
||||
runTest {
|
||||
coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down")
|
||||
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
assertNull(service.resolveTarget(episode))
|
||||
|
||||
jellyfinHasSeries()
|
||||
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() =
|
||||
runTest {
|
||||
jellyfinHasSeries()
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings
|
||||
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
val other = item(BaseItemKind.EPISODE, null, seriesId)
|
||||
assertNull(service.cachedFor(episode))
|
||||
|
||||
val ratings = service.forItem(episode).first()
|
||||
|
||||
assertEquals(80, ratings?.rtCritics?.percent)
|
||||
// moving to another episode of the same series needs no wait and no request
|
||||
assertEquals(ratings, service.cachedFor(other))
|
||||
assertEquals(ratings, service.forItem(other).first())
|
||||
coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollingPastItemsNeverAsksSeerr() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly
|
||||
val jobs =
|
||||
(1..20).map { id ->
|
||||
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } }
|
||||
runCurrent()
|
||||
// focus moves on before the wait has passed
|
||||
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50)
|
||||
job.cancel()
|
||||
job
|
||||
}
|
||||
jobs.forEach(Job::cancel)
|
||||
runCurrent()
|
||||
|
||||
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restingOnAnItemAsksSeerrOnceAfterTheWait() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly
|
||||
var result: Any? = "unset"
|
||||
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } }
|
||||
|
||||
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1)
|
||||
runCurrent()
|
||||
assertEquals("unset", result)
|
||||
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||
|
||||
advanceTimeBy(2)
|
||||
runCurrent()
|
||||
assertNotNull(result)
|
||||
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingOnWhileTheRequestIsRunningCancelsIt() =
|
||||
runTest {
|
||||
var cancelled = false
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers {
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } }
|
||||
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1)
|
||||
runCurrent()
|
||||
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) }
|
||||
|
||||
job.cancel()
|
||||
runCurrent()
|
||||
|
||||
assertEquals(true, cancelled)
|
||||
assertNull(service.cached(SeerrItemType.MOVIE, 8))
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import io.mockk.mockk
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -45,7 +46,7 @@ class SeerrRatingsServiceTest {
|
|||
every { api } returns apiClient
|
||||
every { active } answers { activeFlow }
|
||||
}
|
||||
private val service = SeerrRatingsService(seerrService)
|
||||
private val service = SeerrRatingsService(seerrService, mockk<ApiClient>())
|
||||
|
||||
private fun movieDetails(
|
||||
average: Double?,
|
||||
|
|
@ -253,8 +254,7 @@ class SeerrRatingsServiceTest {
|
|||
// no TMDB id
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
|
||||
// not a movie/series
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
|
||||
// not something that has ratings
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
|
||||
// call fails
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.github.damontecres.wholphin.api.seerr.MoviesApi
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.TvApi
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.EnumSet
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and
|
||||
* the Jellyfin rating of an item is never drawn
|
||||
*/
|
||||
@Config(sdk = [34])
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SourceRatingsRowTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val moviesApi = mockk<MoviesApi>()
|
||||
private val tvApi = mockk<TvApi>()
|
||||
private val seerrApi =
|
||||
mockk<SeerrApiClient> {
|
||||
every { moviesApi } returns this@SourceRatingsRowTest.moviesApi
|
||||
every { tvApi } returns this@SourceRatingsRowTest.tvApi
|
||||
}
|
||||
private val seerrService =
|
||||
mockk<SeerrService> {
|
||||
every { api } returns seerrApi
|
||||
every { active } returns flowOf(true)
|
||||
}
|
||||
private val userLibraryApi = mockk<UserLibraryApi>()
|
||||
private val jellyfin = mockk<ApiClient> { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi }
|
||||
private val service = SeerrRatingsService(seerrService, jellyfin)
|
||||
|
||||
private val seriesId = UUID.randomUUID()
|
||||
private val seriesTmdb = 1396
|
||||
|
||||
/** Every item carries Jellyfin ratings that must never be drawn */
|
||||
private fun item(
|
||||
type: BaseItemKind,
|
||||
tmdb: Int? = null,
|
||||
seriesId: UUID? = null,
|
||||
) = BaseItem(
|
||||
BaseItemDto(
|
||||
id = UUID.randomUUID(),
|
||||
type = type,
|
||||
name = "Title",
|
||||
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) },
|
||||
seriesId = seriesId,
|
||||
communityRating = 7.8f,
|
||||
criticRating = 59f,
|
||||
),
|
||||
)
|
||||
|
||||
private fun movieRatings() {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns
|
||||
MovieMovieIdRatingscombinedGet200Response(
|
||||
rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"),
|
||||
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10),
|
||||
)
|
||||
}
|
||||
|
||||
private fun seriesRatings() {
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns
|
||||
TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled")
|
||||
coEvery { userLibraryApi.getItem(seriesId) } returns
|
||||
successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb")))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the quick details of the items, after what a screen's wait for Seerr would have loaded
|
||||
*/
|
||||
private fun show(
|
||||
vararg items: BaseItem,
|
||||
toggles: EnumSet<DisplayToggle> = EnumSet.allOf(DisplayToggle::class.java),
|
||||
) {
|
||||
runBlocking {
|
||||
items.forEach { service.forItem(it, 0).collect { } }
|
||||
}
|
||||
rule.setContent {
|
||||
WholphinTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalSeerrRatingsService provides service,
|
||||
LocalInterfaceCustomization provides InterfaceCustomization(toggles),
|
||||
) {
|
||||
Column {
|
||||
items.forEach { QuickDetails(it, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists()
|
||||
|
||||
private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist()
|
||||
|
||||
private fun assertJellyfinRatingNotShown() {
|
||||
assertNotShown("7.8")
|
||||
assertNotShown("59%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() {
|
||||
movieRatings()
|
||||
show(item(BaseItemKind.MOVIE, 603))
|
||||
assertShown("96%")
|
||||
assertShown("90%")
|
||||
assertShown("8.8")
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesShowsItsRatings() {
|
||||
seriesRatings()
|
||||
show(item(BaseItemKind.SERIES, seriesTmdb))
|
||||
assertShown("81%")
|
||||
assertShown("45%")
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeAndSeasonShowTheirSeriesRatings() {
|
||||
seriesRatings()
|
||||
show(item(BaseItemKind.EPISODE, seriesId = seriesId))
|
||||
assertShown("81%")
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seasonShowsItsSeriesRatings() {
|
||||
seriesRatings()
|
||||
show(item(BaseItemKind.SEASON, seriesId = seriesId))
|
||||
assertShown("45%")
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() {
|
||||
movieRatings()
|
||||
seriesRatings()
|
||||
show(
|
||||
*listOf(
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.PHOTO,
|
||||
BaseItemKind.PROGRAM,
|
||||
).map { item(it, 603, seriesId) }.toTypedArray(),
|
||||
)
|
||||
assertNotShown("96%")
|
||||
assertNotShown("81%")
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response()
|
||||
coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns
|
||||
mockk {
|
||||
every { voteAverage } returns null
|
||||
every { voteCount } returns null
|
||||
}
|
||||
show(item(BaseItemKind.MOVIE, 5))
|
||||
assertJellyfinRatingNotShown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDisplaySettingsStillControlWhichSourcesAreDrawn() {
|
||||
movieRatings()
|
||||
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING))
|
||||
assertShown("8.8")
|
||||
assertNotShown("96%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun criticSettingHidesImdb() {
|
||||
movieRatings()
|
||||
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING))
|
||||
assertShown("96%")
|
||||
assertNotShown("8.8")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() {
|
||||
val ratings =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = 96,
|
||||
rtCriticsLabel = "Certified Fresh",
|
||||
rtAudienceScore = 30,
|
||||
rtAudienceLabel = "Spilled",
|
||||
imdbScore = 8.8,
|
||||
imdbVotes = 10,
|
||||
)
|
||||
val entries = ratings.visibleEntries(community = true, critic = true)
|
||||
val text = ratingsText(entries, leadingDot = false)
|
||||
|
||||
assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%"))
|
||||
assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8"))
|
||||
// dots only between entries, or also in front when something precedes the ratings
|
||||
assertEquals(2, text.text.split("\u2022").size - 1)
|
||||
assertEquals(3, ratingsText(entries, leadingDot = true).text.split("\u2022").size - 1)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue