120 lines
4.8 KiB
Kotlin
120 lines
4.8 KiB
Kotlin
package org.libremediaconverter
|
|
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.platform.testTag
|
|
import androidx.compose.ui.test.assertIsSelected
|
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
|
import androidx.compose.ui.test.onNodeWithTag
|
|
import androidx.compose.ui.test.onNodeWithText
|
|
import androidx.compose.ui.test.performClick
|
|
import androidx.media3.common.util.UnstableApi
|
|
import org.junit.Rule
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.robolectric.RobolectricTestRunner
|
|
|
|
/**
|
|
* The selected tab has to survive activity recreation, not just recomposition.
|
|
*
|
|
* `remember` covers recomposition only, and `MainActivity` declares no `configChanges`, so
|
|
* every rotation and every resize destroys and recreates the Activity. That is the exact
|
|
* case [AppRoot]'s own KDoc says the shell exists for: from targetSdk 37 the app is resized
|
|
* and rotated whether or not it is ready.
|
|
*
|
|
* [StateRestorationTester] is the tool for it -- `emulateSavedInstanceStateRestore()`
|
|
* disposes the composition and rebuilds it, so anything held only by `remember` is gone and
|
|
* only saved state comes back. It is Compose's own stand-in for the recreation rather than
|
|
* the real thing: it saves into an in-memory map instead of parcelling through a `Bundle`,
|
|
* so it proves `rememberSaveable` is being used -- not that a particular saved
|
|
* representation survives a `Bundle` round trip. A JVM round-trip test on the
|
|
* saver covers the representation.
|
|
*
|
|
* Robolectric rather than the instrumented suite, deliberately -- but not because the
|
|
* instrumented suite is unavailable. It runs on this host for API 33-36
|
|
* (`tools/local-emulator/run-e2e.sh`), and CI runs 33-37. The reason is cost: this test
|
|
* needs a composition and a saved-state round trip, nothing a device supplies, and it runs
|
|
* in the same `./gradlew` invocation as every other JVM test instead of booting an
|
|
* emulator. A loop measured in seconds is a loop people stay inside.
|
|
*/
|
|
@UnstableApi
|
|
@RunWith(RobolectricTestRunner::class)
|
|
class AppRootRestorationTest {
|
|
|
|
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
|
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
|
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
|
@get:Rule
|
|
val composeRule = createComposeRule()
|
|
|
|
private val restoration = StateRestorationTester(composeRule)
|
|
|
|
/**
|
|
* The stub screen is matched by a test tag rather than by text: the label on the bar
|
|
* ("Join") and the enum constant ("JOIN") differ only in case, and a matcher that could
|
|
* pick up either is not an assertion.
|
|
*/
|
|
private fun tagFor(destination: Destination) = "content:${destination.name}"
|
|
|
|
private fun assertShowing(destination: Destination) {
|
|
composeRule.onNodeWithTag(tagFor(destination)).assertExists()
|
|
composeRule.onNodeWithText(destination.label).assertIsSelected()
|
|
}
|
|
|
|
private fun setShell(width: () -> WindowWidthSizeClass) {
|
|
restoration.setContent {
|
|
AppRoot(width()) { destination, modifier ->
|
|
Box(modifier.testTag(tagFor(destination)))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun `the selected tab survives recreation on a phone`() {
|
|
setShell { WindowWidthSizeClass.Compact }
|
|
assertShowing(Destination.CONVERT)
|
|
|
|
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
|
assertShowing(Destination.JOIN)
|
|
|
|
restoration.emulateSavedInstanceStateRestore()
|
|
|
|
assertShowing(Destination.JOIN)
|
|
}
|
|
|
|
@Test
|
|
fun `the selected tab survives recreation on the rail layout`() {
|
|
setShell { WindowWidthSizeClass.Expanded }
|
|
|
|
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
|
assertShowing(Destination.JOIN)
|
|
|
|
restoration.emulateSavedInstanceStateRestore()
|
|
|
|
assertShowing(Destination.JOIN)
|
|
}
|
|
|
|
/**
|
|
* The real rotation: the width class changes across the recreation, so the shell comes
|
|
* back as a rail where it went out as a bottom bar. The tab still has to be the one the
|
|
* user chose.
|
|
*/
|
|
@Test
|
|
fun `the selected tab survives a rotation that also changes the width class`() {
|
|
var width by mutableStateOf(WindowWidthSizeClass.Compact)
|
|
setShell { width }
|
|
|
|
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
|
assertShowing(Destination.JOIN)
|
|
|
|
width = WindowWidthSizeClass.Expanded
|
|
restoration.emulateSavedInstanceStateRestore()
|
|
|
|
assertShowing(Destination.JOIN)
|
|
}
|
|
}
|