diff --git a/app/build.gradle b/app/build.gradle index ec7bc3776..59ca2b6a8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -342,6 +342,10 @@ dependencies { androidTestImplementation libs.androidx.runner androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.assertj.core + androidTestImplementation platform(libs.androidx.compose.bom) + androidTestImplementation libs.androidx.compose.ui.test.junit4 + debugImplementation libs.androidx.compose.ui.test.manifest + } static String getGitWorkingBranch() { diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoCommentsTest.kt b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoCommentsTest.kt deleted file mode 100644 index 4ced9dbc7..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoCommentsTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.schabi.newpipe.error - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import java.io.IOException -import java.net.SocketTimeoutException - -@RunWith(AndroidJUnit4::class) -class ErrorInfoCommentsTest { - private val context: Context by lazy { ApplicationProvider.getApplicationContext() } - // Test 1: Network error on initial load (Resource.Error) - @Test - fun testInitialCommentNetworkError() { - val errorInfo = ErrorInfo( - throwable = SocketTimeoutException("Connection timeout"), - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) - Assert.assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) - Assert.assertTrue(errorInfo.isReportable) - Assert.assertTrue(errorInfo.isRetryable) - Assert.assertNull(errorInfo.recaptchaUrl) - } - - // Test 2: Network error on paging (LoadState.Error) - @Test - fun testPagingNetworkError() { - val errorInfo = ErrorInfo( - throwable = IOException("Paging failed"), - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) - Assert.assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) - Assert.assertTrue(errorInfo.isReportable) - Assert.assertTrue(errorInfo.isRetryable) - Assert.assertNull(errorInfo.recaptchaUrl) - } - - // Test 3: ReCaptcha during comments load - @Test - fun testReCaptchaDuringComments() { - val url = "https://www.google.com/recaptcha/api/fallback?k=test" - val errorInfo = ErrorInfo( - throwable = ReCaptchaException("ReCaptcha needed", url), - userAction = UserAction.REQUESTED_COMMENTS, - request = "comments" - ) - Assert.assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context)) - Assert.assertEquals(url, errorInfo.recaptchaUrl) - Assert.assertFalse(errorInfo.isReportable) - Assert.assertTrue(errorInfo.isRetryable) - } -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java deleted file mode 100644 index 892d1df0f..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.schabi.newpipe.error; - -import android.os.Parcel; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.exceptions.ParsingException; - -import java.util.Arrays; -import java.util.Objects; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * Instrumented tests for {@link ErrorInfo}. - */ -@RunWith(AndroidJUnit4.class) -@LargeTest -public class ErrorInfoTest { - - /** - * @param errorInfo the error info to access - * @return the private field errorInfo.message.stringRes using reflection - */ - private int getMessageFromErrorInfo(final ErrorInfo errorInfo) - throws NoSuchFieldException, IllegalAccessException { - final var message = ErrorInfo.class.getDeclaredField("message"); - message.setAccessible(true); - final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo); - - final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes"); - stringRes.setAccessible(true); - return (int) Objects.requireNonNull(stringRes.get(messageValue)); - } - - @Test - public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException { - final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"), - UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId()); - // Obtain a Parcel object and write the parcelable object to it: - final Parcel parcel = Parcel.obtain(); - info.writeToParcel(parcel, 0); - parcel.setDataPosition(0); - final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel); - - assertTrue(Arrays.toString(infoFromParcel.getStackTraces()) - .contains(ErrorInfoTest.class.getSimpleName())); - assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction()); - assertEquals(ServiceList.YouTube.getServiceInfo().getName(), - infoFromParcel.getServiceName()); - assertEquals("request", infoFromParcel.getRequest()); - assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel)); - - parcel.recycle(); - } -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt new file mode 100644 index 000000000..2dee463ef --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt @@ -0,0 +1,128 @@ +package org.schabi.newpipe.error + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.exceptions.ParsingException +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import java.io.IOException +import java.net.SocketTimeoutException + +/** + * Instrumented tests for {@link ErrorInfo}. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class ErrorInfoTest { + private val context: Context by lazy { ApplicationProvider.getApplicationContext() } + + /** + * @param errorInfo the error info to access + * @return the private field errorInfo.message.stringRes using reflection + */ + @Throws(NoSuchFieldException::class, IllegalAccessException::class) + private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int { + val message = ErrorInfo::class.java.getDeclaredField("message") + message.isAccessible = true + val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage + + val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes") + stringRes.isAccessible = true + return stringRes.get(messageValue) as Int + } + + @Test + @Throws(NoSuchFieldException::class, IllegalAccessException::class) + fun errorInfoTestParcelable() { + val info = ErrorInfo( + ParsingException("Hello"), + UserAction.USER_REPORT, + "request", + ServiceList.YouTube.serviceId + ) + // Obtain a Parcel object and write the parcelable object to it: + val parcel = Parcel.obtain() + info.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR") + val creator = creatorField.get(null) + check(creator is Parcelable.Creator<*>) + val infoFromParcel = requireNotNull( + creator.createFromParcel(parcel) as? ErrorInfo + ) + assertTrue( + infoFromParcel.stackTraces.contentToString() + .contains(ErrorInfoTest::class.java.simpleName) + ) + assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction) + assertEquals( + ServiceList.YouTube.serviceInfo.name, + infoFromParcel.getServiceName() + ) + assertEquals("request", infoFromParcel.request) + assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel)) + + parcel.recycle() + } + + /** + * Test: Network error on initial load (Resource.Error) + */ + + @Test + fun testInitialCommentNetworkError() { + val errorInfo = ErrorInfo( + throwable = SocketTimeoutException("Connection timeout"), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) + assertTrue(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + assertNull(errorInfo.recaptchaUrl) + } + + /** + * Test: Network error on paging (LoadState.Error) + */ + @Test + fun testPagingNetworkError() { + val errorInfo = ErrorInfo( + throwable = IOException("Paging failed"), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) + assertTrue(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + assertNull(errorInfo.recaptchaUrl) + } + + /** + * Test: ReCaptcha during comments load + */ + @Test + fun testReCaptchaDuringComments() { + val url = "https://www.google.com/recaptcha/api/fallback?k=test" + val errorInfo = ErrorInfo( + throwable = ReCaptchaException("ReCaptcha needed", url), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context)) + assertEquals(url, errorInfo.recaptchaUrl) + assertFalse(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt new file mode 100644 index 000000000..a6627ee66 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.ui.theme.AppTheme +import java.net.UnknownHostException + +@RunWith(AndroidJUnit4::class) +class ErrorPanelTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) { + composeRule.setContent { + AppTheme { + ErrorPanel(errorInfo = errorInfo, onRetry = onRetry) + } + } + } + private fun text(@StringRes id: Int) = composeRule.activity.getString(id) + + /** + * Test Network Error + */ + @Test + fun testNetworkErrorShowsRetryWithoutReportButton() { + val networkErrorInfo = ErrorInfo( + throwable = UnknownHostException("offline"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=foo" + ) + + setErrorPanel(networkErrorInfo, onRetry = {}) + composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + .assertDoesNotExist() + } + + /** + * Test Unexpected Error, Shows Report and Retry buttons + */ + @Test + fun unexpectedErrorShowsReportAndRetryButtons() { + val unexpectedErrorInfo = ErrorInfo( + throwable = RuntimeException("Unexpected error"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=bar" + ) + + setErrorPanel(unexpectedErrorInfo, onRetry = {}) + composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertIsDisplayed() + } + + /** + * Test Recaptcha Error shows solve, retry and open in browser buttons + */ + @Test + fun recaptchaErrorShowsSolveAndRetryOpenInBrowserButtons() { + var retryClicked = false + val recaptchaErrorInfo = ErrorInfo( + throwable = ReCaptchaException( + "Recaptcha required", + "https://example.com/captcha" + ), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=baz", + openInBrowserUrl = "https://example.com/watch?v=baz" + ) + + setErrorPanel( + errorInfo = recaptchaErrorInfo, + onRetry = { retryClicked = true } + + ) + composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + assert(retryClicked) { "onRetry callback should have been invoked" } + } + + /** + * Test Content Not Available Error hides retry button + */ + @Test + fun testNonRetryableErrorHidesRetryAndReportButtons() { + val contentNotAvailable = ErrorInfo( + throwable = ContentNotAvailableException("Video has been removed"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=qux" + ) + + setErrorPanel(contentNotAvailable) + + composeRule.onNodeWithText(text(R.string.content_not_available)) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + .assertDoesNotExist() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt new file mode 100644 index 000000000..eed80ead4 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt @@ -0,0 +1,358 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.ui.components.common.ErrorPanel +import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.emptystate.EmptyStateComposable +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.viewmodels.util.Resource +import java.net.UnknownHostException + +class CommentSectionInstrumentedTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val uiStateFlow = MutableStateFlow>(Resource.Loading) + private val pagingFlow = MutableStateFlow(PagingData.empty()) + private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId) + + @Before + fun setUp() { + composeRule.setContent { + AppTheme { + TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow) + } + } + } + + private fun successState(commentCount: Int) = Resource.Success( + CommentInfo( + serviceId = 0, + url = "", + comments = emptyList(), + nextPage = null, + commentCount = commentCount, + isCommentsDisabled = false + ) + ) + + @Test + fun commentListLoadsAndScrolls() { + val comments = (1..25).map { index -> + CommentsInfoItem( + commentText = Description("Comment $index", Description.PLAIN_TEXT), + uploaderName = "Uploader $index", + replies = Page(""), + replyCount = 0 + ) + } + uiStateFlow.value = successState(comments.size) + pagingFlow.value = PagingData.from(comments) + composeRule.waitForIdle() + composeRule.onNodeWithText("Comment 1").assertIsDisplayed() + composeRule.onNodeWithTag("comment_list") + .performScrollToNode(hasText("Comment 25")) + composeRule.onNodeWithText("Comment 25").assertIsDisplayed() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun pagingErrorShowsErrorPanelAndAllowsRetry() { + uiStateFlow.value = successState(10) + pagingFlow.value = PagingData.from( + data = emptyList(), + sourceLoadStates = LoadStates( + refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true) + .and(hasClickAction()) + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty() + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val recoveredComment = CommentsInfoItem( + commentText = Description("Recovered comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(recoveredComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("Recovered comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("Recovered comment").assertIsDisplayed() + + composeRule.onNode(solveMatcher).assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun resourceErrorShowsErrorPanelAndRetry() { + uiStateFlow.value = Resource.Error(UnknownHostException("offline")) + composeRule.waitForIdle() + + composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed() + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val recoveredComment = CommentsInfoItem( + commentText = Description("Recovered comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(recoveredComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("Recovered comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("Recovered comment").assertIsDisplayed() + + composeRule.onNodeWithText(string(R.string.network_error)) + .assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun retryAfterErrorRecoversList() { + uiStateFlow.value = Resource.Error(RuntimeException("boom")) + composeRule.waitForIdle() + + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val firstComment = CommentsInfoItem( + commentText = Description("First comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(firstComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("First comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("First comment").assertIsDisplayed() + + composeRule.onNodeWithText(string(R.string.network_error)) + .assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } +} + +@Composable +private fun TestCommentSection( + uiStateFlow: StateFlow>, + commentsFlow: Flow> +) { + val uiState by uiStateFlow.collectAsState() + val comments = commentsFlow.collectAsLazyPagingItems() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val listState = rememberLazyListState() + val COMMENT_LIST_TAG = "comment_list" + + LazyColumnThemedScrollbar(state = listState) { + LazyColumn( + modifier = Modifier + .testTag(COMMENT_LIST_TAG) + .nestedScroll(nestedScrollInterop), + state = listState + ) { + when (uiState) { + is Resource.Loading -> item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + is Resource.Success -> { + val commentInfo = (uiState as Resource.Success).data + val count = commentInfo.commentCount + + when { + commentInfo.isCommentsDisabled -> item { + EmptyStateComposable( + spec = EmptyStateSpec.DisabledComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + count == 0 -> item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + else -> { + if (count >= 0) { + item { + Text( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = pluralStringResource(R.plurals.comments, count, count), + maxLines = 1, + style = MaterialTheme.typography.titleMedium + ) + } + } + when (val refresh = comments.loadState.refresh) { + is LoadState.Loading -> item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + is LoadState.Error -> item { + Box( + modifier = Modifier.fillMaxWidth() + ) { + ErrorPanel( + errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ), + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + else -> items(comments.itemCount) { index -> + Comment(comment = comments[index]!!) {} + } + } + } + } + } + is Resource.Error -> item { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorPanel( + errorInfo = ErrorInfo( + throwable = (uiState as Resource.Error).throwable, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ), + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt index 74de30ea5..943c59853 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.ui.components.common import android.content.Intent +import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme @@ -86,7 +87,8 @@ fun ErrorPanel( } } -@Preview(showBackground = true, widthDp = 360, heightDp = 640, backgroundColor = 0xffffffff) +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun ErrorPanelPreview() { AppTheme { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bea2550ad..3d12d559e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -88,6 +88,8 @@ androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", nam androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "jetpack-compose" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }