Present watched recommendation seeds and request history truthfully
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows

This commit is contained in:
justinzeus 2026-11-06 20:12:06 +01:00
parent deb24e3c9f
commit 278335f009
21 changed files with 11092 additions and 49 deletions

View file

@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.serialization.json.Json
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
import org.justinvisser.wolfskin.design.ComingInStage
import org.justinvisser.wolfskin.design.ShellPerson
import org.junit.Assert.assertEquals
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
class ActivityTruthProjectionTest {
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
val viewer = ShellPerson("justin", "Justin", "J", false)
val expected = mapOf(
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
"dispatch-723" to "Removed", // A historical play is not a current holding.
"dispatch-728" to "Not out yet", // The Body: unreleased.
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
)
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
val source = rows.getValue(id)
val projected = source.comingIn()
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
assertEquals(affected.summary, projected.summary)
assertFalse(projected.readiness[1] == "Failed")
}
}
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
val row = rows.getValue("dispatch-721")
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
val projected = row.copy(stages = row.stages.orEmpty().map {
when (it.name) {
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
else -> it
}
}).comingIn()
assertEquals(ComingInStage.FileHealth, projected.stage)
assertEquals(summary, projected.summary)
}
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
val row = rows.getValue("request-8")
val projected = row.copy(stages = row.stages.orEmpty().map {
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
}).comingIn()
assertEquals(ComingInStage.Waiting, projected.stage)
}
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
val row = rows.getValue("dispatch-725")
val absent = row.copy(stages = row.stages.orEmpty().map {
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
})
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
}
}

View file

@ -41,7 +41,6 @@ class ConductarrProjectionTest {
assertEquals(ComingInStage.Downloading, request.stage)
assertEquals("Mindel", request.requesterName)
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
assertEquals(1, requests.openCount)
}
}
}

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.ConductarrClient
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ConductarrTruthCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@Test fun requestHistory() = requests("requests-history", "request-2")
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
@Test fun discoverMovies() = discover("movie")
@Test fun discoverSeries() = discover("tv")
private fun requests(name: String, focused: String, expanded: Boolean = false) {
FakeConductarr().use { fake ->
fake.activityBody = resource("activity-history.json")
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
val shell = ShellState(people, "justin").apply {
pickerVisible = false
enterDestination("settings")
requests.update(snapshot)
openRequests()
requests.focus(focused)
if (expanded) requests.key(ShellKey.Right)
}
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
capture(name)
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
rule.onNodeWithText("Requests · 56 requests").assertExists()
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
}
}
}
private fun discover(type: String) {
FakeConductarr().use { fake ->
fake.feedBody = resource("mindel-feed.json")
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
val provider = ConductarrProvider(client)
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
}
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
state.filter = type
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
capture("discover-$type")
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
}
}
}
private fun capture(name: String) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.*
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.ConductarrClient
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
class DiscoverSeedProjectionTest {
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
FakeConductarr().use { fake ->
val feed = fake.wire.getValue("feed").jsonObject
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
]""")))
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
]""")))
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
assertTrue(seeds.first().watched && seeds.first().added)
assertFalse(seeds.last().watched)
assertTrue(seeds.last().added)
}
}
}
}

View file

@ -29,6 +29,8 @@ internal class FakeConductarr : Closeable {
@Volatile var localArt = false
@Volatile var beforeResponse: ((String) -> Unit)? = null
@Volatile var audiencesBody: String? = null
@Volatile var feedBody: String? = null
@Volatile var activityBody: String? = null
private val imageBytes = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
@ -69,6 +71,8 @@ internal class FakeConductarr : Closeable {
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
if (key == "audiences") audiencesBody?.let { return response(it) }
if (key == "feed") feedBody?.let { return response(it) }
if (key == "activity") activityBody?.let { return response(it) }
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
var body = wire.getValue(key).jsonObject