Add 22 tests for LongPressMenuEditor

Also improve some tests for LongPressMenu and add some test utility functions
This commit is contained in:
Stypox 2026-02-10 20:32:15 +01:00
parent 94608137ef
commit ae214a04ff
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
4 changed files with 714 additions and 28 deletions

View File

@ -1,15 +1,31 @@
package org.schabi.newpipe
import android.app.Instrumentation
import android.content.Context
import android.os.SystemClock
import android.view.MotionEvent
import androidx.annotation.StringRes
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.TouchInjectionScope
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.preference.PreferenceManager
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.fail
/**
* Use this instead of calling `InstrumentationRegistry.getInstrumentation()` every time.
*/
val inst: Instrumentation
get() = InstrumentationRegistry.getInstrumentation()
/**
* Use this instead of passing contexts around in instrumented tests.
*/
@ -31,6 +47,15 @@ fun clearPrefs() {
.edit().clear().apply()
}
/**
* E.g. useful to tap outside dialogs to see whether they close.
*/
fun tapAtAbsoluteXY(x: Float, y: Float) {
val t = SystemClock.uptimeMillis()
inst.sendPointerSync(MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0))
inst.sendPointerSync(MotionEvent.obtain(t, t + 50, MotionEvent.ACTION_UP, x, y, 0))
}
/**
* Same as the original `onNodeWithText` except that this takes a [StringRes] instead of a [String].
*/
@ -55,6 +80,11 @@ fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription(
return this.onNodeWithContentDescription(ctx.getString(text), substring, ignoreCase, useUnmergedTree)
}
/**
* Shorthand for `.fetchSemanticsNode().positionOnScreen`.
*/
fun SemanticsNodeInteraction.fetchPosOnScreen() = fetchSemanticsNode().positionOnScreen
/**
* Asserts that [value] is in the range [[l], [r]] (both extremes included).
*/
@ -78,3 +108,36 @@ fun <T : Comparable<T>> assertNotInRange(l: T, r: T, value: T) {
fail("Expected $value to NOT be in range [$l, $r]")
}
}
/**
* Tries to scroll vertically in the container [this] and uses [itemInsideScrollingContainer] to
* compute how much the container actually scrolled. Useful in tandem with [assertMoved] or
* [assertDidNotMove].
*/
fun SemanticsNodeInteraction.scrollVerticallyAndGetOriginalAndFinalY(
itemInsideScrollingContainer: SemanticsNodeInteraction,
startY: TouchInjectionScope.() -> Float = { bottom },
endY: TouchInjectionScope.() -> Float = { top }
): Pair<Float, Float> {
val originalPosition = itemInsideScrollingContainer.fetchPosOnScreen()
this.performTouchInput { swipeUp(startY = startY(), endY = endY()) }
val finalPosition = itemInsideScrollingContainer.fetchPosOnScreen()
assertEquals(originalPosition.x, finalPosition.x)
return Pair(originalPosition.y, finalPosition.y)
}
/**
* Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY].
*/
fun Pair<Float, Float>.assertMoved() {
val (originalY, finalY) = this
assertNotEquals(originalY, finalY)
}
/**
* Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY].
*/
fun Pair<Float, Float>.assertDidNotMove() {
val (originalY, finalY) = this
assertEquals(originalY, finalY)
}

View File

@ -0,0 +1,611 @@
package org.schabi.newpipe.ui.components.menu
import android.os.Build
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.TouchInjectionScope
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso
import androidx.test.espresso.device.DeviceInteraction.Companion.setDisplaySize
import androidx.test.espresso.device.EspressoDevice.Companion.onDevice
import androidx.test.espresso.device.rules.DisplaySizeRule
import androidx.test.espresso.device.sizeclass.HeightSizeClass
import androidx.test.espresso.device.sizeclass.WidthSizeClass
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import kotlin.math.absoluteValue
import kotlin.math.sign
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.assertInRange
import org.schabi.newpipe.ctx
import org.schabi.newpipe.fetchPosOnScreen
import org.schabi.newpipe.inst
import org.schabi.newpipe.onNodeWithContentDescription
import org.schabi.newpipe.onNodeWithText
import org.schabi.newpipe.scrollVerticallyAndGetOriginalAndFinalY
import org.schabi.newpipe.tapAtAbsoluteXY
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi
import org.schabi.newpipe.ui.theme.AppTheme
@RunWith(AndroidJUnit4::class)
class LongPressMenuEditorTest {
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<ComponentActivity>()
// Test rule for restoring device to its starting display size when a test case finishes.
// See https://developer.android.com/training/testing/different-screens/tools#resize-displays.
@get:Rule(order = 2)
val displaySizeRule: TestRule =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
DisplaySizeRule()
} else {
RuleChain.emptyRuleChain()
}
/**
* Sets up the [LongPressMenuEditorPage] in the [composeRule] Compose content for running tests.
* Handles setting enabled header and actions via shared preferences, and closing the dialog
* when it is dismissed.
*/
private fun setEditor(
onDismissRequest: () -> Unit = {},
isHeaderEnabled: Boolean = true,
actionArrangement: List<LongPressAction.Type> = LongPressAction.Type.entries
) {
storeIsHeaderEnabledToSettings(ctx, isHeaderEnabled)
storeLongPressActionArrangementToSettings(ctx, actionArrangement)
composeRule.setContent {
var isEditorVisible by rememberSaveable { mutableStateOf(true) }
if (isEditorVisible) {
AppTheme {
LongPressMenuEditorPage {
isEditorVisible = false
onDismissRequest()
}
}
}
}
}
private fun closeEditorAndAssertNewSettings(
isHeaderEnabled: Boolean,
actionArrangement: List<LongPressAction.Type>
) {
composeRule.onNodeWithContentDescription(R.string.back)
.assertIsDisplayed()
Espresso.pressBackUnconditionally()
composeRule.waitUntil {
composeRule.onNodeWithContentDescription(R.string.back)
.runCatching { assertDoesNotExist() }
.isSuccess
}
assertEquals(isHeaderEnabled, loadIsHeaderEnabledFromSettings(ctx))
assertEquals(actionArrangement, loadLongPressActionArrangementFromSettings(ctx))
}
/**
* Checks whether the action (or the header) found by text [label] is above or below the text
* indicating that all actions below are disabled.
*/
private fun assertActionEnabledStatus(
@StringRes label: Int,
expectedEnabled: Boolean
) {
val buttonBounds = composeRule.onNodeWithText(label)
.getUnclippedBoundsInRoot()
val hiddenActionTextBounds = composeRule.onNodeWithText(R.string.long_press_menu_hidden_actions)
.getUnclippedBoundsInRoot()
assertEquals(expectedEnabled, buttonBounds.top < hiddenActionTextBounds.top)
}
/**
* The editor should always have all actions visible. Works as expected only if the screen is
* big enough to hold all items at once, otherwise LazyColumn will hide some lazily.
*/
private fun assertHeaderAndAllActionsExist() {
for (label in listOf(R.string.long_press_menu_header) + LongPressAction.Type.entries.map { it.label }) {
composeRule.onNodeWithText(label)
.assertExists()
}
}
/**
* Long-press-and-move is used to change the arrangement of items in the editor. If you pass
* [longPressDurationMs]`=0` you can simulate the user just dragging across the screen,
* because there was no long press.
*/
private fun SemanticsNodeInteraction.longPressThenMove(
dx: TouchInjectionScope.() -> Int = { 0 },
dy: TouchInjectionScope.() -> Int = { 0 },
longPressDurationMs: Long = 1000
): SemanticsNodeInteraction {
return performTouchInput {
down(center)
advanceEventTime(longPressDurationMs) // perform long press
val dy = dy()
repeat(dy.absoluteValue) {
moveBy(Offset(0f, dy.sign.toFloat()), 100)
}
val dx = dx()
repeat(dx.absoluteValue) {
moveBy(Offset(dx.sign.toFloat(), 0f), 100)
}
up()
}
}
@Test
fun pressingBackButtonCallsCallback() {
var calledCount = 0
setEditor(onDismissRequest = { calledCount += 1 })
composeRule.onNodeWithContentDescription(R.string.back)
.performClick()
composeRule.waitUntil { calledCount == 1 }
}
/**
* Opens the reset dialog by pressing on the corresponding button, either with DPAD or touch.
*/
private fun openResetDialog(useDpad: Boolean) {
if (useDpad) {
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
} else {
composeRule.onNodeWithContentDescription(R.string.reset_to_defaults)
.performClick()
}
composeRule.waitUntil {
composeRule.onNodeWithText(R.string.long_press_menu_reset_to_defaults_confirm)
.isDisplayed()
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testEveryActionAndHeaderExists1() {
// need a large screen to ensure all items are visible
onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.EXPANDED)
setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries.filter { it.id % 2 == 0 })
assertHeaderAndAllActionsExist()
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testEveryActionAndHeaderExists2() {
// need a large screen to ensure all items are visible
onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.EXPANDED)
setEditor(isHeaderEnabled = false, actionArrangement = listOf())
assertHeaderAndAllActionsExist()
}
@Test
fun testResetButtonCancel() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
openResetDialog(useDpad = false)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
composeRule.onNodeWithText(R.string.cancel)
.performClick()
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
}
@Test
fun testResetButtonTapOutside() {
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue))
openResetDialog(useDpad = true)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true)
tapAtAbsoluteXY(200f, 200f)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true)
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(Enqueue))
}
@Test
fun testResetButtonPressBack() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf())
openResetDialog(useDpad = false)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
Espresso.pressBack()
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf())
}
@Test
fun testResetButtonOk() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
openResetDialog(useDpad = true)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
composeRule.onNodeWithText(R.string.ok)
.performClick()
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = true)
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true)
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = getDefaultEnabledLongPressActions(ctx))
}
@Test
fun testDraggingItemToDisable() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true)
val kodiOriginalPos = composeRule.onNodeWithText(PlayWithKodi.label).fetchPosOnScreen()
// long-press then move the Enqueue item down
composeRule.onNodeWithText(Enqueue.label)
.longPressThenMove(dy = { 3 * height })
// assert that only Enqueue was disabled
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = false)
assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true)
// assert that the Kodi item moved horizontally but not vertically
val kodiFinalPos = composeRule.onNodeWithText(PlayWithKodi.label).fetchPosOnScreen()
assertEquals(kodiOriginalPos.y, kodiFinalPos.y)
assertNotEquals(kodiOriginalPos.x, kodiFinalPos.x)
// make sure the new setting is saved
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi))
}
@Test
fun testDraggingHeaderToDisable() {
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi))
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true)
// long-press then move the header down
composeRule.onNodeWithText(R.string.long_press_menu_header)
.longPressThenMove(dy = { 3 * height })
// assert that only the header was disabled
assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true)
// make sure the new setting is saved
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDraggingItemWithoutLongPressOnlyScrolls() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
// scroll up by 3 pixels
val (originalY, finalY) = composeRule.onNodeWithTag("LongPressMenuEditorGrid")
.scrollVerticallyAndGetOriginalAndFinalY(
itemInsideScrollingContainer = composeRule.onNodeWithText(Enqueue.label),
startY = { bottom },
endY = { bottom - 30 }
)
assertInRange(originalY - 40, originalY - 20, finalY)
// scroll back down by dragging on an item (without long pressing, longPressDurationMs = 0!)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
composeRule.onNodeWithText(Enqueue.label)
.longPressThenMove(dy = { 3 * height }, longPressDurationMs = 0)
assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true)
// make sure that we are back to the original scroll state
val posAfterScrollingBack = composeRule.onNodeWithText(Enqueue.label).fetchPosOnScreen()
assertEquals(originalY, posAfterScrollingBack.y)
// make sure that the item was not moved
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDraggingItemToBottomScrollsDown() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
composeRule.onNodeWithText(PlayWithKodi.label)
.assertExists()
// drag the Enqueue item to the bottom of the screen
val rootBottom = composeRule.onNodeWithTag("LongPressMenuEditorGrid")
.fetchSemanticsNode()
.boundsInWindow
.bottom
composeRule.onNodeWithText(Enqueue.label)
.longPressThenMove(dy = { (rootBottom - center.y).toInt() })
// the Kodi button does not exist anymore because the screen scrolled past it
composeRule.onNodeWithText(PlayWithKodi.label)
.assertDoesNotExist()
composeRule.onNodeWithText(Enqueue.label)
.assertExists()
// make sure that Enqueue is now disabled
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDraggingItemToTopScrollsUp() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
setEditor(isHeaderEnabled = true, actionArrangement = listOf())
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
.assertExists()
// scroll grid to the bottom (hacky way to achieve so is to swipe up 10 times)
composeRule.onNodeWithTag("LongPressMenuEditorGrid")
.performTouchInput { swipeUp() }
// the enabled description does not exist anymore because the screen scrolled past it
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
.assertDoesNotExist()
// find any action that is now visible on screen
val actionToDrag = LongPressAction.Type.entries
.first { composeRule.onNodeWithText(it.label).runCatching { isDisplayed() }.isSuccess }
// drag it to the top of the screen (using a large dy since going out of the screen bounds
// does not invalidate the touch gesture)
composeRule.onNodeWithText(actionToDrag.label)
.longPressThenMove(dy = { -2000 })
// the enabled description now should exist again because the view scrolled back up
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
.assertExists()
composeRule.onNodeWithText(actionToDrag.label)
.assertExists()
// make sure the actionToDrag is now enabled
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(actionToDrag))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDpadScrolling() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
composeRule.onNodeWithText(Enqueue.label).assertExists()
repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) }
composeRule.onNodeWithText(Enqueue.label).assertDoesNotExist() // scrolled down!
repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_UP) }
composeRule.onNodeWithText(Enqueue.label).assertExists() // scrolled back up!
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDpadScrollingWhileDraggingHeader() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi))
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertDoesNotExist()
// grab the header which is always in top left
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// ensure that the header was picked up by checking the presence of the placeholder
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertExists()
// same checks as in testDpadScrolling
composeRule.onNodeWithText(Enqueue.label).assertExists()
repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) }
composeRule.onNodeWithText(Enqueue.label).assertDoesNotExist() // scrolled down!
repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_UP) }
composeRule.onNodeWithText(Enqueue.label).assertExists() // scrolled back up!
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDpadDraggingHeader() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM)
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi))
val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertDoesNotExist()
// grab the header which is always in top left
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// the header was grabbed and is thus in an offset position
val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertNotEquals(originalHeaderPos.x, dragHeaderPos.x)
assertNotEquals(originalHeaderPos.y, dragHeaderPos.y)
// ensure that the header was picked up by checking the presence of the placeholder
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertExists()
// move down a few times and release
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// the header was released in yet another position
val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertEquals(originalHeaderPos.x, endHeaderPos.x) // always first item
assertNotEquals(originalHeaderPos.y, endHeaderPos.y)
assertNotEquals(dragHeaderPos.x, endHeaderPos.x)
assertNotEquals(dragHeaderPos.y, endHeaderPos.y)
// make sure the header is now disabled
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testDpadDraggingItem() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM)
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi))
val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertDoesNotExist()
// grab the Enqueue item which is just right of the header which is always in top left
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// ensure the header was not picked up by checking that it is still in the same position
// (though the y might have changed because of scrolling)
val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertEquals(originalHeaderPos.x, dragHeaderPos.x)
// ensure that the Enqueue item was picked up by checking the presence of the placeholder
composeRule.onNodeWithText(R.string.detail_drag_description)
.assertExists()
// move down a few times and release
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// make sure the Enqueue item is now disabled
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(PlayWithKodi))
}
@Test
fun testNoneMarkerIsShownIfNoItemsEnabled() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf())
assertActionEnabledStatus(R.string.none, true)
}
@Test
fun testNoneMarkerIsShownIfNoItemsDisabled() {
setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries)
// scroll grid to the bottom (hacky way to achieve so is to swipe up 10 times)
composeRule.onNodeWithTag("LongPressMenuEditorGrid")
.performTouchInput { repeat(10) { swipeUp() } }
assertActionEnabledStatus(R.string.none, false)
}
@Test
fun testDpadReordering() {
setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi))
// grab the Enqueue item which is just right of the header which is always in top left
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// move the item right (past PlayWithKodi) and release it
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// the items now should have swapped
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(PlayWithKodi, Enqueue))
}
@Test
fun testDpadHeaderIsAlwaysInFirstPosition() {
setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries)
val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
// grab the header which is always in top left
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// the header was grabbed and is thus in an offset position
val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertNotEquals(originalHeaderPos.x, dragHeaderPos.x)
assertNotEquals(originalHeaderPos.y, dragHeaderPos.y)
// even after moving the header around through the enabled actions ...
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT)
inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER)
// ... after releasing it its position will still be the original
val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertEquals(originalHeaderPos.x, endHeaderPos.x)
assertEquals(originalHeaderPos.y, endHeaderPos.y)
// nothing should have changed
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries)
}
@Test
fun testTouchReordering() {
setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi))
// move the Enqueue item to the right
composeRule.onNodeWithText(Enqueue.label)
.longPressThenMove(dx = { 200.dp.value.toInt() })
// the items now should have swapped
closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi, Enqueue))
}
@Test
fun testTouchHeaderIsAlwaysInFirstPosition() {
setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries)
val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
// grab the header and move it around through the enabled actions
composeRule.onNodeWithText(R.string.long_press_menu_header)
.longPressThenMove(dx = { 2 * width }, dy = { 2 * height })
// after releasing it its position will still be the original
val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen()
assertEquals(originalHeaderPos.x, endHeaderPos.x)
assertEquals(originalHeaderPos.y, endHeaderPos.y)
// nothing should have changed
closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries)
}
}

