Build the Wolfskin TV shell and navigation
This commit is contained in:
parent
de452448ba
commit
f60a22e435
97 changed files with 4363 additions and 91 deletions
|
|
@ -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}" })
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
78
wolfskin-design/src/test/resources/wolfskin-shell.json
Normal file
78
wolfskin-design/src/test/resources/wolfskin-shell.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue