Merge remote-tracking branch 'origin/main' into fm/tv-fb1-visual
All checks were successful
review/code-gpt Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
review/design-claude Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner

This commit is contained in:
justinzeus 2026-11-06 20:39:32 +01:00
commit 12fd12f754
22 changed files with 11163 additions and 53 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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff