package social.grain import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.feature.profile.CollapsingHeaderTabs import androidx.compose.ui.unit.dp import social.grain.ui.theme.GrainTheme /** * The profile header is measured outside the pager and reported at whatever * height the collapse has left it, which is easy to get wrong in a way that * silently drops everything below the first row — the display name, the handle * and the bio all vanished that way once. */ @RunWith(AndroidJUnit4::class) class ProfileHeaderCollapseTest { @get:Rule val composeRule = createComposeRule() private val lines = (1..6).map { "header line $it" } private fun setContent(scrollableContent: Boolean = false) { composeRule.setContent { GrainTheme { Surface(Modifier.fillMaxSize()) { CollapsingHeaderTabs( pagerState = rememberPagerState { 2 }, header = { Column { lines.forEach { Text(it) } } }, tabRow = { Text("Galleries") }, content = { if (scrollableContent) { LazyVerticalGrid( columns = GridCells.Fixed(3), modifier = Modifier.testTag("grid"), ) { items((1..60).toList()) { Box(Modifier.fillMaxWidth().height(120.dp)) { Text("$it") } } } } else { Text("photos") } }, ) } } } } @Test fun everyLineOfTheHeaderIsShown() { setContent() lines.forEach { composeRule.onNodeWithText(it).assertIsDisplayed() } } @Test fun theTabBarAndTheGridSitBelowTheWholeHeader() { setContent() val lastLine = composeRule.onNodeWithText(lines.last()).fetchSemanticsNode() val tabs = composeRule.onNodeWithText("Galleries").fetchSemanticsNode() val grid = composeRule.onNodeWithText("photos").fetchSemanticsNode() val headerBottom = lastLine.positionInRoot.y + lastLine.size.height assert(tabs.positionInRoot.y >= headerBottom) { "tab bar overlapped the header: ${tabs.positionInRoot.y} < $headerBottom" } assert(grid.positionInRoot.y >= tabs.positionInRoot.y + tabs.size.height) { "grid overlapped the tab bar: ${grid.positionInRoot.y} < ${tabs.positionInRoot.y}" } } @Test fun scrollingTheGridTakesTheHeaderWithItAndBringsItBack() { setContent(scrollableContent = true) val restingTop = composeRule.onNodeWithText(lines.first()).fetchSemanticsNode().positionInRoot.y composeRule.onNodeWithTag("grid").performTouchInput { swipeUp() } composeRule.waitForIdle() val collapsedTop = composeRule.onNodeWithText(lines.first()).fetchSemanticsNode().positionInRoot.y assert(collapsedTop < restingTop) { "header did not collapse: $collapsedTop is not above $restingTop" } // The tab bar rides with the header rather than crossing it, whatever // the collapse has done. val lastLine = composeRule.onNodeWithText(lines.last()).fetchSemanticsNode() val tabs = composeRule.onNodeWithText("Galleries").fetchSemanticsNode() assert(tabs.positionInRoot.y >= lastLine.positionInRoot.y + lastLine.size.height) { "tab bar overlapped the header once collapsed" } } }