package social.grain import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.XrpcClient import social.grain.data.api.getFeed import social.grain.data.auth.AuthManager import social.grain.data.model.GrainGallery import social.grain.ui.components.GalleryCard import social.grain.ui.theme.GrainTheme import java.util.concurrent.TimeUnit /** * Renders real galleries through the real card. * * Feed data is messier than any fixture: portrait and landscape mixed in one * gallery, missing titles, null fav counts, single-photo posts. Composing live * responses catches the layout assumptions a hand-written sample would satisfy * by accident — an `aspectRatio` of zero crashes the whole feed, not one card. */ @RunWith(AndroidJUnit4::class) class GalleryCardRenderTest { @get:Rule val composeRule = createComposeRule() private val client = XrpcClient( baseUrl = AuthManager.SERVER_URL, http = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .build(), json = Json { ignoreUnknownKeys = true explicitNulls = false coerceInputValues = true }, ) private fun liveGalleries(limit: Int): List = runBlocking { client.getFeed(feed = "recent", limit = limit).items.orEmpty() } @Test fun rendersAFeedOfLiveGalleriesWithoutCrashing() { val galleries = liveGalleries(20) assertTrue("No galleries to render", galleries.isNotEmpty()) composeRule.setContent { GrainTheme { LazyColumn(Modifier.fillMaxSize()) { items(galleries, key = { it.uri }) { GalleryCard(gallery = it) } } } } composeRule.waitForIdle() // Every card draws a favorite control, so finding one proves the first // card composed all the way through rather than bailing early. val favoriteNodes = composeRule.onAllNodesWithContentDescription("Favorite") .fetchSemanticsNodes() val unfavoriteNodes = composeRule.onAllNodesWithContentDescription("Unfavorite") .fetchSemanticsNodes() assertTrue( "No engagement row rendered — the card didn't compose", favoriteNodes.isNotEmpty() || unfavoriteNodes.isNotEmpty(), ) } @Test fun rendersTheAuthorHandleOnTheFirstCard() { val gallery = liveGalleries(1).first() composeRule.setContent { GrainTheme { GalleryCard(gallery = gallery) } } composeRule.waitForIdle() val name = gallery.creator.displayName?.takeIf { it.isNotBlank() } ?: gallery.creator.handle composeRule.onNodeWithText(name).assertExists() } @Test fun aGalleryWithNoPhotosStillRenders() { // The appview can return a gallery whose photos are all deleted; the // card must degrade to header-plus-caption rather than throw. val stripped = liveGalleries(1).first().copy(items = emptyList()) composeRule.setContent { GrainTheme { GalleryCard(gallery = stripped) } } composeRule.waitForIdle() } }