Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.arflix.tv.data.repository

import android.os.Bundle
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arflix.tv.di.RepositoryAccessEntryPoint
import dagger.hilt.android.EntryPointAccessors
import java.time.YearMonth
import java.time.ZoneId
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith

/** Opt-in account benchmark: only disposable calendar metadata is cleared, never account/settings. */
@RunWith(AndroidJUnit4::class)
class ReleaseCalendarLiveDeviceTest {
@Test fun configuredWatchlistsLoadColdAndWarmWithoutLosingReleases() = runBlocking {
assumeTrue(InstrumentationRegistry.getArguments().getString("calendarLive") == "true")
val instrumentation = InstrumentationRegistry.getInstrumentation()
val access = EntryPointAccessors.fromApplication(instrumentation.targetContext, RepositoryAccessEntryPoint::class.java)
val repository = access.releaseCalendarRepository()
val profile = access.profileManager().getProfileIdSync()
val month = YearMonth.now()
val timezone = ZoneId.systemDefault()
repository.invalidateMetadata()
val start = SystemClock.elapsedRealtime()
var firstReleaseMs: Long? = null
var lastReleaseMs = 0L
val discovered = mutableSetOf<String>()
var lists = CalendarWatchlists(profile, emptyMap())
val cold = repository.loadCalendar(profile, month, timezone, "NL") { progress ->
lists = progress.watchlists
if (firstReleaseMs == null && progress.month.entries.isNotEmpty()) {
firstReleaseMs = SystemClock.elapsedRealtime() - start
}
val ids = progress.month.entries.map { it.id }.toSet()
if (ids.any { it !in discovered }) lastReleaseMs = SystemClock.elapsedRealtime() - start
discovered.addAll(ids)
}
val coldMs = SystemClock.elapsedRealtime() - start
assertTrue("This live test requires configured watchlist titles", mergeCalendarWatchlists(lists.items).isNotEmpty())
assertTrue("The configured account must have releases this month", cold.entries.isNotEmpty())
val warmStart = SystemClock.elapsedRealtime()
var warmFirstMs: Long? = null
val warm = repository.loadMonth(lists, month, timezone, "NL") { progress ->
if (warmFirstMs == null && progress.entries.isNotEmpty()) warmFirstMs = SystemClock.elapsedRealtime() - warmStart
}
val warmMs = SystemClock.elapsedRealtime() - warmStart
assertEquals("Warm loading must not omit releases", cold.entries.map { it.id }.toSet(), warm.entries.map { it.id }.toSet())
instrumentation.sendStatus(0, Bundle().apply {
putString("stream", "CALENDAR_LIVE titles=${mergeCalendarWatchlists(lists.items).size} releases=${cold.entries.size} " +
"coldFirst=${firstReleaseMs}ms coldLastRelease=${lastReleaseMs}ms coldComplete=${coldMs}ms warmFirst=${warmFirstMs}ms warmComplete=${warmMs}ms " +
"providerWarnings=${lists.warnings.size} metadataWarnings=${cold.warnings.size - lists.warnings.size}\n")
})
val failedTitles = mergeCalendarWatchlists(lists.items).filter { title ->
cold.warnings.any { it == "Release dates for ${title.media.title} are unavailable." }
}
for (title in failedTitles.take(2)) {
val status = try {
if (title.media.mediaType == com.arflix.tv.data.model.MediaType.TV) {
access.tmdbApi().getTvDetails(title.media.id, com.arflix.tv.util.Constants.TMDB_API_KEY)
} else access.tmdbApi().getMovieDetails(title.media.id, com.arflix.tv.util.Constants.TMDB_API_KEY)
"recovered"
} catch (error: retrofit2.HttpException) { "HTTP_${error.code()}" }
catch (_: Exception) { "network_failure" }
instrumentation.sendStatus(0, Bundle().apply {
putString("stream", "CALENDAR_METADATA_SAMPLE type=${title.media.mediaType} status=$status\n")
})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import com.arflix.tv.data.model.CalendarRelease
import com.arflix.tv.data.model.CalendarReleaseKind
import com.arflix.tv.data.model.ReleaseCalendarSource
import com.arflix.tv.ui.components.AppTopBarContentTopInset
import com.arflix.tv.ui.components.AppTopBarHeight
import com.arflix.tv.ui.components.SidebarItem
import com.arflix.tv.ui.screens.watchlist.calendar.ReleaseCalendarUiState
import com.arflix.tv.ui.theme.ArvioTvTheme
Expand Down Expand Up @@ -133,13 +132,27 @@ class LibraryCalendarDeviceTest {
show(selectCalendar = false)
val tags = listOf("app-topbar", "topbar-profile") + SidebarItem.entries.map { "topbar-item-${it.name}" }
val normalBounds = tags.map { compose.onNodeWithTag(it).getUnclippedBoundsInRoot() }
val normalTabs = compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot()
val normalCalendarTab = compose.onNodeWithText("Calendar").getUnclippedBoundsInRoot()
val normalTabHeight = normalCalendarTab.bottom - normalCalendarTab.top
LibrarySection.entries.forEach { section ->
compose.onNodeWithText(section.label).performClick()
compose.waitForIdle()
assertEquals("All Library sections keep the same compact tab spacing", normalTabs,
compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot())
}
compose.onNodeWithText("Calendar").performClick()
compose.waitForIdle()
assertEquals("Switching Library tabs must not resize or move the shared topbar", normalBounds,
tags.map { compose.onNodeWithTag(it).getUnclippedBoundsInRoot() })
compose.onNodeWithTag("app-topbar").assertHeightIsEqualTo(AppTopBarContentTopInset)
compose.onNodeWithTag("library-section-tabs").assertTopPositionInRootIsEqualTo(AppTopBarHeight - 10.dp)
compose.onNodeWithTag("library-section-tabs").assertTopPositionInRootIsEqualTo(LibraryTabsTopInset)
val tabs = compose.onNodeWithTag("library-section-tabs").getUnclippedBoundsInRoot()
val controlsBottom = tags.drop(1).maxOf { compose.onNodeWithTag(it).getUnclippedBoundsInRoot().bottom }
assertTrue("Library tabs sit just below the visible topbar controls", tabs.top - controlsBottom <= 12.dp)
assertEquals("Calendar uses the same tab position and height as the other Library pages", normalTabs, tabs)
val calendarTab = compose.onNodeWithText("Calendar").getUnclippedBoundsInRoot()
assertEquals(normalTabHeight, calendarTab.bottom - calendarTab.top)
tags.drop(1).forEach { tag ->
assertTrue("Calendar content must not overlap a topbar control",
compose.onNodeWithTag(tag).getUnclippedBoundsInRoot().bottom <= tabs.top)
Expand Down Expand Up @@ -191,7 +204,7 @@ class LibraryCalendarDeviceTest {
compose.runOnIdle { assertEquals(MediaType.TV to 100088, opened) }
}

@Test fun sixWeekMonthKeepsReleaseCountsVisibleAndFocusCrossesMonthEdges() {
@Test fun sixWeekMonthKeepsReleaseCountsVisibleAndFocusStaysWithinMonth() {
val selected = referenceDate.plusMonths(1)
show(selectedDate = selected, configure = ::novemberFixture)
compose.onAllNodes(hasAnyAncestor(hasTestTag("calendar-month-grid")) and hasClickAction()).assertCountEquals(42)
Expand All @@ -207,12 +220,31 @@ class LibraryCalendarDeviceTest {

val first = LocalDate.of(2026, 11, 1)
focusDay(first)
key(Key.DirectionLeft)
assertDate(first.minusDays(1))
compose.onNodeWithTag("calendar-day-2026-10-31").assertIsFocused()
key(Key.DirectionRight)
repeat(4) { key(Key.DirectionLeft) }
assertDate(first)
compose.onNodeWithTag("calendar-day-$first").assertIsFocused()
key(Key.DirectionUp)
compose.onNodeWithTag("calendar-previous-month").assertIsFocused()
key(Key.DirectionDown)
compose.onNodeWithTag("calendar-day-$first").assertIsFocused()
focusDay(first.withDayOfMonth(30))
repeat(4) { key(Key.DirectionRight) }
assertDate(first.withDayOfMonth(30))
compose.onNodeWithText("November 2026").assertIsDisplayed()
}

@Test fun nativeRemoteRepeatsCannotJumpMonthsOrActivateReleases() {
show(selectedDate = LocalDate.of(2026, 10, 1))
focusDay(LocalDate.of(2026, 10, 1))
val instrumentation = InstrumentationRegistry.getInstrumentation()
repeat(35) { instrumentation.sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) }
compose.waitForIdle()
assertDate(LocalDate.of(2026, 10, 31))
repeat(35) { instrumentation.sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_DPAD_LEFT) }
compose.waitForIdle()
assertDate(LocalDate.of(2026, 10, 1))
compose.onNodeWithTag("calendar-day-2026-10-01").assertIsFocused()
compose.runOnIdle { assertEquals(null, opened) }
}

@Test fun emptyDayRemoteShortcutUsesTheSelectedSourceAndReturnsToItsDay() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.arflix.tv.ui.screens.watchlist

import androidx.lifecycle.ViewModelStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.arflix.tv.data.model.*
import com.arflix.tv.data.repository.*
import com.arflix.tv.ui.screens.watchlist.calendar.ReleaseCalendarViewModel
import io.mockk.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import java.time.YearMonth

/** Controlled provider delays exercise the real ViewModel without changing account data. */
@RunWith(AndroidJUnit4::class)
class ReleaseCalendarPreloadDeviceTest {
private suspend fun fixture(block: suspend (ReleaseCalendarViewModel, ReleaseCalendarRepository) -> Unit) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val profiles = mockk<ProfileManager>()
every { profiles.activeProfileId } returns MutableStateFlow("calendar-preload-fixture")
every { profiles.getProfileIdSync() } returns "calendar-preload-fixture"
val watchlist = mockk<WatchlistRepository>()
val items = listOf(MediaItem(1, "Fixture movie"))
every { watchlist.watchlistItems } returns MutableStateFlow(items)
coEvery { watchlist.getLocalWatchlistItems() } returns items
val repository = mockk<ReleaseCalendarRepository>()
coEvery { repository.loadMonth(any(), any(), any(), any(), any(), any(), any()) } returns CalendarMonthResult(emptyList(), emptyList())
val directory = java.io.File(context.cacheDir, "calendar-preload-test-${System.nanoTime()}")
val store = ViewModelStore()
val viewModel = withContext(Dispatchers.Main) {
ReleaseCalendarViewModel(context, repository, watchlist, profiles, ReleaseCalendarCache(directory))
.also { store.put("calendar", it) }
}
try { block(viewModel, repository) }
finally {
withContext(Dispatchers.Main) { store.clear() }
directory.deleteRecursively()
}
}

private fun progress(): CalendarLoadProgress {
val month = YearMonth.now()
val media = MediaItem(1, "Fixture movie")
val entry = CalendarRelease("fixture", media, month.atDay(4), kind = CalendarReleaseKind.MOVIE, sourceIds = setOf("arvio"))
return CalendarLoadProgress(CalendarWatchlists("calendar-preload-fixture", mapOf(ReleaseCalendarSource.ARVIO to listOf(media))),
CalendarMonthResult(listOf(entry), emptyList(), setOf(MediaType.MOVIE to 1)), true, setOf(MediaType.MOVIE to 1))
}

@Test fun doesNotFetchBeforeVisibleOrIdleAndForegroundPromotionSurvivesIdleCancellation() = runBlocking {
fixture { viewModel, repository ->
val started = CompletableDeferred<Unit>()
val finish = CompletableDeferred<Unit>()
coEvery { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } coAnswers {
started.complete(Unit)
arg<suspend (CalendarLoadProgress) -> Unit>(6)(progress())
finish.await()
progress().month
}
delay(300)
coVerify(exactly = 0) { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) }
val preload = launch(Dispatchers.Main) { viewModel.preloadIdle() }
withTimeout(10_000) { started.await() }
withContext(Dispatchers.Main) { viewModel.onVisible() }
preload.cancelAndJoin()
finish.complete(Unit)
val state = withTimeout(10_000) { viewModel.uiState.first { !it.isLoading } }
assertEquals(listOf("fixture"), state.entries.map { it.id })
coVerify(exactly = 1) { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) }
}
}

@Test fun cancelledPreloadResumesOnEntryEvenWithAFreshCompletedProviderSnapshot() = runBlocking {
fixture { viewModel, repository ->
val started = CompletableDeferred<Unit>()
val cancelled = CompletableDeferred<Unit>()
coEvery { repository.loadCalendar(any(), any(), any(), any(), any(), any(), any()) } coAnswers {
arg<suspend (CalendarLoadProgress) -> Unit>(6)(progress().copy(month = CalendarMonthResult(emptyList(), emptyList())))
started.complete(Unit)
try { awaitCancellation() } finally { cancelled.complete(Unit) }
}
coEvery { repository.loadMonth(any(), any(), any(), any(), any(), false, any()) } returns progress().month
val preload = launch(Dispatchers.Main) { viewModel.preloadIdle() }
withTimeout(10_000) { started.await() }
preload.cancelAndJoin()
withTimeout(10_000) { cancelled.await() }
assertTrue(viewModel.uiState.value.isLoading)
withContext(Dispatchers.Main) { viewModel.onVisible() }
val resumed = withTimeout(10_000) { viewModel.uiState.first { !it.isLoading } }
assertEquals(listOf("fixture"), resumed.entries.map { it.id })
coVerify(exactly = 1) { repository.loadMonth(any(), any(), any(), any(), any(), false, any()) }
}
}
}
9 changes: 9 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.arflix.tv.data.api

import com.google.gson.annotations.SerializedName
import com.google.gson.JsonObject
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
Expand All @@ -11,6 +12,14 @@ import retrofit2.http.QueryMap
*/
interface TmdbApi {

@GET("tv/{tv_id}")
suspend fun getTvSeasons(
@Path("tv_id") tvId: Int,
@Query("api_key") apiKey: String,
@Query("append_to_response") appendToResponse: String,
@Query("language") language: String? = null
): JsonObject

@GET("trending/movie/day")
suspend fun getTrendingMovies(
@Query("api_key") apiKey: String,
Expand Down
Loading
Loading