Build the Wolfskin TV shell and navigation

This commit is contained in:
justinzeus 2026-11-05 14:38:38 +01:00
parent de452448ba
commit f60a22e435
97 changed files with 4363 additions and 91 deletions

View file

@ -0,0 +1,38 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Sample the shipped Compose spring, not a separately approximated motion function. */
class NavSpringTest {
@Test
fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() {
val animation = TargetBasedAnimation(
animationSpec = spring<Float>(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
typeConverter = Float.VectorConverter,
initialValue = 478f,
targetValue = 688f,
)
val times = listOf(50, 99, 150, 200)
val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f)
val values = times.map { animation.getValueFromNanos(it * 1_000_000L) }
values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) }
assertTrue((values[1] - 478) / 210 in .55f.. .8f)
assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f)
var previous = 478f
for (millisecond in 0..1000) {
val value = animation.getValueFromNanos(millisecond * 1_000_000L)
assertTrue("Spring reversed at $millisecond ms", value >= previous)
assertTrue("Spring overshot at $millisecond ms", value <= 688f)
previous = value
}
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" })
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
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.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.json.JSONObject
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ShellCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@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
}
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
val state = ShellState(fixturePeople(), "justin")
configure(state)
rule.setContent {
WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st001_launch() = capture("st-001")
@Test fun st002_gear() {
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount)
}
@Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading }
@Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable }
@Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true }
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
}
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
fun fixturePeople(): List<ShellPerson> {
val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) }
val people = json.getJSONArray("profiles")
return List(people.length()) { index ->
val person = people.getJSONObject(index)
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
}
}

View file

@ -0,0 +1,114 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class ShellKeysTest {
@get:Rule val rule = createComposeRule()
private val state = ShellState(fixturePeople(), "justin")
private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter)
private fun replay(keys: List<Key>, presses: Int) {
assertEquals("Acceptance press count", presses, keys.size)
rule.setContent { WolfskinTheme { ShellSurface(state) } }
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
}
@Test fun switchPersonInSixPresses() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6)
assertEquals("mindel", state.person.key)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertFalse(state.navFocused)
assertFalse(state.tabs.contains("anime"))
}
@Test fun openSettingsInEightPresses() {
replay(settings, 8)
assertEquals("settings", state.route)
assertEquals(0, state.settingsRow)
assertFalse(state.pickerVisible)
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertTrue(state.navFocused)
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
assertFalse(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
assertTrue(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
assertFalse(state.gearFocused)
assertEquals(2, state.pickerIndex)
}
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
replay(listOf(Key.DirectionCenter), 1)
val view = state.view
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) }
assertSame(view, state.view)
assertEquals(4, state.view.row)
assertEquals(2, state.view.cards["row-4"])
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) }
assertEquals(state.tabs.size, state.navIndex)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
assertEquals(0, state.navIndex)
}
@Test fun destinationViewsAreIsolatedAndReselectResets() {
replay(listOf(Key.DirectionCenter), 1)
rule.runOnIdle {
state.enterDestination("movies")
val movies = state.view
movies.row = 3; movies.cards["movies"] = 2
state.enterDestination("shows")
state.view.row = 1
state.enterDestination("movies")
assertSame(movies, state.view)
state.focusNav(2); state.key(ShellKey.Ok)
assertNotSame(movies, state.view)
assertEquals(0, state.view.row)
assertTrue(state.view.cards.isEmpty())
}
}
@Test fun heldOkCannotActivateNewOverlay() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3)
rule.runOnIdle {
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) }
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
}
assertTrue(state.pickerVisible)
assertEquals("justin", state.person.key)
}
}

View file

@ -0,0 +1,78 @@
{
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
"profiles": [
{
"key": "justin",
"name": "Justin",
"initial": "J",
"color": "#62c4ff",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Anime": {
"kind": "library",
"collection": "anime"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "mindel",
"name": "Mindel",
"initial": "M",
"color": "#ff9d72",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "shared",
"name": "Shared",
"initial": "S",
"color": "#9be38f",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
}
]
}