From ae214a04ff075c051283962ba9adff8325ec2373 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Feb 2026 20:32:15 +0100 Subject: [PATCH] Add 22 tests for LongPressMenuEditor Also improve some tests for LongPressMenu and add some test utility functions --- .../schabi/newpipe/InstrumentedTestUtil.kt | 63 ++ .../menu/LongPressMenuEditorTest.kt | 611 ++++++++++++++++++ .../ui/components/menu/LongPressMenuTest.kt | 62 +- .../ui/components/menu/LongPressMenuEditor.kt | 6 +- 4 files changed, 714 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt index b8b9f0415..761de0a22 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -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 > 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 { + 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.assertMoved() { + val (originalY, finalY) = this + assertNotEquals(originalY, finalY) +} + +/** + * Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY]. + */ +fun Pair.assertDidNotMove() { + val (originalY, finalY) = this + assertEquals(originalY, finalY) +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt new file mode 100644 index 000000000..190bc6861 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt @@ -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() + + // 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.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 + ) { + 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) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index e497adffd..b0153cdcc 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -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, actionArrangement: List, - expectedShownActions: List + expectedShownActions: List, + 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 diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index 6d755984e..a0c898185 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -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) ) }