Align TV layouts and close recovery gaps

This commit is contained in:
justinzeus 2026-11-06 02:15:09 +01:00
parent bfe3dbbb16
commit 04b1bdd6aa
22 changed files with 522 additions and 53 deletions

View file

@ -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)
}
}
}
}
}

View file

@ -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) } }

View file

@ -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 } })
}
}

View file

@ -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) }
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}