diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index 00f970b1..b748367e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -67,4 +67,39 @@ class ConductarrAudienceRecoveryTest { } } } + + @Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + 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) + } + } + } + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index 13ac36d4..c4e63d27 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -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) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 37b665ce..dc0b780d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -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() 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() + coEvery { facade.read(personA.id) } returns library + val cache = mockk(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 } }) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt new file mode 100644 index 00000000..e312a66d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt @@ -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() + + @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() + val facade = mockk() + 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) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt new file mode 100644 index 00000000..92afbe07 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -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() + val views = mockk() + val items = mockk() + 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() + val release = CompletableDeferred() + val offsets = mutableListOf() + 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() + val snapshots = mutableListOf() + 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) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 5363848c..279c3966 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -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(), reopened.last().items) + } + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 034f46a7..7687e595 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -48,9 +48,9 @@ class WolfskinHomeViewModel @Inject constructor( private var catalogueSnapshot = DiscoverSnapshot() private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null - suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() - withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } + withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } @@ -58,9 +58,11 @@ class WolfskinHomeViewModel @Inject constructor( } /** The authenticated read path is shared by release and offline fixture-driven tests. */ - internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { val revision = ++loadRevision - if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) { + val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?: + (person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester)) + if (!matchesUser) { snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false) return } @@ -130,7 +132,7 @@ class WolfskinHomeViewModel @Inject constructor( fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { when (action) { HomeAction.Discover -> openRoot(shell, "discover") - HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) } + HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { @@ -200,7 +202,7 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { val identity by model.conductarrState.collectAsState() - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) } + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index fda961a9..f4188e3f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -53,7 +53,7 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She is TitleAction.Requests -> shell.openRequests() is TitleAction.Hide -> if (current != null && requests != null) { val item = current.data.requestTitle(shell.person) - if (action.hidden) requests.hide(item) else requests.restore(item) + if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) requests.overlay = null // TitleState already owns the native hide receipt and Undo. } else -> error("Not a Conductarr title action") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 781f55dd..3d182180 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -142,7 +142,8 @@ class WolfskinShellViewModel @Inject constructor( } suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, + retry = conductarr.identity.value?.signedIn != true) loadHousehold(user) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 525f2b41..fec2c537 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -75,19 +75,24 @@ fun WolfskinLibraryContent( LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading + if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) + val entryRevision = shell.inputRevision if (fixture != null) { grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } grid.status = LibraryStatus.Ready } else { snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> + val focusedId = grid.focusedTitle?.id grid.source = snapshot.items - grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready + focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } + grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } - if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 2f176581..cc9497f1 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -3,6 +3,8 @@ package org.justinvisser.wolfskin.tv.read import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import com.github.damontecres.wholphin.ui.nav.Destination import org.jellyfin.sdk.api.client.ApiClient @@ -47,7 +49,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { } /** A destination spans every matching accessible library; containers never become titles. */ - suspend fun readLibrary(userId: UUID, destination: String): List { + fun readLibrary(userId: UUID, destination: String): Flow> = flow { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> destination(library.name.orEmpty(), library.collectionType) == destination } @@ -76,8 +78,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { ) } offset += chunk.size + if (chunk.isNotEmpty()) emit(titles.values.toList()) } while (chunk.isNotEmpty() && offset < response.totalRecordCount) } - return titles.values.toList() + if (titles.isEmpty()) emit(emptyList()) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index 263922eb..85395ba0 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List, val savedAtMillis: Lon /** Library snapshots share the existing atomic disk store, partitioned by server and person. */ class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { - fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow + var current: List = emptyList() try { - val current = load() + load().collect { chunk -> + current = chunk + if (titles == null) emit(LibrarySnapshot(chunk, null, false)) + } val time = now() + if (titles != null) emit(LibrarySnapshot(current, time, false)) try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ } - emit(LibrarySnapshot(current, time, false)) } catch (exception: Exception) { if (exception is CancellationException) throw exception - emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true)) + emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true)) } }.flowOn(Dispatchers.IO) diff --git a/docker/README.md b/docker/README.md index b1ea4087..aeba3db5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -533,6 +533,48 @@ person's title/person projection. Title and performer disk copies remain isolate by both server and authenticated user; native retry and cache-isolation regressions exercise these boundaries without a live service. +## TV fidelity and source gates + +Library/Discover page headings use the existing exact CSS line-box layout. +Request sheets reserve the inherited inline text descent around their ratings; +person rows and receipt panels therefore share the locked frame's vertical +coordinates. Settings row title/facts also retain their CSS line boxes. These +are local component fixes: shared typography tokens, Home and caption styling +do not shift. + +Home ratings observations use independent six-hour, 300-entry title and +series/season caches. A season retains each observed episode's own score and +timestamp; changing a Jellyfin episode UUID does not lose the observation, and +another episode or season never borrows that score. Episodes without a complete +catalogue identity remain isolated by their Jellyfin identity. + +Cold library grids publish each cumulative 200-item SDK chunk before the next +read completes. An existing complete saved copy stays visible throughout a +refresh, and only a completed read replaces it on disk. A cold later-chunk +failure keeps the already-readable titles with a retry tail, without saving an +incomplete copy. Appended titles preserve the focused title even when the +destination's sort inserts them before it. Catalogue row keys use the same +media-type/TMDB identity as their cards. + +Undo restores a dismissed pick silently; Show again announces “Back in your +picks.” Title pages own their own toast so the request adapter never duplicates +it. The saved Discover retry tail is labelled More picks, not Try again twice. + +People retry retains an already signed-in Conductarr client and its uncertain +submission ledger; only a signed-out identity is rebuilt. Home authorizes its +Jellyfin read by the selected person's saved Jellyfin user ID, so a lost +Conductarr operator or a renamed household profile does not hide that user's +own library. A different saved user ID still fails closed. + +`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite. +It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and +`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color +constructors outside generated tokens, token font sizes below 12 sp, raw +household library labels, and page-counter/navigation wording. Its negative +cases plant each violation independently. Existing native capture checks also +verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures +the usable first chunk while the final read is still suspended. + ## Toolchain and disk use `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index a70e5097..df050bc3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.platform.testTag -import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.Layout import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D @@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) { is DiscoverOverlayState.Hidden -> { ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) } + DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } } } @@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) { Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), color = C.muted, style = DiscoverType.body) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) } + DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } } } @@ -72,15 +73,10 @@ fun DiscoverOverlay(state: DiscoverState) { @Composable private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { - Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle) + Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body) - if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) { - placeable.placeRelative(0, D.sheetRatingTop.roundToPx()) - } - }) + Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) + if (state.isSignedIn) SheetRatings(sheet.title.ratings) when (sheet.phase) { RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.LoadingRecipients -> { @@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { } } +/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */ +@Composable +private fun SheetRatings(ratings: List) { + if (ratings.isEmpty()) return + Layout(content = { + DiscoverRatings(ratings) + Text(" ", color = C.muted, style = DiscoverType.body) + }) { children, constraints -> + val row = children[0].measure(constraints.copy(minHeight = 0)) + val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + val mark = DiscoverType.ratings.fontSize.toPx() + val baseline = (row.height + mark) / 2f + val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat()) + val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent + val top = D.sheetRatingTop.roundToPx() + val bottom = D.sheetRatingBottom.roundToPx() + layout(row.width, kotlin.math.round(height).toInt() + top + bottom) { + row.placeRelative(0, top) + } + } +} + @Composable private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 47ec35ff..65143485 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -300,7 +300,7 @@ class DiscoverState( ShellKey.Ok -> when (titleButton) { 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false) 1 -> openRequest(true) - 2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) } + 2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) } } else -> Unit } @@ -328,13 +328,13 @@ class DiscoverState( ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() } + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() } } is DiscoverOverlayState.Restore -> when (key) { ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null } null -> Unit } @@ -380,9 +380,9 @@ class DiscoverState( } } - fun restore(item: DiscoverTitle) { + fun restore(item: DiscoverTitle, showToast: Boolean) { if (!canRequest) { refresh(); return } - hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks." + hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null updateCollectionCards() val profile = shell.person.key val version = (dismissalVersions[item.key] ?: 0) + 1 @@ -412,7 +412,7 @@ class DiscoverState( val lanes = service?.let { serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) - return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes + return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt index 89ea3093..d36d0f85 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt @@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) { private data class Entry(val ratings: List, val readAt: Long) private val entries = linkedMapOf() + private val seasons = linkedMapOf>() fun read(title: HomeTitle): List? { - val key = key(title) - val entry = entries[key] ?: return null - if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null } + val season = seasonKey(title) + val bucket = season?.let { seasons[it] } + val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)] + if (entry == null) return null + if (clock() - entry.readAt >= lifetimeMillis) { + if (season == null) entries.remove(key(title)) else { + bucket?.remove(title.episode) + if (bucket.isNullOrEmpty()) seasons.remove(season) + } + return null + } return entry.ratings } fun save(title: HomeTitle, ratings: List) { - val key = key(title) - entries.remove(key) - entries[key] = Entry(ratings, clock()) - while (entries.size > capacity) entries.remove(entries.keys.first()) + val entry = Entry(ratings, clock()) + val season = seasonKey(title) + if (season != null) { + val bucket = seasons.remove(season) ?: mutableMapOf() + bucket[requireNotNull(title.episode)] = entry + seasons[season] = bucket + while (seasons.size > capacity) seasons.remove(seasons.keys.first()) + } else { + val key = key(title) + entries.remove(key) + entries[key] = entry + while (entries.size > capacity) entries.remove(entries.keys.first()) + } } private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}" + private fun seasonKey(title: HomeTitle): String? = + if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null) + "${title.tmdbId}:${title.season}" else null + companion object { private const val capacity = 300 private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 9b5bf22e..07082c4f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -84,7 +84,7 @@ fun LibraryScreen( } LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } LaunchedEffect(grid.status) { - if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab) + if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab) } val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap @@ -128,7 +128,10 @@ fun LibraryScreen( LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } else { - items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row -> + items((items.size + grid.columns - 1) / grid.columns, key = { row -> + val title = items[row * grid.columns] + "grid-${if (grid.catalogue) title.catalogueKey else title.id}" + }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { @@ -174,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav Box(Modifier.fillMaxWidth().height(L.row_lhead)) { Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle) + Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle) if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) savedAt?.let { SavedPill(it) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 48a8220b..0bc4d335 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -98,8 +98,8 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Column(Modifier.weight(1f)) { val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" - Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (group.wolfskin) SettingsPill("Wolfskin", focused) SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index 8d95e376..ae66b19b 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -91,6 +91,22 @@ class DiscoverKeysTest { assertNull(state.overlay) assertEquals("The Amazing Spider-Man", state.hero?.title) assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }) + assertNull("Undo restores silently; only Show again announces the return", state.toast) + } + + @Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() { + replay("Round 3 · Not interested on a pick (Discover hero)") + val title = (state.overlay as DiscoverOverlayState.Hidden).title + press(Key.DirectionRight, Key.DirectionCenter) + rule.runOnIdle { + val lane = state.lanes.first { it.hidden } + state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key }) + } + press(Key.DirectionCenter) + assertTrue(state.overlay is DiscoverOverlayState.Restore) + press(Key.DirectionCenter) + assertFalse(state.isHidden(title)) + assertEquals("Back in your picks.", state.toast) } @Test fun heldOkOpensSheetButNeverSubmits() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt index 0a93e74e..fdab628e 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt @@ -30,6 +30,34 @@ class HomeRatingCacheTest { assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399))) } + @Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() { + var now = 0L + val cache = HomeRatingCache { now } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + val pilot = listOf(HomeRating("tmdb", 8.1, episode = true)) + val second = listOf(HomeRating("tmdb", 7.2, episode = true)) + cache.save(episode, pilot) + cache.save(episode.copy(id = "episode-b", episode = 2), second) + assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy"))) + assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2))) + assertNull(cache.read(episode.copy(season = 2))) + assertNull(cache.read(episode.copy(tmdbId = 43))) + now = 6L * 60 * 60 * 1000 + assertNull(cache.read(episode)) + } + + @Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() { + val cache = HomeRatingCache { 0L } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) } + repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) } + assertEquals(ratings, cache.read(episode)) + repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) } + assertNull(cache.read(episode)) + assertEquals(ratings, cache.read(episode.copy(season = 301))) + assertEquals(ratings, cache.read(title.copy(tmdbId = 100))) + } + @Test fun decimalScoresFollowLocaleButPercentagesDoNot() { assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL"))) assertEquals("7.8", ratings.single().format(Locale.US)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index b8d27262..c4130d39 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -172,6 +172,23 @@ class LibraryKeysTest { assertEquals("Title 18", grid.focusedTitle!!.title) } + @Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() { + val titles = List(12) { index -> + val type = if (index < 6) "movie" else "tv" + LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString()) + } + val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it }) + shell.pickerVisible = false + shell.enterDestination("discover") + rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } + press(Key.DirectionDown) + assertEquals(6, grid.index) + press(Key.DirectionCenter) + assertEquals("tv", opened!!.type) + assertEquals("0", opened!!.tmdbId) + assertEquals("tv title 0", opened!!.title) + } + @Test fun scoresFollowLocaleAndUnratedSortsLast() { assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL"))) assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt new file mode 100644 index 00000000..ec217257 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */ +class OwnedSourceAuditTest { + @Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) } + val violations = roots.flatMap { root -> + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source -> + audit(source.name, source.readText()).map { "$source:$it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun plantedViolationsFailEachDoneBarRule() { + val cases = listOf( + Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"), + Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"), + Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"), + Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"), + Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"), + Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"), + Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"), + Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"), + Triple("Screen.kt", "Text(\"DGiC\")", "wording"), + Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"), + Triple("Screen.kt", "Text(\"Next page\")", "wording"), + Triple("Screen.kt", "Text(\"Previous page\")", "wording"), + Triple("Screen.kt", "Text(\"Page 2\")", "wording"), + ) + cases.forEach { (name, source, rule) -> + assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") }) + } + assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty()) + assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty()) + } + + private fun audit(name: String, source: String): List { + val generated = name == "WolfskinTokens.kt" + return source.lineSequence().mapIndexedNotNull { index, line -> + val code = line.trimStart() + if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null + val rules = buildList { + if (!generated && units.containsMatchIn(line)) add("unit") + if (!generated && colors.containsMatchIn(line)) add("color") + if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor") + if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording") + } + rules.map { "${index + 1}:$it" } + }.flatten().toList() + } + + companion object { + private val units = Regex("\\.(?:dp|sp)\\b") + private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)") + private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b") + private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"") + private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE) + } +}