Cover outgoing title and person Back ownership
Some checks failed
review/code-claude Code APPROVE: Home delta fine, no blocker
review/design-claude Design BLOCK: picker gear moved without approval (B1)

This commit is contained in:
justinzeus 2026-11-07 19:19:45 +01:00
parent c5dbe91076
commit a27e922675

View file

@ -0,0 +1,107 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.PersonRead
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleEntryBackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
private lateinit var conductarr: ConductarrSession
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
if (::conductarr.isInitialized) conductarr.close()
}
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
@Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true)
private fun verifyOutgoingBack(personPage: Boolean) {
val activity = rule.activity
val navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key) {
if (it == ShellAction.Exit) navigation.requestExit()
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = false }
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
val destination = Destination.Wolfskin(route)
val facade = mockk<TitleReadFacade>()
if (personPage) {
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } returns PersonRead(TitlePerson(id.toString(), "Outgoing actor"),
listOf(TitleItem("credit", "Credit", "movie")))
} else {
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } returns TitlePageData(TitleItem(id.toString(), "Outgoing film", "movie", held = true))
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true))
shell.enterDestination(route)
navigation.navigateTo(destination)
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent {
WolfskinTheme {
rememberWolfskinBackHandler(shell, navigation)
val current = navigation.backStack.last()
LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") }
Box {
ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} }
// Retain the real outgoing destination just as NavDisplay does during its exit animation.
ScreenEntry(shell, route, current == destination) {
TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
}
}
}
}
rule.waitUntil(5_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
(if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused
}
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.waitForIdle()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
assertFalse(shell.navFocused)
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(16)
assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused)
assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(16)
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
}
}