Align TV fidelity and recovery behavior #37

Merged
justinzeus merged 2 commits from fm/tv-fidelity-followups into main 2026-11-06 02:35:14 +01:00
22 changed files with 524 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,83 @@
// 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
}
rule.runOnIdle {
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)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<LibraryTitle> {
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = 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())
}
}

View file

@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, 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<LibraryTitle>): Flow<LibrarySnapshot> = flow {
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = 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<LibraryTitle> = 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)

View file

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

View file

@ -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<DiscoverRating>) {
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)

View file

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

View file

@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
private val entries = linkedMapOf<String, Entry>()
private val seasons = linkedMapOf<String, MutableMap<Int, Entry>>()
fun read(title: HomeTitle): List<HomeRating>? {
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<HomeRating>) {
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()

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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