View File

@ -21,11 +21,10 @@ import androidx.compose.ui.test.isNotDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
@ -44,19 +43,23 @@ import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.delay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.assertDidNotMove
import org.schabi.newpipe.assertInRange
import org.schabi.newpipe.assertMoved
import org.schabi.newpipe.assertNotInRange
import org.schabi.newpipe.ctx
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.onNodeWithContentDescription
import org.schabi.newpipe.onNodeWithText
import org.schabi.newpipe.scrollVerticallyAndGetOriginalAndFinalY
import org.schabi.newpipe.tapAtAbsoluteXY
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.BackgroundShuffled
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi
@ -135,7 +138,8 @@ class LongPressMenuTest {
.isDisplayed()
}
Espresso.pressBack()
composeRule.onNodeWithContentDescription(R.string.back)
.performClick()
composeRule.waitUntil {
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
.isNotDisplayed()
@ -458,14 +462,16 @@ class LongPressMenuTest {
fun assertOnlyAndAllArrangedActionsDisplayed(
availableActions: List<LongPressAction.Type>,
actionArrangement: List<LongPressAction.Type>,
expectedShownActions: List<LongPressAction.Type>
expectedShownActions: List<LongPressAction.Type>,
onDismissRequest: () -> Unit = {}
) {
setLongPressMenu(
longPressActions = availableActions.map { LongPressAction(it) {} },
// whether the header is enabled or not shouldn't influence the result, so enable it
// at random (but still deterministically)
isHeaderEnabled = ((expectedShownActions + availableActions).sumOf { it.id } % 2) == 0,
actionArrangement = actionArrangement
actionArrangement = actionArrangement,
onDismissRequest = onDismissRequest
)
for (type in LongPressAction.Type.entries) {
composeRule.onNodeWithText(type.label)
@ -563,54 +569,58 @@ class LongPressMenuTest {
@Test
fun testFewActionsOnNormalScreenAreNotScrollable() {
var dismissedCount = 0
assertOnlyAndAllArrangedActionsDisplayed(
availableActions = listOf(ShowDetails, ShowChannelDetails),
actionArrangement = listOf(ShowDetails, ShowChannelDetails),
expectedShownActions = listOf(ShowDetails, ShowChannelDetails)
expectedShownActions = listOf(ShowDetails, ShowChannelDetails),
onDismissRequest = { dismissedCount += 1 }
)
// try to scroll and confirm that items don't move because the menu is not overflowing the
// screen height
composeRule.onNodeWithTag("LongPressMenuGrid")
.assert(hasScrollAction())
val originalPosition = composeRule.onNodeWithText(ShowDetails.label)
.fetchSemanticsNode()
.positionOnScreen
composeRule.onNodeWithTag("LongPressMenuGrid")
.performTouchInput { swipeUp() }
val finalPosition = composeRule.onNodeWithText(ShowDetails.label)
.fetchSemanticsNode()
.positionOnScreen
assertEquals(originalPosition, finalPosition)
.scrollVerticallyAndGetOriginalAndFinalY(composeRule.onNodeWithText(ShowDetails.label))
.assertDidNotMove()
// also test that clicking on the top of the screen does not close the dialog because it
// spans all of the screen
tapAtAbsoluteXY(100f, 100f)
composeRule.waitUntil { dismissedCount == 1 }
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
fun testAllActionsOnSmallScreenAreScrollable() {
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
var dismissedCount = 0
assertOnlyAndAllArrangedActionsDisplayed(
availableActions = LongPressAction.Type.entries,
actionArrangement = LongPressAction.Type.entries,
expectedShownActions = LongPressAction.Type.entries
expectedShownActions = LongPressAction.Type.entries,
onDismissRequest = { dismissedCount += 1 }
)
val anItemIsNotVisible = LongPressAction.Type.entries.any {
composeRule.onNodeWithText(it.label).isNotDisplayed()
}
assertEquals(true, anItemIsNotVisible)
assertTrue(anItemIsNotVisible)
// try to scroll and confirm that items move
composeRule.onNodeWithTag("LongPressMenuGrid")
.assert(hasScrollAction())
val originalPosition = composeRule.onNodeWithText(Enqueue.label)
.scrollVerticallyAndGetOriginalAndFinalY(composeRule.onNodeWithText(Enqueue.label))
.assertMoved()
// also test that clicking on the top of the screen does not close the dialog because it
// spans all of the screen (tap just above the grid bounds on the drag handle, to avoid
// clicking on an action that would close the dialog)
val gridBounds = composeRule.onNodeWithTag("LongPressMenuGrid")
.fetchSemanticsNode()
.positionOnScreen
composeRule.onNodeWithTag("LongPressMenuGrid")
.performTouchInput { swipeUp() }
val finalPosition = composeRule.onNodeWithText(Enqueue.label)
.fetchSemanticsNode()
.positionOnScreen
assertNotEquals(originalPosition, finalPosition)
.boundsInWindow
tapAtAbsoluteXY(gridBounds.center.x, gridBounds.top - 1)
assertTrue(composeRule.runCatching { waitUntil { dismissedCount == 1 } }.isFailure)
}
@Test

View File

@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
@ -132,7 +133,8 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) {
)
// `.focusTarget().onKeyEvent()` handles DPAD on Android TVs
.focusTarget()
.onKeyEvent { event -> state.onKeyEvent(event, columns) },
.onKeyEvent { event -> state.onKeyEvent(event, columns) }
.testTag("LongPressMenuEditorGrid"),
// same width as the LongPressMenu
columns = GridCells.Adaptive(MinButtonWidth),
userScrollEnabled = false,
@ -202,7 +204,7 @@ private fun ResetToDefaultsButton(onClick: () -> Unit) {
TooltipIconButton(
onClick = { showDialog = true },
icon = Icons.Default.RestartAlt,
contentDescription = stringResource(R.string.playback_reset)
contentDescription = stringResource(R.string.reset_to_defaults)
)
}