Align TV layouts and close recovery gaps
This commit is contained in:
parent
bfe3dbbb16
commit
04b1bdd6aa
22 changed files with 522 additions and 53 deletions
|
|
@ -67,4 +67,39 @@ class ConductarrAudienceRecoveryTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
ConductarrPreference.initialize(context)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val previousUrl = ConductarrPreference.baseUrl
|
||||
FakeConductarr().use { fake ->
|
||||
ConductarrPreference.updateBaseUrl(fake.url)
|
||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
session.authenticate()
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
try {
|
||||
val identity = session.identity.value!!
|
||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||
org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared")
|
||||
fake.postStatus = 503
|
||||
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||
identity.provider.operations(identity.operator!!).request(intent).phase)
|
||||
model.retryConductarr(server, user)
|
||||
val refreshed = session.identity.value!!
|
||||
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||
refreshed.provider.operations(refreshed.operator!!).request(intent).phase)
|
||||
assertEquals(1, fake.calls.count { it.method == "POST" })
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class ConductarrHomeSurfaceTest {
|
|||
try {
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
||||
rule.setContent { WolfskinTheme {
|
||||
LaunchedEffect(model) { model.loadLibrary(server, user, "shared") }
|
||||
LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) }
|
||||
val snapshot = model.snapshot
|
||||
CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) {
|
||||
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class HomeRecoveryTest {
|
|||
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
|
||||
private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString())
|
||||
private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString())
|
||||
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
|
||||
private val library = HomeLibrary(resume = listOf(film))
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ class HomeRecoveryTest {
|
|||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
|
|
@ -63,10 +65,10 @@ class HomeRecoveryTest {
|
|||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||
startedA.await()
|
||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
model.loadLibrary(server, personB, "mindel")
|
||||
model.loadLibrary(server, personB, viewerB)
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertNull(model.snapshot.savedAt)
|
||||
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
||||
|
|
@ -83,9 +85,23 @@ class HomeRecoveryTest {
|
|||
every { cache.read(any()) } returns null
|
||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model.loadLibrary(server, personA, "justin")
|
||||
model.loadLibrary(server, personA, viewerA)
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNull(model.toast)
|
||||
}
|
||||
|
||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
model.loadLibrary(server, personA, viewerB.copy(key = "justin"))
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The real host exposes usable remote focus before the remaining library chunks arrive. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryProgressiveSurfaceTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("movies")
|
||||
focusNav(currentTab)
|
||||
}
|
||||
val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null)
|
||||
val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val facade = mockk<LibraryReadFacade>()
|
||||
every { facade.audiences } returns null
|
||||
every { facade.readLibrary(user.id, "movies") } returns flow {
|
||||
emit(first)
|
||||
release.await()
|
||||
emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie"))
|
||||
}
|
||||
val scope = "progressive:${UUID.randomUUID()}"
|
||||
val navigation = NavigationManager()
|
||||
val seeAll = WolfskinSeeAllLists()
|
||||
try {
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||
WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier)
|
||||
} } }
|
||||
rule.waitUntil(timeoutMillis = 10_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
shell.view.library?.source?.size == 200
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(500)
|
||||
assertFalse(shell.navFocused)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath)
|
||||
release.complete(Unit)
|
||||
rule.waitUntil(timeoutMillis = 10_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
shell.view.library?.source?.size == 201
|
||||
}
|
||||
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||
assertFalse(shell.navFocused)
|
||||
} finally { release.complete(Unit) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||
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.ItemSortBy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.justinvisser.wolfskin.tv.read.LibrarySnapshot
|
||||
import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class LibraryReadFacadeTest {
|
||||
@Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking {
|
||||
val api = mockk<ApiClient>()
|
||||
val views = mockk<UserViewsApi>()
|
||||
val items = mockk<ItemsApi>()
|
||||
every { api.userViewsApi } returns views
|
||||
every { api.itemsApi } returns items
|
||||
val user = UUID(0, 1)
|
||||
val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||
coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library))
|
||||
val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) }
|
||||
val nextRead = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val offsets = mutableListOf<Int>()
|
||||
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||
assertEquals(200, offsets.last())
|
||||
nextRead.complete(Unit)
|
||||
release.await()
|
||||
successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202)
|
||||
}
|
||||
}
|
||||
val facade = LibraryReadFacade(api)
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
val published = CompletableDeferred<LibrarySnapshot>()
|
||||
val snapshots = mutableListOf<LibrarySnapshot>()
|
||||
val job = launch {
|
||||
store.read("server:justin", "movies") {
|
||||
facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) }
|
||||
}.collect {
|
||||
snapshots += it
|
||||
if (!published.isCompleted) published.complete(it)
|
||||
}
|
||||
}
|
||||
val initial = published.await()
|
||||
assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id })
|
||||
nextRead.await()
|
||||
assertTrue(job.isActive)
|
||||
release.complete(Unit)
|
||||
job.join()
|
||||
val final = snapshots.last()
|
||||
assertEquals(201, final.items.size)
|
||||
assertEquals("Updated film", final.items.first().title)
|
||||
assertEquals("Last film", final.items.last().title)
|
||||
assertFalse(final.unreachable)
|
||||
val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single()
|
||||
assertEquals(final.items, saved.items)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
|
|||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
|
@ -20,7 +21,7 @@ class LibrarySnapshotStoreTest {
|
|||
var now = 1000L
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||
val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true))
|
||||
store.read("server:justin", "movies") { items }.toList()
|
||||
store.read("server:justin", "movies") { flowOf(items) }.toList()
|
||||
val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList()
|
||||
assertEquals(items, fresh.single().items)
|
||||
now += 11 * 60_000
|
||||
|
|
@ -39,11 +40,31 @@ class LibrarySnapshotStoreTest {
|
|||
|
||||
@Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking {
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
store.read("server:justin", "movies") { emptyList() }.toList()
|
||||
store.read("server:justin", "movies") { flowOf(emptyList()) }.toList()
|
||||
val current = listOf(LibraryTitle("b", "New Film", "movie"))
|
||||
val reads = store.read("server:justin", "movies", force = true) { current }.toList()
|
||||
val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList()
|
||||
assertTrue(reads.first().items.isEmpty())
|
||||
assertEquals(current, reads.last().items)
|
||||
assertFalse(reads.last().unreachable)
|
||||
}
|
||||
|
||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||
for (hasSaved in listOf(false, true)) {
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||
if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList()
|
||||
val reads = store.read("server:justin", "movies", force = true) {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
emit(partial)
|
||||
throw java.io.IOException("Later chunk failed")
|
||||
}
|
||||
}.toList()
|
||||
assertEquals(if (hasSaved) complete else partial, reads.last().items)
|
||||
assertTrue(reads.last().unreachable)
|
||||
assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis)
|
||||
val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList()
|
||||
assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue